feat(ops): standardize "Do Stuff" + fiber parity with F device_view

Standardize the device-ops entry so users coming from F aren't lost:
- New shared DoStuffButton.vue (canonical "Do Stuff" label + icon): resolves
  the device by serial>service_location>customer and opens the ONE device view
  (EquipmentDetail). Reused in SubscriptionDetail (replaces bespoke button).
- DeviceStrip (fiche): tooltip hint + right-click "Do Stuff — ouvrir l'appareil"
  (works even for devices without ACS data, e.g. wireless), so the term is
  discoverable on the customer card too.

Bring the canonical device view to F's function set (device_view.php):
- On-demand "Do Stuff en direct (F)" fetch (/collab/dostuff) surfaces the fields
  the passive poller lacks: VoIP, IPTV, distance, WAN IP, line profile, firmware,
  mgmt GUI — reserved tech-3 XGS-PON, ~30 s, never auto (OLT load).
- "Copier pour le ticket" (F's copy button): plaintext device summary to clipboard.

Verified: SPA build clean, leak-check 0 secrets, deployed to /opt/ops-app
(index.969e330c.js live, 303508 B served, nginx.conf preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 13:59:28 -04:00
parent 77e4411ba2
commit 80fdb91591
4 changed files with 173 additions and 36 deletions

View File

@ -39,17 +39,27 @@
<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-separator dark class="q-my-xs" />
<div class="text-grey-4" style="font-size:0.7rem"><q-icon name="settings_input_antenna" size="11px" /> Clic : Do Stuff signal, actions, VoIP/IPTV</div>
</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>
<!-- Do Stuff toujours accessible (clic gauche ou ce menu) même pour un appareil sans données ACS (sans-fil). -->
<q-menu context-menu>
<q-list dense style="min-width:180px">
<q-item clickable v-close-popup @click="$emit('open-device', eq)">
<q-item-section avatar><q-icon name="settings_input_antenna" size="18px" color="deep-purple-6" /></q-item-section>
<q-item-section>Do Stuff ouvrir l'appareil</q-item-section>
</q-item>
<template v-if="getDevice(eq.serial_number)">
<q-separator />
<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>
</template>
</q-list>
</q-menu>
</div>

View File

@ -0,0 +1,59 @@
<template>
<!-- DoStuffButton entrée CANONIQUE « Do Stuff » (terme de F, zéro réapprentissage). Réutilisable partout
un service/abonnement/appareil est affiché : résout l'appareil (par série, sinon lieu de service, sinon
client) et OUVRE la vue équipement unique (EquipmentDetail), vivent signal/débit/actions/VoIP/IPTV.
Ouvre via la prop openModal si fournie, sinon émet 'navigate' (que la fiche/DetailModal parent gère). -->
<span class="dostuff-entry">
<q-btn dense no-caps :flat="flat" :unelevated="!flat" :size="size"
:color="flat ? 'deep-purple-7' : 'deep-purple-6'" icon="settings_input_antenna"
:loading="loading" :label="label" @click.stop="openDoStuff">
<q-tooltip>{{ tooltip }}</q-tooltip>
</q-btn>
<span v-if="msg" class="text-caption text-grey-6 q-ml-sm">{{ msg }}</span>
</span>
</template>
<script setup>
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs } from 'src/api/erp'
const props = defineProps({
// Cibles de résolution (au moins une) priorité : série exacte > lieu de service > client.
serial: { type: String, default: '' },
serviceLocation: { type: String, default: '' },
customer: { type: String, default: '' },
// Ouverture : si fournie, on appelle openModal(doctype, name, title) ; sinon on émet 'navigate'.
openModal: { type: Function, default: null },
label: { type: String, default: 'Do Stuff' },
tooltip: { type: String, default: "Ouvre l'appareil : signal, débit, VoIP/IPTV et actions réseau (comme « Do Stuff » de F)" },
size: { type: String, default: '' },
flat: { type: Boolean, default: false },
})
const emit = defineEmits(['navigate'])
const $q = useQuasar()
const loading = ref(false)
const msg = ref('')
// Résout l'appareil du service et ouvre sa vue équipement (source unique = Service Equipment).
async function openDoStuff () {
loading.value = true; msg.value = ''
try {
let eqs = []
const fields = ['name', 'equipment_type', 'status', 'serial_number']
if (props.serial) eqs = await listDocs('Service Equipment', { filters: { serial_number: props.serial }, fields, limit: 5 })
if (!eqs.length && props.serviceLocation) eqs = await listDocs('Service Equipment', { filters: { service_location: props.serviceLocation }, fields, limit: 10 })
if (!eqs.length && props.customer) eqs = await listDocs('Service Equipment', { filters: { customer: props.customer }, fields, limit: 10 })
if (!eqs.length) { msg.value = 'Aucun appareil lié'; return }
// Préfère un appareil actif portant une série (l'ONU/le modem principal).
const active = eqs.filter(e => String(e.status || '').toLowerCase() !== 'retiré')
const pick = active.find(e => e.serial_number) || active[0] || eqs.find(e => e.serial_number) || eqs[0]
const title = (pick.equipment_type || 'Appareil') + (pick.serial_number ? ' — ' + pick.serial_number : '')
if (props.openModal) props.openModal('Service Equipment', pick.name, title)
else emit('navigate', 'Service Equipment', pick.name, title)
} catch (e) {
msg.value = ''
$q.notify({ type: 'negative', message: 'Do Stuff : ' + (e.message || e) })
} finally { loading.value = false }
}
</script>

View File

@ -65,6 +65,38 @@
</div>
</div>
<!-- Do Stuff en direct (F) : interroge l'OLT via n8n (comme F) remonte VoIP / IPTV / distance / WAN / profil de
ligne que le poller passif n'expose pas. À la demande (~30 s). + « Copier pour le ticket » (bouton copy de F). -->
<div v-if="device && doc.serial_number && doc.olt_ip" class="dsf">
<div class="dsf-bar">
<q-btn v-if="!liveF && !liveFLoading" dense flat no-caps size="sm" color="deep-purple-6" icon="bolt"
label="Do Stuff en direct (F)" @click="fetchLiveF">
<q-tooltip>Interroge l'OLT en direct (VoIP, IPTV, distance, WAN, profil) ~30 s, comme F</q-tooltip>
</q-btn>
<div v-if="liveFLoading" class="text-caption text-deep-purple-6"><q-spinner-dots size="18px" /> Interrogation de l'OLT (~30 s)</div>
<template v-if="liveF && !liveFLoading">
<span class="dsf-title"><q-icon :name="liveF.ok ? (liveF.online ? 'check_circle' : 'cancel') : 'info'" :color="liveF.ok ? (liveF.online ? 'green-6' : 'red-6') : 'grey-5'" size="15px" /> Direct (F)<span v-if="!liveF.ok" class="text-grey-6 q-ml-xs"> {{ liveF.error }}</span></span>
<q-btn dense flat round size="xs" icon="refresh" color="grey-6" @click="fetchLiveF"><q-tooltip>Réinterroger</q-tooltip></q-btn>
</template>
<q-space />
<q-btn dense flat no-caps size="sm" color="grey-7" icon="content_copy" label="Copier pour le ticket" @click="copyForTicket">
<q-tooltip>Copie un résumé de l'appareil à coller dans un ticket comme F</q-tooltip>
</q-btn>
</div>
<div v-if="liveF && liveF.ok" class="dsf-tiles">
<span v-if="liveF.rxPower != null" class="dsf-chip"><q-icon name="network_check" size="12px" /> {{ liveF.signal }} · Rx {{ liveF.rxPower }}<span v-if="liveF.txPower != null"> / Tx {{ liveF.txPower }}</span> dBm</span>
<span v-if="liveF.distance != null" class="dsf-chip"><q-icon name="straighten" size="12px" /> {{ liveF.distance }} m</span>
<span v-if="liveF.voip" class="dsf-chip"><q-icon name="call" size="12px" /> VoIP {{ liveF.voip }}</span>
<span v-if="liveF.iptv" class="dsf-chip"><q-icon name="live_tv" size="12px" /> IPTV {{ liveF.iptv }}</span>
<span v-if="liveF.wanIp" class="dsf-chip"><q-icon name="public" size="12px" /> WAN {{ liveF.wanIp }}</span>
<span v-if="liveF.profileId" class="dsf-chip"><q-icon name="speed" size="12px" /> Profil {{ liveF.profileId }}</span>
<span v-if="liveF.uptime" class="dsf-chip"><q-icon name="schedule" size="12px" /> {{ liveF.uptime }}</span>
<span v-if="liveF.lastDown" class="dsf-chip dsf-warn"><q-icon name="history" size="12px" /> {{ liveF.lastDown }}</span>
<span v-if="liveF.firmware" class="dsf-chip"><q-icon name="memory" size="12px" /> {{ liveF.firmware }}</span>
<a v-if="liveF.managementIp" :href="'https://' + liveF.managementIp + '/'" target="_blank" class="dsf-chip dsf-link"><q-icon name="router" size="12px" /> GUI {{ liveF.managementIp }}</a>
</div>
</div>
<!-- Identité de l'appareil (repliée par défaut) -->
<div class="collapse-head" @click="showIdentity = !showIdentity"><q-icon :name="showIdentity ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Identité de l'appareil</div>
<div v-show="showIdentity || !device" class="modal-field-grid">
@ -532,6 +564,55 @@ const refreshing = ref(false)
const deleting = ref(false)
const portCtx = ref(null)
// Do Stuff en direct (F) : /collab/dostuff interroge l'OLT via n8n (VoIP/IPTV/distance/WAN/profil/firmware).
// ~30 s, à la demande (charge l'OLT) jamais auto à l'ouverture. Réservé tech-3 XGS-PON (sinon « OLT non reconnue »).
const liveF = ref(null); const liveFLoading = ref(false)
async function fetchLiveF () {
const sn = props.doc.serial_number, olt = props.doc.olt_ip
if (!sn || !olt) return
liveF.value = null; liveFLoading.value = true
try {
const r = await fetch(`${HUB_URL}/collab/dostuff?serial=${encodeURIComponent(sn)}&olt=${encodeURIComponent(olt)}`)
const res = await r.json()
if (!res.ok && res.recognized === false) res.error = 'OLT suivie par notre sonde (données ci-dessus) — direct réservé aux OLT XGS-PON (tech 3)'
liveF.value = res
} catch (e) { liveF.value = { ok: false, error: e.message } } finally { liveFLoading.value = false }
}
// « Copier pour le ticket » (bouton copy de F) : résumé plaintext de l'appareil, collable tel quel dans un ticket.
function copyForTicket () {
const d = props.doc, dev = device.value || {}, lf = liveF.value, st = status.value
const L = []
const desc = [d.equipment_type, d.brand, d.model].filter(Boolean).join(' ')
L.push('Appareil : ' + (desc || '—'))
if (d.serial_number) L.push('SN : ' + d.serial_number)
if (d.mac_address) L.push('MAC : ' + d.mac_address)
L.push('Statut : ' + (st.online === true ? 'En ligne' : st.online === false ? 'Hors ligne' : '?') + (st.label ? ' (' + st.label + ')' : ''))
const rx = (lf && lf.rxPower != null) ? lf.rxPower : dev.rxPower
const tx = (lf && lf.txPower != null) ? lf.txPower : dev.txPower
if (rx != null && rx !== 0) L.push('Signal : Rx ' + rx + (tx != null && tx !== 0 ? ' / Tx ' + tx : '') + ' dBm')
if (lf && lf.distance != null) L.push('Distance : ' + lf.distance + ' m')
if (d.olt_ip) L.push('OLT : ' + (d.olt_name || d.olt_ip) + ' — ' + [d.olt_slot, d.olt_port, d.olt_ontid].filter(x => x != null).join('/'))
const mgmt = (lf && lf.managementIp) || managementIp.value
if (mgmt) L.push('Gestion : https://' + mgmt + '/')
const wan = (lf && lf.wanIp) || dev.ip
if (wan) L.push('WAN IP : ' + wan)
if (lf && lf.voip) L.push('VoIP : ' + lf.voip)
if (lf && lf.iptv) L.push('IPTV : ' + lf.iptv)
if (lf && lf.profileId) L.push('Profil ligne : ' + lf.profileId)
const fw = (lf && lf.firmware) || dev.firmware
if (fw) L.push('Firmware : ' + fw)
if (dev.uptime) L.push('Uptime : ' + formatUptime(dev.uptime))
const txt = L.join('\n')
const done = () => $q.notify({ type: 'positive', message: 'Résumé copié — collable dans un ticket', timeout: 1500 })
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => fallbackCopy(txt, done))
else fallbackCopy(txt, done)
}
function fallbackCopy (txt, done) {
try { const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done() }
catch (e) { $q.notify({ type: 'negative', message: 'Copie impossible' }) }
}
const managementIp = computed(() => {
const iface = device.value?.interfaces?.find(i => i.role === 'management')
return iface?.ip || null
@ -910,6 +991,14 @@ onUnmounted(() => stopWanLive())
.wan-tick { flex: 1; min-width: 2px; height: 100%; border-radius: 1px; opacity: .85; }
.dv-wan-btn { float: right; margin-top: -2px; }
.wan-spark { width: 100%; height: 38px; display: block; margin-top: 4px; }
.dsf { margin: 8px 0 10px; padding: 8px 10px; border: 1px solid #ede9fe; background: #faf5ff; border-radius: 8px; }
.dsf-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.dsf-title { font-size: 0.76rem; font-weight: 600; color: #6b21a8; display: flex; align-items: center; gap: 4px; }
.dsf-tiles { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.dsf-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 0.74rem; color: #334155; background: #fff; border: 1px solid #e2e8f0; border-radius: 5px; padding: 2px 7px; }
.dsf-chip.dsf-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.dsf-link { color: #1e40af; text-decoration: none; }
.dsf-link:hover { text-decoration: underline; }
.collapse-head { display: flex; align-items: center; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #475569; padding: 6px 2px; border-top: 1px solid #f1f5f9; user-select: none; }
.collapse-head:hover { color: #1e293b; }
.diag-section { margin-top: 8px; }

View File

@ -1,10 +1,8 @@
<template>
<!-- Accès DIRECT au « Do Stuff » de l'appareil du service (signal, débit WAN en direct, actions) comme le bouton
« Do Stuff » de F. Résout l'appareil du lieu de service (sinon du client) et ouvre sa vue équipement. -->
<!-- Entrée « Do Stuff » CANONIQUE (composant partagé) même bouton partout : ouvre la vue équipement unique. -->
<div class="q-mb-sm">
<q-btn dense no-caps unelevated color="deep-purple-6" icon="settings_input_antenna"
:loading="dsLoading" label="Do Stuff — appareil" @click="openDoStuff" />
<span v-if="dsMsg" class="text-caption text-grey-6 q-ml-sm">{{ dsMsg }}</span>
<DoStuffButton :service-location="doc.service_location" :customer="doc.customer"
@navigate="(dt, name, t) => $emit('navigate', dt, name, t)" />
</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="subStatusClass(doc.status)">{{ doc.status }}</span></div>
@ -48,32 +46,13 @@
</template>
<script setup>
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs } from 'src/api/erp'
import DoStuffButton from 'src/components/shared/DoStuffButton.vue'
import { formatDate, formatMoney } from 'src/composables/useFormatters'
import { subStatusClass } from 'src/composables/useStatusClasses'
const props = defineProps({
defineProps({
doc: { type: Object, required: true },
docName: String,
})
const emit = defineEmits(['save-field', 'toggle-recurring', 'navigate'])
const $q = useQuasar()
const dsLoading = ref(false); const dsMsg = ref('')
// « Do Stuff » : résout l'appareil du service (par lieu de service, sinon par client) et ouvre sa vue équipement
// (EquipmentDetail = signal Rx/Tx, débit WAN en direct, actions). navigate openModal du parent (fiche).
async function openDoStuff () {
dsLoading.value = true; dsMsg.value = ''
try {
let eqs = []
if (props.doc.service_location) eqs = await listDocs('Service Equipment', { filters: { service_location: props.doc.service_location }, fields: ['name', 'equipment_type', 'status', 'serial_number'], limit: 10 })
if (!eqs.length && props.doc.customer) eqs = await listDocs('Service Equipment', { filters: { customer: props.doc.customer }, fields: ['name', 'equipment_type', 'status', 'serial_number'], limit: 10 })
if (!eqs.length) { dsMsg.value = 'Aucun appareil lié à ce service'; return }
const active = eqs.filter(e => String(e.status || '').toLowerCase() !== 'retiré')
const pick = active.find(e => e.serial_number) || active[0] || eqs.find(e => e.serial_number) || eqs[0]
emit('navigate', 'Service Equipment', pick.name)
} catch (e) { dsMsg.value = ''; $q.notify({ type: 'negative', message: 'Do Stuff : ' + (e.message || e) }) } finally { dsLoading.value = false }
}
defineEmits(['save-field', 'toggle-recurring', 'navigate'])
</script>