feat: contract → chain → subscription → prorated invoice lifecycle + tech group claim

- contracts.js: built-in install chain fallback when no Flow Template matches
  on_contract_signed — every accepted contract now creates a master Issue +
  chained Dispatch Jobs (fiber_install template) so we never lose a signed
  contract to a missing flow config.
- acceptance.js: export createDeferredJobs + propagate assigned_group into
  Dispatch Job payload (was only in notes, not queryable).
- dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal,
  setJobStatusWithChain) + terminal-node detection that activates pending
  Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits
  a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing
  convention: activation day is free, first period starts next day.
- dispatch.js: fix Sales Invoice 417 by resolving company default income
  account (Ventes - T) and passing company + income_account on each item.
- dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech
  self-assignment from the group queue; enriches with customer_name /
  service_location via per-job fetches since those fetch_from fields aren't
  queryable in list API.
- TechTasksPage.vue: redesigned mobile-first UI with progress arc, status
  chips, and new "Tâches du groupe" section showing claimable unassigned
  jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked.
- NetworkPage.vue + poller-control.js: poller toggle semantics flipped —
  green when enabled, red/gray when paused; explicit status chips for clarity.

E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress →
Completed walks chain → SUB-0000100002 activated (start=2026-04-24) →
SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 20:40:54 -04:00
parent 07365d3b71
commit aa5921481b
20 changed files with 2539 additions and 606 deletions

View File

@ -5,7 +5,7 @@ module.exports = configure(function () {
return { return {
boot: ['pinia'], boot: ['pinia'],
css: ['app.scss'], css: ['app.scss', 'tech.scss'],
extras: ['material-icons'], extras: ['material-icons'],

363
apps/ops/src/css/tech.scss Normal file
View File

@ -0,0 +1,363 @@
// TARGO Tech Module Mobile-first design tokens + base components
//
// The tech surface (`/j/*` routes) targets glove-on-screen readability,
// one-thumb reach, and TARGO brand identity. We deliberately don't reuse
// Quasar's default component chrome here — custom CSS gives us the pill
// radii, shadow layering, and motion curves trending design expects.
// 1. Tokens
// Scoped to `.tech-surface` so the rest of the ops app is unaffected.
.tech-surface {
// TARGO palette derived from existing #3f3d7a #5c59a8 gradient
--tg-900: #2a2858; // deep night, pressed/active
--tg-800: #363566;
--tg-700: #3f3d7a; // current header start
--tg-500: #5c59a8; // current header end
--tg-300: #8b88d4; // tints, subtle accents
--tg-100: #dcdaf0; // avatar backgrounds
--tg-50: #f2f1fb; // wash backgrounds
// Gradients
--tg-hero: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%);
--tg-hero-deep: linear-gradient(160deg, #2a2858 0%, #3f3d7a 60%, #5c59a8 100%);
// Signal tuned warmer than Quasar defaults, better color-blind legibility
--sig-go: #10b981; // Completed / success
--sig-work: #f59e0b; // In Progress
--sig-stop: #ef4444; // Urgent / error
--sig-wait: #94a3b8; // On Hold (chain-blocked, invisible to tech)
--sig-info: #3b82f6; // Scheduled, neutral
// Surface + ink (warmer than current #eef1f5)
--surface: #fafbfd;
--surface-elev: #ffffff;
--surface-wash: #f2f1fb; // same as --tg-50
--surface-over: rgba(255,255,255,0.88); // for floating islands with backdrop blur
--ink-900: #0f172a;
--ink-700: #334155;
--ink-500: #64748b;
--ink-400: #94a3b8;
--ink-300: #cbd5e1;
// Elevation system (two-layer lift, not a single fat drop shadow)
--elev-1: 0 1px 2px rgba(15,23,42,0.04), 0 2px 6px rgba(15,23,42,0.03);
--elev-2: 0 2px 8px rgba(15,23,42,0.06), 0 12px 32px rgba(15,23,42,0.04);
--elev-3: 0 4px 14px rgba(15,23,42,0.08), 0 24px 48px rgba(15,23,42,0.06);
--elev-fab: 0 8px 24px rgba(16,185,129,0.35);
--elev-fab-purple: 0 8px 24px rgba(92,89,168,0.35);
// Radii
--r-sm: 10px;
--r-md: 16px;
--r-lg: 20px;
--r-xl: 28px;
--r-pill: 999px;
// Motion Apple spring for page-level, shorter spring for micro
--ease-spring: cubic-bezier(0.32, 0.72, 0, 1);
--ease-micro: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 160ms;
--dur-mid: 240ms;
--dur-slow: 360ms;
// Type scale
--t-display: 600 2rem/1.1 system-ui, -apple-system, sans-serif; // 32px hero
--t-h1: 700 1.5rem/1.2 system-ui, -apple-system, sans-serif; // 24px
--t-h2: 700 1.25rem/1.25 system-ui, -apple-system, sans-serif; // 20px
--t-body: 500 1rem/1.4 system-ui, -apple-system, sans-serif; // 16px
--t-body-sm: 500 0.9375rem/1.4 system-ui, -apple-system, sans-serif; // 15px
--t-caption: 500 0.8125rem/1.3 system-ui, -apple-system, sans-serif; // 13px
--t-micro: 600 0.6875rem/1.2 system-ui, -apple-system, sans-serif; // 11px
// Base surface
background: var(--surface);
color: var(--ink-900);
min-height: 100vh;
font-feature-settings: 'cv11', 'ss01'; // nicer tabular digits
}
// Every tech page needs room below its content for the floating tab island
// (96px pill + 16px margin + safe-area). We do this with a utility class
// `.tg-page` so pages opt in explicitly, and for the legacy Quasar
// `<q-page padding>` pages we extend that element with extra padding too.
.tech-surface.q-page {
padding-bottom: calc(96px + env(safe-area-inset-bottom)) !important;
}
// 2. Typography utilities
.tech-surface {
.t-display { font: var(--t-display); letter-spacing: -0.02em; }
.t-h1 { font: var(--t-h1); letter-spacing: -0.01em; }
.t-h2 { font: var(--t-h2); letter-spacing: -0.005em; }
.t-body { font: var(--t-body); }
.t-body-sm { font: var(--t-body-sm); }
.t-caption { font: var(--t-caption); color: var(--ink-500); }
.t-micro { font: var(--t-micro); text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-500); }
.t-ink-900 { color: var(--ink-900); }
.t-ink-700 { color: var(--ink-700); }
.t-ink-500 { color: var(--ink-500); }
.t-ink-400 { color: var(--ink-400); }
.t-ink-on-hero { color: rgba(255,255,255,0.95); }
.t-ink-on-hero-dim { color: rgba(255,255,255,0.7); }
}
// 3. Card elevated pill-radius, no borders
.tech-surface {
.tg-card {
background: var(--surface-elev);
border-radius: var(--r-lg);
box-shadow: var(--elev-2);
padding: 18px;
transition: transform var(--dur-fast) var(--ease-micro),
box-shadow var(--dur-fast) var(--ease-micro);
&.tg-card--wash { background: var(--surface-wash); box-shadow: none; }
&.tg-card--flat { box-shadow: var(--elev-1); }
&.tg-card--tap {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active { transform: scale(0.985); box-shadow: var(--elev-1); }
}
&.tg-card--done { opacity: 0.62; }
}
}
// 4. Status chip pill badge (replaces border-left indicator)
.tech-surface {
.tg-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: var(--r-pill);
font: var(--t-micro);
white-space: nowrap;
.tg-chip-dot {
width: 6px; height: 6px; border-radius: 50%;
display: inline-block;
}
&.is-go { background: rgba(16,185,129,0.12); color: #047857; .tg-chip-dot { background: var(--sig-go); } }
&.is-work { background: rgba(245,158,11,0.14); color: #b45309; .tg-chip-dot { background: var(--sig-work); } }
&.is-stop { background: rgba(239,68,68,0.12); color: #b91c1c; .tg-chip-dot { background: var(--sig-stop); } }
&.is-wait { background: rgba(148,163,184,0.16); color: #475569; .tg-chip-dot { background: var(--sig-wait); } }
&.is-info { background: rgba(59,130,246,0.12); color: #1d4ed8; .tg-chip-dot { background: var(--sig-info); } }
&.is-tg { background: rgba(92,89,168,0.12); color: var(--tg-700); .tg-chip-dot { background: var(--tg-500); } }
}
}
// 5. Button system pill, FAB, ghost
.tech-surface {
.tg-btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 8px;
min-height: 52px;
padding: 0 24px;
border: 0;
border-radius: var(--r-pill);
font: 700 1rem/1 system-ui, -apple-system, sans-serif;
letter-spacing: 0.01em;
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-micro),
box-shadow var(--dur-fast) var(--ease-micro),
background var(--dur-fast) var(--ease-micro);
-webkit-tap-highlight-color: transparent;
user-select: none;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:active:not(:disabled) { transform: scale(0.97); }
&.is-block { width: 100%; }
// Variants
&.is-primary {
background: var(--tg-700); color: #fff;
box-shadow: var(--elev-fab-purple);
&:active { background: var(--tg-900); }
}
&.is-success {
background: var(--sig-go); color: #fff;
box-shadow: var(--elev-fab);
&:active { background: #059669; }
}
&.is-warning {
background: var(--sig-work); color: #fff;
box-shadow: 0 8px 24px rgba(245,158,11,0.35);
}
&.is-ghost {
background: var(--surface-wash); color: var(--tg-700);
box-shadow: none;
&:active { background: var(--tg-100); }
}
&.is-outline {
background: transparent; color: var(--tg-700);
box-shadow: inset 0 0 0 1.5px var(--tg-300);
}
&.is-text {
background: transparent; color: var(--tg-700);
box-shadow: none; min-height: 40px; padding: 0 12px;
}
&.is-compact { min-height: 40px; padding: 0 16px; font-size: 0.9375rem; }
}
// Sticky floating CTA pill sits above the tab bar
.tg-fab-cta {
position: fixed;
left: 16px; right: 16px;
bottom: calc(var(--tab-island-bottom, 88px) + env(safe-area-inset-bottom));
z-index: 20;
}
}
// 6. Hero header big gradient banner
.tech-surface {
.tg-hero {
background: var(--tg-hero-deep);
color: #fff;
padding: 18px 18px 28px;
border-radius: 0 0 var(--r-xl) var(--r-xl);
position: relative;
overflow: hidden;
// Subtle radial highlight for depth
&::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(1200px 400px at 85% -20%, rgba(255,255,255,0.14), transparent 60%);
pointer-events: none;
}
.tg-hero-inner { position: relative; z-index: 1; }
}
}
// 7. Floating tab island (bottom nav)
.tech-surface .tg-tab-island,
.tg-tab-island {
position: fixed;
left: 16px; right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom));
height: 64px;
border-radius: var(--r-xl);
background: var(--surface-over);
backdrop-filter: saturate(180%) blur(22px);
-webkit-backdrop-filter: saturate(180%) blur(22px);
box-shadow: var(--elev-3);
border: 1px solid rgba(15,23,42,0.04);
display: flex; align-items: center; justify-content: space-around;
z-index: 30;
padding: 0 8px;
.tg-tab {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px;
height: 56px; border-radius: var(--r-md);
cursor: pointer; color: var(--ink-500);
transition: color var(--dur-fast) var(--ease-micro);
-webkit-tap-highlight-color: transparent;
text-decoration: none;
.tg-tab-icon { font-size: 24px; line-height: 1; }
.tg-tab-label { font: var(--t-micro); }
&.is-active {
color: var(--tg-700);
.tg-tab-icon { transform: translateY(-1px); }
}
&:active { background: var(--tg-50); }
}
}
// 8. Page spacing (reserve room for floating tab island)
// Bottom-room is handled at the q-page level (.tech-surface.q-page above),
// so inner content containers don't need to add their own bottom padding —
// they just flow naturally and the tab-island gap is guaranteed.
.tech-surface .tg-page { min-height: 100vh; }
// 9. Peek sheet (bottom card that slides up)
.tech-surface {
.tg-peek-backdrop {
position: fixed; inset: 0; z-index: 40;
background: rgba(15,23,42,0.4);
backdrop-filter: blur(2px);
opacity: 0; pointer-events: none;
transition: opacity var(--dur-mid) var(--ease-micro);
&.is-open { opacity: 1; pointer-events: auto; }
}
.tg-peek {
position: fixed; left: 0; right: 0; bottom: 0;
z-index: 41;
background: var(--surface-elev);
border-radius: var(--r-xl) var(--r-xl) 0 0;
box-shadow: var(--elev-3);
padding: 8px 0 calc(16px + env(safe-area-inset-bottom));
transform: translateY(100%);
transition: transform var(--dur-mid) var(--ease-spring);
max-height: 82vh;
overflow-y: auto;
overscroll-behavior: contain;
&.is-open { transform: translateY(0); }
.tg-peek-handle {
width: 40px; height: 4px; border-radius: 2px;
background: var(--ink-300); margin: 6px auto 4px;
}
}
}
// 10. Progress arc (SVG ring for hero stat)
.tech-surface {
.tg-arc {
position: relative;
display: inline-flex; align-items: center; justify-content: center;
svg { transform: rotate(-90deg); }
.tg-arc-track { stroke: rgba(255,255,255,0.16); fill: none; }
.tg-arc-fill { stroke: #fff; fill: none; stroke-linecap: round;
transition: stroke-dashoffset var(--dur-slow) var(--ease-spring); }
.tg-arc-center {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center;
}
}
}
// 11. Offline / system banner
.tech-surface .tg-banner {
position: fixed;
top: env(safe-area-inset-top); left: 0; right: 0;
z-index: 50;
padding: 6px 16px;
background: var(--sig-stop); color: #fff;
font: var(--t-caption);
text-align: center;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
// 12. Avatar
.tech-surface .tg-avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: var(--tg-900);
color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font: 700 0.875rem/1 system-ui;
letter-spacing: 0.02em;
}
// 13. Route transitions (slide-up for carddetail)
.tech-slide-enter-active, .tech-slide-leave-active {
transition: transform var(--dur-mid) var(--ease-spring),
opacity var(--dur-mid) var(--ease-micro);
}
.tech-slide-enter-from { transform: translateY(24px); opacity: 0; }
.tech-slide-leave-to { transform: translateY(24px); opacity: 0; }

View File

@ -1,60 +1,57 @@
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf" class="tech-surface">
<q-page-container> <q-page-container>
<router-view /> <router-view v-slot="{ Component }">
<transition name="tech-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</q-page-container> </q-page-container>
<!-- Bottom navigation --> <!-- Floating tab island -->
<q-footer class="tech-footer" bordered> <!--
<transition name="slide-down"> Design note: fixed bottom, 16px side margins, pill radius, backdrop-blur
<div v-if="!isOnline" class="offline-banner"> surface. Sits on top of content content reserves room via `.tg-page`
<q-icon name="wifi_off" size="14px" class="q-mr-xs" /> Hors ligne padding (96px). Three tabs instead of four: Diagnostic moves into "Plus"
</div> as a list item since it's a once-a-day tool, not primary.
</transition> -->
<q-tabs v-model="tab" dense no-caps active-color="primary" indicator-color="primary" class="tech-tabs"> <nav class="tg-tab-island">
<q-route-tab name="tasks" icon="assignment" label="Taches" to="/j" exact /> <router-link to="/j" class="tg-tab" :class="{ 'is-active': isActive('tasks') }" exact>
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/j/scan" /> <span class="material-icons tg-tab-icon">assignment</span>
<q-route-tab name="diag" icon="speed" label="Diagnostic" to="/j/diagnostic" /> <span class="tg-tab-label">Tâches</span>
<q-route-tab name="more" icon="more_horiz" label="Plus" to="/j/more" /> </router-link>
</q-tabs> <router-link to="/j/scan" class="tg-tab" :class="{ 'is-active': isActive('scan') }">
</q-footer> <span class="material-icons tg-tab-icon">qr_code_scanner</span>
<span class="tg-tab-label">Scanner</span>
</router-link>
<router-link to="/j/more" class="tg-tab" :class="{ 'is-active': isActive('more') }">
<span class="material-icons tg-tab-icon">more_horiz</span>
<span class="tg-tab-label">Plus</span>
</router-link>
</nav>
</q-layout> </q-layout>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
const tab = ref('tasks') const route = useRoute()
const isOnline = ref(navigator.onLine)
function onOnline () { isOnline.value = true } // Match routes to tab ids. Sub-routes (e.g. /j/job/ABC) stay on "tasks".
function onOffline () { isOnline.value = false } function isActive (tab) {
const p = route.path
onMounted(() => { if (tab === 'scan') return p.startsWith('/j/scan')
window.addEventListener('online', onOnline) if (tab === 'more') return p.startsWith('/j/more') || p.startsWith('/j/diagnostic')
window.addEventListener('offline', onOffline) if (tab === 'tasks') return p === '/j' || p.startsWith('/j/job')
}) return false
onUnmounted(() => { }
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tech-footer { // Layout tokens already live in tech.scss; this file keeps only structural
background: white; // overrides specific to the layout shell.
:deep(.q-page-container) {
padding-bottom: 0 !important; // our pages handle their own via .tg-page
} }
.tech-tabs {
:deep(.q-tab) {
min-height: 56px;
font-size: 11px;
}
}
.offline-banner {
display: flex; align-items: center; justify-content: center;
background: #c62828; color: white; font-size: 13px; font-weight: 500; padding: 4px 0;
}
.slide-down-enter-active, .slide-down-leave-active { transition: max-height 0.3s ease, opacity 0.3s ease; overflow: hidden; }
.slide-down-enter-from, .slide-down-leave-to { max-height: 0; opacity: 0; }
.slide-down-enter-to, .slide-down-leave-from { max-height: 30px; opacity: 1; }
</style> </style>

View File

@ -0,0 +1,141 @@
<template>
<article class="tg-card tg-card--tap job-card" :class="{ 'tg-card--done': done }" @click="$emit('click')">
<!-- Top row: ID + time -->
<header class="card-top">
<div class="card-top-left">
<span v-if="index" class="card-order">{{ index }}</span>
<span v-else-if="status === 'work'" class="card-pulse"><i /></span>
<span v-else-if="done" class="material-icons card-done-ic">check_circle</span>
<span class="card-id">{{ job.name }}</span>
</div>
<span v-if="job.scheduled_time" class="card-time">{{ fmtTime(job.scheduled_time) }}</span>
</header>
<!-- Title -->
<h3 class="card-title">{{ job.subject || 'Sans titre' }}</h3>
<!-- Location -->
<div v-if="job.service_location_name" class="card-location">
<span class="material-icons">place</span>
<span>{{ job.service_location_name }}</span>
</div>
<!-- Bottom row: chips -->
<footer class="card-bottom">
<span class="tg-chip" :class="chipClass">
<span class="tg-chip-dot" />
{{ chipLabel }}
</span>
<span v-if="job.duration_h" class="tg-chip is-tg" style="margin-left:auto">
<span class="material-icons" style="font-size:12px">schedule</span>
{{ job.duration_h }}h
</span>
</footer>
</article>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
job: { type: Object, required: true },
// Card visual status: 'tg' | 'work' | 'stop' | 'go' | 'info'
status: { type: String, default: 'tg' },
// Ordinal shown in the corner badge (for route order in upcoming)
index: { type: Number, default: 0 },
// Renders card in muted "completed" state
done: { type: Boolean, default: false },
})
defineEmits(['click'])
const chipClass = computed(() => {
if (props.done) return 'is-go'
if (props.status === 'work') return 'is-work'
if (props.status === 'stop') return 'is-stop'
if (props.job.priority === 'urgent') return 'is-stop'
if (props.job.priority === 'high') return 'is-work'
return 'is-tg'
})
const chipLabel = computed(() => {
if (props.done) return 'Terminée'
if (props.status === 'work') return 'En cours'
if (props.job.priority === 'urgent') return 'Urgent'
if (props.job.priority === 'high') return 'Haute priorité'
return 'À venir'
})
function fmtTime (t) {
if (!t) return ''
const [h, m] = t.split(':')
return `${h}h${m}`
}
</script>
<style lang="scss" scoped>
.job-card {
margin-bottom: 12px;
padding: 16px 18px;
}
.card-top {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.card-top-left { display: flex; align-items: center; gap: 8px; }
.card-order {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: var(--tg-700); color: #fff;
font: 700 0.72rem/1 system-ui;
}
.card-pulse {
width: 10px; height: 10px; border-radius: 50%;
background: var(--sig-work);
position: relative;
i {
position: absolute; inset: -4px;
border-radius: 50%;
background: var(--sig-work);
opacity: 0.35;
animation: pulse 1.6s ease-out infinite;
}
}
@keyframes pulse {
0% { transform: scale(0.5); opacity: 0.5; }
100% { transform: scale(1.8); opacity: 0; }
}
.card-done-ic { font-size: 18px; color: var(--sig-go); }
.card-id {
font: 600 0.78rem/1 system-ui;
color: var(--tg-700);
letter-spacing: 0.01em;
}
.card-time {
font: 700 0.9rem/1 system-ui;
color: var(--ink-900);
font-variant-numeric: tabular-nums;
}
.card-title {
font: 700 1.0625rem/1.3 system-ui, -apple-system, sans-serif;
color: var(--ink-900);
letter-spacing: -0.005em;
margin: 0 0 6px;
}
.card-location {
display: flex; align-items: center; gap: 4px;
font: 500 0.8125rem/1.3 system-ui;
color: var(--ink-500);
margin-bottom: 12px;
.material-icons { font-size: 14px; }
}
.card-bottom {
display: flex; align-items: center;
gap: 6px;
}
</style>

View File

@ -25,7 +25,7 @@
is a deliberate, visible operation that needs to fail fast. is a deliberate, visible operation that needs to fail fast.
--> -->
<template> <template>
<q-page padding class="tech-device-page"> <q-page padding class="tech-device-page tech-surface">
<q-btn flat icon="arrow_back" label="Retour" @click="$router.back()" class="q-mb-sm" /> <q-btn flat icon="arrow_back" label="Retour" @click="$router.back()" class="q-mb-sm" />
<q-spinner v-if="loading" size="lg" class="block q-mx-auto q-mt-xl" /> <q-spinner v-if="loading" size="lg" class="block q-mx-auto q-mt-xl" />

View File

@ -1,6 +1,6 @@
<template> <template>
<q-page padding> <q-page padding class="tech-surface tech-diag-page">
<div class="text-h6 q-mb-md">Diagnostic reseau</div> <div class="text-h6 q-mb-md">Diagnostic réseau</div>
<!-- Speed test --> <!-- Speed test -->
<q-card flat bordered class="q-mb-md"> <q-card flat bordered class="q-mb-md">

View File

@ -1,169 +1,235 @@
<template> <template>
<q-page class="job-detail-page"> <q-page class="tech-surface">
<!-- Top bar --> <!-- Collapsed topbar with translucent blur -->
<div class="job-topbar"> <header class="detail-topbar">
<q-btn flat dense icon="arrow_back" @click="$router.back()" color="white" /> <button class="topbar-btn" @click="$router.back()" aria-label="Retour">
<div class="col text-center"> <span class="material-icons">arrow_back</span>
<div class="text-subtitle2">{{ job?.subject || job?.name || 'Job' }}</div> </button>
<div class="text-caption text-grey-4">{{ job?.name }}</div> <div class="topbar-title">
<div class="t-caption" style="color:var(--tg-700);font-weight:700">{{ job?.name || '…' }}</div>
<div class="topbar-subject">{{ job?.subject || 'Job' }}</div>
</div> </div>
<q-btn flat dense icon="more_vert" color="white"> <button class="topbar-btn" @click="menuOpen = !menuOpen" aria-label="Menu">
<q-menu> <span class="material-icons">more_vert</span>
<q-list dense> </button>
<q-item clickable v-close-popup @click="openInErp"> <!-- Popover menu -->
<q-item-section avatar><q-icon name="open_in_new" size="xs" /></q-item-section> <transition name="fade">
<q-item-section>Ouvrir dans ERPNext</q-item-section> <div v-if="menuOpen" class="topbar-menu" @click.self="menuOpen = false">
</q-item> <div class="topbar-menu-card">
</q-list> <button class="topbar-menu-item" @click="openInErp(); menuOpen = false">
</q-menu> <span class="material-icons">open_in_new</span> Ouvrir dans ERPNext
</q-btn> </button>
</div> </div>
</div>
</transition>
</header>
<q-spinner v-if="loading" size="lg" color="primary" class="block q-mx-auto q-mt-xl" /> <!-- Loading state -->
<div v-if="loading" class="loading-wrap">
<div class="loading-spinner" />
</div>
<template v-if="job && !loading"> <template v-if="job && !loading">
<!-- Status hero --> <!-- Status hero strip -->
<div class="job-status-hero" :class="'status-' + (job.status || '').toLowerCase().replace(/\s/g, '-')"> <div class="status-hero">
<q-badge :color="statusColor" :label="statusLabel" class="text-body2 q-px-md q-py-xs" /> <span class="tg-chip" :class="statusChipClass">
<div class="row q-mt-md q-gutter-sm justify-center"> <span class="tg-chip-dot" />
<q-btn v-if="job.status === 'Scheduled' || job.status === 'assigned'" unelevated color="indigo" icon="directions_car" {{ statusLabel }}
label="En route" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" /> </span>
<q-btn v-if="job.status === 'In Progress' || job.status === 'in_progress'" unelevated color="positive" icon="check_circle" <div v-if="job.priority === 'urgent' || job.priority === 'high'" style="margin-left:8px">
label="Terminer" @click="updateStatus('Completed')" :loading="saving" class="action-btn" /> <span class="tg-chip" :class="job.priority === 'urgent' ? 'is-stop' : 'is-work'">
<q-btn v-if="job.status === 'Completed'" flat color="grey" icon="replay" {{ job.priority === 'urgent' ? 'Urgent' : 'Haute priorité' }}
label="Rouvrir" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" /> </span>
</div> </div>
</div> </div>
<!-- Content --> <div class="tg-page tg-page--content">
<div class="job-content q-pa-md">
<!-- Info card --> <!-- Location + GPS -->
<q-card flat bordered class="q-mb-md"> <section v-if="locationAddress || job.customer_name" class="tg-card tg-card--wash section-card">
<q-card-section class="q-pb-none"><div class="text-overline text-grey-6">INFORMATIONS</div></q-card-section> <div class="section-head-inline t-micro">Client & adresse</div>
<q-card-section> <div v-if="job.customer_name" class="info-line">
<q-input v-model="job.subject" label="Sujet" outlined dense class="q-mb-sm" <span class="material-icons info-icon">person</span>
<span class="t-body">{{ job.customer_name }}</span>
</div>
<div v-if="locationAddress" class="info-line">
<span class="material-icons info-icon" style="color:var(--sig-stop)">place</span>
<span class="t-body" style="flex:1">{{ locationAddress }}</span>
<button class="tg-btn is-ghost is-compact" @click="openGps">
<span class="material-icons" style="font-size:18px">navigation</span>
GPS
</button>
</div>
<div v-if="locationDetail?.contact_name" class="info-line" style="gap:8px">
<span class="material-icons info-icon">support_agent</span>
<span class="t-body-sm">
{{ locationDetail.contact_name }}
<span v-if="locationDetail.contact_phone"> · {{ locationDetail.contact_phone }}</span>
</span>
</div>
</section>
<!-- Job info (editable fields kept compact) -->
<section class="tg-card section-card">
<div class="section-head-inline t-micro">Détails</div>
<label class="field-wrap">
<span class="field-label">Sujet</span>
<input v-model="job.subject" class="field-input"
@blur="saveField('subject', job.subject)" /> @blur="saveField('subject', job.subject)" />
</label>
<div class="row q-gutter-sm q-mb-sm"> <div class="field-row-pair">
<q-select v-model="job.job_type" :options="jobTypes" label="Type" outlined dense <label class="field-wrap">
class="col" emit-value map-options @update:model-value="saveField('job_type', $event)" /> <span class="field-label">Type</span>
<q-select v-model="job.priority" :options="priorities" label="Priorité" outlined dense <select v-model="job.job_type" class="field-input"
class="col" emit-value map-options @update:model-value="saveField('priority', $event)" /> @change="saveField('job_type', job.job_type)">
<option v-for="t in jobTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</label>
<label class="field-wrap">
<span class="field-label">Priorité</span>
<select v-model="job.priority" class="field-input"
@change="saveField('priority', job.priority)">
<option v-for="p in priorities" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
</div> </div>
<div class="row q-gutter-sm q-mb-sm"> <div class="field-row-pair">
<q-input v-model="job.scheduled_time" label="Heure" type="time" outlined dense class="col" <label class="field-wrap">
<span class="field-label">Heure</span>
<input v-model="job.scheduled_time" type="time" class="field-input"
@blur="saveField('scheduled_time', job.scheduled_time)" /> @blur="saveField('scheduled_time', job.scheduled_time)" />
<q-input v-model="displayDuration" label="Durée (h)" type="number" step="0.5" min="0.5" max="12" </label>
outlined dense class="col" <label class="field-wrap">
@blur="saveField('duration_h', parseFloat(displayDuration) || 1)" /> <span class="field-label">Durée (h)</span>
<input v-model.number="job.duration_h" type="number" step="0.5" min="0.5" max="12" class="field-input"
@blur="saveField('duration_h', job.duration_h)" />
</label>
</div> </div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense <label class="field-wrap">
autogrow rows="2" @blur="saveField('description', job.description)" /> <span class="field-label">Notes</span>
</q-card-section> <textarea v-model="job.description" rows="3" class="field-input field-input--ta"
</q-card> @blur="saveField('description', job.description)" />
</label>
<!-- Location --> </section>
<q-card flat bordered class="q-mb-md" v-if="job.service_location_name || job.customer_name">
<q-card-section class="q-pb-none"><div class="text-overline text-grey-6">CLIENT & ADRESSE</div></q-card-section>
<q-card-section>
<div v-if="job.customer_name" class="row items-center q-mb-xs">
<q-icon name="person" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.customer_name }}</span>
</div>
<div v-if="locationAddress" class="row items-center q-mb-sm">
<q-icon name="place" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ locationAddress }}</span>
<q-space />
<q-btn flat dense round icon="navigation" color="primary" @click="openGps" title="Naviguer" />
</div>
<div v-if="locationDetail?.contact_name" class="text-caption text-grey">
Contact: {{ locationDetail.contact_name }}
<span v-if="locationDetail.contact_phone"> {{ locationDetail.contact_phone }}</span>
</div>
</q-card-section>
</q-card>
<!-- Equipment --> <!-- Equipment -->
<q-card flat bordered class="q-mb-md"> <section class="tg-card section-card">
<q-card-section class="q-pb-none row items-center"> <div class="section-head-row">
<div class="text-overline text-grey-6 col">ÉQUIPEMENTS ({{ equipment.length }})</div> <span class="t-micro">Équipements · {{ equipment.length }}</span>
<q-btn flat dense size="sm" icon="add" color="primary" label="Ajouter" @click="addEquipmentMenu = true" /> <button class="tg-btn is-text" @click="addEquipmentMenu = true">
</q-card-section> <span class="material-icons" style="font-size:18px">add</span> Ajouter
<q-card-section v-if="loadingEquip" class="text-center"> </button>
<q-spinner size="sm" /> </div>
</q-card-section>
<q-list v-else-if="equipment.length" separator> <div v-if="loadingEquip" class="text-center" style="padding:24px">
<q-item v-for="eq in equipment" :key="eq.name" clickable <div class="loading-spinner loading-spinner--sm" />
</div>
<div v-else-if="equipment.length" class="equip-list">
<button v-for="eq in equipment" :key="eq.name" class="equip-row"
@click="$router.push({ name: 'tech-device', params: { serial: eq.serial_number } })"> @click="$router.push({ name: 'tech-device', params: { serial: eq.serial_number } })">
<q-item-section avatar> <span class="material-icons equip-icon" :style="{ color: eqStatusColor(eq.status) }">
<q-icon :name="eqIcon(eq.equipment_type)" :color="eqStatusColor(eq.status)" /> {{ eqIcon(eq.equipment_type) }}
</q-item-section> </span>
<q-item-section> <div class="equip-text">
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label> <div class="t-body">{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</div>
<q-item-label caption class="mono">{{ eq.serial_number }}</q-item-label> <div class="t-caption mono">{{ eq.serial_number }}</div>
</q-item-section> </div>
<q-item-section side> <span class="tg-chip" :class="eqStatusChip(eq.status)" style="margin-left:auto">
<q-badge :color="eqStatusColor(eq.status)" :label="eq.status || '—'" /> {{ eq.status || '—' }}
</q-item-section> </span>
</q-item> </button>
</q-list> </div>
<q-card-section v-else class="text-center text-grey text-caption">
Aucun équipement lié
</q-card-section>
</q-card>
<!-- Add equipment bottom-sheet --> <div v-else class="empty-inline t-caption">Aucun équipement lié</div>
<q-dialog v-model="addEquipmentMenu" position="bottom"> </section>
<q-card style="width: 100%; max-width: 400px"> </div>
<q-card-section class="text-h6">Ajouter un équipement</q-card-section>
<q-list>
<q-item clickable v-close-popup @click="goScan">
<q-item-section avatar><q-icon name="qr_code_scanner" color="primary" /></q-item-section>
<q-item-section>
<q-item-label>Scanner un code-barres / QR</q-item-label>
<q-item-label caption>Utiliser la caméra pour détecter un SN ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="searchEquipDialog = true">
<q-item-section avatar><q-icon name="search" color="orange" /></q-item-section>
<q-item-section>
<q-item-label>Rechercher un équipement existant</q-item-label>
<q-item-label caption>Par numéro de série ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="createEquipDialog = true">
<q-item-section avatar><q-icon name="add_circle" color="green" /></q-item-section>
<q-item-section>
<q-item-label>Créer un nouvel équipement</q-item-label>
<q-item-label caption>Saisir manuellement les informations</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</q-dialog>
<!-- Search existing equipment --> <!-- Floating CTA pill (always in thumb reach) -->
<div class="tg-fab-cta">
<button v-if="isScheduled"
class="tg-btn is-primary is-block"
@click="updateStatus('In Progress')" :disabled="saving">
<span class="material-icons">directions_car</span>
En route vers le client
</button>
<button v-else-if="isInProgress"
class="tg-btn is-success is-block"
@click="updateStatus('Completed')" :disabled="saving">
<span class="material-icons">check_circle</span>
Marquer comme terminée
</button>
<button v-else-if="isCompleted"
class="tg-btn is-ghost is-block"
@click="updateStatus('In Progress')" :disabled="saving">
<span class="material-icons">replay</span>
Rouvrir la tâche
</button>
<!-- Testing shortcut: jump straight to Completed -->
<button v-if="canTerminateDirect"
class="tg-btn is-outline is-compact is-block"
style="margin-top:8px"
@click="updateStatus('Completed')" :disabled="saving">
<span class="material-icons" style="font-size:18px">fast_forward</span>
Terminer directement (test)
</button>
</div>
</template>
<!-- Add-equipment bottom sheet -->
<transition name="fade">
<div v-if="addEquipmentMenu" class="tg-peek-backdrop is-open" @click="addEquipmentMenu = false" />
</transition>
<div class="tg-peek" :class="{ 'is-open': addEquipmentMenu }">
<div class="tg-peek-handle" />
<div style="padding: 4px 20px 20px">
<div class="t-h2" style="margin-bottom:12px">Ajouter un équipement</div>
<button class="equip-option" @click="goScan; addEquipmentMenu = false">
<span class="material-icons equip-option-icon" style="color:var(--tg-700)">qr_code_scanner</span>
<div>
<div class="t-body">Scanner un code-barres / QR</div>
<div class="t-caption">Caméra + détection auto du SN ou MAC</div>
</div>
</button>
<button class="equip-option" @click="addEquipmentMenu = false; searchEquipDialog = true">
<span class="material-icons equip-option-icon" style="color:var(--sig-work)">search</span>
<div>
<div class="t-body">Rechercher un équipement existant</div>
<div class="t-caption">Par numéro de série ou MAC</div>
</div>
</button>
<button class="equip-option" @click="addEquipmentMenu = false; createEquipDialog = true">
<span class="material-icons equip-option-icon" style="color:var(--sig-go)">add_circle</span>
<div>
<div class="t-body">Créer un nouvel équipement</div>
<div class="t-caption">Saisir manuellement</div>
</div>
</button>
</div>
</div>
<!-- Search existing equipment (basic dialog, stays Quasar for now) -->
<q-dialog v-model="searchEquipDialog"> <q-dialog v-model="searchEquipDialog">
<q-card style="min-width: 340px"> <q-card style="min-width:340px;border-radius:20px">
<q-card-section class="text-h6">Rechercher un équipement</q-card-section> <q-card-section class="text-h6">Rechercher un équipement</q-card-section>
<q-card-section> <q-card-section>
<q-input v-model="eqSearchText" label="Numéro de série ou MAC" outlined dense autofocus <q-input v-model="eqSearchText" label="Numéro de série ou MAC" outlined dense autofocus
@keyup.enter="searchEquipment" debounce="400" @update:model-value="searchEquipment"> @keyup.enter="searchEquipment" debounce="400" @update:model-value="searchEquipment">
<template v-slot:append><q-icon name="search" /></template> <template v-slot:append><q-icon name="search" /></template>
</q-input> </q-input>
<q-list v-if="eqSearchResults.length" bordered separator class="q-mt-sm" style="max-height: 250px; overflow-y: auto"> <q-list v-if="eqSearchResults.length" bordered separator class="q-mt-sm" style="max-height:250px;overflow-y:auto">
<q-item v-for="eq in eqSearchResults" :key="eq.name" clickable @click="linkEquipToJob(eq)"> <q-item v-for="eq in eqSearchResults" :key="eq.name" clickable @click="linkEquipToJob(eq)">
<q-item-section> <q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label> <q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">SN: {{ eq.serial_number }}</q-item-label> <q-item-label caption class="mono">SN: {{ eq.serial_number }}</q-item-label>
<q-item-label caption v-if="eq.customer_name">Client: {{ eq.customer_name }}</q-item-label> <q-item-label caption v-if="eq.customer_name">Client: {{ eq.customer_name }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side><q-icon name="link" color="primary" /></q-item-section>
<q-icon name="link" color="primary" />
</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<div v-if="eqSearchText && !eqSearchResults.length && !eqSearching" class="text-caption text-grey q-mt-sm text-center"> <div v-if="eqSearchText && !eqSearchResults.length && !eqSearching" class="text-caption text-grey q-mt-sm text-center">
@ -178,7 +244,7 @@
<!-- Create new equipment --> <!-- Create new equipment -->
<q-dialog v-model="createEquipDialog"> <q-dialog v-model="createEquipDialog">
<q-card style="min-width: 340px"> <q-card style="min-width:340px;border-radius:20px">
<q-card-section class="text-h6">Nouvel équipement</q-card-section> <q-card-section class="text-h6">Nouvel équipement</q-card-section>
<q-card-section> <q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense class="q-mb-sm" autofocus /> <q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense class="q-mb-sm" autofocus />
@ -193,9 +259,6 @@
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div>
</template>
</q-page> </q-page>
</template> </template>
@ -205,6 +268,7 @@ import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp' import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -212,6 +276,7 @@ const router = useRouter()
const jobName = computed(() => route.params.name) const jobName = computed(() => route.params.name)
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const menuOpen = ref(false)
const job = ref(null) const job = ref(null)
const locationDetail = ref(null) const locationDetail = ref(null)
const equipment = ref([]) const equipment = ref([])
@ -228,11 +293,6 @@ const creatingEquip = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre'] const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' }) const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const displayDuration = computed({
get: () => job.value?.duration_h || 1,
set: v => { if (job.value) job.value.duration_h = v },
})
const jobTypes = [ const jobTypes = [
{ label: 'Installation', value: 'Installation' }, { label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Repair' }, { label: 'Réparation', value: 'Repair' },
@ -241,7 +301,6 @@ const jobTypes = [
{ label: 'Livraison', value: 'Delivery' }, { label: 'Livraison', value: 'Delivery' },
{ label: 'Autre', value: 'Other' }, { label: 'Autre', value: 'Other' },
] ]
const priorities = [ const priorities = [
{ label: 'Basse', value: 'low' }, { label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' }, { label: 'Moyenne', value: 'medium' },
@ -249,14 +308,30 @@ const priorities = [
{ label: 'Urgente', value: 'urgent' }, { label: 'Urgente', value: 'urgent' },
] ]
const statusColor = computed(() => { // Status helpers
const map = { Scheduled: 'blue', assigned: 'blue', 'In Progress': 'orange', in_progress: 'orange', Completed: 'green', Cancelled: 'grey' } const isScheduled = computed(() => job.value && ['Scheduled', 'assigned', 'open', 'Open', 'Assigned'].includes(job.value.status))
return map[job.value?.status] || 'grey' const isInProgress = computed(() => job.value && ['In Progress', 'in_progress'].includes(job.value.status))
}) const isCompleted = computed(() => job.value?.status === 'Completed')
const canTerminateDirect = computed(() =>
job.value && !isInProgress.value && !isCompleted.value && job.value.status !== 'Cancelled'
)
const statusLabel = computed(() => { const statusLabel = computed(() => {
const map = { Scheduled: 'Planifié', assigned: 'Assigné', 'In Progress': 'En cours', in_progress: 'En cours', Completed: 'Terminé', Cancelled: 'Annulé' } const map = {
Scheduled: 'Planifié', assigned: 'Assigné', open: 'À faire', Open: 'À faire', Assigned: 'Assigné',
'In Progress': 'En cours', in_progress: 'En cours',
Completed: 'Terminé', Cancelled: 'Annulé', 'On Hold': 'En attente',
}
return map[job.value?.status] || job.value?.status || '' return map[job.value?.status] || job.value?.status || ''
}) })
const statusChipClass = computed(() => {
const s = job.value?.status
if (isCompleted.value) return 'is-go'
if (isInProgress.value) return 'is-work'
if (s === 'Cancelled') return 'is-wait'
if (s === 'On Hold') return 'is-wait'
return 'is-info'
})
const locationAddress = computed(() => { const locationAddress = computed(() => {
const loc = locationDetail.value const loc = locationDetail.value
@ -264,18 +339,23 @@ const locationAddress = computed(() => {
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ') return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
}) })
// Equipment display helpers
function eqIcon (type) { function eqIcon (type) {
const map = { ONT: 'settings_input_hdmi', Modem: 'router', Routeur: 'wifi', 'Décodeur TV': 'tv', VoIP: 'phone' } const map = { ONT: 'settings_input_hdmi', Modem: 'router', Routeur: 'wifi', 'Décodeur TV': 'tv', VoIP: 'phone' }
return map[type] || 'memory' return map[type] || 'memory'
} }
function eqStatusColor (s) { function eqStatusColor (s) {
if (s === 'Actif') return 'green' if (s === 'Actif') return 'var(--sig-go)'
if (s === 'Défectueux' || s === 'Perdu') return 'red' if (s === 'Défectueux' || s === 'Perdu') return 'var(--sig-stop)'
return 'grey' return 'var(--ink-400)'
}
function eqStatusChip (s) {
if (s === 'Actif') return 'is-go'
if (s === 'Défectueux' || s === 'Perdu') return 'is-stop'
return 'is-wait'
} }
// --- Load job + related data --- // Load job + related data
async function loadJob () { async function loadJob () {
loading.value = true loading.value = true
try { try {
@ -309,28 +389,43 @@ async function loadEquipment () {
finally { loadingEquip.value = false } finally { loadingEquip.value = false }
} }
// --- Save / status --- // Save / status
async function saveField (field, value) { async function saveField (field, value) {
if (!job.value?.name) return if (!job.value?.name) return
try { await updateDoc('Dispatch Job', job.value.name, { [field]: value }) } try { await updateDoc('Dispatch Job', job.value.name, { [field]: value }) }
catch (e) { Notify.create({ type: 'negative', message: 'Erreur sauvegarde: ' + e.message }) } catch (e) { Notify.create({ type: 'negative', message: 'Erreur sauvegarde: ' + e.message }) }
} }
/**
* Status changes go through targo-hub /dispatch/job-status that endpoint
* is the single place where the chain-walk lives (unblocks depends_on
* children on Completed, broadcasts SSE). Direct ERPNext PUTs would skip
* both and the chain wouldn't advance.
*/
async function updateStatus (status) { async function updateStatus (status) {
saving.value = true saving.value = true
try { try {
await updateDoc('Dispatch Job', job.value.name, { status }) const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job: job.value.name, status }),
})
if (!res.ok) throw new Error(`Status update failed (${res.status})`)
const result = await res.json()
job.value.status = status job.value.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé', Scheduled: 'Job réouvert' } const msg = status === 'Completed'
Notify.create({ type: 'positive', message: msgs[status] || status, icon: status === 'Completed' ? 'check_circle' : 'directions_car' }) ? (result.unblocked?.length
? `Tâche terminée — ${result.unblocked.length} prochaine${result.unblocked.length > 1 ? 's' : ''} débloquée${result.unblocked.length > 1 ? 's' : ''}`
: 'Tâche terminée ✓')
: status === 'In Progress' ? 'En route ! 🚗'
: status === 'Scheduled' ? 'Tâche rouverte' : status
Notify.create({ type: 'positive', message: msg, icon: status === 'Completed' ? 'check_circle' : 'directions_car' })
} catch (e) { } catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { saving.value = false } } finally { saving.value = false }
} }
// --- GPS & misc --- // GPS & misc
function openGps () { function openGps () {
const loc = locationDetail.value const loc = locationDetail.value
if (loc?.latitude && loc?.longitude) { if (loc?.latitude && loc?.longitude) {
@ -360,8 +455,7 @@ function goScan () {
}) })
} }
// --- Equipment search / create / link --- // Equipment search / create / link
async function searchEquipment () { async function searchEquipment () {
const text = eqSearchText.value?.trim() const text = eqSearchText.value?.trim()
if (!text || text.length < 2) { eqSearchResults.value = []; return } if (!text || text.length < 2) { eqSearchResults.value = []; return }
@ -429,30 +523,180 @@ onMounted(loadJob)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.job-detail-page { padding: 0 !important; display: flex; flex-direction: column; min-height: 100%; } // Floating FAB is 52px tall + 16px margin + 88px tab-island clearance.
// Reserve additional bottom room on this page so scroll reaches the final
// card without it being hidden under the CTA pill.
.tech-surface.q-page { padding-bottom: 188px !important; }
.job-topbar { // Topbar (translucent, blur, sticky)
display: flex; align-items: center; padding: 8px 8px 8px 4px; .detail-topbar {
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%); color: white;
position: sticky; top: 0; z-index: 10; position: sticky; top: 0; z-index: 10;
display: flex; align-items: center;
padding: calc(env(safe-area-inset-top) + 8px) 12px 8px;
background: var(--surface-over);
backdrop-filter: saturate(180%) blur(22px);
-webkit-backdrop-filter: saturate(180%) blur(22px);
border-bottom: 1px solid rgba(15,23,42,0.05);
}
.topbar-btn {
width: 40px; height: 40px;
border: 0; background: transparent;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--ink-700);
-webkit-tap-highlight-color: transparent;
&:active { background: var(--tg-50); }
}
.topbar-title {
flex: 1; min-width: 0; text-align: center;
padding: 0 4px;
}
.topbar-subject {
font: 700 0.9375rem/1.25 system-ui;
color: var(--ink-900);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.topbar-menu {
position: fixed; inset: 0;
z-index: 50;
background: rgba(15,23,42,0.2);
backdrop-filter: blur(2px);
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 60px 8px 0;
}
.topbar-menu-card {
background: var(--surface-elev);
border-radius: var(--r-md);
box-shadow: var(--elev-3);
padding: 4px;
min-width: 220px;
}
.topbar-menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 10px 14px;
border: 0; background: transparent;
border-radius: var(--r-sm);
font: var(--t-body-sm);
text-align: left; cursor: pointer;
&:active { background: var(--tg-50); }
.material-icons { font-size: 18px; color: var(--ink-500); }
} }
.job-status-hero { // Status hero strip
text-align: center; padding: 20px 16px; background: #f5f7fa; .status-hero {
border-bottom: 1px solid rgba(0,0,0,0.06); display: flex; align-items: center;
&.status-in-progress, &.status-in_progress { background: #fff8e1; } padding: 14px 18px;
&.status-completed { background: #e8f5e9; } background: var(--surface-wash);
&.status-cancelled { background: #fafafa; }
} }
.action-btn { min-width: 140px; font-weight: 600; border-radius: 12px; } // Section card spacing
.section-card {
.job-content { margin-bottom: 14px;
flex: 1; }
overflow-y: auto; .section-head-inline {
padding-bottom: 80px !important; // clear TechLayout bottom tab bar margin-bottom: 10px;
}
.section-head-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
} }
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85em; } // Inline info lines (for location card)
.text-overline { font-size: 0.68rem; letter-spacing: 0.08em; } .info-line {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
&:not(:last-child) { border-bottom: 1px solid rgba(15,23,42,0.05); }
.info-icon { font-size: 18px; color: var(--ink-500); }
}
// Form fields custom, no Quasar chrome
.field-wrap {
display: block; margin-bottom: 12px;
&:last-child { margin-bottom: 0; }
}
.field-label {
display: block;
font: var(--t-caption);
color: var(--ink-500);
margin-bottom: 4px;
}
.field-input {
width: 100%;
min-height: 44px;
padding: 10px 14px;
border: 1.5px solid var(--ink-300);
border-radius: var(--r-sm);
background: var(--surface-elev);
font: var(--t-body);
color: var(--ink-900);
outline: none;
transition: border 160ms, box-shadow 160ms;
&:focus {
border-color: var(--tg-500);
box-shadow: 0 0 0 4px rgba(92,89,168,0.12);
}
}
.field-input--ta {
min-height: 80px; resize: vertical;
font: var(--t-body-sm);
}
.field-row-pair {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
margin-bottom: 12px;
}
// Equipment list
.equip-list {
display: flex; flex-direction: column; gap: 8px;
}
.equip-row {
display: flex; align-items: center; gap: 12px;
padding: 12px;
border: 0;
background: var(--surface-wash);
border-radius: var(--r-md);
cursor: pointer;
text-align: left;
-webkit-tap-highlight-color: transparent;
transition: background 160ms;
&:active { background: var(--tg-100); }
}
.equip-icon { font-size: 22px; flex-shrink: 0; }
.equip-text { flex: 1; min-width: 0; }
.mono { font-family: 'SF Mono', 'Menlo', 'Fira Code', monospace; font-size: 0.85em; }
.equip-option {
display: flex; align-items: center; gap: 14px;
width: 100%; padding: 14px;
border: 0; background: transparent;
border-radius: var(--r-md);
text-align: left; cursor: pointer;
margin-bottom: 4px;
-webkit-tap-highlight-color: transparent;
&:active { background: var(--tg-50); }
}
.equip-option-icon { font-size: 28px; flex-shrink: 0; }
.empty-inline {
text-align: center; padding: 16px;
color: var(--ink-500);
}
// Loading
.loading-wrap {
display: flex; align-items: center; justify-content: center;
padding: 80px 0;
}
.loading-spinner {
width: 40px; height: 40px;
border: 3px solid var(--tg-100);
border-top-color: var(--tg-500);
border-radius: 50%;
animation: lspin 0.8s linear infinite;
&--sm { width: 24px; height: 24px; border-width: 2px; }
}
@keyframes lspin { to { transform: rotate(360deg); } }
.fade-enter-active, .fade-leave-active { transition: opacity 200ms; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style> </style>

View File

@ -1,22 +1,60 @@
<template> <template>
<q-page padding> <q-page class="tech-surface">
<div class="text-h6 q-mb-md">Plus</div> <!-- Hero -->
<q-list> <section class="tg-hero more-hero">
<q-item> <div class="tg-hero-inner">
<q-item-section avatar> <div class="t-caption t-ink-on-hero-dim" style="margin-bottom:4px">Paramètres</div>
<q-icon :name="isOnline ? 'wifi' : 'wifi_off'" :color="isOnline ? 'positive' : 'negative'" /> <div class="t-h1 t-ink-on-hero">Plus d'options</div>
</q-item-section> </div>
<q-item-section> </section>
<q-item-label>{{ isOnline ? 'En ligne' : 'Hors ligne' }}</q-item-label>
</q-item-section> <div class="tg-page more-content">
</q-item> <!-- Status card -->
<q-separator /> <section class="tg-card tg-card--wash section-card">
<q-item clickable @click="logout"> <div class="section-head-inline t-micro">Connexion</div>
<q-item-section avatar><q-icon name="logout" color="negative" /></q-item-section> <div class="more-row">
<q-item-section><q-item-label class="text-negative">Deconnexion</q-item-label></q-item-section> <span class="material-icons" :style="{ color: isOnline ? 'var(--sig-go)' : 'var(--sig-stop)' }">
</q-item> {{ isOnline ? 'wifi' : 'wifi_off' }}
</q-list> </span>
<div class="text-caption text-grey text-center q-mt-xl">Targo Ops v2.0 Vue technicien</div> <div class="col">
<div class="t-body">{{ isOnline ? 'En ligne' : 'Hors ligne' }}</div>
<div class="t-caption">{{ isOnline ? 'Synchronisation active' : 'Les changements seront envoyés à la reconnexion' }}</div>
</div>
<span class="tg-chip" :class="isOnline ? 'is-go' : 'is-stop'">
<span class="tg-chip-dot" />
{{ isOnline ? 'OK' : 'Attente' }}
</span>
</div>
</section>
<!-- Tools -->
<section class="tg-card section-card">
<div class="section-head-inline t-micro">Outils</div>
<button class="more-action" @click="$router.push('/j/diagnostic')">
<span class="material-icons more-action-ic" style="color:var(--tg-500)">speed</span>
<div class="col">
<div class="t-body">Diagnostic réseau</div>
<div class="t-caption">Test de vitesse + vérification hôtes</div>
</div>
<span class="material-icons t-ink-400">chevron_right</span>
</button>
</section>
<!-- Account -->
<section class="tg-card section-card">
<div class="section-head-inline t-micro">Compte</div>
<button class="more-action" @click="logout">
<span class="material-icons more-action-ic" style="color:var(--sig-stop)">logout</span>
<div class="col">
<div class="t-body" style="color:var(--sig-stop)">Déconnexion</div>
<div class="t-caption">Terminer la session SSO</div>
</div>
<span class="material-icons t-ink-400">chevron_right</span>
</button>
</section>
<div class="t-caption text-center" style="margin-top:24px">Targo Ops · Vue technicien · v2.1</div>
</div>
</q-page> </q-page>
</template> </template>
@ -26,8 +64,49 @@ import { ref, onMounted, onUnmounted } from 'vue'
const isOnline = ref(navigator.onLine) const isOnline = ref(navigator.onLine)
function onOnline () { isOnline.value = true } function onOnline () { isOnline.value = true }
function onOffline () { isOnline.value = false } function onOffline () { isOnline.value = false }
onMounted(() => { window.addEventListener('online', onOnline); window.addEventListener('offline', onOffline) }) onMounted(() => {
onUnmounted(() => { window.removeEventListener('online', onOnline); window.removeEventListener('offline', onOffline) }) window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
})
onUnmounted(() => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
})
function logout () { window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/' } function logout () {
window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/'
}
</script> </script>
<style lang="scss" scoped>
.more-hero {
padding: 24px 18px 28px;
}
.more-content {
padding: 20px 16px 8px;
}
.section-card {
margin-bottom: 14px;
}
.section-head-inline {
margin-bottom: 10px;
}
.more-row {
display: flex; align-items: center; gap: 12px;
.col { flex: 1; min-width: 0; }
}
.more-action {
display: flex; align-items: center; gap: 14px;
width: 100%; padding: 12px 4px;
border: 0; background: transparent;
text-align: left; cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 160ms;
border-radius: var(--r-md);
&:active { background: var(--tg-50); }
&:not(:last-child) { border-bottom: 1px solid rgba(15,23,42,0.05); }
.col { flex: 1; min-width: 0; }
.more-action-ic { font-size: 22px; flex-shrink: 0; }
}
.text-center { text-align: center; }
</style>

View File

@ -28,7 +28,7 @@
- same query-param contract from TechJobDetailPage.goScan() - same query-param contract from TechJobDetailPage.goScan()
--> -->
<template> <template>
<q-page padding class="scan-page"> <q-page padding class="scan-page tech-surface">
<!-- Job context banner set by TechJobDetailPage via ?job=&customer=&location= --> <!-- Job context banner set by TechJobDetailPage via ?job=&customer=&location= -->
<q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1"> <q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1">
<q-card-section class="q-py-sm row items-center no-wrap"> <q-card-section class="q-py-sm row items-center no-wrap">

View File

@ -1,185 +1,255 @@
<template> <template>
<q-page class="tasks-page"> <q-page class="tech-surface">
<!-- Tech header --> <!-- Offline banner (fixed top) -->
<div class="tech-header"> <transition name="slide-down">
<div class="tech-header-row"> <div v-if="!isOnline" class="tg-banner">
<div class="tech-header-left"> <span class="material-icons" style="font-size:16px">wifi_off</span>
<div class="tech-date">{{ todayLabel }}</div> Hors ligne les modifications seront synchronisées à la reconnexion
<div class="tech-name">{{ techName }}</div>
</div> </div>
<div class="tech-header-right"> </transition>
<q-badge :color="isOnline ? 'green' : 'grey'" :label="isOnline ? 'En ligne' : 'Hors ligne'" class="tech-status-badge" />
<q-btn flat dense round icon="swap_horiz" color="white" size="sm" @click="loadTasks" :loading="loading" /> <!-- Hero: greeting + progress arc -->
<q-avatar size="36px" color="indigo-8" text-color="white" class="tech-avatar">{{ initials }}</q-avatar> <section class="tg-hero">
<div class="tg-hero-inner">
<!-- Top row: avatar + name + refresh -->
<div class="hero-top">
<div class="hero-left">
<div class="tg-avatar">{{ initials }}</div>
<div>
<div class="t-caption t-ink-on-hero-dim" style="text-transform:capitalize">{{ todayLabel }}</div>
<div class="hero-name">{{ techName }}</div>
</div> </div>
</div> </div>
<button class="hero-refresh" @click="loadTasks" :disabled="loading" aria-label="Rafraîchir">
<span class="material-icons" :class="{ spinning: loading }">refresh</span>
</button>
</div>
<!-- Stats cards --> <!-- Progress arc + count -->
<div class="stats-row"> <div class="hero-progress">
<div class="stat-card" @click="statFilter = 'all'"> <div class="tg-arc">
<div class="stat-value">{{ jobs.length }}</div> <svg :width="ARC_SIZE" :height="ARC_SIZE" viewBox="0 0 120 120">
<div class="stat-label">Total</div> <circle class="tg-arc-track" cx="60" cy="60" :r="ARC_R" stroke-width="10" />
<circle class="tg-arc-fill" cx="60" cy="60" :r="ARC_R" stroke-width="10"
:stroke-dasharray="arcCircumference"
:stroke-dashoffset="arcOffset" />
</svg>
<div class="tg-arc-center">
<div class="arc-numerator">{{ doneCount }}</div>
<div class="arc-denom">/ {{ jobs.length || '—' }}</div>
</div> </div>
<div class="stat-card" @click="statFilter = 'todo'">
<div class="stat-value">{{ todoCount }}</div>
<div class="stat-label">A faire</div>
</div> </div>
<div class="stat-card stat-done" @click="statFilter = 'done'"> <div class="hero-progress-text">
<div class="stat-value">{{ doneCount }}</div> <div class="t-h2 t-ink-on-hero">{{ heroHeadline }}</div>
<div class="stat-label">Faits</div> <div class="t-caption t-ink-on-hero-dim">{{ heroSubline }}</div>
</div> </div>
</div> </div>
</div> </div>
</section>
<!-- Job list --> <!-- Job list -->
<div class="jobs-list q-pa-md"> <div class="tg-page tg-page--content">
<!-- Upcoming section --> <!-- In progress (always first most likely what tech is doing) -->
<div v-if="upcomingJobs.length" class="section-label">A VENIR ({{ upcomingJobs.length }})</div> <div v-if="inProgressJobs.length" class="section-head">
<div v-for="(job, idx) in upcomingJobs" :key="job.name" class="job-card" <span class="t-micro">En cours</span>
:class="{ 'job-card-urgent': job.priority === 'urgent' || job.priority === 'high' }" <span class="t-micro t-ink-400">{{ inProgressJobs.length }}</span>
@click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<span class="job-order">{{ idx + 1 }}</span>
<span class="job-id">{{ job.name }}</span>
<span v-if="job.priority === 'urgent' || job.priority === 'high'" class="job-priority-dot" />
</div> </div>
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div> <JobCard v-for="job in inProgressJobs" :key="job.name"
:job="job" status="work"
@click="openSheet(job)" />
<!-- Upcoming -->
<div v-if="upcomingJobs.length" class="section-head" :class="{ 'section-head--spaced': inProgressJobs.length }">
<span class="t-micro">À venir</span>
<span class="t-micro t-ink-400">{{ upcomingJobs.length }}</span>
</div> </div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div> <JobCard v-for="(job, idx) in upcomingJobs" :key="job.name"
<div v-if="job.service_location_name" class="job-card-location"> :job="job" :status="upcomingStatus(job)" :index="idx + 1"
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }} @click="openSheet(job)" />
<!-- Completed (collapsible) -->
<div v-if="completedJobs.length" class="section-head section-head--spaced"
@click="showCompleted = !showCompleted" style="cursor:pointer">
<span class="t-micro">Terminées</span>
<span class="t-micro t-ink-400">{{ completedJobs.length }}</span>
<span class="material-icons section-head-chevron" :class="{ 'is-open': showCompleted }">expand_more</span>
</div> </div>
<div class="job-card-badges"> <template v-if="showCompleted">
<span v-if="job.duration_h" class="job-badge"> <JobCard v-for="job in completedJobs" :key="job.name"
<q-icon name="schedule" size="12px" /> {{ job.duration_h }}h :job="job" status="go" done
@click="openSheet(job)" />
</template>
<!-- Empty state -->
<div v-if="!loading && jobs.length === 0" class="empty-state">
<div class="empty-icon"><span class="material-icons">event_available</span></div>
<div class="t-h2 t-ink-900" style="margin-top:16px">Aucune tâche aujourd'hui</div>
<div class="t-caption" style="margin-top:4px">Profite de la pause </div>
</div>
<!-- Group-subscription feed: unassigned jobs the tech can claim. -->
<!-- Lives below the personal list so techs check it deliberately. -->
<div v-if="groupJobs.length" class="section-head section-head--spaced"
@click="showGroupJobs = !showGroupJobs" style="cursor:pointer">
<span class="material-icons" style="font-size:18px;vertical-align:-4px;margin-right:4px">groups</span>
<span class="t-micro">Tâches du groupe</span>
<span class="t-micro t-ink-400">{{ groupJobs.length }} disponible{{ groupJobs.length > 1 ? 's' : '' }}</span>
<span class="material-icons section-head-chevron" :class="{ 'is-open': showGroupJobs }">expand_more</span>
</div>
<template v-if="showGroupJobs">
<div v-for="job in groupJobs" :key="'grp-' + job.name" class="group-card">
<div class="group-card-row">
<div class="col" style="min-width:0">
<div class="t-caption t-ink-600">{{ job.assigned_group || 'Non assigné' }}
<span v-if="job.priority === 'urgent' || job.priority === 'high'"
class="tg-chip is-compact"
:class="job.priority === 'urgent' ? 'is-stop' : 'is-work'"
style="margin-left:6px">
{{ job.priority === 'urgent' ? 'Urgent' : 'Haute' }}
</span> </span>
</div> </div>
<div class="t-body t-ink-900" style="font-weight:600;margin-top:2px">{{ job.subject }}</div>
<div class="t-caption t-ink-500" style="margin-top:2px">
<template v-if="job.customer_name">{{ job.customer_name }}</template>
<template v-if="job.service_location_name"> · {{ job.service_location_name }}</template>
</div> </div>
<div class="t-micro t-ink-400" style="margin-top:2px">
<!-- In progress section --> <template v-if="job.scheduled_date">
<div v-if="inProgressJobs.length" class="section-label q-mt-md">EN COURS ({{ inProgressJobs.length }})</div> <span class="material-icons" style="font-size:12px;vertical-align:-2px">event</span>
<div v-for="job in inProgressJobs" :key="job.name" class="job-card job-card-progress" @click="openSheet(job)"> {{ fmtDate(job.scheduled_date) }}{{ job.scheduled_time ? ' · ' + fmtTime(job.scheduled_time) : '' }}
<div class="job-card-header"> </template>
<div class="job-card-left"> <template v-else>Non planifié</template>
<q-spinner-dots size="14px" color="orange" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div> </div>
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div> </div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div> <button class="tg-btn is-primary is-compact" :disabled="claimingJob === job.name" @click="claimJob(job)">
<div v-if="job.service_location_name" class="job-card-location"> <span v-if="claimingJob === job.name" class="material-icons spinning" style="font-size:16px">autorenew</span>
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }} <span v-else class="material-icons" style="font-size:16px">pan_tool</span>
Prendre
</button>
</div>
</div>
</template>
<div v-if="!loading && jobs.length === 0 && groupJobs.length === 0" class="empty-state-hint">
<span class="t-caption t-ink-500">Aucune tâche disponible dans vos groupes non plus.</span>
</div> </div>
</div> </div>
<!-- Completed section --> <!-- Peek sheet: job detail -->
<div v-if="completedJobs.length && (statFilter === 'all' || statFilter === 'done')" class="section-label q-mt-md">TERMINES ({{ completedJobs.length }})</div> <transition name="fade">
<div v-for="job in (statFilter === 'all' || statFilter === 'done' ? completedJobs : [])" :key="job.name" <div v-if="sheetOpen" class="tg-peek-backdrop is-open" @click="sheetOpen = false" />
class="job-card job-card-done" @click="openSheet(job)"> </transition>
<div class="job-card-header"> <div class="tg-peek" :class="{ 'is-open': sheetOpen }" v-if="sheetJob">
<div class="job-card-left"> <div class="tg-peek-handle" />
<q-icon name="check_circle" size="16px" color="green" class="q-mr-xs" /> <div style="padding: 8px 20px 20px">
<span class="job-id">{{ job.name }}</span> <!-- Title row -->
</div> <div class="peek-title-row">
<div class="job-time text-grey">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title text-grey">{{ job.subject || 'Sans titre' }}</div>
</div>
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl q-pa-lg">
<q-icon name="event_available" size="48px" color="grey-4" class="q-mb-md" /><br>
Aucun job aujourd'hui
</div>
</div>
<!-- Job detail bottom sheet -->
<q-dialog v-model="sheetOpen" position="bottom" seamless>
<q-card class="bottom-sheet" v-if="sheetJob">
<div class="sheet-handle" />
<q-card-section class="q-pb-sm">
<div class="row items-start no-wrap">
<div class="col">
<div class="row items-center q-gutter-xs q-mb-xs">
<span class="sheet-job-id">{{ sheetJob.name }}</span>
<span v-if="sheetJob.priority === 'urgent' || sheetJob.priority === 'high'" class="job-priority-dot" />
<q-badge v-if="sheetJob.priority === 'urgent'" color="red" label="Urgent" />
<q-badge v-else-if="sheetJob.priority === 'high'" color="orange" label="Haute" />
</div>
<div class="sheet-title">{{ sheetJob.subject || 'Sans titre' }}</div>
</div>
<q-btn flat dense round icon="close" @click="sheetOpen = false" />
</div>
</q-card-section>
<!-- Address -->
<q-card-section v-if="sheetJob.service_location_name" class="q-py-sm">
<div class="sheet-info-row">
<q-icon name="place" size="20px" color="red-5" class="q-mr-sm" />
<div class="col">
<div class="sheet-info-label">Adresse</div>
<div class="sheet-info-value">{{ sheetJob.service_location_name }}</div>
</div>
<q-btn flat dense color="primary" label="Carte" icon="navigation" @click="openGps(sheetJob)" />
</div>
</q-card-section>
<!-- Duration + client -->
<q-card-section class="q-py-sm">
<div class="row q-gutter-md">
<div v-if="sheetJob.duration_h" class="sheet-info-row col">
<q-icon name="schedule" size="20px" color="grey-6" class="q-mr-sm" />
<div> <div>
<div class="sheet-info-label">Duree estimee</div> <div class="t-caption" style="color:var(--tg-700);font-weight:700">{{ sheetJob.name }}</div>
<div class="sheet-info-value">{{ sheetJob.duration_h }}h</div> <div class="t-h2" style="margin-top:2px">{{ sheetJob.subject || 'Sans titre' }}</div>
</div> </div>
<button class="peek-close" @click="sheetOpen = false" aria-label="Fermer">
<span class="material-icons">close</span>
</button>
</div> </div>
<div v-if="sheetJob.customer_name" class="sheet-info-row col">
<q-icon name="person" size="20px" color="grey-6" class="q-mr-sm" /> <!-- Priority chip -->
<div v-if="sheetJob.priority === 'urgent' || sheetJob.priority === 'high'" style="margin-top:8px">
<span class="tg-chip" :class="sheetJob.priority === 'urgent' ? 'is-stop' : 'is-work'">
<span class="tg-chip-dot" />
{{ sheetJob.priority === 'urgent' ? 'Urgent' : 'Haute priorité' }}
</span>
</div>
<!-- Info rows -->
<div v-if="sheetJob.service_location_name" class="info-row">
<span class="material-icons info-icon" style="color:var(--sig-stop)">place</span>
<div class="col">
<div class="t-caption">Adresse</div>
<div class="t-body">{{ sheetJob.service_location_name }}</div>
</div>
<button class="tg-btn is-ghost is-compact" @click="openGps(sheetJob)">
<span class="material-icons" style="font-size:18px">navigation</span>
Carte
</button>
</div>
<div class="info-row-pair">
<div v-if="sheetJob.scheduled_time" class="info-row">
<span class="material-icons info-icon">schedule</span>
<div> <div>
<div class="sheet-info-label">Client</div> <div class="t-caption">Heure</div>
<div class="sheet-info-value">{{ sheetJob.customer_name }}</div> <div class="t-body">{{ fmtTime(sheetJob.scheduled_time) }}</div>
</div>
</div>
<div v-if="sheetJob.customer_name" class="info-row">
<span class="material-icons info-icon">person</span>
<div>
<div class="t-caption">Client</div>
<div class="t-body">{{ sheetJob.customer_name }}</div>
</div> </div>
</div> </div>
</div> </div>
</q-card-section>
<!-- Description --> <!-- Description -->
<q-card-section v-if="sheetJob.description" class="q-py-sm"> <div v-if="sheetJob.description" class="info-row-stack">
<div class="sheet-info-label q-mb-xs">Notes</div> <div class="t-caption">Notes</div>
<div class="text-body2" v-html="sheetJob.description" /> <div class="t-body-sm" v-html="sheetJob.description" />
</q-card-section> </div>
<!-- Action buttons --> <!-- Primary action (contextual to status) -->
<q-card-section class="q-pt-sm"> <div style="display:flex;flex-direction:column;gap:10px;margin-top:20px">
<div class="row q-gutter-sm"> <button v-if="isScheduled(sheetJob)"
<q-btn v-if="sheetJob.status === 'Scheduled' || sheetJob.status === 'assigned'" unelevated color="indigo" icon="directions_car" class="tg-btn is-primary is-block"
label="En route" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" /> @click="doStatus(sheetJob, 'In Progress')" :disabled="saving">
<q-btn v-if="sheetJob.status === 'In Progress' || sheetJob.status === 'in_progress'" unelevated color="positive" icon="check_circle" <span class="material-icons">directions_car</span>
label="Terminer" class="col action-btn" @click="doStatus(sheetJob, 'Completed')" :loading="saving" /> En route
<q-btn v-if="sheetJob.status === 'Completed'" unelevated color="blue-grey" icon="replay" </button>
label="Rouvrir" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" /> <button v-if="isInProgress(sheetJob)"
class="tg-btn is-success is-block"
@click="doStatus(sheetJob, 'Completed')" :disabled="saving">
<span class="material-icons">check_circle</span>
Marquer comme terminée
</button>
<!-- Testing shortcut: direct Completed from any non-done state -->
<button v-if="canTerminateDirect(sheetJob)"
class="tg-btn is-outline is-compact"
@click="doStatus(sheetJob, 'Completed')" :disabled="saving">
<span class="material-icons" style="font-size:18px">fast_forward</span>
Terminer directement (test)
</button>
<button v-if="sheetJob.status === 'Completed'"
class="tg-btn is-ghost is-block"
@click="doStatus(sheetJob, 'In Progress')" :disabled="saving">
<span class="material-icons">replay</span>
Rouvrir
</button>
<!-- Secondary actions -->
<div class="peek-actions-row">
<button class="tg-btn is-outline is-compact" @click="goScan(sheetJob)">
<span class="material-icons" style="font-size:18px">qr_code_scanner</span>
Scanner
</button>
<button class="tg-btn is-outline is-compact" @click="goDetail(sheetJob)">
<span class="material-icons" style="font-size:18px">open_in_full</span>
Détails
</button>
</div>
</div>
</div> </div>
<div class="row q-gutter-sm q-mt-xs">
<q-btn outline color="primary" icon="qr_code_scanner" label="Scanner" class="col"
@click="goScan(sheetJob)" />
<q-btn outline color="grey-8" icon="open_in_full" label="Details" class="col"
@click="goDetail(sheetJob)" />
</div> </div>
</q-card-section>
</q-card>
</q-dialog>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' import JobCard from '../components/JobCard.vue'
const props = defineProps({ const props = defineProps({
token: { type: String, default: '' }, token: { type: String, default: '' },
@ -187,71 +257,126 @@ const props = defineProps({
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const statFilter = ref('all')
const jobs = ref([]) const jobs = ref([])
const saving = ref(false) const saving = ref(false)
const isOnline = ref(navigator.onLine) const isOnline = ref(navigator.onLine)
// Magic link: tech identity from token verification const showCompleted = ref(false)
const magicTechId = ref('') const magicTechId = ref('')
// Bottom sheet // Group subscription feed unassigned claimable jobs.
// Showing them below the personal list acts as an opt-in subscription:
// techs with idle time scroll down and pick up extra work without a
// dispatcher round-trip.
const groupJobs = ref([])
const showGroupJobs = ref(true)
const claimingJob = ref('')
// Peek sheet state
const sheetOpen = ref(false) const sheetOpen = ref(false)
const sheetJob = ref(null) const sheetJob = ref(null)
// Tech identity
const techName = ref('Technicien')
const initials = computed(() => {
const parts = techName.value.split(' ')
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: techName.value.slice(0, 2).toUpperCase()
})
// Progress arc math
const ARC_SIZE = 96
const ARC_R = 50
const arcCircumference = 2 * Math.PI * ARC_R
const arcPct = computed(() => jobs.value.length ? doneCount.value / jobs.value.length : 0)
const arcOffset = computed(() => arcCircumference * (1 - arcPct.value))
// Date + counts
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
const todayLabel = computed(() => const todayLabel = computed(() =>
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' }) new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
) )
// Get tech name from Authentik headers (X-Authentik-Name) or fallback
const techName = ref('Technicien')
const initials = computed(() => {
const parts = techName.value.split(' ')
return parts.length >= 2 ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() : techName.value.slice(0, 2).toUpperCase()
})
const todoCount = computed(() => jobs.value.filter(j => j.status !== 'Completed' && j.status !== 'Cancelled').length)
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length) const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
const upcomingJobs = computed(() => { const inProgressJobs = computed(() =>
if (statFilter.value === 'done') return [] jobs.value.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
return jobs.value.filter(j => j.status === 'Scheduled' || j.status === 'assigned' || j.status === 'open') )
const upcomingJobs = computed(() =>
jobs.value
.filter(j => ['open', 'Scheduled', 'assigned', 'Open', 'Assigned'].includes(j.status))
.sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || '')) .sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || ''))
}) )
const inProgressJobs = computed(() => {
if (statFilter.value === 'done') return []
return jobs.value.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
})
const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed')) const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed'))
const heroHeadline = computed(() => {
if (!jobs.value.length) return 'Aucune tâche'
if (doneCount.value === jobs.value.length) return 'Journée terminée 🎉'
if (doneCount.value === 0) return `${upcomingJobs.value.length + inProgressJobs.value.length} à faire`
return `${doneCount.value} sur ${jobs.value.length} terminées`
})
const heroSubline = computed(() => {
const next = inProgressJobs.value[0] || upcomingJobs.value[0]
if (!next) return 'Profite de la pause ☕'
return `Prochain · ${fmtTime(next.scheduled_time) || 'dès que possible'}`
})
// Card status classification
function upcomingStatus (job) {
if (job.priority === 'urgent') return 'stop'
if (job.priority === 'high') return 'work'
return 'tg'
}
function isScheduled (j) { return ['Scheduled', 'assigned', 'open', 'Open', 'Assigned'].includes(j.status) }
function isInProgress (j) { return ['In Progress', 'in_progress'].includes(j.status) }
function canTerminateDirect (j) {
// Shown only when tech is testing the flow direct skip from openCompleted
return j.status && j.status !== 'Completed' && j.status !== 'Cancelled'
}
function fmtTime (t) { function fmtTime (t) {
if (!t) return '' if (!t) return ''
const [h, m] = t.split(':') const [h, m] = t.split(':')
return `${h}h${m}` return `${h}h${m}`
} }
function fmtDate (iso) {
function openSheet (job) { if (!iso) return ''
sheetJob.value = job try {
sheetOpen.value = true const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
} catch { return iso }
} }
// Use the same auth pattern as ops app nginx injects token function openSheet (job) { sheetJob.value = job; sheetOpen.value = true }
// API calls
async function apiFetch (url) { async function apiFetch (url) {
const res = await fetch(BASE_URL + url) const res = await fetch(BASE_URL + url)
if (!res.ok) throw new Error('API ' + res.status) if (!res.ok) throw new Error('API ' + res.status)
return (await res.json()).data || [] return (await res.json()).data || []
} }
async function apiUpdate (doctype, name, data) { /**
const res = await fetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), { * Update job status through targo-hub (not direct ERPNext PUT).
method: 'PUT', * The /dispatch/job-status endpoint is the canonical write path:
* 1. PUTs the status change to ERPNext
* 2. Walks the chain (unblocks depends_on children on Completed)
* 3. Broadcasts SSE so other clients refresh in real time
* Going direct to ERPNext would skip steps 2+3 and the chain wouldn't walk.
*/
async function apiSetStatus (jobName, status) {
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify({ job: jobName, status }),
}) })
if (!res.ok) throw new Error('Update failed') if (!res.ok) {
const err = await res.text().catch(() => 'Unknown error')
throw new Error(`Status update failed (${res.status}): ${err}`)
}
return res.json()
} }
/** Verify magic link token via targo-hub and return tech_id */
async function verifyMagicToken (token) { async function verifyMagicToken (token) {
try { try {
const res = await fetch(`${HUB_URL}/magic-link/verify?token=${encodeURIComponent(token)}`) const res = await fetch(`${HUB_URL}/magic-link/verify?token=${encodeURIComponent(token)}`)
@ -266,7 +391,6 @@ async function loadTasks () {
try { try {
let techId = '' let techId = ''
// 1. If magic link token provided, verify it and get tech identity
if (props.token) { if (props.token) {
techId = await verifyMagicToken(props.token) techId = await verifyMagicToken(props.token)
if (!techId) { if (!techId) {
@ -275,14 +399,13 @@ async function loadTasks () {
return return
} }
magicTechId.value = techId magicTechId.value = techId
// Load tech full name from ERPNext
try { try {
const techs = await apiFetch('/api/resource/Dispatch Technician/' + encodeURIComponent(techId) + '?fields=["name","full_name"]') const techDoc = await apiFetch('/api/resource/Dispatch Technician/' + encodeURIComponent(techId) + '?fields=["name","full_name"]')
if (techs && techs.full_name) techName.value = techs.full_name if (techDoc && techDoc.full_name) techName.value = techDoc.full_name
else techName.value = techId else techName.value = techId
} catch { techName.value = techId } } catch { techName.value = techId }
} else { } else {
// 2. Fallback: identify tech from Authentik session (nginx-injected auth) // Authentik session fallback
try { try {
const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user') const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user')
if (me.ok) { if (me.ok) {
@ -293,29 +416,39 @@ async function loadTasks () {
const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1') const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1')
if (techs.length) { if (techs.length) {
techId = techs[0].name techId = techs[0].name
if (techs[0].full_name) techName.value = techs[0].full_name techName.value = techs[0].full_name || prettify(userName)
else techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
} else { } else {
techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) techName.value = prettify(userName)
} }
} catch { techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } } catch { techName.value = prettify(userName) }
} }
} }
} catch {} } catch {}
} }
// Build filters: if we know the tech, filter by assigned_tech + date // Filter: open/scheduled/in-progress/completed (everything except 'On Hold' which
const filters = techId // is the chain-gated pending state those shouldn't show in the active list)
? [['scheduled_date', '=', today], ['assigned_tech', '=', techId]] const statusFilter = ['open', 'Open', 'Scheduled', 'assigned', 'Assigned', 'In Progress', 'in_progress', 'Completed']
: { scheduled_date: today } const filterArr = [
['scheduled_date', '=', today],
['status', 'in', statusFilter],
]
if (techId) filterArr.push(['assigned_tech', '=', techId])
const params = new URLSearchParams({ const params = new URLSearchParams({
fields: JSON.stringify(['name', 'subject', 'status', 'customer', 'customer_name', 'service_location', fields: JSON.stringify(['name', 'subject', 'status', 'customer', 'customer_name',
'service_location_name', 'scheduled_time', 'description', 'job_type', 'duration_h', 'priority']), 'service_location', 'service_location_name', 'scheduled_time', 'description',
filters: JSON.stringify(filters), 'job_type', 'duration_h', 'priority', 'depends_on', 'step_order']),
filters: JSON.stringify(filterArr),
limit_page_length: 50, limit_page_length: 50,
order_by: 'scheduled_time asc', order_by: 'scheduled_time asc',
}) })
jobs.value = await apiFetch('/api/resource/Dispatch Job?' + params) jobs.value = await apiFetch('/api/resource/Dispatch Job?' + params)
// Load the group-subscription feed alongside the personal list. Fire-and-
// forget if it fails we just hide the section instead of blocking the
// main UX.
await loadGroupJobs().catch(() => {})
} catch (e) { } catch (e) {
Notify.create({ type: 'warning', message: 'Erreur chargement: ' + e.message }) Notify.create({ type: 'warning', message: 'Erreur chargement: ' + e.message })
} finally { } finally {
@ -323,14 +456,80 @@ async function loadTasks () {
} }
} }
/**
* Pull unassigned claimable jobs from the hub. The hub endpoint filters by
* status {open, Scheduled} and assigned_tech empty it does NOT filter by
* the tech's own group yet (we don't track techgroup membership yet; that's
* a future tags/skills layer). In the meantime techs see the whole pool with
* the `assigned_group` badge visible so they can self-select what they can
* handle.
*/
async function loadGroupJobs () {
try {
const res = await fetch(`${HUB_URL}/dispatch/group-jobs`)
if (!res.ok) { groupJobs.value = []; return }
const data = await res.json()
groupJobs.value = (data.jobs || []).filter(j => j.assigned_group)
} catch {
groupJobs.value = []
}
}
async function claimJob (job) {
const techId = magicTechId.value
if (!techId) {
Notify.create({ type: 'warning', message: 'Identité technicien introuvable — connexion requise.' })
return
}
claimingJob.value = job.name
try {
const res = await fetch(`${HUB_URL}/dispatch/claim-job`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job: job.name, tech_id: techId }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
if (res.status === 409) {
Notify.create({ type: 'warning', message: data.error || 'Déjà pris.', timeout: 4500 })
} else {
Notify.create({ type: 'negative', message: 'Échec: ' + (data.error || res.status) })
}
await loadTasks()
return
}
Notify.create({ type: 'positive', message: `Tâche prise en charge: ${job.subject}`, icon: 'check' })
// Optimistic: remove from the group list, reload personal list
groupJobs.value = groupJobs.value.filter(j => j.name !== job.name)
await loadTasks()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur réseau: ' + e.message })
} finally {
claimingJob.value = ''
}
}
function prettify (email) {
return email.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
async function doStatus (job, status) { async function doStatus (job, status) {
saving.value = true saving.value = true
try { try {
await apiUpdate('Dispatch Job', job.name, { status }) const result = await apiSetStatus(job.name, status)
job.status = status job.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job termine' } const msgs = {
'In Progress': 'En route ! 🚗',
Completed: result?.unblocked?.length
? `Tâche terminée — ${result.unblocked.length} prochaine${result.unblocked.length > 1 ? 's' : ''} tâche${result.unblocked.length > 1 ? 's' : ''} débloquée${result.unblocked.length > 1 ? 's' : ''}`
: 'Tâche terminée ✓',
}
Notify.create({ type: 'positive', message: msgs[status] || status }) Notify.create({ type: 'positive', message: msgs[status] || status })
if (status === 'Completed') sheetOpen.value = false if (status === 'Completed') {
sheetOpen.value = false
// Reload to pick up unblocked siblings/children
await loadTasks()
}
} catch (e) { } catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { } finally {
@ -356,76 +555,198 @@ function goDetail (job) {
router.push({ path: '/j/job/' + job.name }) router.push({ path: '/j/job/' + job.name })
} }
// SSE: real-time chain updates
// When another surface (dispatcher, another tech, webhook) completes a job,
// we receive `job-unblocked` events for any dependents that just opened up.
// Just refresh the list cheaper than surgical state merging and avoids
// race conditions when multiple events arrive in quick succession.
let sseSource = null
function connectSSE () {
try {
sseSource = new EventSource(`${HUB_URL}/sse?topics=dispatch`)
sseSource.addEventListener('job-unblocked', (e) => {
try {
const data = JSON.parse(e.data)
// Only refresh if it's for us (or no tech specified conservative refresh)
if (!data.tech || data.tech === magicTechId.value) {
loadTasks()
Notify.create({
type: 'info',
message: `Nouvelle tâche débloquée : ${data.subject || data.job}`,
icon: 'flag',
timeout: 4000,
})
}
} catch {}
})
sseSource.addEventListener('job-status', (e) => {
// Picks up status changes made by dispatchers or other techs
try {
const data = JSON.parse(e.data)
const local = jobs.value.find(j => j.name === data.job)
if (local && local.status !== data.status) local.status = data.status
} catch {}
})
sseSource.addEventListener('job-claimed', (e) => {
// Another tech took a job drop it from our claimable feed so we
// don't try to grab something someone else already has.
try {
const data = JSON.parse(e.data)
if (data.tech !== magicTechId.value) {
groupJobs.value = groupJobs.value.filter(j => j.name !== data.job)
}
} catch {}
})
} catch {}
}
function disconnectSSE () {
if (sseSource) { sseSource.close(); sseSource = null }
}
onMounted(() => { onMounted(() => {
window.addEventListener('online', () => { isOnline.value = true }) window.addEventListener('online', () => { isOnline.value = true })
window.addEventListener('offline', () => { isOnline.value = false }) window.addEventListener('offline', () => { isOnline.value = false })
loadTasks() loadTasks()
connectSSE()
}) })
onUnmounted(disconnectSSE)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tasks-page { padding: 0 !important; background: #eef1f5; min-height: 100vh; } // Group subscription card claimable jobs visually distinct from personal ones
.group-card {
.tech-header { margin-top: 10px;
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%); padding: 12px 14px;
color: white; padding: 16px 16px 0; border-radius: 0 0 20px 20px; background: var(--tg-bg-card, #fff);
border: 1px dashed var(--tg-border, #d0d7de);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
} }
.tech-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .group-card-row {
.tech-date { font-size: 0.78rem; opacity: 0.7; text-transform: capitalize; } display: flex; align-items: center; gap: 12px;
.tech-name { font-size: 1.25rem; font-weight: 700; }
.tech-header-right { display: flex; align-items: center; gap: 8px; }
.tech-status-badge { font-size: 0.65rem; padding: 3px 8px; border-radius: 10px; }
.tech-avatar { font-weight: 700; font-size: 0.8rem; }
.stats-row { display: flex; gap: 10px; margin: 0 -4px; transform: translateY(24px); }
.stat-card {
flex: 1; background: #4a4880; border-radius: 12px; padding: 12px 8px;
text-align: center; cursor: pointer; transition: background 0.15s;
&:active { background: #5a589a; }
} }
.stat-card.stat-done .stat-value { color: #ff6b6b; } .empty-state-hint {
.stat-value { font-size: 1.5rem; font-weight: 800; line-height: 1.2; } margin-top: 16px;
.stat-label { font-size: 0.7rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; } text-align: center;
.jobs-list { padding-top: 36px !important; }
.section-label { font-size: 0.72rem; font-weight: 700; color: #8b8fa3; letter-spacing: 0.05em; margin-bottom: 8px; }
.job-card {
background: white; border-radius: 14px; padding: 14px 16px; margin-bottom: 10px;
cursor: pointer; border-left: 4px solid #5c59a8;
box-shadow: 0 1px 4px rgba(0,0,0,0.05); transition: transform 0.1s;
&:active { transform: scale(0.98); }
} }
.job-card-urgent { border-left-color: #ef4444; } .tg-chip.is-compact {
.job-card-progress { border-left-color: #f59e0b; background: #fffbeb; } padding: 2px 8px;
.job-card-done { border-left-color: #22c55e; opacity: 0.7; } font-size: 11px;
border-radius: 999px;
display: inline-flex; align-items: center; gap: 4px;
}
// `.spinning` + `@keyframes spin` already defined below for the hero refresh button.
.job-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } // Hero row layouts (the rest is from tech.scss global tokens)
.job-card-left { display: flex; align-items: center; gap: 6px; } .hero-top {
.job-order { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 22px;
}
.hero-left {
display: flex; align-items: center; gap: 12px;
}
.hero-name {
font: 700 1.125rem/1.2 system-ui, -apple-system, sans-serif;
color: #fff; letter-spacing: -0.005em;
}
.hero-refresh {
width: 40px; height: 40px;
border-radius: 50%;
border: 0; background: rgba(255,255,255,0.12);
color: #fff;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%; cursor: pointer;
background: #5c59a8; color: white; font-size: 0.7rem; font-weight: 700; transition: background 160ms;
&:active { background: rgba(255,255,255,0.22); }
&:disabled { opacity: 0.6; }
.spinning { animation: spin 0.9s linear infinite; }
} }
.job-id { font-size: 0.78rem; font-weight: 600; color: #5c59a8; } @keyframes spin { to { transform: rotate(360deg); } }
.job-priority-dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; display: inline-block; }
.job-time { font-size: 0.82rem; font-weight: 600; color: #374151; }
.job-card-title { font-size: 0.95rem; font-weight: 600; color: #1f2937; margin-bottom: 4px; } .hero-progress {
.job-card-location { font-size: 0.78rem; color: #6b7280; margin-bottom: 6px; display: flex; align-items: center; gap: 2px; } display: flex; align-items: center; gap: 18px;
.job-card-badges { display: flex; gap: 8px; } }
.job-badge { .hero-progress-text { flex: 1; min-width: 0; }
display: inline-flex; align-items: center; gap: 3px;
background: #f3f4f6; border-radius: 8px; padding: 2px 8px; .arc-numerator {
font-size: 0.72rem; font-weight: 600; color: #6b7280; font: 800 1.75rem/1 system-ui, -apple-system, sans-serif;
color: #fff; letter-spacing: -0.02em;
}
.arc-denom {
font: 600 0.75rem/1 system-ui, -apple-system, sans-serif;
color: rgba(255,255,255,0.65); margin-top: 2px;
} }
.bottom-sheet { border-radius: 20px 20px 0 0; max-height: 70vh; width: 100%; } // Content padding (hero handles top; q-page bottom-reserve handles tab-island)
.sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 10px auto 4px; } .tg-page--content {
.sheet-job-id { font-size: 0.82rem; font-weight: 700; color: #5c59a8; } padding: 20px 16px 8px;
.sheet-title { font-size: 1.15rem; font-weight: 700; color: #1f2937; } }
.sheet-info-row { display: flex; align-items: center; }
.sheet-info-label { font-size: 0.72rem; color: #9ca3af; } // Section header
.sheet-info-value { font-size: 0.95rem; font-weight: 600; color: #1f2937; } .section-head {
.action-btn { font-weight: 700; border-radius: 12px; min-height: 48px; font-size: 0.9rem; } display: flex; align-items: center; gap: 8px;
padding: 0 4px;
margin-bottom: 10px;
&--spaced { margin-top: 22px; }
.section-head-chevron {
margin-left: auto;
font-size: 20px; color: var(--ink-400);
transition: transform 200ms;
&.is-open { transform: rotate(180deg); }
}
}
// Peek sheet internals
.peek-title-row {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
}
.peek-close {
width: 36px; height: 36px;
border: 0; background: var(--surface-wash);
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--ink-700);
&:active { background: var(--tg-100); }
}
.info-row {
display: flex; align-items: center; gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(15,23,42,0.05);
.col { flex: 1; min-width: 0; }
.info-icon { font-size: 20px; color: var(--ink-500); }
}
.info-row-pair {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
.info-row { border-bottom: 1px solid rgba(15,23,42,0.05); padding: 12px 0; }
}
.info-row-stack {
padding: 12px 0;
border-bottom: 1px solid rgba(15,23,42,0.05);
.t-body-sm { margin-top: 4px; }
}
.peek-actions-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
// Empty state
.empty-state {
text-align: center; padding: 60px 24px;
.empty-icon {
width: 72px; height: 72px;
border-radius: 50%;
background: var(--tg-50);
display: inline-flex; align-items: center; justify-content: center;
margin-bottom: 8px;
.material-icons { font-size: 36px; color: var(--tg-500); }
}
}
// Transitions
.fade-enter-active, .fade-leave-active { transition: opacity 200ms; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-down-enter-active, .slide-down-leave-active {
transition: transform 240ms cubic-bezier(0.32, 0.72, 0, 1), opacity 200ms;
}
.slide-down-enter-from, .slide-down-leave-to { transform: translateY(-100%); opacity: 0; }
</style> </style>

View File

@ -27,6 +27,88 @@
<!-- TAB: GPON --> <!-- TAB: GPON -->
<div v-show="activeTab === 'gpon'"> <div v-show="activeTab === 'gpon'">
<!-- Pause polling (test phase) -->
<div class="poller-card q-mb-md">
<div class="row items-center no-wrap q-gutter-md">
<q-icon name="bolt" size="22px"
:color="pollerState.device.paused || pollerState.olt.paused ? 'negative' : 'positive'" />
<div class="col">
<div class="text-weight-medium">Pollers réseau</div>
<div class="text-caption text-grey-7">
Désactive temporairement les sondes GenieACS + SNMP pendant la phase de test.
<span v-if="pollerState.device.paused || pollerState.olt.paused" class="text-negative">
· Certains pollers sont en pause.
</span>
</div>
</div>
<q-btn flat dense size="sm" icon="refresh" :loading="pollerLoading" @click="fetchPollerState" />
</div>
<q-separator spaced />
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6">
<div class="row items-center no-wrap">
<div class="col">
<div class="text-body2 text-weight-medium row items-center q-gutter-xs">
<span>Poller ONT (GenieACS)</span>
<q-chip
dense square size="sm"
:color="pollerState.device.paused ? 'negative' : 'positive'"
text-color="white"
:icon="pollerState.device.paused ? 'pause' : 'check'"
:label="pollerState.device.paused ? 'En pause' : 'Actif'"
class="poller-chip"
/>
</div>
<div class="text-caption text-grey-6">
{{ pollerState.device.paused ? 'Sondes GenieACS arrêtées' : 'Sonde ~6000 appareils / 5 min' }}
<template v-if="pollerState.device.lastChange">
· dernier changement {{ formatPollerTs(pollerState.device.lastChange) }}
</template>
</div>
</div>
<q-toggle
:model-value="!pollerState.device.paused"
color="positive"
:disable="pollerLoading"
@update:model-value="(v) => setPollerPaused('device', !v)"
/>
</div>
</div>
<div class="col-12 col-sm-6">
<div class="row items-center no-wrap">
<div class="col">
<div class="text-body2 text-weight-medium row items-center q-gutter-xs">
<span>Poller OLT (SNMP)</span>
<q-chip
dense square size="sm"
:color="pollerState.olt.paused ? 'negative' : 'positive'"
text-color="white"
:icon="pollerState.olt.paused ? 'pause' : 'check'"
:label="pollerState.olt.paused ? 'En pause' : 'Actif'"
class="poller-chip"
/>
</div>
<div class="text-caption text-grey-6">
{{ pollerState.olt.paused ? 'Sondes SNMP arrêtées' : '4 OLT / 5 min' }}
<template v-if="pollerState.olt.lastChange">
· dernier changement {{ formatPollerTs(pollerState.olt.lastChange) }}
</template>
</div>
</div>
<q-toggle
:model-value="!pollerState.olt.paused"
color="positive"
:disable="pollerLoading"
@update:model-value="(v) => setPollerPaused('olt', !v)"
/>
</div>
</div>
</div>
</div>
<!-- Search bar (GPON tab) --> <!-- Search bar (GPON tab) -->
<div class="row items-center q-mb-md q-col-gutter-sm"> <div class="row items-center q-mb-md q-col-gutter-sm">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
@ -629,6 +711,52 @@ const locations = ref([])
const oltRegistry = ref([]) // OLTs with metadata (address, coords) const oltRegistry = ref([]) // OLTs with metadata (address, coords)
const snmpStats = reactive({}) // keyed by OLT IP { onuCount, onlineCount, lastPoll, lastError } const snmpStats = reactive({}) // keyed by OLT IP { onuCount, onlineCount, lastPoll, lastError }
// Poller pause (test phase) hub /admin/pollers state
const pollerLoading = ref(false)
const pollerState = reactive({
device: { paused: false, lastChange: null, reason: '' },
olt: { paused: false, lastChange: null, reason: '' },
})
async function fetchPollerState () {
pollerLoading.value = true
try {
const res = await fetch(`${hubUrl}/admin/pollers`)
if (res.ok) {
const data = await res.json()
if (data.device) Object.assign(pollerState.device, data.device)
if (data.olt) Object.assign(pollerState.olt, data.olt)
}
} catch (e) { /* silent — leave defaults */ }
finally { pollerLoading.value = false }
}
async function setPollerPaused (kind, paused) {
pollerLoading.value = true
try {
const res = await fetch(`${hubUrl}/admin/pollers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [kind]: { paused, reason: paused ? 'Test phase — ops SPA toggle' : '' } }),
})
if (res.ok) {
const data = await res.json()
if (data.state?.device) Object.assign(pollerState.device, data.state.device)
if (data.state?.olt) Object.assign(pollerState.olt, data.state.olt)
Notify.create({
type: paused ? 'warning' : 'positive',
message: `Poller ${kind === 'device' ? 'ONT' : 'OLT'} ${paused ? 'mis en pause' : 'réactivé'}`,
position: 'top', timeout: 1800,
})
} else throw new Error(`HTTP ${res.status}`)
} catch (e) {
Notify.create({ type: 'negative', message: `Échec: ${e.message}`, position: 'top' })
} finally { pollerLoading.value = false }
}
function formatPollerTs (iso) {
if (!iso) return ''
try { return new Date(iso).toLocaleString('fr-CA', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short' }) }
catch { return iso }
}
// Edit location state // Edit location state
const editingLoc = ref(null) const editingLoc = ref(null)
const editForm = reactive({ olt_ip: '', slot: 0, port: 0, ont_id: 0, ont_serial: '', address_line: '', city: '', postal_code: '' }) const editForm = reactive({ olt_ip: '', slot: 0, port: 0, ont_id: 0, ont_serial: '', address_line: '', city: '', postal_code: '' })
@ -1398,6 +1526,7 @@ onMounted(() => {
loadOltRegistry() loadOltRegistry()
loadLocations() loadLocations()
fetchSnmpStats() fetchSnmpStats()
fetchPollerState()
// Auto-load topology if on that tab // Auto-load topology if on that tab
if (activeTab.value === 'topology') loadTopology() if (activeTab.value === 'topology') loadTopology()
}) })
@ -1406,6 +1535,21 @@ onMounted(() => {
<style lang="scss"> <style lang="scss">
.net-tree { max-width: 1100px; } .net-tree { max-width: 1100px; }
.poller-card {
padding: 14px 16px;
background: var(--ops-surface, #fff);
border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.poller-chip {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: .02em;
.q-icon { font-size: 14px; }
}
.net-olt { margin-bottom: 4px; } .net-olt { margin-bottom: 4px; }
.net-olt-header { .net-olt-header {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;

View File

@ -171,18 +171,26 @@ async function createDeferredJobs (steps, ctx, quotationName) {
const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : '' const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : ''
// Chain gating: only the root job (no depends_on) is born "open".
// Dependents wait in "On Hold" — dispatch.unblockDependents() flips them
// to "open" when their parent completes. This keeps the tech's active
// list uncluttered (only currently-actionable work shows up).
const payload = { const payload = {
ticket_id: ticketId, ticket_id: ticketId,
subject: step.subject || 'Tâche', subject: step.subject || 'Tâche',
address: ctx.address || '', address: ctx.address || '',
duration_h: step.duration_h || 1, duration_h: step.duration_h || 1,
priority: step.priority || 'medium', priority: step.priority || 'medium',
status: 'open', status: dependsOn ? 'On Hold' : 'open',
job_type: step.job_type || 'Autre', job_type: step.job_type || 'Autre',
source_issue: ctx.issue || '', source_issue: ctx.issue || '',
customer: ctx.customer || '', customer: ctx.customer || '',
service_location: ctx.service_location || '', service_location: ctx.service_location || '',
order_source: 'Quotation', order_source: ctx.order_source || 'Quotation',
// assigned_group drives group-based subscription in the Tech PWA
// (techs see "Tâches du groupe" matching their assigned_group and can
// self-assign). Was previously only in notes which isn't queryable.
assigned_group: step.assigned_group || '',
depends_on: dependsOn, depends_on: dependsOn,
parent_job: parentJob, parent_job: parentJob,
step_order: step.step_order || (i + 1), step_order: step.step_order || (i + 1),
@ -190,7 +198,7 @@ async function createDeferredJobs (steps, ctx, quotationName) {
on_close_webhook: step.on_close_webhook || '', on_close_webhook: step.on_close_webhook || '',
notes: [ notes: [
step.assigned_group ? `Groupe: ${step.assigned_group}` : '', step.assigned_group ? `Groupe: ${step.assigned_group}` : '',
`Quotation: ${quotationName}`, `Source: ${quotationName}`,
'Créé automatiquement après acceptation client', 'Créé automatiquement après acceptation client',
].filter(Boolean).join(' | '), ].filter(Boolean).join(' | '),
scheduled_date: step.scheduled_date || '', scheduled_date: step.scheduled_date || '',
@ -217,67 +225,101 @@ async function createDeferredJobs (steps, ctx, quotationName) {
return createdJobs return createdJobs
} }
// ── Create subscriptions for recurring items on accepted quotation ─────────── // ── Create Service Subscriptions for recurring items on accepted quotation ──
//
// Writes the *custom* Service Subscription doctype (not Frappe's built-in
// Subscription). Rows are born with status='En attente' and start_date=today
// as a placeholder — dispatch.activateSubscriptionForJob() flips status to
// 'Actif' and rewrites start_date to the real activation day when the final
// job in the install chain completes.
//
// Linkage to the chain is implicit: (customer, service_location, status='En
// attente') uniquely identifies a pending subscription. On terminal job
// completion we look it up and activate + prorate.
function _guessServiceCategory (item) {
const name = `${item.item_name || ''} ${item.item_code || ''} ${item.description || ''}`.toLowerCase()
if (/iptv|tv|t[ée]l[ée]|cha[îi]ne/.test(name)) return 'IPTV'
if (/voip|t[ée]l[ée]phon|ligne|pbx/.test(name)) return 'VoIP'
if (/bundle|combo|forfait.*combin|duo|trio/.test(name)) return 'Bundle'
if (/h[ée]bergement|hosting|cloud|email/.test(name)) return 'Hébergement'
if (/internet|fibre|fiber|dsl|\bmbps\b|\bgbps\b/.test(name)) return 'Internet'
return 'Autre'
}
function _extractSpeeds (item) {
const src = `${item.item_name || ''} ${item.description || ''}`
const both = src.match(/(\d+)\s*\/\s*(\d+)\s*mbps/i)
if (both) return { down: parseInt(both[1], 10), up: parseInt(both[2], 10) }
const down = src.match(/(\d+)\s*mbps/i)
if (down) return { down: parseInt(down[1], 10), up: null }
return { down: null, up: null }
}
function _extractDurationMonths (item) {
const m = (item.description || '').match(/×\s*(\d+)\s*mois/i) ||
(item.description || '').match(/(\d+)\s*mois/i)
return m ? parseInt(m[1], 10) : null
}
async function createDeferredSubscriptions (quotation, ctx) { async function createDeferredSubscriptions (quotation, ctx) {
const { erpFetch } = require('./helpers') const { erpFetch } = require('./helpers')
const customer = ctx.customer || quotation.customer || quotation.party_name || '' const customer = ctx.customer || quotation.customer || quotation.party_name || ''
if (!customer) return const serviceLocation = ctx.service_location || ''
if (!customer) return []
if (!serviceLocation) {
log(' ! createDeferredSubscriptions: no service_location in ctx — skipping')
return []
}
const recurringItems = (quotation.items || []).filter(i => const recurringItems = (quotation.items || []).filter(i =>
(i.description || '').includes('$/mois') (i.description || '').includes('$/mois')
) )
if (!recurringItems.length) return if (!recurringItems.length) return []
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
const created = []
for (const item of recurringItems) { for (const item of recurringItems) {
// Extract monthly rate from description like "Service — 49.99$/mois × 12 mois" // Extract monthly rate from description like "Service — 49.99$/mois × 12 mois"
const rateMatch = (item.description || '').match(/([\d.]+)\$\/mois/) const rateMatch = (item.description || '').match(/([\d.]+)\$\/mois/)
const rate = rateMatch ? parseFloat(rateMatch[1]) : item.rate const monthlyPrice = rateMatch ? parseFloat(rateMatch[1]) : Number(item.rate) || 0
const speeds = _extractSpeeds(item)
const durationMonths = _extractDurationMonths(item)
const payload = {
customer,
service_location: serviceLocation,
status: 'En attente', // waits for final job completion
service_category: _guessServiceCategory(item),
plan_name: item.item_name || item.item_code || 'Abonnement',
speed_down: speeds.down || 0,
speed_up: speeds.up || 0,
monthly_price: monthlyPrice,
billing_cycle: 'Mensuel',
contract_duration: durationMonths || 0,
// start_date is required by the doctype — use today as a placeholder.
// Real activation date is rewritten by activateSubscriptionForJob().
start_date: today,
notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`,
}
try { try {
// Create or find Subscription Plan const res = await erpFetch('/api/resource/Service%20Subscription', {
let planName = null
const planPayload = {
plan_name: item.item_name || item.item_code,
item: item.item_code || item.item_name,
currency: 'CAD',
price_determination: 'Fixed Rate',
cost: rate,
billing_interval: 'Month',
billing_interval_count: 1,
}
const planRes = await erpFetch('/api/resource/Subscription%20Plan', {
method: 'POST', method: 'POST',
body: JSON.stringify(planPayload), body: JSON.stringify(payload),
}) })
if (planRes.status === 200 && planRes.data?.data) { if (res.status === 200 && res.data?.data) {
planName = planRes.data.data.name created.push(res.data.data.name)
log(` + Service Subscription ${res.data.data.name} (En attente) — ${item.item_name}`)
} else { } else {
// Try to find existing plan log(` ! Service Subscription creation returned ${res.status} for ${item.item_name}`)
const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code || item.item_name)}`)
if (findRes.status === 200) planName = findRes.data?.data?.name
} }
await erpFetch('/api/resource/Subscription', {
method: 'POST',
body: JSON.stringify({
party_type: 'Customer',
party: customer,
company: 'TARGO',
status: 'Active',
start_date: today,
generate_invoice_at: 'Beginning of the current subscription period',
days_until_due: 30,
follow_calendar_months: 1,
plans: planName ? [{ plan: planName, qty: item.qty || 1 }] : [],
}),
})
log(` + Subscription created for ${item.item_name}`)
} catch (e) { } catch (e) {
log(` ! Subscription creation failed for ${item.item_name}: ${e.message}`) log(` ! Service Subscription creation failed for ${item.item_name}: ${e.message}`)
} }
} }
return created
} }
// ── PDF generation via ERPNext ──────────────────────────────────────────────── // ── PDF generation via ERPNext ────────────────────────────────────────────────
@ -689,4 +731,7 @@ module.exports = {
generateAcceptanceToken, generateAcceptanceToken,
generateAcceptanceLink, generateAcceptanceLink,
createDocuSealSubmission, createDocuSealSubmission,
// Exposed so other modules (contracts.js) can build the same chained
// Dispatch Job structure without duplicating the On-Hold / depends_on logic.
createDeferredJobs,
} }

View File

@ -214,13 +214,15 @@ async function processCheckout (body) {
} }
const parentJob = createdJobs.length > 0 ? createdJobs[0].name : '' const parentJob = createdJobs.length > 0 ? createdJobs[0].name : ''
// Chain gating — root job "open", dependents "On Hold" until parent
// completes (see dispatch.unblockDependents).
const jobPayload = { const jobPayload = {
ticket_id: ticketId, ticket_id: ticketId,
subject: step.subject, subject: step.subject,
address: fullAddress, address: fullAddress,
duration_h: step.duration_h || 1, duration_h: step.duration_h || 1,
priority: step.priority || 'medium', priority: step.priority || 'medium',
status: 'open', status: dependsOn ? 'On Hold' : 'open',
job_type: step.job_type || 'Autre', job_type: step.job_type || 'Autre',
customer: customerName, customer: customerName,
sales_order: orderName || '', sales_order: orderName || '',

View File

@ -405,10 +405,22 @@ async function handle (req, res, method, path) {
log(`Contract ${contractName} accepted by ${payload.sub}`) log(`Contract ${contractName} accepted by ${payload.sub}`)
// Fire flow trigger: on_contract_signed // Fire flow trigger: on_contract_signed — then guarantee tasks exist.
// Non-blocking — flow runtime handles its own errors so the acceptance //
// endpoint never fails because of downstream automation. // The Flow Runtime is the "configurable" path: an admin-defined Flow
_fireFlowTrigger('on_contract_signed', { // Template with trigger_event='on_contract_signed' can fan out Issue +
// Dispatch Job chains. That path is optional though — if no template is
// active, dispatchEvent() silently returns [] and the contract ends up
// with zero downstream tasks (which is exactly what bit us on CTR-00007).
//
// To make contract → tasks bulletproof, we await dispatchEvent and run a
// built-in fallback when nothing matched: one Issue (master ticket) +
// a chained fiber_install project-template (4 jobs, On-Hold gated).
//
// This runs in the background — the HTTP response returns right away.
;(async () => {
try {
const results = await _fireFlowTrigger('on_contract_signed', {
doctype: 'Service Contract', doctype: 'Service Contract',
docname: contractName, docname: contractName,
customer: payload.sub, customer: payload.sub,
@ -416,7 +428,18 @@ async function handle (req, res, method, path) {
contract_type: payload.contract_type, contract_type: payload.contract_type,
signed_at: now, signed_at: now,
}, },
}).catch(e => log('flow trigger on_contract_signed failed:', e.message)) })
const ranCount = Array.isArray(results) ? results.length : 0
if (ranCount > 0) {
log(`[contract] ${contractName}: Flow Runtime handled on_contract_signed (${ranCount} template(s))`)
return
}
log(`[contract] ${contractName}: no active Flow Template for on_contract_signed — running built-in install chain`)
await _createBuiltInInstallChain(contractName, payload)
} catch (e) {
log(`[contract] ${contractName}: on_contract_signed automation failed: ${e.message}`)
}
})()
return json(res, 200, { ok: true, contract: contractName }) return json(res, 200, { ok: true, contract: contractName })
} }
@ -424,6 +447,112 @@ async function handle (req, res, method, path) {
return json(res, 404, { error: 'Contract endpoint not found' }) return json(res, 404, { error: 'Contract endpoint not found' })
} }
// ─────────────────────────────────────────────────────────────────────────────
// Built-in install chain — fallback when no Flow Template handles the signing
// ─────────────────────────────────────────────────────────────────────────────
//
// Pulls the full contract doc to resolve service_location + address, then
// creates:
// 1. One Issue (master ticket) referencing the contract, visible in ERPNext
// Issue list for traceability. Links to customer.
// 2. N Dispatch Jobs from project-templates.js::fiber_install — the first
// is born "open", rest are "On Hold" until their parent completes (chain
// walk via dispatch.unblockDependents).
//
// Idempotency: we tag the Issue with the contract name in subject. If we
// detect an existing Issue for this contract+customer, we skip to avoid
// duplicate chains on retried webhooks.
async function _createBuiltInInstallChain (contractName, payload) {
// 1. Fetch full contract to get service_location, address, customer_name
const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`)
if (r.status !== 200 || !r.data?.data) {
log(`[contract] built-in chain skipped — contract ${contractName} not readable`)
return
}
const contract = r.data.data
const customer = contract.customer || payload.sub
const serviceLocation = contract.service_location || ''
const customerName = contract.customer_name || customer
const contractType = contract.contract_type || payload.contract_type || 'Résidentiel'
// Resolve address for field techs (Dispatch Job needs a geocodable address)
let address = ''
if (serviceLocation) {
try {
const locR = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(serviceLocation)}`)
if (locR.status === 200 && locR.data?.data) {
const loc = locR.data.data
address = [loc.address_line_1, loc.address_line_2, loc.city, loc.province, loc.postal_code]
.filter(Boolean).join(', ')
}
} catch (e) { log(`[contract] address lookup failed: ${e.message}`) }
}
// 2. Idempotency check — did we already build a chain for this contract?
try {
const dupFilters = JSON.stringify([['subject', 'like', `%${contractName}%`], ['customer', '=', customer]])
const dup = await erpFetch(`/api/resource/Issue?filters=${encodeURIComponent(dupFilters)}&limit_page_length=1`)
if (dup.status === 200 && Array.isArray(dup.data?.data) && dup.data.data.length) {
log(`[contract] ${contractName}: chain already exists (Issue ${dup.data.data[0].name}) — skipping`)
return
}
} catch { /* non-fatal — proceed to create */ }
// 3. Create master Issue
const issuePayload = {
doctype: 'Issue',
subject: `Activation contrat ${contractName}${customerName}`,
description:
`Contrat ${contractName} accepté le ${new Date(payload.signed_at || Date.now()).toLocaleString('fr-CA')}.\n` +
`Type: ${contractType}\n` +
`Client: ${customerName} (${customer})\n` +
(serviceLocation ? `Emplacement: ${serviceLocation}\n` : '') +
(address ? `Adresse: ${address}\n` : '') +
`\nCe ticket regroupe les tâches d'activation créées automatiquement.`,
priority: 'Medium',
issue_type: 'Activation',
status: 'Open',
customer,
}
let issueName = ''
try {
const ir = await erpFetch('/api/resource/Issue', {
method: 'POST',
body: JSON.stringify(issuePayload),
})
if (ir.status === 200 || ir.status === 201) {
issueName = ir.data?.data?.name || ''
log(`[contract] ${contractName}: master Issue ${issueName} created`)
} else {
log(`[contract] ${contractName}: Issue creation returned ${ir.status} — continuing with jobs only`)
}
} catch (e) {
log(`[contract] ${contractName}: Issue creation failed: ${e.message} — continuing with jobs only`)
}
// 4. Build the chained Dispatch Jobs using acceptance.createDeferredJobs
// (same proven On-Hold / depends_on chaining used by online checkout).
const { getTemplateSteps } = require('./project-templates')
const steps = getTemplateSteps('fiber_install')
if (!steps.length) {
log(`[contract] ${contractName}: no fiber_install template — chain aborted`)
return
}
const { createDeferredJobs } = require('./acceptance')
const ctx = {
customer,
service_location: serviceLocation,
address,
issue: issueName,
}
try {
const jobs = await createDeferredJobs(steps, ctx, contractName)
log(`[contract] ${contractName}: created ${jobs.length} chained Dispatch Job(s) under Issue ${issueName || '(none)'}`)
} catch (e) {
log(`[contract] ${contractName}: chained job creation failed: ${e.message}`)
}
}
async function createTerminationInvoice (contract, calc, reason) { async function createTerminationInvoice (contract, calc, reason) {
const items = [] const items = []
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
@ -615,4 +744,13 @@ function addMonths (dateStr, months) {
function round2 (v) { return Math.round(v * 100) / 100 } function round2 (v) { return Math.round(v * 100) / 100 }
module.exports = { handle, calculateTerminationFee, generateInvoiceNote, generateContractLink, renderAcceptancePage } module.exports = {
handle,
calculateTerminationFee,
generateInvoiceNote,
generateContractLink,
renderAcceptancePage,
// Exposed so ops tools / one-shot scripts can retro-create the install chain
// for contracts that were signed before the built-in fallback existed.
createBuiltInInstallChain: _createBuiltInInstallChain,
}

View File

@ -193,6 +193,13 @@ const FAST_PROJECTION = '_id,_lastInform,InternetGatewayDevice.DeviceInfo.Serial
async function pollOnlineStatus () { async function pollOnlineStatus () {
if (!cfg.GENIEACS_NBI_URL) return if (!cfg.GENIEACS_NBI_URL) return
// Pause gate — admin toggle via /admin/pollers. Skip silently-ish when
// paused so the log doesn't spam every 5 minutes.
if (require('./poller-control').isPaused('device')) {
if ((pollCount % 12) === 0) log('Device poll: skipped (paused via /admin/pollers)')
pollCount++
return
}
const startMs = Date.now() const startMs = Date.now()
const { httpRequest } = require('./helpers') const { httpRequest } = require('./helpers')
const allDevices = [] const allDevices = []

View File

@ -288,9 +288,228 @@ async function createDispatchJob ({ subject, address, priority, duration_h, job_
return { success: true, job_id: r.data?.data?.name || ticketId, ...payload } return { success: true, job_id: r.data?.data?.name || ticketId, ...payload }
} }
// ── Chain-walk helpers ──────────────────────────────────────────────────────
// A Dispatch Job can have `depends_on` → another Dispatch Job. When a job is
// created as part of a chain (acceptance.js → createDeferredJobs), the
// dependent child is born with status='On Hold' so it doesn't show up in the
// tech's active list. When the parent flips to 'Completed', we walk the chain:
// find all `depends_on == parent && status == 'On Hold'` and flip them to
// 'open' so the next step becomes visible.
//
// We also handle fan-out (multiple children of one parent) and fire SSE
// `job-unblocked` so the tech SPA can refresh without a poll.
async function unblockDependents (jobName) {
if (!jobName) return []
const filters = encodeURIComponent(JSON.stringify([
['depends_on', '=', jobName],
['status', '=', 'On Hold'],
]))
const fields = encodeURIComponent(JSON.stringify(['name', 'subject', 'assigned_tech', 'scheduled_date']))
const r = await erpFetch(`/api/resource/Dispatch%20Job?filters=${filters}&fields=${fields}&limit_page_length=20`)
const deps = (r.status === 200 && Array.isArray(r.data?.data)) ? r.data.data : []
const unblocked = []
for (const j of deps) {
try {
const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(j.name)}`, {
method: 'PUT', body: JSON.stringify({ status: 'open' }),
})
if (up.status < 400) {
unblocked.push(j.name)
require('./sse').broadcast('dispatch', 'job-unblocked', {
job: j.name, subject: j.subject, tech: j.assigned_tech, unblocked_by: jobName,
})
log(` ↳ unblocked ${j.name} (depends_on=${jobName})`)
}
} catch (e) { log(` ! unblock failed for ${j.name}: ${e.message}`) }
}
return unblocked
}
// Chain terminal check — is `jobName` the LAST still-open job in its chain?
// A chain is identified by its root (parent_job === '' or self). A job is
// terminal when every sibling under the same root is Completed or Cancelled.
async function _isChainTerminal (jobName) {
const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['parent_job']))}`)
if (jRes.status !== 200 || !jRes.data?.data) return false
const rootName = jRes.data.data.parent_job || jobName
const stillOpen = ['open', 'Scheduled', 'In Progress', 'On Hold']
const rootRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(rootName)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`)
if (rootRes.status === 200 && stillOpen.includes(rootRes.data?.data?.status)) return false
const childFilter = encodeURIComponent(JSON.stringify([
['parent_job', '=', rootName],
['name', '!=', jobName],
['status', 'in', stillOpen],
]))
const childRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`)
return !(childRes.status === 200 && Array.isArray(childRes.data?.data) && childRes.data.data.length)
}
// Memoized company default income account — Sales Invoice items require
// an income_account that belongs to the company, and the default line-item
// resolution leaves it as None (→ 417 at validation time). We read the
// Company.default_income_account once per process and reuse it.
let _cachedIncomeAccount = null
async function _defaultIncomeAccount (company) {
if (_cachedIncomeAccount) return _cachedIncomeAccount
try {
const r = await erpFetch(`/api/resource/Company/${encodeURIComponent(company)}?fields=${encodeURIComponent(JSON.stringify(['default_income_account']))}`)
const acc = r.data?.data?.default_income_account
if (acc) { _cachedIncomeAccount = acc; return acc }
} catch (_) { /* fall through */ }
// Hard-coded fallback for TARGO if the lookup failed for some reason
_cachedIncomeAccount = 'Ventes - T'
return _cachedIncomeAccount
}
// Activate every 'En attente' Service Subscription for (customer, service_location)
// belonging to the completed job, rewrite start_date to today, and emit a
// prorated Sales Invoice for the remaining days of the current month.
// Returns { activated: [...], invoices: [...] }.
async function activateSubscriptionForJob (jobName) {
const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['customer', 'service_location']))}`)
if (jRes.status !== 200 || !jRes.data?.data) return { activated: [], invoices: [] }
const { customer, service_location } = jRes.data.data
if (!customer || !service_location) return { activated: [], invoices: [] }
const subFilter = encodeURIComponent(JSON.stringify([
['customer', '=', customer],
['service_location', '=', service_location],
['status', '=', 'En attente'],
]))
const subFields = encodeURIComponent(JSON.stringify(['name', 'monthly_price', 'plan_name', 'service_category']))
const subRes = await erpFetch(`/api/resource/Service%20Subscription?filters=${subFilter}&fields=${subFields}&limit_page_length=10`)
const pending = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : []
if (!pending.length) return { activated: [], invoices: [] }
const today = new Date()
const todayStr = today.toISOString().split('T')[0]
// Billing convention: activation day is free (courtesy), subscription
// start_date = tomorrow, prorata covers tomorrow → end-of-month inclusive.
// This matches the commercial policy ("dès demain, jusqu'à la fin du mois")
// and means when the normal monthly run hits the 1st, the customer pays
// a full month with no overlap.
const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
const daysLeft = daysInMonth - today.getDate() // from tomorrow → EOM inclusive
const endOfMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`
// Edge case: activation on the last day of the month → no prorata (daysLeft === 0)
// → we skip the invoice and the subscription simply starts billing next month.
const activated = []
const invoices = []
for (const sub of pending) {
// 1. Flip subscription to Actif + start_date=tomorrow (courtesy day)
const up = await erpFetch(`/api/resource/Service%20Subscription/${encodeURIComponent(sub.name)}`, {
method: 'PUT',
body: JSON.stringify({ status: 'Actif', start_date: tomorrowStr }),
})
if (up.status >= 400) {
log(` ! activate ${sub.name} returned ${up.status}`)
continue
}
activated.push(sub.name)
log(` ✓ Service Subscription ${sub.name} → Actif (start_date=${tomorrowStr})`)
// 2. Prorated invoice for (daysLeft / daysInMonth) × monthly_price
// Covers tomorrow → EOM. Negative monthly_price values are valid
// (credit subscriptions like "Rabais à durée limitée") — we still
// emit the proportional credit invoice so the math balances.
const monthly = Number(sub.monthly_price || 0)
if (monthly === 0) continue
if (daysLeft <= 0) {
log(`${sub.name}: activation on last day — no prorata (next full month billed normally)`)
continue
}
const proratedAmount = Number(((monthly * daysLeft) / daysInMonth).toFixed(2))
if (proratedAmount === 0) continue
try {
// Resolve company + default income account. Sales Invoice items REQUIRE
// an income_account that belongs to the company; otherwise ERPNext 417s
// with "The Income Account None does not belong to the company TARGO".
const company = 'TARGO'
const incomeAccount = await _defaultIncomeAccount(company)
const invPayload = {
customer,
company,
posting_date: todayStr,
due_date: todayStr,
items: [{
item_name: `${sub.plan_name || 'Abonnement'} — prorata ${daysLeft}/${daysInMonth} jours`,
description: `Activation confirmée le ${todayStr}. Facturation du ${tomorrowStr} au ${endOfMonth} (${daysLeft}/${daysInMonth} jours). Service: ${sub.service_category || ''} · Tarif mensuel: ${monthly.toFixed(2)}$ · Prorata: ${proratedAmount.toFixed(2)}$`,
qty: 1,
rate: proratedAmount,
amount: proratedAmount,
income_account: incomeAccount,
}],
}
const inv = await erpFetch('/api/resource/Sales%20Invoice', {
method: 'POST', body: JSON.stringify(invPayload),
})
if (inv.status === 200 && inv.data?.data?.name) {
invoices.push({ name: inv.data.data.name, amount: proratedAmount, subscription: sub.name })
log(` ✓ Sales Invoice ${inv.data.data.name}${proratedAmount.toFixed(2)}$ prorata for ${sub.name}`)
require('./sse').broadcast('dispatch', 'subscription-activated', {
subscription: sub.name, invoice: inv.data.data.name, amount: proratedAmount, customer,
})
} else {
log(` ! invoice creation returned ${inv.status} for ${sub.name}`)
}
} catch (e) { log(` ! invoice creation failed for ${sub.name}: ${e.message}`) }
}
return { activated, invoices }
}
// Thin wrapper that PUTs the status + runs unblock logic. Used by
// tech-mobile (token auth) and the ops SPA (session auth) through a single
// code path — status changes always walk the chain, no matter who wrote them.
async function setJobStatusWithChain (jobName, status) {
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, {
method: 'PUT', body: JSON.stringify({ status }),
})
if (r.status >= 400) throw new Error(`ERPNext ${r.status}`)
require('./sse').broadcast('dispatch', 'job-status', { job: jobName, status })
const unblocked = status === 'Completed' ? await unblockDependents(jobName) : []
// Terminal-node detection: if this Completed job is the last open one in
// the chain (no unblocked dependents AND no other siblings pending),
// activate the linked Service Subscription and emit a prorated invoice.
let activation = { activated: [], invoices: [] }
if (status === 'Completed' && unblocked.length === 0) {
if (await _isChainTerminal(jobName)) {
activation = await activateSubscriptionForJob(jobName)
}
}
return { ok: true, job: jobName, status, unblocked, ...activation }
}
async function handle (req, res, method, path) { async function handle (req, res, method, path) {
const sub = path.replace('/dispatch/', '') const sub = path.replace('/dispatch/', '')
// POST /dispatch/job-status — update status + auto-unblock dependents
// This is the canonical status-write endpoint. Anything that flips a
// Dispatch Job status (tech SPA, dispatcher SPA, tech-mobile token page)
// should go through here so the chain-walk runs in exactly one place.
if (sub === 'job-status' && method === 'POST') {
try {
const body = await parseBody(req)
if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' })
const result = await setJobStatusWithChain(body.job, body.status)
return json(res, 200, result)
} catch (e) {
log('job-status error:', e.message)
return json(res, 500, { error: e.message })
}
}
// POST /dispatch/best-tech — find optimal tech for a job location // POST /dispatch/best-tech — find optimal tech for a job location
if (sub === 'best-tech' && method === 'POST') { if (sub === 'best-tech' && method === 'POST') {
try { try {
@ -319,6 +538,124 @@ async function handle (req, res, method, path) {
} }
} }
// GET /dispatch/group-jobs?group=Tech+Targo&exclude_tech=TECH-001
// List unassigned jobs that a tech can self-claim. The tech PWA uses this
// to render the "Tâches du groupe" subscription feed.
//
// Filters applied server-side:
// - status in ['open', 'Scheduled'] (no 'On Hold' — those are chain-gated)
// - assigned_tech empty/null
// - optional: assigned_group == group (when provided)
// - exclude current tech so the list only shows claimable work
if (sub === 'group-jobs' && method === 'GET') {
try {
const params = require('url').parse(req.url, true).query
const group = params.group || ''
const filters = [
['status', 'in', ['open', 'Scheduled']],
// Frappe API requires `is not set` for empty-link queries (not = '').
['assigned_tech', 'is', 'not set'],
]
if (group) filters.push(['assigned_group', '=', group])
// `_name` fields (customer_name, service_location_name) are "fetched"
// (fetch_from → Customer.customer_name) and Frappe blocks them from
// list queries. Pull the base links here and enrich client-side via
// a second batch query below. We also keep `scheduled_time` out of
// the list query — it's not marked queryable on this doctype — and
// re-add it per-job via the enrichment loop.
const fields = ['name', 'subject', 'customer', 'service_location',
'scheduled_date', 'priority', 'assigned_group',
'job_type', 'duration_h', 'source_issue']
const qs = new URLSearchParams({
filters: JSON.stringify(filters),
fields: JSON.stringify(fields),
limit_page_length: '50',
order_by: 'scheduled_date asc, modified desc',
})
const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status, body: r.data?.exception?.slice(0, 200) })
const jobs = r.data?.data || []
// Enrich with customer_name + service_location_name so the tech PWA can
// render address/customer without N extra round-trips. Parallel fetch
// with a small cache in case multiple jobs share the same customer.
const custNames = new Map()
const locNames = new Map()
await Promise.all(jobs.map(async j => {
if (j.customer && !custNames.has(j.customer)) {
try {
const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(j.customer)}?fields=["customer_name"]`)
if (cr.status === 200) custNames.set(j.customer, cr.data?.data?.customer_name || j.customer)
} catch { custNames.set(j.customer, j.customer) }
}
if (j.service_location && !locNames.has(j.service_location)) {
try {
const lr = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(j.service_location)}?fields=["address_line_1","city"]`)
if (lr.status === 200) {
const d = lr.data?.data || {}
locNames.set(j.service_location, [d.address_line_1, d.city].filter(Boolean).join(', ') || j.service_location)
}
} catch { locNames.set(j.service_location, j.service_location) }
}
}))
for (const j of jobs) {
j.customer_name = custNames.get(j.customer) || ''
j.service_location_name = locNames.get(j.service_location) || ''
}
return json(res, 200, { jobs })
} catch (e) {
log('group-jobs error:', e.message)
return json(res, 500, { error: e.message })
}
}
// POST /dispatch/claim-job { job, tech_id }
// Tech self-assignment. Accepts only jobs that are truly up for grabs
// (status open/Scheduled + no assigned_tech). Idempotent per-tech: a tech
// re-claiming a job they already own returns 200 (no-op). Another tech
// trying to grab it returns 409 so the UI can refresh.
if (sub === 'claim-job' && method === 'POST') {
try {
const body = await parseBody(req)
if (!body.job || !body.tech_id) return json(res, 400, { error: 'job and tech_id required' })
const jobName = body.job
const techId = body.tech_id
const r = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`)
if (r.status !== 200) return json(res, 404, { error: 'Job not found' })
const job = r.data.data
if (job.assigned_tech && job.assigned_tech !== techId) {
return json(res, 409, { error: 'Déjà pris par un autre technicien', assigned_to: job.assigned_tech })
}
if (job.assigned_tech === techId) {
return json(res, 200, { ok: true, job: jobName, tech: techId, note: 'already yours' })
}
if (!['open', 'Scheduled'].includes(job.status)) {
return json(res, 409, { error: `Statut "${job.status}" — ce travail n'est pas disponible` })
}
const u = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`, {
method: 'PUT',
body: JSON.stringify({ assigned_tech: techId, status: 'assigned' }),
})
if (u.status !== 200) return json(res, 500, { error: 'Update failed', erp: u.data })
// Broadcast so other techs' "Tâches du groupe" feeds refresh and the job
// disappears from their claimable list.
try {
require('./sse').broadcast('dispatch', 'job-claimed', {
job: jobName, tech: techId, subject: job.subject || '',
})
} catch { /* sse best-effort */ }
log(`[dispatch] ${techId} claimed ${jobName}`)
return json(res, 200, { ok: true, job: jobName, tech: techId })
} catch (e) {
log('claim-job error:', e.message)
return json(res, 500, { error: e.message })
}
}
// POST /dispatch/create-job — create + optionally auto-assign to best tech // POST /dispatch/create-job — create + optionally auto-assign to best tech
if (sub === 'create-job' && method === 'POST') { if (sub === 'create-job' && method === 'POST') {
try { try {
@ -399,4 +736,4 @@ async function agentCreateDispatchJob ({ customer_id, service_location, subject,
} }
} }
module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots } module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob }

View File

@ -363,8 +363,15 @@ async function logOnuEvent (olt, onu, event, reason) {
} }
} }
let _oltSkipCount = 0
async function pollAllOlts () { async function pollAllOlts () {
if (!olts.size) return if (!olts.size) return
// Pause gate — admin toggle via /admin/pollers. Skip silently-ish when
// paused (log every 12th tick = ~1h) so the log doesn't fill with "skipped".
if (require('./poller-control').isPaused('olt')) {
if ((_oltSkipCount++ % 12) === 0) log('OLT poll: skipped (paused via /admin/pollers)')
return
}
const startMs = Date.now() const startMs = Date.now()
let ok = 0, failed = 0 let ok = 0, failed = 0
for (const [, olt] of olts) { for (const [, olt] of olts) {

View File

@ -0,0 +1,108 @@
'use strict'
// Poller pause control — test-phase friendly on/off switches for the two
// network pollers that run inside targo-hub:
//
// 1. device — GenieACS device-cache poll (~6000 ONTs every 5 min)
// 2. olt — SNMP sweep of the 4 OLTs (every 5 min)
//
// State is persisted to data/poller-control.json so pauses survive container
// restarts. Each poller's tick function first calls isPaused('x') and bails
// early (logging a one-liner so you can see pauses in the container log).
//
// HTTP surface:
// GET /admin/pollers → { device: {paused, ...}, olt: {...} }
// POST /admin/pollers { device, olt } → updates the flags (partial ok)
//
// Auth alignment: same tier as /olt/config + /olt/poll (unauthenticated at
// hub layer — the ops SPA that calls these is already behind Authentik SSO
// at the edge). Don't expose this without that front door in place.
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const STATE_PATH = path.join(__dirname, '..', 'data', 'poller-control.json')
const DEFAULT_STATE = {
device: { paused: false, lastChange: null, reason: '' },
olt: { paused: false, lastChange: null, reason: '' },
}
let state = { ...DEFAULT_STATE }
function load () {
try {
if (fs.existsSync(STATE_PATH)) {
const raw = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'))
state = {
device: { ...DEFAULT_STATE.device, ...(raw.device || {}) },
olt: { ...DEFAULT_STATE.olt, ...(raw.olt || {}) },
}
if (state.device.paused || state.olt.paused) {
log(`[poller-control] loaded — device.paused=${state.device.paused} olt.paused=${state.olt.paused}`)
}
}
} catch (e) { log(`[poller-control] load failed: ${e.message} — using defaults`) }
}
function persist () {
try {
const dir = path.dirname(STATE_PATH)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2))
} catch (e) { log(`[poller-control] persist failed: ${e.message}`) }
}
function isPaused (kind) {
return !!(state[kind] && state[kind].paused)
}
function setPaused (kind, paused, reason = '') {
if (!state[kind]) return false
const prev = state[kind].paused
state[kind].paused = !!paused
state[kind].lastChange = new Date().toISOString()
state[kind].reason = reason || ''
if (prev !== state[kind].paused) {
log(`[poller-control] ${kind} poller ${paused ? 'PAUSED' : 'RESUMED'}${reason ? `${reason}` : ''}`)
}
persist()
return true
}
function getState () {
return JSON.parse(JSON.stringify(state))
}
// ── HTTP handler ────────────────────────────────────────────────────────────
async function handle (req, res, method, reqPath) {
if (reqPath === '/admin/pollers' && method === 'GET') {
return json(res, 200, getState())
}
if (reqPath === '/admin/pollers' && method === 'POST') {
const body = await parseBody(req)
const updates = {}
for (const kind of ['device', 'olt']) {
if (body && typeof body[kind] === 'object' && body[kind] !== null) {
if (typeof body[kind].paused === 'boolean') {
setPaused(kind, body[kind].paused, body[kind].reason || '')
updates[kind] = true
}
} else if (body && typeof body[kind] === 'boolean') {
// shorthand: { device: true, olt: false }
setPaused(kind, body[kind])
updates[kind] = true
}
}
return json(res, 200, { ok: true, updates, state: getState() })
}
return json(res, 404, { error: 'not found' })
}
// initial load on require
load()
module.exports = { isPaused, setPaused, getState, handle, load }

View File

@ -81,12 +81,11 @@ async function handleStatus (req, res, path) {
if (!allowed.includes(body.status)) return json(res, 400, { error: 'Invalid status' }) if (!allowed.includes(body.status)) return json(res, 400, { error: 'Invalid status' })
try { try {
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, { // Single source of truth: setJobStatusWithChain PUTs status, broadcasts SSE,
method: 'PUT', body: JSON.stringify({ status: body.status }), // and walks the chain (unblocks `depends_on` children on Completed).
}) const { setJobStatusWithChain } = require('./dispatch')
if (r.status >= 400) return json(res, r.status, { error: 'ERPNext error' }) const result = await setJobStatusWithChain(body.job, body.status)
require('./sse').broadcast('dispatch', 'job-status', { job: body.job, status: body.status, tech: payload.sub }) return json(res, 200, { ...result, tech: payload.sub })
return json(res, 200, { ok: true })
} catch (e) { return json(res, 500, { error: e.message }) } } catch (e) { return json(res, 500, { error: e.message }) }
} }

View File

@ -101,6 +101,7 @@ const server = http.createServer(async (req, res) => {
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/) const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams) if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path) if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url) if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path) if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url) if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)