Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
22 KiB
Vue
317 lines
22 KiB
Vue
<template>
|
|
<q-page class="q-pa-md" style="background:#f7f8fa">
|
|
<div class="row items-center q-mb-md">
|
|
<div>
|
|
<div class="text-h6 text-weight-bold">Sync F ↔ ERPNext <span class="text-grey-6 text-subtitle2">— tableau de bord</span></div>
|
|
<div class="text-caption text-grey-7">Moteurs modulaires (un par domaine), orchestration + vue unifiées. F autoritaire ; ERPNext reflète F.</div>
|
|
</div>
|
|
<q-space />
|
|
<q-btn outline color="grey-7" icon="confirmation_number" label="Rafraîchir tickets" :disable="syncing" no-caps class="q-mr-sm" @click="startSync('tickets')"><q-tooltip>Tirer les tickets legacy → Dispatch (lourd ~1 min ; sinon auto aux 15 min + sync d'assignation live en Planification)</q-tooltip></q-btn>
|
|
<q-btn unelevated color="teal-7" icon="sync" label="Synchroniser maintenant" :loading="syncing" no-caps class="q-mr-sm" @click="startSync('core')"><q-tooltip>Comptes + paiements + soldes, tout de suite (~30 s). Factures/services : cron horaire.</q-tooltip></q-btn>
|
|
<q-btn flat color="primary" icon="refresh" label="Recalculer" :loading="loading || sLoading" @click="reload" no-caps />
|
|
</div>
|
|
|
|
<!-- Progression de la synchronisation manuelle -->
|
|
<q-card v-if="syncRun" flat bordered class="q-mb-md" :class="syncRun.running ? 'bg-blue-1' : 'bg-grey-2'">
|
|
<q-card-section class="q-py-sm">
|
|
<div class="row items-center q-mb-xs">
|
|
<q-icon :name="syncRun.running ? 'sync' : 'check_circle'" :color="syncRun.running ? 'blue-7' : 'green-7'" :class="{ spin: syncRun.running }" class="q-mr-sm" />
|
|
<span class="text-weight-medium">{{ syncRun.running ? 'Synchronisation en cours…' : 'Synchronisation terminée' }}</span>
|
|
<span class="text-caption text-grey-7 q-ml-sm">{{ syncRun.elapsed_s }} s · {{ syncRun.done }}/{{ syncRun.total }}</span>
|
|
<q-space />
|
|
<q-btn v-if="!syncRun.running" flat dense round size="sm" icon="close" @click="syncRun = null" />
|
|
</div>
|
|
<q-linear-progress :value="syncRun.total ? syncRun.done / syncRun.total : 0" :indeterminate="syncRun.running && !syncRun.done" color="teal" rounded size="7px" class="q-mb-sm" />
|
|
<div class="row q-col-gutter-md">
|
|
<div v-for="s in syncRun.steps" :key="s.key" class="col-auto row items-center">
|
|
<q-icon :name="stepIcon(s.status)" :color="stepColor(s.status)" size="16px" :class="{ spin: s.status === 'running' }" class="q-mr-xs" />
|
|
<span class="text-caption">{{ s.label }}<span v-if="s.detail" class="text-grey-7"> — {{ s.detail }}</span></span>
|
|
</div>
|
|
</div>
|
|
<div v-if="syncRun.note" class="text-caption text-grey-6 q-mt-xs"><q-icon name="info" size="13px" /> {{ syncRun.note }}</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- ── BANDE UNIFIÉE : tous les domaines en ordre de dépendance (DAG) ── -->
|
|
<q-card flat bordered class="q-mb-md">
|
|
<q-card-section class="row items-center q-py-sm">
|
|
<div class="text-weight-bold">État par domaine</div>
|
|
<span class="text-caption text-grey-6 q-ml-sm">— ordre d'exécution = dépendances (un module ne tourne jamais avant ses prérequis)</span>
|
|
<q-space />
|
|
<template v-if="health.healthy != null">
|
|
<q-badge v-if="health.healthy" color="green-2" text-color="green-9"><q-icon name="check_circle" size="13px" class="q-mr-xs" />facturation à jour</q-badge>
|
|
<q-badge v-else color="orange-2" text-color="orange-9"><q-icon name="warning" size="13px" class="q-mr-xs" />facturation à vérifier</q-badge>
|
|
<span v-if="health.last_cycle_min_ago != null" class="text-caption text-grey-6 q-ml-sm">dernier cycle il y a {{ health.last_cycle_min_ago }} min</span>
|
|
</template>
|
|
</q-card-section>
|
|
<q-separator />
|
|
<div class="row q-pa-sm items-stretch">
|
|
<div class="col-6 col-md-2 q-pa-xs" v-for="d in domains" :key="d.key">
|
|
<q-card flat bordered class="dcard fit">
|
|
<div class="row items-center no-wrap q-mb-xs">
|
|
<q-badge rounded color="indigo-1" text-color="indigo-9" class="q-mr-xs">{{ d.order }}</q-badge>
|
|
<span class="text-weight-medium">{{ d.label }}</span>
|
|
<q-space />
|
|
<q-icon :name="d.icon" size="15px" :color="d.iconColor" />
|
|
</div>
|
|
<div class="num" :class="d.colorClass">{{ d.value }}</div>
|
|
<div class="lbl">{{ d.metric }}</div>
|
|
<div class="text-caption text-grey-6 ellipsis" :title="d.sub">{{ d.sub }}</div>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
<q-card-section class="text-caption text-grey-7 q-pt-none">
|
|
<q-icon name="account_tree" size="14px" /> clients → adresses · services · tickets → factures → paiements.
|
|
<code>disabled</code>/<code>is_suspended</code> = dérivé des services (jamais synchronisé) — hors « à réviser ».
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<q-banner dense rounded class="bg-green-1 text-green-9 q-mb-md">
|
|
<template #avatar><q-icon name="lock" color="green-7" /></template>
|
|
<b>Lecture seule.</b> Politique : <b>ADD</b> = remplir un champ vide (sûr) · <b>CHANGE</b> = valeur divergente (révision) ·
|
|
<b>NEVER-CLOBBER</b> = F vide → jamais écrasé · statut client (<code>disabled</code>) = dérivé, jamais synchronisé.
|
|
</q-banner>
|
|
|
|
<div class="text-subtitle2 text-weight-bold text-grey-8 q-mb-sm"><q-icon name="group" size="18px" /> Détail clients <span class="text-caption text-grey-6">— aperçu legacy-sync</span></div>
|
|
|
|
<div v-if="loading" class="column items-center q-pa-xl text-grey-6">
|
|
<q-spinner-dots size="40px" color="primary" /><div class="q-mt-sm">Calcul de l'aperçu clients… (~10 s)</div>
|
|
</div>
|
|
<div v-else-if="err" class="text-negative q-pa-md">{{ err }}</div>
|
|
|
|
<template v-else-if="rep">
|
|
<div class="row q-col-gutter-md q-mb-md">
|
|
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-blue-8">{{ cs.would_create }}</div><div class="lbl">Comptes à créer</div></q-card></div>
|
|
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-green-8">{{ cs.safe_add }}</div><div class="lbl">À enrichir (sûr)</div></q-card></div>
|
|
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-orange-8">{{ cs.needs_review }}</div><div class="lbl">À réviser (actionnable)</div></q-card></div>
|
|
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-grey-7">{{ cs.unchanged }}</div><div class="lbl">Inchangés</div></q-card></div>
|
|
</div>
|
|
|
|
<div class="row q-col-gutter-md">
|
|
<div class="col-12 col-md-4">
|
|
<q-card flat bordered>
|
|
<q-card-section class="text-weight-bold bg-green-1 text-green-9">Enrichissements sûrs (ADD) — {{ cs.safe_add }} clients</q-card-section>
|
|
<q-list dense separator>
|
|
<q-item v-for="(n,f) in rep.customers.add_by_field" :key="f"><q-item-section>{{ f }}</q-item-section><q-item-section side class="text-weight-bold">{{ n }}</q-item-section></q-item>
|
|
<q-item v-if="!Object.keys(rep.customers.add_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
|
|
</q-list>
|
|
<q-card-section class="text-caption text-grey-7">Remplit des champs ERPNext vides (email/tél…). Réversible. <code>applySafeAdds(confirm:'SAFE-ADD')</code> — non exécuté.</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<q-card flat bordered>
|
|
<q-card-section class="text-weight-bold bg-orange-1 text-orange-9 row items-center q-py-sm">
|
|
<span>À réviser (CHANGE) — {{ cs.needs_review }}</span>
|
|
<q-space />
|
|
<q-btn dense unelevated color="orange-8" size="sm" no-caps icon="done_all" label="Tout appliquer" :loading="batchApplying === 'tous'" :disable="!!batchApplying || !cs.needs_review" @click="confirmApplyAll" />
|
|
</q-card-section>
|
|
<q-list dense separator>
|
|
<q-item v-for="(n,f) in rep.customers.review_by_field" :key="f">
|
|
<q-item-section>{{ f }}</q-item-section>
|
|
<q-item-section side>
|
|
<div class="row items-center no-wrap q-gutter-xs">
|
|
<q-badge color="orange-2" text-color="orange-9">{{ n }}</q-badge>
|
|
<q-btn dense flat size="sm" icon="visibility" color="grey-7" @click="showDetail(f)"><q-tooltip>Voir le détail</q-tooltip></q-btn>
|
|
<q-btn dense flat color="teal-7" size="sm" no-caps label="Appliquer" :loading="batchApplying === f" :disable="!!batchApplying || f === 'customer_name'" @click="applyBatch(f)"><q-tooltip v-if="f === 'customer_name'">Nom protégé — jamais écrasé</q-tooltip></q-btn>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="!Object.keys(rep.customers.review_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
|
|
</q-list>
|
|
<q-card-section v-if="reviewDetail" class="bg-grey-1 q-py-sm">
|
|
<div class="row items-center q-mb-xs"><span class="text-caption text-weight-medium">{{ reviewDetail.field }} — {{ reviewDetail.total }} changement(s)</span><q-space /><q-btn flat dense round size="xs" icon="close" @click="reviewDetail = null" /></div>
|
|
<div v-for="(it,i) in reviewDetail.items" :key="i" class="text-caption ellipsis">{{ it.customer_name }} <span class="text-grey-6">#{{ it.legacy_account_id }}</span> : <span v-for="d in it.change" :key="d.field"><s>{{ d.from }}</s> → <b>{{ d.to }}</b></span></div>
|
|
<div v-if="reviewDetail.total > reviewDetail.items.length" class="text-caption text-grey-6">… +{{ reviewDetail.total - reviewDetail.items.length }} autres</div>
|
|
</q-card-section>
|
|
<q-card-section class="text-caption text-grey-7">F gagne, sur décision. Le nom n'est jamais écrasé. Applique par champ ou tout d'un coup.</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<q-card flat bordered class="q-mb-md">
|
|
<q-card-section class="text-weight-bold bg-blue-grey-1 text-blue-grey-9">Statut — dérivé (non-actionnable)</q-card-section>
|
|
<q-list dense separator>
|
|
<q-item><q-item-section>F actif / ERPNext désactivé</q-item-section><q-item-section side class="text-weight-bold">{{ sd.f_active_erp_disabled }}</q-item-section></q-item>
|
|
<q-item><q-item-section>F inactif / ERPNext actif</q-item-section><q-item-section side class="text-weight-bold">{{ sd.f_inactive_erp_enabled }}</q-item-section></q-item>
|
|
</q-list>
|
|
<q-card-section class="text-caption text-grey-7">Statut dérivé des abonnements — <b>jamais synchronisé</b>. Un client hors zone reste vendable (cross-sell), pas « churn ».</q-card-section>
|
|
</q-card>
|
|
<q-card flat bordered>
|
|
<q-card-section class="text-weight-bold">Never-clobber (protégés)</q-card-section>
|
|
<q-list dense separator>
|
|
<q-item v-for="(n,f) in rep.customers.never_clobber_by_field" :key="f"><q-item-section>{{ f }}</q-item-section><q-item-section side class="text-weight-bold">{{ n }}</q-item-section></q-item>
|
|
<q-item v-if="!Object.keys(rep.customers.never_clobber_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
|
|
</q-list>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row q-col-gutter-md q-mt-xs">
|
|
<div class="col-12 col-md-6">
|
|
<q-card flat bordered>
|
|
<q-card-section class="text-weight-bold">Nouveaux comptes (échantillon)</q-card-section>
|
|
<q-markup-table flat dense>
|
|
<thead><tr><th class="text-left">legacy_account_id</th><th class="text-left">Nom</th><th class="text-left">Statut F</th></tr></thead>
|
|
<tbody><tr v-for="c in rep.customers.creates_sample" :key="c.legacy_account_id"><td>{{ c.legacy_account_id }}</td><td>{{ c.customer_name }}</td><td>{{ c.status }}</td></tr></tbody>
|
|
</q-markup-table>
|
|
</q-card>
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<q-card flat bordered>
|
|
<q-card-section class="text-weight-bold">À réviser (échantillon)</q-card-section>
|
|
<q-list dense separator>
|
|
<q-item v-for="r in rep.customers.needs_review_sample" :key="r.legacy_account_id">
|
|
<q-item-section>
|
|
<q-item-label>{{ r.customer_name }} <span class="text-grey-6">#{{ r.legacy_account_id }}</span></q-item-label>
|
|
<q-item-label caption><span v-for="d in r.change" :key="d.field" class="chg">{{ d.field }}: <s>{{ d.from }}</s> → <b>{{ d.to }}</b></span></q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-btn dense flat color="teal-7" icon="check" label="Appliquer" size="sm" no-caps :loading="applyingId === r.legacy_account_id" @click="applyChange(r)">
|
|
<q-tooltip>Accepter la valeur de F (écrit dans ERPNext). Réversible. Le nom n'est jamais écrasé.</q-tooltip>
|
|
</q-btn>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="!(rep.customers.needs_review_sample||[]).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
|
|
</q-list>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-caption text-grey-6 q-mt-md">Aperçu clients calculé {{ rep.started_at }} · F {{ cs.f_total }} comptes / ERPNext {{ cs.erp_total }}.</div>
|
|
</template>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
|
|
const $q = useQuasar()
|
|
|
|
const loading = ref(false)
|
|
const err = ref('')
|
|
const rep = ref(null)
|
|
const cs = computed(() => (rep.value && rep.value.customers.summary) || {})
|
|
const sd = computed(() => (rep.value && rep.value.customers.status_divergence) || {})
|
|
|
|
// ── Bande unifiée (rapide, /sync/status) ──
|
|
const sLoading = ref(false)
|
|
const status = ref(null)
|
|
const health = computed(() => (status.value && status.value.billing_health) || {})
|
|
const fmtN = (v) => v == null ? '—' : Number(v).toLocaleString('fr-CA', { maximumFractionDigits: 0 })
|
|
const fmtCur = (v) => v == null ? '—' : '$' + Number(v).toLocaleString('fr-CA', { maximumFractionDigits: 0 })
|
|
const lagCls = (l) => (l == null ? 'text-grey-6' : l > 0 ? 'text-orange-8' : 'text-green-8')
|
|
const createCls = (n) => (n == null ? 'text-grey-6' : n > 0 ? 'text-blue-8' : 'text-green-8')
|
|
const domains = computed(() => {
|
|
const m = (status.value && status.value.modules) || {}
|
|
const okIcon = (cond) => cond ? 'check_circle' : 'pending'
|
|
return [
|
|
{ key: 'clients', label: 'Clients', order: 1, value: fmtN(m.clients?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.clients?.erp)}`, colorClass: createCls(m.clients?.to_create), icon: okIcon(!m.clients?.to_create), iconColor: m.clients?.to_create ? 'blue-5' : 'green-5' },
|
|
{ key: 'adresses', label: 'Adresses', order: 2, value: fmtN(m.adresses?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.adresses?.erp)}`, colorClass: createCls(m.adresses?.to_create), icon: okIcon(!m.adresses?.to_create), iconColor: m.adresses?.to_create ? 'blue-5' : 'green-5' },
|
|
{ key: 'services', label: 'Services', order: 2, value: fmtN(m.services?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.services?.erp)}`, colorClass: createCls(m.services?.to_create), icon: okIcon(!m.services?.to_create), iconColor: m.services?.to_create ? 'blue-5' : 'green-5' },
|
|
{ key: 'factures', label: 'Factures', order: 3, value: fmtN(m.factures?.lag), metric: 'en retard', sub: `${fmtN(m.factures?.synced)} vivantes · ${fmtCur(m.factures?.ar_open)} dû`, colorClass: lagCls(m.factures?.lag), icon: okIcon(!m.factures?.lag), iconColor: m.factures?.lag ? 'orange-5' : 'green-5' },
|
|
{ key: 'paiements', label: 'Paiements', order: 4, value: fmtN(m.paiements?.lag), metric: 'en retard', sub: `${fmtN(m.paiements?.total)} au total`, colorClass: lagCls(m.paiements?.lag), icon: okIcon(!m.paiements?.lag), iconColor: m.paiements?.lag ? 'orange-5' : 'green-5' },
|
|
{ key: 'tickets', label: 'Tickets', order: 2, value: '·', metric: 'moteur séparé', sub: 'osTicket → Dispatch', colorClass: 'text-grey-6', icon: 'open_in_new', iconColor: 'grey-5' },
|
|
]
|
|
})
|
|
|
|
async function loadStatus () {
|
|
sLoading.value = true
|
|
try { const r = await fetch(`${HUB_URL}/sync/status`); if (r.ok) status.value = await r.json() } catch {}
|
|
sLoading.value = false
|
|
}
|
|
|
|
async function load () {
|
|
loading.value = true; err.value = ''
|
|
try {
|
|
const res = await fetch(`${HUB_URL}/legacy-sync/preview`)
|
|
if (!res.ok) throw new Error('Échec du calcul (' + res.status + ')')
|
|
rep.value = await res.json()
|
|
} catch (e) { err.value = e.message }
|
|
loading.value = false
|
|
}
|
|
function reload () { loadStatus(); load() }
|
|
|
|
// ── Synchronisation manuelle + progression ──
|
|
const syncing = ref(false)
|
|
const syncRun = ref(null)
|
|
let _pollTimer = null
|
|
const stepIcon = (s) => ({ pending: 'schedule', running: 'sync', done: 'check_circle', error: 'error' }[s] || 'schedule')
|
|
const stepColor = (s) => ({ pending: 'grey-5', running: 'blue-6', done: 'green-6', error: 'red-6' }[s] || 'grey-5')
|
|
async function startSync (scope) {
|
|
if (syncing.value) return
|
|
syncing.value = true; syncRun.value = null
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/sync/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'F-WINS', scope }) })
|
|
const d = await r.json()
|
|
if (d.already_running) { $q.notify({ type: 'warning', message: 'Une synchronisation est déjà en cours' }); poll(); return }
|
|
if (!d.started) { $q.notify({ type: 'negative', message: d.error || 'Échec' }); syncing.value = false; return }
|
|
poll()
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }); syncing.value = false }
|
|
}
|
|
function poll () {
|
|
clearTimeout(_pollTimer)
|
|
fetch(`${HUB_URL}/sync/run-status`).then(r => r.json()).then(d => {
|
|
syncRun.value = d
|
|
if (d.running) { _pollTimer = setTimeout(poll, 1500) }
|
|
else { syncing.value = false; const ok = (d.steps || []).filter(s => s.status === 'error').length === 0; $q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Synchronisation terminée' : 'Terminée avec des erreurs' }); reload() }
|
|
}).catch(() => { syncing.value = false })
|
|
}
|
|
// Appliquer un changement révisé (accepter F) — par compte
|
|
const applyingId = ref(null)
|
|
async function applyChange (r) {
|
|
applyingId.value = r.legacy_account_id
|
|
try {
|
|
const res = await fetch(`${HUB_URL}/legacy-sync/apply-change`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ legacy_account_id: r.legacy_account_id, confirm: 'F-WINS' }) })
|
|
const d = await res.json()
|
|
if (d.applied) {
|
|
$q.notify({ type: 'positive', message: `Appliqué à ${r.customer_name} : ${Object.keys(d.patch).join(', ')}` })
|
|
rep.value.customers.needs_review_sample = rep.value.customers.needs_review_sample.filter(x => x.legacy_account_id !== r.legacy_account_id)
|
|
loadStatus()
|
|
} else $q.notify({ type: 'warning', message: d.error || d.note || 'Rien à appliquer' })
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { applyingId.value = null }
|
|
}
|
|
// Appliquer en LOT (par champ ou tout) + détail
|
|
const batchApplying = ref(null)
|
|
const reviewDetail = ref(null)
|
|
async function applyBatch (field) {
|
|
const label = field || 'tous'
|
|
if (batchApplying.value) return
|
|
batchApplying.value = label
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/legacy-sync/apply-review-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field, confirm: 'F-WINS' }) })
|
|
const d = await r.json()
|
|
if (d.applied != null) { $q.notify({ type: 'positive', message: `${d.applied} changement(s) appliqué(s)${field ? ' · ' + field : ''}` }); reviewDetail.value = null; load() }
|
|
else $q.notify({ type: 'negative', message: d.error || 'Échec' })
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchApplying.value = null }
|
|
}
|
|
async function showDetail (field) {
|
|
if (reviewDetail.value && reviewDetail.value.field === field) { reviewDetail.value = null; return }
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/legacy-sync/apply-review-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field }) }) // dry-run
|
|
const d = await r.json()
|
|
reviewDetail.value = { field, items: d.sample || [], total: d.would_apply || 0 }
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
|
}
|
|
function confirmApplyAll () {
|
|
const bf = rep.value.customers.review_by_field || {}
|
|
const detail = Object.entries(bf).filter(([f]) => f !== 'customer_name').map(([f, n]) => `${f}: ${n}`).join(' · ')
|
|
$q.dialog({ title: 'Tout appliquer (F gagne)', message: `Accepter F sur ${cs.value.needs_review} client(s) ?<br><span class="text-caption">${detail}</span><br><br>Le nom est protégé. Réversible.`, html: true, cancel: true, ok: { label: 'Appliquer', color: 'orange-8' } }).onOk(() => applyBatch(null))
|
|
}
|
|
onBeforeUnmount(() => clearTimeout(_pollTimer))
|
|
onMounted(reload)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.stat { padding: 12px; text-align: center; }
|
|
.stat .num { font-size: 1.7rem; font-weight: 700; line-height: 1; }
|
|
.stat .lbl { font-size: 0.72rem; color: #64748b; margin-top: 4px; }
|
|
.dcard { padding: 10px; }
|
|
.dcard .num { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
|
|
.dcard .lbl { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: .02em; }
|
|
.chg { display: inline-block; margin-right: 10px; }
|
|
code { background: #eef2ff; padding: 1px 4px; border-radius: 3px; }
|
|
.spin { animation: spin 1s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|