- config/dispatch-priority.js (NOUVEAU) : DISPATCH_PRIORITIES = SOURCE UNIQUE des 3 niveaux (Urgent/Moyenne/Basse, couleurs 🔴🟠⚪) — élimine 5 copies locales (UnifiedCreateModal, TaskNode, CreateOfferModal, TechJobDetailPage, PlanificationPage POOL_PRIOS) et corrige la dérive « Moyenne » bleue de CreateOfferModal - PlanificationPage/TechJobDetailPage : éditeur de compétences SkillSelect → /roster/job-skills ; FIX : SkillSelect émet une CSV (pas un tableau) → les handlers n'effaçaient plus les compétences ; panneau job (statut/reporter/réserver un tech) ; sommaire avant publication + auto-save + journal + annuler - UnifiedCreateModal/ProjectWizard/QuoteWizard/TaskNode : formulaire de création unifié, divulgation progressive, dédup client (courriel/téléphone) + message d'erreur convivial ; suppression de NewTicketDialog (fusionné) - IssueDetail : fil de ticket (nom d'expéditeur) ; app terrain lecture/commentaire - useConversationDisplay/useHelpers/wizard-constants : priorités alignées sur 3 niveaux Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
20 KiB
Vue
262 lines
20 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 · FORFAITS — services + promos VISIBLES avant de demander le contact (lead-first) -->
|
|
<q-carousel-slide name="forfaits" class="q-pa-md column no-wrap">
|
|
<div class="text-subtitle1 text-weight-medium">Forfaits Internet<span v-if="chosen.fiber"> disponibles ici</span></div>
|
|
<div class="text-caption text-grey-7 q-mb-sm">Prix promotionnels — rabais 24 mois déjà appliqué. Choisissez-en un (facultatif) ou continuez.</div>
|
|
<div v-if="!chosen.fiber" class="text-caption text-orange-8 q-mb-sm"><q-icon name="info" size="14px" /> Fibre à valider à cette adresse — prix indicatifs.</div>
|
|
<div class="qw-tiers column q-gutter-sm">
|
|
<q-card v-for="t in tiers" :key="t.id" flat bordered class="qw-tier" :class="{ sel: chosen.tier && chosen.tier.id === t.id }" @click="pickTier(t)">
|
|
<q-card-section class="row items-center no-wrap q-py-sm">
|
|
<q-icon :name="chosen.tier && chosen.tier.id === t.id ? 'check_circle' : (t.icon || 'wifi')" size="26px" :color="chosen.tier && chosen.tier.id === t.id ? 'teal-7' : 'grey-6'" class="q-mr-md" />
|
|
<div class="col">
|
|
<div class="row items-center no-wrap">
|
|
<span class="text-weight-bold">{{ t.label }}</span>
|
|
<q-badge v-if="t.badge" :color="t.default ? 'teal-7' : 'grey-6'" class="q-ml-sm">{{ t.badge }}</q-badge>
|
|
</div>
|
|
<div class="text-caption text-grey-7">{{ t.speed }} · {{ t.desc }}</div>
|
|
</div>
|
|
<div class="text-right q-pl-sm">
|
|
<div class="text-h6 text-teal-8 text-weight-bold" style="line-height:1.1">{{ money(t.price_effective) }}<span class="text-caption text-grey-6">/mois</span></div>
|
|
<div v-if="t.price_base > t.price_effective" class="text-caption text-grey-5"><s>{{ money(t.price_base) }}</s></div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
<div class="text-caption text-grey-6 q-mt-sm">+ Installation {{ money(99.95) }} (frais unique)</div>
|
|
</q-carousel-slide>
|
|
|
|
<!-- 3 · 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="Voir les forfaits" :disable="!chosen.address" @click="go(1)" />
|
|
<q-btn v-else-if="step === 'forfaits'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Continuer" @click="go(2)" />
|
|
<q-btn v-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers la soumission" :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'
|
|
import { INTERNET_HERO_TIERS } from 'src/data/wizard-constants' // forfaits « héros » (prix promo 24 mois) — vitrine AVANT le contact
|
|
|
|
const props = defineProps({ modelValue: Boolean })
|
|
// « qualified » : émis avec le customer (doc ERPNext) prêt pour ProjectWizard, + l'adresse/fibre + le forfait d'intérêt.
|
|
const emit = defineEmits(['update:modelValue', 'qualified'])
|
|
const $q = useQuasar()
|
|
|
|
// Ordre lead-first : Adresse (dispo fibre) → Forfaits (services + promos VISIBLES avant de demander quoi que ce soit) → Contact.
|
|
const steps = [{ key: 'adresse', label: 'Adresse' }, { key: 'forfaits', label: 'Forfaits' }, { 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, tier: null })
|
|
|
|
// ── Étape « Forfaits » : vitrine des services + promos à l'adresse, AVANT de demander le contact ──
|
|
const tiers = INTERNET_HERO_TIERS
|
|
function money (n) { return (Math.round(Number(n || 0) * 100) / 100).toFixed(2).replace('.', ',') + ' $' }
|
|
function pickTier (t) { chosen.tier = (chosen.tier && chosen.tier.id === t.id) ? null : t } // clic = sélectionne / re-clic = désélectionne (facultatif)
|
|
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 = [] }
|
|
|
|
// Cherche une fiche EXISTANTE par courriel/mobile → évite de créer un doublon (garde-fou).
|
|
async function findExistingCustomer (email, mobile) {
|
|
const or = []; const em = (email || '').trim(); const digits = (mobile || '').replace(/\D/g, '')
|
|
if (em) or.push(['email_id', '=', em])
|
|
if (digits.length >= 10) { const d = digits.slice(-10); or.push(['cell_phone', 'like', '%' + d + '%'], ['phone', 'like', '%' + d + '%']) }
|
|
if (!or.length) return null
|
|
try { const r = await listDocs('Customer', { or_filters: or, fields: ['name', 'customer_name', 'cell_phone', 'phone', 'email_id'], limit: 1 }); const rows = Array.isArray(r) ? r : (r.data || []); return rows[0] || null } catch (e) { return null }
|
|
}
|
|
// Message d'erreur LISIBLE (jamais le stack trace Frappe brut).
|
|
function friendlyError (e) {
|
|
const m = String((e && e.message) || e || '')
|
|
if (/duplicate|unique|already exists/i.test(m)) return 'Un client avec ces coordonnées existe déjà — cherchez-le dans l\'étape « Contact ».'
|
|
if (/mandatory|required|reqd/i.test(m)) return 'Il manque une information obligatoire pour créer le client.'
|
|
if (/permission|not permitted|forbidden|\b403\b/i.test(m)) return 'Permission insuffisante pour créer un client.'
|
|
return 'Impossible de créer le prospect. Réessayez, ou cherchez une fiche existante.'
|
|
}
|
|
const submitting = ref(false)
|
|
async function finish () {
|
|
if (!chosen.customer) {
|
|
if (!leadContactOk.value) { showLeadErr.value = true; return }
|
|
submitting.value = true
|
|
try {
|
|
// 1) DÉ-DUP : ce courriel/mobile correspond-il déjà à une fiche ? → on la réutilise (pas de doublon).
|
|
const existing = await findExistingCustomer(lead.email, lead.mobile)
|
|
if (existing) {
|
|
chosen.customer = existing
|
|
$q.notify({ type: 'info', icon: 'how_to_reg', message: 'Fiche existante réutilisée — ' + (existing.customer_name || existing.name), timeout: 2600 })
|
|
} else {
|
|
const name = [lead.first.trim(), lead.last.trim()].filter(Boolean).join(' ') || (lead.email || lead.mobile)
|
|
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: friendlyError(e) }); submitting.value = false; return }
|
|
submitting.value = false
|
|
}
|
|
// Handoff : ProjectWizard (panier + rabais rab-x + devis + acceptation) prend le relais, pré-rempli.
|
|
// On transmet le forfait d'intérêt (facultatif) pour pré-remplir le panier — le client l'a déjà vu/choisi.
|
|
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 }, tier: chosen.tier ? { id: chosen.tier.id, code: chosen.tier.code, label: chosen.tier.label } : null })
|
|
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-tier { cursor: pointer; border-radius: 10px; transition: border-color .15s, background .15s, box-shadow .15s; }
|
|
.qw-tier:hover { border-color: #14b8a6; }
|
|
.qw-tier.sel { border-color: #0f766e; background: #f0fdfa; box-shadow: 0 0 0 1px #0f766e inset; }
|
|
.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>
|