refactor(ops): extract DeviceStrip from ClientDetailPage

T7 découpage géants — sort le bandeau d'appareils (chips ONT/routeur +
menu Ajouter) dans components/customer/DeviceStrip.vue. Consomme le
composable singleton useDeviceStatus (couche données ACS/OLT partagée) ;
les helpers de présentation propres aux appareils (signalColor,
formatTimeAgo, doReboot, doRefreshParams) déménagent DANS le composant
(utilisés nulle part ailleurs). Le parent émet open-device + add-*.
ClientDetailPage -108 lignes ; imports/destructures device retirés.

Vérifié en local sur hub LIVE (C-LPB4) : chip vert + point acs-online
réel, tooltip ACS complet (SN TPLGC4160688, OLT oltRem03 1/3/0, En ligne
il y a 4m, Fibre Up, 17 clients WiFi, FW/WAN IP/SSID), 0 warning console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 21:19:37 -04:00
parent b9df6d720a
commit dc28437d5f
2 changed files with 174 additions and 121 deletions

View File

@ -0,0 +1,161 @@
<template>
<div class="device-strip">
<div v-for="eq in equipment" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)"
@click="$emit('open-device', eq)">
<q-icon :name="deviceLucideIcon(eq.equipment_type)" size="20px" />
<span v-if="isOnline(eq.serial_number) === true" class="acs-dot acs-online" />
<span v-else-if="isOnline(eq.serial_number) === false" class="acs-dot acs-offline" />
<span v-else-if="eq.serial_number && !getDevice(eq.serial_number)" class="acs-dot acs-probing" />
<q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]" style="max-width:320px">
<div><strong>{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}</strong></div>
<div>SN: {{ eq.serial_number }}</div>
<template v-if="eq.mac_address"><div>MAC: {{ eq.mac_address }}</div></template>
<template v-if="eq.olt_name"><div>OLT: {{ eq.olt_name }} Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}</div></template>
<template v-else-if="eq.ip_address"><div>IP: {{ eq.ip_address }}</div></template>
<div>{{ eq.status }}</div>
<template v-if="getDevice(eq.serial_number) || combinedStatus(eq.serial_number).source !== 'unknown'">
<q-separator dark class="q-my-xs" />
<div :style="{ color: combinedStatus(eq.serial_number).online ? '#4ade80' : '#f87171' }">
{{ combinedStatus(eq.serial_number).online ? '● En ligne' : '● Hors ligne' }}
<template v-if="getDevice(eq.serial_number)?.lastInform"> {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}</template>
</div>
<div class="text-grey-4" style="font-size:0.7rem">{{ combinedStatus(eq.serial_number).detail }}</div>
<template v-if="getDevice(eq.serial_number)?.opticalStatus">
<div>Fibre: <span :style="{ color: getDevice(eq.serial_number).opticalStatus === 'Up' ? '#4ade80' : '#f87171' }">{{ getDevice(eq.serial_number).opticalStatus }}</span></div>
</template>
<template v-if="getDevice(eq.serial_number).rxPower != null && getDevice(eq.serial_number).rxPower !== 0">
<div :style="{ color: signalColor(eq.serial_number) }">
Rx: {{ getDevice(eq.serial_number).rxPower }} dBm
<template v-if="getDevice(eq.serial_number).txPower != null && getDevice(eq.serial_number).txPower !== 0"> / Tx: {{ getDevice(eq.serial_number).txPower }} dBm</template>
</div>
</template>
<template v-if="getDevice(eq.serial_number).wifi">
<div v-if="getDevice(eq.serial_number).wifi.totalClients != null || getDevice(eq.serial_number).wifi.radio1?.clients != null">
WiFi: {{ getDevice(eq.serial_number).wifi.totalClients ?? ((getDevice(eq.serial_number).wifi.radio1?.clients || 0) + (getDevice(eq.serial_number).wifi.radio2?.clients || 0) + (getDevice(eq.serial_number).wifi.radio3?.clients || 0)) }} clients
<span v-if="getDevice(eq.serial_number).wifi.meshClients > 0">({{ getDevice(eq.serial_number).wifi.meshClients }} via mesh)</span>
</div>
</template>
<template v-if="getDevice(eq.serial_number).firmware"><div>FW: {{ getDevice(eq.serial_number).firmware }}</div></template>
<template v-if="getDevice(eq.serial_number).ip"><div>WAN IP: {{ getDevice(eq.serial_number).ip }}</div></template>
<template v-if="getDevice(eq.serial_number).ssid"><div>SSID: {{ getDevice(eq.serial_number).ssid }}</div></template>
</template>
</q-tooltip>
<q-menu context-menu v-if="getDevice(eq.serial_number)">
<q-list dense style="min-width:160px">
<q-item clickable v-close-popup @click="doReboot(eq)">
<q-item-section avatar><q-icon name="restart_alt" size="18px" /></q-item-section>
<q-item-section>Redémarrer</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="doRefreshParams(eq)">
<q-item-section avatar><q-icon name="sync" size="18px" /></q-item-section>
<q-item-section>Rafraîchir params</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
<div class="device-icon-chip device-add-chip">
<q-icon name="add" size="20px" />
<q-tooltip>Ajouter</q-tooltip>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<q-list dense style="min-width:240px">
<q-item clickable v-close-popup @click="$emit('add-submission')">
<q-item-section avatar><q-icon name="description" size="18px" color="primary" /></q-item-section>
<q-item-section>
<q-item-label>Nouvelle soumission</q-item-label>
<q-item-label caption>Wizard avec étapes dispatchables</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="$emit('add-service')">
<q-item-section avatar><q-icon name="wifi" size="18px" color="info" /></q-item-section>
<q-item-section>Forfait / Service (direct)</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="$emit('add-rebate')">
<q-item-section avatar><q-icon name="sell" size="18px" color="negative" /></q-item-section>
<q-item-section>Rabais / Crédit</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="$emit('add-equipment')">
<q-item-section avatar><q-icon name="router" size="18px" color="positive" /></q-item-section>
<q-item-section>Équipement</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
</div>
</template>
<script setup>
import { Notify } from 'quasar'
import { deviceColorClass } from 'src/composables/useStatusClasses'
import { deviceLucideIcon } from 'src/config/device-icons'
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
defineProps({
equipment: { type: Array, default: () => [] },
})
defineEmits(['open-device', 'add-submission', 'add-service', 'add-rebate', 'add-equipment'])
// useDeviceStatus is a singleton composable shares the live GenieACS/OLT
// device map that ClientDetailPage populates via fetchStatus/fetchOltStatus.
const { getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams, fetchStatus } = useDeviceStatus()
// Device-only presentation/action helpers (used nowhere else)
function signalColor (serial) {
const q = signalQuality(serial)
return q === 'excellent' ? '#4ade80' : q === 'good' ? '#a3e635' : q === 'fair' ? '#fbbf24' : '#f87171'
}
function formatTimeAgo (dateStr) {
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'à l\'instant'
if (mins < 60) return `il y a ${mins}m`
const hrs = Math.floor(mins / 60)
return hrs < 24 ? `il y a ${hrs}h` : `il y a ${Math.floor(hrs / 24)}j`
}
async function doReboot (eq) {
try {
await rebootDevice(eq.serial_number)
Notify.create({ type: 'positive', message: `Redémarrage envoyé: ${eq.serial_number}`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
}
}
async function doRefreshParams (eq) {
try {
await refreshDeviceParams(eq.serial_number)
Notify.create({ type: 'info', message: `Rafraîchissement lancé: ${eq.serial_number}`, position: 'top' })
setTimeout(() => fetchStatus([eq]), 3000)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
}
}
</script>
<style lang="scss" scoped>
.device-strip { display: flex; flex-wrap: wrap; gap: 6px; }
.device-icon-chip {
display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; transition: transform 0.15s; position: relative;
&:hover { transform: scale(1.1); }
&.small { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
&.dev-green { background: #dcfce7; color: #16a34a; }
&.dev-red { background: #fee2e2; color: #dc2626; }
&.dev-grey { background: #f1f5f9; color: #94a3b8; }
}
.acs-dot { position: absolute; top: 2px; right: 2px; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid #fff; }
.acs-online { background: #22c55e; box-shadow: 0 0 4px #22c55e88; }
.acs-offline { background: #ef4444; box-shadow: 0 0 4px #ef444488; }
.acs-probing { background: #f59e0b; animation: acs-pulse 1.2s ease-in-out infinite; }
@keyframes acs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #f59e0b88; }
50% { opacity: 0.3; box-shadow: 0 0 8px #f59e0bcc; }
}
.device-add-chip {
background: #f1f5f9; color: #94a3b8; border: 2px dashed #cbd5e1;
&:hover { border-color: #6366f1; color: #6366f1; background: #eef2ff; }
}
</style>

View File

@ -123,90 +123,14 @@
&middot; <InlineField :value="loc.network_id" field="network_id" doctype="Service Location" :docname="loc.name"
placeholder="Network ID" @saved="v => loc.network_id = v.value" />
</div>
<div class="device-strip">
<div v-for="eq in locEquip(loc.name)" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)"
@click="openModal('Service Equipment', eq.name, eq.equipment_type + (eq.brand ? ' — ' + eq.brand : ''))">
<q-icon :name="deviceLucideIcon(eq.equipment_type)" size="20px" />
<span v-if="isOnline(eq.serial_number) === true" class="acs-dot acs-online" />
<span v-else-if="isOnline(eq.serial_number) === false" class="acs-dot acs-offline" />
<span v-else-if="eq.serial_number && !getDevice(eq.serial_number)" class="acs-dot acs-probing" />
<q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]" style="max-width:320px">
<div><strong>{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}</strong></div>
<div>SN: {{ eq.serial_number }}</div>
<template v-if="eq.mac_address"><div>MAC: {{ eq.mac_address }}</div></template>
<template v-if="eq.olt_name"><div>OLT: {{ eq.olt_name }} Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}</div></template>
<template v-else-if="eq.ip_address"><div>IP: {{ eq.ip_address }}</div></template>
<div>{{ eq.status }}</div>
<template v-if="getDevice(eq.serial_number) || combinedStatus(eq.serial_number).source !== 'unknown'">
<q-separator dark class="q-my-xs" />
<div :style="{ color: combinedStatus(eq.serial_number).online ? '#4ade80' : '#f87171' }">
{{ combinedStatus(eq.serial_number).online ? '● En ligne' : '● Hors ligne' }}
<template v-if="getDevice(eq.serial_number)?.lastInform"> {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}</template>
</div>
<div class="text-grey-4" style="font-size:0.7rem">{{ combinedStatus(eq.serial_number).detail }}</div>
<template v-if="getDevice(eq.serial_number)?.opticalStatus">
<div>Fibre: <span :style="{ color: getDevice(eq.serial_number).opticalStatus === 'Up' ? '#4ade80' : '#f87171' }">{{ getDevice(eq.serial_number).opticalStatus }}</span></div>
</template>
<template v-if="getDevice(eq.serial_number).rxPower != null && getDevice(eq.serial_number).rxPower !== 0">
<div :style="{ color: signalColor(eq.serial_number) }">
Rx: {{ getDevice(eq.serial_number).rxPower }} dBm
<template v-if="getDevice(eq.serial_number).txPower != null && getDevice(eq.serial_number).txPower !== 0"> / Tx: {{ getDevice(eq.serial_number).txPower }} dBm</template>
</div>
</template>
<template v-if="getDevice(eq.serial_number).wifi">
<div v-if="getDevice(eq.serial_number).wifi.totalClients != null || getDevice(eq.serial_number).wifi.radio1?.clients != null">
WiFi: {{ getDevice(eq.serial_number).wifi.totalClients ?? ((getDevice(eq.serial_number).wifi.radio1?.clients || 0) + (getDevice(eq.serial_number).wifi.radio2?.clients || 0) + (getDevice(eq.serial_number).wifi.radio3?.clients || 0)) }} clients
<span v-if="getDevice(eq.serial_number).wifi.meshClients > 0">({{ getDevice(eq.serial_number).wifi.meshClients }} via mesh)</span>
</div>
</template>
<template v-if="getDevice(eq.serial_number).firmware"><div>FW: {{ getDevice(eq.serial_number).firmware }}</div></template>
<template v-if="getDevice(eq.serial_number).ip"><div>WAN IP: {{ getDevice(eq.serial_number).ip }}</div></template>
<template v-if="getDevice(eq.serial_number).ssid"><div>SSID: {{ getDevice(eq.serial_number).ssid }}</div></template>
</template>
</q-tooltip>
<q-menu context-menu v-if="getDevice(eq.serial_number)">
<q-list dense style="min-width:160px">
<q-item clickable v-close-popup @click="doReboot(eq)">
<q-item-section avatar><q-icon name="restart_alt" size="18px" /></q-item-section>
<q-item-section>Redémarrer</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="doRefreshParams(eq)">
<q-item-section avatar><q-icon name="sync" size="18px" /></q-item-section>
<q-item-section>Rafraîchir params</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
<div class="device-icon-chip device-add-chip">
<q-icon name="add" size="20px" />
<q-tooltip>Ajouter</q-tooltip>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<q-list dense style="min-width:240px">
<q-item clickable v-close-popup @click="openWizardForAddress(loc)">
<q-item-section avatar><q-icon name="description" size="18px" color="primary" /></q-item-section>
<q-item-section>
<q-item-label>Nouvelle soumission</q-item-label>
<q-item-label caption>Wizard avec étapes dispatchables</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="openAddService(loc)">
<q-item-section avatar><q-icon name="wifi" size="18px" color="info" /></q-item-section>
<q-item-section>Forfait / Service (direct)</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
<q-item-section avatar><q-icon name="sell" size="18px" color="negative" /></q-item-section>
<q-item-section>Rabais / Crédit</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="openAddEquipment(loc)">
<q-item-section avatar><q-icon name="router" size="18px" color="positive" /></q-item-section>
<q-item-section>Équipement</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
</div>
<DeviceStrip
:equipment="locEquip(loc.name)"
@open-device="eq => openModal('Service Equipment', eq.name, eq.equipment_type + (eq.brand ? ' — ' + eq.brand : ''))"
@add-submission="openWizardForAddress(loc)"
@add-service="openAddService(loc)"
@add-rebate="openAddService(loc, 'rabais')"
@add-equipment="openAddEquipment(loc)"
/>
</div>
<div class="info-block subs-block">
@ -1137,14 +1061,13 @@ import { authFetch } from 'src/api/auth'
import { BASE_URL, ERP_DESK_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { formatDate, formatDateTime, formatMoney, staffColor, staffInitials, noteAuthorName } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
import { locStatusClass, ticketStatusClass, invStatusClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal'
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
import { useConversations } from 'src/composables/useConversations'
import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols, serviceContractCols } from 'src/config/table-columns'
import { deviceLucideIcon } from 'src/config/device-icons'
import DetailModal from 'src/components/shared/DetailModal.vue'
import DataTable from 'src/components/shared/DataTable.vue'
import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
@ -1166,6 +1089,7 @@ import { locInlineFields, equipTypeOptions, equipStatusOptions, defaultSectionsO
import { useUserPrefs } from 'src/composables/useUserPrefs'
import { erpPdfUrl } from 'src/utils/erp-pdf'
import RewardDialog from 'src/components/customer/RewardDialog.vue'
import DeviceStrip from 'src/components/customer/DeviceStrip.vue'
const $q = useQuasar()
const { can } = usePermissions()
@ -1176,7 +1100,9 @@ const ticketsExpanded = ref(false)
const invoicesExpanded = ref(false)
const paymentsExpanded = ref(false)
const { fetchStatus, fetchOltStatus, getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams } = useDeviceStatus()
// Device status still needed at the page level to seed the shared map (fetchStatus/fetchOltStatus
// passed to useClientData); DeviceStrip consumes the same singleton composable for display.
const { fetchStatus, fetchOltStatus } = useDeviceStatus()
const {
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
@ -1509,40 +1435,6 @@ async function confirmDeleteSub (sub) {
function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }
function formatTimeAgo (dateStr) {
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'à l\'instant'
if (mins < 60) return `il y a ${mins}m`
const hrs = Math.floor(mins / 60)
return hrs < 24 ? `il y a ${hrs}h` : `il y a ${Math.floor(hrs / 24)}j`
}
function signalColor (serial) {
const q = signalQuality(serial)
return q === 'excellent' ? '#4ade80' : q === 'good' ? '#a3e635' : q === 'fair' ? '#fbbf24' : '#f87171'
}
async function doReboot (eq) {
try {
await rebootDevice(eq.serial_number)
Notify.create({ type: 'positive', message: `Redémarrage envoyé: ${eq.serial_number}`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
}
}
async function doRefreshParams (eq) {
try {
await refreshDeviceParams(eq.serial_number)
Notify.create({ type: 'info', message: `Rafraîchissement lancé: ${eq.serial_number}`, position: 'top' })
setTimeout(() => fetchStatus([eq]), 3000)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
}
}
const locCollapsed = reactive({})
const locHasSubs = (locName) => subscriptions.value.some(s => s.service_location === locName)
const toggleLocCollapse = (locName) => { locCollapsed[locName] = locCollapsed[locName] === false }