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:
parent
07365d3b71
commit
aa5921481b
|
|
@ -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
363
apps/ops/src/css/tech.scss
Normal 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 card→detail) ────────────────────────
|
||||||
|
.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; }
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
141
apps/ops/src/modules/tech/components/JobCard.vue
Normal file
141
apps/ops/src/modules/tech/components/JobCard.vue
Normal 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>
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -1,153 +1,221 @@
|
||||||
<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
|
||||||
|
|
@ -161,9 +229,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 open→Completed
|
||||||
|
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 tech→group 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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
108
services/targo-hub/lib/poller-control.js
Normal file
108
services/targo-hub/lib/poller-control.js
Normal 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 }
|
||||||
|
|
@ -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 }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user