gigafibre-fsm/apps/ops/src/components/shared/QuoteWizard.vue
louispaulb 042c5545f7 ops: MapLibre/OSM basemap (drop Mapbox) + satellite toggle, unified Créer flow, wizard pricing, infra-first reverse client
- Maps: MapLibre GL + self-hosted Protomaps tiles + ESRI satellite toggle
- FAB 'Créer' chooser (incl. Soumission wizard) via useCreateSignal
- Quote wizard: new default tiers (80/500/1500 @ 39.95/49.95/49.95), competitor
  price-match, mini-map pin w/ green check, carry qualified address
- api/address.reverse() + reverseGeocodeOSM tries RQA-backed hub, Nominatim fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00

201 lines
15 KiB
Vue

<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :maximized="$q.screen.lt.md" persistent>
<q-card class="qw-card column no-wrap">
<q-card-section class="row items-center no-wrap q-py-sm">
<q-icon name="request_quote" size="22px" color="teal-7" class="q-mr-sm" />
<div class="text-h6 col ellipsis">Nouvelle soumission</div>
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
</q-card-section>
<!-- Progression (slides) -->
<div class="qw-steps row items-center no-wrap q-px-md q-pb-sm">
<template v-for="(s, i) in steps" :key="s.key">
<div class="qw-step row items-center no-wrap" :class="{ done: i < stepIdx, current: i === stepIdx }">
<div class="qw-dot row items-center justify-center"><q-icon v-if="i < stepIdx" name="check" size="14px" /><span v-else>{{ i + 1 }}</span></div>
<div class="qw-step-lbl gt-xs">{{ s.label }}</div>
</div>
<div v-if="i < steps.length - 1" class="qw-line col" :class="{ done: i < stepIdx }"></div>
</template>
</div>
<q-separator />
<q-carousel v-model="step" animated swipeable transition-prev="slide-right" transition-next="slide-left" class="qw-body col" :navigation="false" :arrows="false" keep-alive>
<!-- 1 · ADRESSE + qualification fibre -->
<q-carousel-slide name="adresse" class="q-pa-md column no-wrap">
<div class="text-subtitle1 text-weight-medium">Adresse de service</div>
<div class="text-caption text-grey-7 q-mb-sm">Cherchez l'adresse civique — on vérifie la disponibilité de la fibre avant tout (base RQA + réseau Targo).</div>
<q-input dense outlined v-model="addrQuery" placeholder="Ex. 123 rue Principale, Ville…" :loading="addrLoading" clearable autofocus @update:model-value="searchAddr">
<template #prepend><q-icon name="place" /></template>
</q-input>
<q-list v-if="addrResults.length" bordered class="qw-results q-mt-xs">
<q-item v-for="(a, i) in addrResults" :key="i" clickable @click="pickAddress(a)">
<q-item-section avatar style="min-width:30px"><q-icon :name="a.fiber_available ? 'check_circle' : 'cancel'" :color="a.fiber_available ? 'positive' : 'grey-5'" /></q-item-section>
<q-item-section><q-item-label>{{ a.address_full }}</q-item-label><q-item-label caption>{{ a.fiber_available ? ('Fibre disponible' + (a.max_speed ? ' · jusqu\'à ' + a.max_speed + ' Mbps' : '')) : 'Fibre non disponible à cette adresse' }}</q-item-label></q-item-section>
</q-item>
</q-list>
<q-banner v-if="chosen.address" class="qw-chosen q-mt-md" :class="chosen.fiber ? 'bg-green-1' : 'bg-orange-1'">
<template #avatar><q-icon :name="chosen.fiber ? 'wifi' : 'wifi_off'" :color="chosen.fiber ? 'positive' : 'orange-8'" /></template>
<div class="text-weight-medium">{{ chosen.address }}</div>
<div class="text-caption">{{ chosen.fiber ? ('✓ Fibre disponible' + (chosen.maxSpeed ? ' — jusqu\'à ' + chosen.maxSpeed + ' Mbps' : '')) : '⚠ Fibre non desservie ici — soumission possible mais à valider (hors zone / à déployer).' }}</div>
</q-banner>
<!-- Mini-carte : repère (✓ vert) sur l'adresse trouvée → confirmation visuelle ; bouton satellite (voir le bâtiment) -->
<div v-show="chosen.address && chosen.lat != null" class="qw-map-wrap q-mt-sm">
<div ref="qwMapEl" class="qw-map"></div>
<q-btn round dense size="sm" class="qw-sat-btn" :color="satellitePref ? 'primary' : 'grey-9'" text-color="white" :icon="satellitePref ? 'map' : 'satellite_alt'" @click="toggleMiniSat"><q-tooltip>{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
</div>
</q-carousel-slide>
<!-- 2 · CONTACT (fiche existante ou nouveau prospect) -->
<q-carousel-slide name="contact" class="q-pa-md column no-wrap">
<div class="text-subtitle1 text-weight-medium">Contact</div>
<div class="text-caption text-grey-7 q-mb-sm">Associez une fiche existante, ou créez un prospect (courriel <b>ou</b> mobile suffit pour la confirmation).</div>
<q-input dense outlined v-model="custQuery" placeholder="Chercher par nom · téléphone · courriel…" :loading="custLoading" clearable @update:model-value="searchCust" class="q-mb-xs">
<template #prepend><q-icon name="search" /></template>
</q-input>
<q-list v-if="custResults.length" bordered class="qw-results q-mb-sm">
<q-item v-for="c in custResults" :key="c.name" clickable @click="pickCustomer(c)" :active="chosen.customer && chosen.customer.name === c.name" active-class="bg-teal-1">
<q-item-section avatar style="min-width:30px"><q-icon name="badge" color="teal-7" /></q-item-section>
<q-item-section><q-item-label>{{ c.customer_name || c.name }}</q-item-label><q-item-label caption>{{ [c.cell_phone || c.phone, c.email_id].filter(Boolean).join(' · ') || c.name }}</q-item-label></q-item-section>
</q-item>
</q-list>
<q-banner v-if="chosen.customer" class="bg-teal-1 q-mb-sm">
<template #avatar><q-icon name="how_to_reg" color="teal-8" /></template>
Fiche existante : <b>{{ chosen.customer.customer_name || chosen.customer.name }}</b>
<template #action><q-btn flat dense no-caps label="Changer" @click="chosen.customer = null" /></template>
</q-banner>
<template v-else>
<div class="text-caption text-grey-6 q-mb-xs">…ou nouveau prospect :</div>
<q-btn-toggle v-model="lead.type" :options="[{label:'Résidentiel',value:'Individual'},{label:'Commercial',value:'Company'}]" unelevated dense no-caps toggle-color="teal-7" color="grey-3" text-color="grey-8" class="q-mb-sm" />
<div class="row q-col-gutter-sm">
<q-input class="col-6" dense outlined v-model="lead.first" label="Prénom" />
<q-input class="col-6" dense outlined v-model="lead.last" :label="lead.type === 'Company' ? 'Entreprise' : 'Nom'" />
<q-input class="col-6" dense outlined v-model="lead.email" label="Courriel" type="email" :error="showLeadErr && !leadContactOk" />
<q-input class="col-6" dense outlined v-model="lead.mobile" label="Mobile" mask="(###) ###-####" :error="showLeadErr && !leadContactOk" />
</div>
<div v-if="showLeadErr && !leadContactOk" class="text-caption text-negative q-mt-xs">Au moins un courriel ou un mobile est requis.</div>
</template>
</q-carousel-slide>
</q-carousel>
<q-separator />
<q-card-actions class="row items-center q-px-md q-py-sm">
<q-btn flat no-caps icon="chevron_left" label="Retour" :disable="stepIdx === 0" @click="go(stepIdx - 1)" />
<q-space />
<q-btn v-if="step === 'adresse'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Continuer" :disable="!chosen.address" @click="go(1)" />
<q-btn v-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers les services" :loading="submitting" @click="finish" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { useAddressSearch } from 'src/composables/useAddressSearch'
import { createPlanMap, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // init carte MapLibre/OSM commune + satellite ESRI
import { listDocs, createDoc } from 'src/api/erp'
const props = defineProps({ modelValue: Boolean })
// « qualified » : émis avec le customer (doc ERPNext) prêt pour ProjectWizard, + l'adresse/fibre pour info.
const emit = defineEmits(['update:modelValue', 'qualified'])
const $q = useQuasar()
const steps = [{ key: 'adresse', label: 'Adresse' }, { key: 'contact', label: 'Contact' }]
const step = ref('adresse')
const stepIdx = computed(() => Math.max(0, steps.findIndex(s => s.key === step.value)))
function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.value = steps[n].key }
// ── Étape 1 : adresse + fibre ──
const { addrResults, addrLoading, searchAddr } = useAddressSearch()
const addrQuery = ref('')
const chosen = reactive({ address: '', ville: '', code_postal: '', lat: null, lon: null, fiber: false, maxSpeed: null, zone: null, customer: null })
function pickAddress (a) {
chosen.address = a.address_full; chosen.ville = a.ville || ''; chosen.code_postal = a.code_postal || ''
chosen.lat = a.latitude != null ? +a.latitude : null; chosen.lon = a.longitude != null ? +a.longitude : null
chosen.fiber = !!a.fiber_available; chosen.maxSpeed = a.max_speed || null; chosen.zone = a.zone_tarifaire != null ? a.zone_tarifaire : null // zone tarifaire → le prix dépend de l'adresse
addrResults.value = []; addrQuery.value = a.address_full
}
// ── Mini-carte : repère (✓ vert) sur l'adresse trouvée (MapLibre/OSM auto-hébergé) ──
const qwMapEl = ref(null); let _qwMap = null; let _qwMarker = null
function initMiniMap () {
if (!qwMapEl.value || chosen.lat == null || chosen.lon == null) return
if (_qwMap) { _qwMap.setCenter([chosen.lon, chosen.lat]); if (_qwMarker) _qwMarker.setLngLat([chosen.lon, chosen.lat]); setTimeout(() => { try { _qwMap.resize() } catch (e) {} }, 60); return }
_qwMap = createPlanMap(qwMapEl.value, { center: [chosen.lon, chosen.lat], zoom: 15 })
_qwMap.on('load', () => {
try { _qwMap.resize() } catch (e) {}
addSatelliteLayer(_qwMap, satellitePref.value) // satellite (voir le bâtiment) — masqué par défaut, bouton pour basculer
const el = document.createElement('div'); el.className = 'qw-pin'; el.innerHTML = '<span class="material-icons">check</span>'
_qwMarker = new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([chosen.lon, chosen.lat]).addTo(_qwMap)
})
}
function toggleMiniSat () { setSatellitePref(!satellitePref.value); if (_qwMap) setSatelliteVisible(_qwMap, satellitePref.value) }
watch(satellitePref, (v) => { if (_qwMap) setSatelliteVisible(_qwMap, v) })
function destroyMiniMap () { if (_qwMap) { try { _qwMap.remove() } catch (e) {} _qwMap = null; _qwMarker = null } }
watch(() => [props.modelValue, step.value, chosen.lat, chosen.lon], () => {
if (props.modelValue && step.value === 'adresse' && chosen.lat != null) nextTick(() => setTimeout(initMiniMap, 80))
})
watch(() => props.modelValue, (open) => { if (!open) destroyMiniMap() })
onBeforeUnmount(destroyMiniMap)
// ── Étape 2 : contact (recherche fiche + création prospect) ──
const custQuery = ref(''); const custResults = ref([]); const custLoading = ref(false); let custT = null
const lead = reactive({ type: 'Individual', first: '', last: '', email: '', mobile: '' })
const showLeadErr = ref(false)
const leadContactOk = computed(() => !!(lead.email.trim() || lead.mobile.replace(/\D/g, '').length >= 10))
function searchCust (q) {
clearTimeout(custT); if (!q || q.trim().length < 3) { custResults.value = []; return }
custLoading.value = true
custT = setTimeout(async () => {
try {
const digits = q.replace(/\D/g, '')
const or = [['customer_name', 'like', '%' + q + '%'], ['email_id', 'like', '%' + q + '%']]
if (digits.length >= 4) or.push(['cell_phone', 'like', '%' + digits.slice(-10) + '%'], ['phone', 'like', '%' + digits.slice(-10) + '%'])
const r = await listDocs('Customer', { or_filters: or, fields: ['name', 'customer_name', 'cell_phone', 'phone', 'email_id'], limit: 8 })
custResults.value = Array.isArray(r) ? r : (r.data || [])
} catch (e) { custResults.value = [] } finally { custLoading.value = false }
}, 320)
}
function pickCustomer (c) { chosen.customer = c; custResults.value = [] }
const submitting = ref(false)
async function finish () {
if (!chosen.customer) {
if (!leadContactOk.value) { showLeadErr.value = true; return }
const name = [lead.first.trim(), lead.last.trim()].filter(Boolean).join(' ') || (lead.email || lead.mobile)
submitting.value = true
try {
const doc = await createDoc('Customer', {
customer_name: name, customer_type: lead.type, customer_group: lead.type === 'Company' ? 'Commercial' : 'Individual', territory: 'Canada',
...(lead.email.trim() ? { email_id: lead.email.trim() } : {}), ...(lead.mobile.replace(/\D/g, '') ? { cell_phone: lead.mobile } : {}),
})
chosen.customer = doc && (doc.data || doc)
$q.notify({ type: 'positive', icon: 'person_add', message: 'Prospect créé — ' + name, timeout: 2000 })
} catch (e) { $q.notify({ type: 'negative', message: 'Création du prospect impossible : ' + (e.message || e) }); submitting.value = false; return }
submitting.value = false
}
// Handoff : ProjectWizard (panier + rabais rab-x + devis + acceptation) prend le relais, pré-rempli.
emit('qualified', { customer: chosen.customer, address: { address: chosen.address, ville: chosen.ville, code_postal: chosen.code_postal, lat: chosen.lat, lon: chosen.lon, fiber: chosen.fiber, zone: chosen.zone } })
emit('update:modelValue', false)
}
</script>
<style scoped>
.qw-card { width: 620px; max-width: 96vw; height: 70vh; max-height: 720px; }
.q-dialog--maximized .qw-card { width: 100%; height: 100%; max-height: none; }
.qw-steps { gap: 4px; }
.qw-step { gap: 6px; } .qw-dot { width: 24px; height: 24px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 12px; font-weight: 700; flex: 0 0 auto; transition: all .2s; }
.qw-step.current .qw-dot { background: #0f766e; color: #fff; } .qw-step.done .qw-dot { background: #14b8a6; color: #fff; }
.qw-step-lbl { font-size: 12px; color: #64748b; font-weight: 600; } .qw-step.current .qw-step-lbl { color: #0f766e; }
.qw-line { height: 2px; background: #e2e8f0; margin: 0 6px; border-radius: 2px; } .qw-line.done { background: #14b8a6; }
.qw-body { min-height: 0; } .qw-body :deep(.q-carousel__slide) { overflow-y: auto; }
.qw-results { border-radius: 8px; max-height: 260px; overflow-y: auto; }
.qw-chosen { border-radius: 8px; }
.qw-map-wrap { position: relative; }
.qw-map { height: 180px; border-radius: 10px; overflow: hidden; border: 1px solid #e2e8f0; }
.qw-sat-btn { position: absolute; top: 6px; left: 6px; z-index: 3; }
/* Repère « adresse trouvée » : goutte verte + ✓ blanc */
:deep(.qw-pin) { width: 30px; height: 30px; border-radius: 50% 50% 50% 0; background: #16a34a; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,.3); border: 2px solid #fff; }
:deep(.qw-pin .material-icons) { transform: rotate(45deg); color: #fff; font-size: 16px; }
</style>