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:
parent
77e4411ba2
commit
80fdb91591
|
|
@ -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>
|
||||
|
|
|
|||
59
apps/ops/src/components/shared/DoStuffButton.vue
Normal file
59
apps/ops/src/components/shared/DoStuffButton.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<!-- DoStuffButton — entrée CANONIQUE « Do Stuff » (terme de F, zéro réapprentissage). Réutilisable partout où
|
||||
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), où 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>
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user