Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
422 lines
16 KiB
Vue
422 lines
16 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<!-- Real-time outage alerts -->
|
|
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
|
|
|
|
<!-- KPI cards -->
|
|
<div class="row q-col-gutter-md q-mb-lg">
|
|
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
|
|
<div class="ops-card ops-stat">
|
|
<div class="row items-center no-wrap q-gutter-x-sm">
|
|
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
|
|
<div>
|
|
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
|
|
<div class="ops-stat-label">{{ stat.label }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin controls -->
|
|
<div class="row q-col-gutter-md q-mb-lg">
|
|
<div class="col-12 col-md-6">
|
|
<div class="ops-card">
|
|
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur</div>
|
|
<div class="row items-center q-gutter-md">
|
|
<q-chip :color="schedulerEnabled ? 'positive' : 'negative'" text-color="white" icon="schedule">
|
|
{{ schedulerEnabled ? 'Actif' : 'Désactivé' }}
|
|
</q-chip>
|
|
<q-btn
|
|
:label="schedulerEnabled ? 'Désactiver' : 'Activer'"
|
|
:color="schedulerEnabled ? 'negative' : 'positive'"
|
|
:icon="schedulerEnabled ? 'pause' : 'play_arrow'"
|
|
:loading="togglingScheduler"
|
|
dense no-caps
|
|
@click="toggleScheduler"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<div class="ops-card">
|
|
<div class="text-subtitle1 text-weight-bold q-mb-sm">Facturation récurrente</div>
|
|
|
|
<!-- Step indicators -->
|
|
<div class="row items-center q-gutter-x-sm q-mb-sm">
|
|
<q-chip :color="billingStep >= 1 ? 'primary' : 'grey-4'" text-color="white" dense size="sm" icon="receipt_long">1. Générer</q-chip>
|
|
<q-icon name="chevron_right" size="16px" color="grey-5" />
|
|
<q-chip :color="billingStep >= 2 ? 'amber-8' : 'grey-4'" text-color="white" dense size="sm" icon="publish">2. Soumettre</q-chip>
|
|
<q-icon name="chevron_right" size="16px" color="grey-5" />
|
|
<q-chip :color="billingStep >= 3 ? 'teal-7' : 'grey-4'" text-color="white" dense size="sm" icon="credit_score">3. PPA</q-chip>
|
|
</div>
|
|
|
|
<!-- Draft count indicator -->
|
|
<div v-if="draftInvoiceCount > 0" class="q-mb-sm">
|
|
<q-banner dense rounded class="bg-amber-1 text-amber-9" style="font-size:0.8rem">
|
|
<template #avatar><q-icon name="pending_actions" color="amber-8" /></template>
|
|
{{ draftInvoiceCount }} facture(s) en brouillon à soumettre
|
|
</q-banner>
|
|
</div>
|
|
|
|
<div class="row items-center q-gutter-sm">
|
|
<q-btn
|
|
label="Générer"
|
|
color="primary"
|
|
icon="receipt_long"
|
|
:loading="runningBilling"
|
|
dense no-caps
|
|
@click="runBilling"
|
|
>
|
|
<q-tooltip>Génère les factures récurrentes pour les abonnements actifs</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
label="Soumettre"
|
|
color="amber-8"
|
|
icon="publish"
|
|
:loading="submittingDrafts"
|
|
:disable="draftInvoiceCount === 0"
|
|
dense no-caps
|
|
@click="submitDrafts"
|
|
>
|
|
<q-tooltip>Soumet les {{ draftInvoiceCount }} facture(s) en brouillon</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
label="PPA"
|
|
color="teal-7"
|
|
icon="credit_score"
|
|
:loading="runningPPA"
|
|
dense no-caps
|
|
@click="runPPA"
|
|
>
|
|
<q-tooltip>Prélève automatiquement les cartes enregistrées</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
<div class="q-mt-xs q-gutter-xs" style="min-height:20px">
|
|
<span v-if="billingResult" class="text-caption" :class="billingResult.ok ? 'text-positive' : 'text-negative'">
|
|
{{ billingResult.message }}
|
|
</span>
|
|
<span v-if="submitResult" class="text-caption" :class="submitResult.ok ? 'text-positive' : 'text-negative'">
|
|
{{ submitResult.message }}
|
|
</span>
|
|
<span v-if="ppaResult" class="text-caption" :class="ppaResult.ok ? 'text-positive' : 'text-negative'">
|
|
{{ ppaResult.message }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent activity -->
|
|
<div class="row q-col-gutter-md">
|
|
<div class="col-12 col-md-6">
|
|
<div class="ops-card">
|
|
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
|
|
<q-list separator>
|
|
<q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push('/tickets')">
|
|
<q-item-section avatar style="min-width:32px">
|
|
<q-icon name="confirmation_number" size="20px"
|
|
:color="t.priority === 'Urgent' ? 'red' : t.priority === 'High' ? 'orange-8' : 'grey-5'" />
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ t.subject }}</q-item-label>
|
|
<q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<span class="ops-badge open">{{ t.priority }}</span>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="!openTickets.length">
|
|
<q-item-section>
|
|
<q-item-label caption>Aucun ticket ouvert</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<div class="ops-card">
|
|
<div class="text-subtitle1 text-weight-bold q-mb-sm">
|
|
Dispatch aujourd'hui
|
|
<q-badge v-if="todayJobs.length" :label="todayJobs.length" color="indigo-6" class="q-ml-sm" />
|
|
</div>
|
|
<q-list separator>
|
|
<q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/dispatch')">
|
|
<q-item-section avatar style="min-width:32px">
|
|
<q-icon :name="j.status === 'completed' ? 'check_circle' : j.assigned_tech ? 'person' : 'radio_button_unchecked'"
|
|
:color="j.status === 'completed' ? 'green-6' : j.assigned_tech ? 'blue-6' : 'grey-5'" size="20px" />
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ j.subject || j.name }}</q-item-label>
|
|
<q-item-label caption>
|
|
<span v-if="j.assigned_tech">{{ j.assigned_tech }}</span>
|
|
<span v-if="j.customer"> · {{ j.customer }}</span>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<span class="ops-badge" :class="j.status === 'completed' ? 'active' : j.status === 'assigned' ? 'draft' : 'closed'">{{ j.status }}</span>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="!todayJobs.length">
|
|
<q-item-section>
|
|
<q-item-label caption>Aucune tâche planifiée aujourd'hui</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { listDocs, countDocs } from 'src/api/erp'
|
|
import { authFetch } from 'src/api/auth'
|
|
import { BASE_URL } from 'src/config/erpnext'
|
|
import { HUB_SSE_URL } from 'src/config/dispatch'
|
|
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
|
|
|
|
const stats = ref([
|
|
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
|
|
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
|
|
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
|
|
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
|
|
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
|
|
])
|
|
|
|
const openTickets = ref([])
|
|
const todayJobs = ref([])
|
|
|
|
// Scheduler controls
|
|
const schedulerEnabled = ref(false)
|
|
const togglingScheduler = ref(false)
|
|
|
|
async function fetchSchedulerStatus () {
|
|
try {
|
|
const res = await authFetch(BASE_URL + '/api/method/scheduler_status')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
schedulerEnabled.value = data.message?.status === 'enabled'
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
async function toggleScheduler () {
|
|
togglingScheduler.value = true
|
|
try {
|
|
const res = await authFetch(BASE_URL + '/api/method/toggle_scheduler', { method: 'POST' })
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
schedulerEnabled.value = data.message?.status === 'enabled'
|
|
}
|
|
} catch {}
|
|
togglingScheduler.value = false
|
|
}
|
|
|
|
// ── Billing pipeline: Generate → Submit → PPA ──────────────────
|
|
const billingStep = ref(0) // 0=idle, 1=generated, 2=submitted, 3=ppa done
|
|
const runningBilling = ref(false)
|
|
const billingResult = ref(null)
|
|
const submittingDrafts = ref(false)
|
|
const submitResult = ref(null)
|
|
const runningPPA = ref(false)
|
|
const ppaResult = ref(null)
|
|
const draftInvoiceCount = ref(0)
|
|
|
|
async function refreshDraftCount () {
|
|
try {
|
|
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?doctype=Sales+Invoice&filters=' +
|
|
encodeURIComponent(JSON.stringify({ docstatus: 0, is_return: 0 })))
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
draftInvoiceCount.value = data.message || 0
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
async function runBilling () {
|
|
runningBilling.value = true
|
|
billingResult.value = null
|
|
submitResult.value = null
|
|
ppaResult.value = null
|
|
billingStep.value = 0
|
|
try {
|
|
// Create a Process Subscription doc (submitting it enqueues the background job)
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const res = await authFetch(BASE_URL + '/api/resource/Process Subscription', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ posting_date: today }),
|
|
})
|
|
if (!res.ok) throw new Error('Erreur création: ' + res.status)
|
|
const doc = (await res.json()).data
|
|
|
|
// Submit it to trigger the background job (must pass full doc with modified timestamp)
|
|
const submitRes = await authFetch(BASE_URL + '/api/method/frappe.client.submit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ doc: doc }),
|
|
})
|
|
if (!submitRes.ok) throw new Error('Erreur soumission: ' + submitRes.status)
|
|
|
|
// Poll background jobs until complete (max 60s)
|
|
billingResult.value = { ok: true, message: 'Génération en cours...' }
|
|
const startDrafts = draftInvoiceCount.value
|
|
let attempts = 0
|
|
while (attempts < 30) {
|
|
await new Promise(r => setTimeout(r, 2000))
|
|
await refreshDraftCount()
|
|
attempts++
|
|
// Check if background job finished by looking at job queue
|
|
const jobRes = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + new URLSearchParams({
|
|
doctype: 'RQ Job',
|
|
filters: JSON.stringify({ job_name: ['like', '%process_all%'], status: ['in', ['queued', 'started']] }),
|
|
limit_page_length: 1,
|
|
}))
|
|
if (jobRes.ok) {
|
|
const jobData = await jobRes.json()
|
|
const jobs = jobData.data || jobData.message || []
|
|
if (!jobs.length) break // no more running jobs
|
|
} else {
|
|
break // can't check, assume done
|
|
}
|
|
}
|
|
|
|
await refreshDraftCount()
|
|
const newDrafts = draftInvoiceCount.value - startDrafts
|
|
billingResult.value = { ok: true, message: `Génération terminée. ${newDrafts > 0 ? newDrafts + ' nouvelle(s) facture(s)' : 'Aucune nouvelle facture'}.` }
|
|
billingStep.value = 1
|
|
} catch (e) {
|
|
billingResult.value = { ok: false, message: e.message }
|
|
}
|
|
runningBilling.value = false
|
|
}
|
|
|
|
async function submitDrafts () {
|
|
submittingDrafts.value = true
|
|
submitResult.value = null
|
|
try {
|
|
// Get all draft invoices (docstatus=0, not credit notes)
|
|
const draftsRes = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + new URLSearchParams({
|
|
doctype: 'Sales Invoice',
|
|
filters: JSON.stringify({ docstatus: 0, is_return: 0 }),
|
|
fields: JSON.stringify(['name']),
|
|
limit_page_length: 500,
|
|
}))
|
|
if (!draftsRes.ok) throw new Error('Erreur lecture drafts: ' + draftsRes.status)
|
|
const drafts = (await draftsRes.json()).message || []
|
|
|
|
if (!drafts.length) {
|
|
submitResult.value = { ok: true, message: 'Aucune facture en brouillon.' }
|
|
submittingDrafts.value = false
|
|
return
|
|
}
|
|
|
|
// Submit each draft (must fetch full doc for modified timestamp)
|
|
let submitted = 0
|
|
let errors = 0
|
|
for (const d of drafts) {
|
|
try {
|
|
const docRes = await authFetch(BASE_URL + '/api/resource/Sales%20Invoice/' + encodeURIComponent(d.name))
|
|
if (!docRes.ok) { errors++; continue }
|
|
const fullDoc = (await docRes.json()).data
|
|
const r = await authFetch(BASE_URL + '/api/method/frappe.client.submit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ doc: fullDoc }),
|
|
})
|
|
if (r.ok) submitted++
|
|
else errors++
|
|
} catch {
|
|
errors++
|
|
}
|
|
}
|
|
|
|
await refreshDraftCount()
|
|
submitResult.value = {
|
|
ok: errors === 0,
|
|
message: `${submitted} facture(s) soumise(s)${errors ? ', ' + errors + ' erreur(s)' : ''}.`,
|
|
}
|
|
billingStep.value = 2
|
|
} catch (e) {
|
|
submitResult.value = { ok: false, message: e.message }
|
|
}
|
|
submittingDrafts.value = false
|
|
}
|
|
|
|
async function runPPA () {
|
|
runningPPA.value = true
|
|
ppaResult.value = null
|
|
try {
|
|
const res = await fetch(HUB_SSE_URL + '/payments/ppa-run', { method: 'POST' })
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const charged = data.charged?.length || 0
|
|
const failed = data.failed?.length || 0
|
|
const skipped = data.skipped?.length || 0
|
|
ppaResult.value = {
|
|
ok: true,
|
|
message: `PPA: ${charged} prélevé(s), ${failed} échoué(s), ${skipped} ignoré(s)`,
|
|
}
|
|
billingStep.value = 3
|
|
} else {
|
|
const err = await res.json().catch(() => ({}))
|
|
ppaResult.value = { ok: false, message: err.error || 'Erreur ' + res.status }
|
|
}
|
|
} catch (e) {
|
|
ppaResult.value = { ok: false, message: e.message }
|
|
}
|
|
runningPPA.value = false
|
|
}
|
|
|
|
onMounted(async () => {
|
|
fetchSchedulerStatus()
|
|
refreshDraftCount()
|
|
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
|
const [clients, tickets, todayDispatch, openTix] = await Promise.all([
|
|
countDocs('Customer', { disabled: 0 }),
|
|
countDocs('Issue', { status: 'Open' }),
|
|
listDocs('Dispatch Job', {
|
|
filters: { scheduled_date: today },
|
|
fields: ['name', 'subject', 'status', 'assigned_tech', 'customer', 'priority', 'source_issue'],
|
|
limit: 50, orderBy: 'start_time asc',
|
|
}).catch(() => []),
|
|
listDocs('Issue', {
|
|
filters: { status: 'Open' },
|
|
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
|
|
limit: 10, orderBy: 'opening_date desc',
|
|
}),
|
|
])
|
|
|
|
// Abonnés = unique customers with active subscriptions (via server script)
|
|
let abonnes = 0
|
|
let monthlyRev = 0
|
|
try {
|
|
const [subRes, revRes] = await Promise.all([
|
|
authFetch(BASE_URL + '/api/method/subscriber_count').then(r => r.ok ? r.json() : null).catch(() => null),
|
|
listDocs('Service Subscription', {
|
|
filters: { status: 'Actif' },
|
|
fields: ['actual_price'],
|
|
limit: 0,
|
|
}).catch(() => []),
|
|
])
|
|
abonnes = subRes?.message?.count || clients
|
|
monthlyRev = revRes.reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
|
|
} catch {
|
|
abonnes = clients
|
|
}
|
|
|
|
stats.value[0].value = abonnes.toLocaleString()
|
|
stats.value[1].value = clients.toLocaleString()
|
|
stats.value[2].value = monthlyRev ? (Math.round(monthlyRev).toLocaleString() + ' $') : '...'
|
|
stats.value[3].value = tickets.toLocaleString()
|
|
stats.value[4].value = todayDispatch.length.toLocaleString()
|
|
|
|
openTickets.value = openTix
|
|
todayJobs.value = todayDispatch
|
|
})
|
|
</script>
|