3 formateurs canoniques ajoutés (initials [2 premiers mots, '?'], fmtClock [«8:30»→«8h30»], formatDateLong) + remplacement des copies locales IDENTIQUES (vérifiées à l'impl) : - initials : PlanificationPage + RouteMap - fmtTime → fmtClock : JobCard + TechTasksPage - formatDate → formatDateLong : ReportRevenueExplorer + ReportInternetCher Les VRAIES variantes laissées telles quelles (staffInitials 1er+dernier ; fmtDate string ; fmtDur RendezVous ; fmtMoney multi-devise ; formatDate Settings/ConversationPanel/Gifts date+heure). Build vert, leak 0. Première tranche de l'audit workflow 26 pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
750 lines
29 KiB
Vue
750 lines
29 KiB
Vue
<template>
|
|
<q-page class="tech-surface">
|
|
<!-- Offline banner (fixed top) -->
|
|
<transition name="slide-down">
|
|
<div v-if="!isOnline" class="tg-banner">
|
|
<span class="material-icons" style="font-size:16px">wifi_off</span>
|
|
Hors ligne — les modifications seront synchronisées à la reconnexion
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Hero: greeting + progress arc -->
|
|
<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>
|
|
<button class="hero-refresh" @click="loadTasks" :disabled="loading" aria-label="Rafraîchir">
|
|
<span class="material-icons" :class="{ spinning: loading }">refresh</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Progress arc + count -->
|
|
<div class="hero-progress">
|
|
<div class="tg-arc">
|
|
<svg :width="ARC_SIZE" :height="ARC_SIZE" viewBox="0 0 120 120">
|
|
<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="hero-progress-text">
|
|
<div class="t-h2 t-ink-on-hero">{{ heroHeadline }}</div>
|
|
<div class="t-caption t-ink-on-hero-dim">{{ heroSubline }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Job list -->
|
|
<div class="tg-page tg-page--content">
|
|
<!-- In progress (always first — most likely what tech is doing) -->
|
|
<div v-if="inProgressJobs.length" class="section-head">
|
|
<span class="t-micro">En cours</span>
|
|
<span class="t-micro t-ink-400">{{ inProgressJobs.length }}</span>
|
|
</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>
|
|
<JobCard v-for="(job, idx) in upcomingJobs" :key="job.name"
|
|
:job="job" :status="upcomingStatus(job)" :index="idx + 1"
|
|
@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>
|
|
<template v-if="showCompleted">
|
|
<JobCard v-for="job in completedJobs" :key="job.name"
|
|
: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>
|
|
</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 class="t-micro t-ink-400" style="margin-top:2px">
|
|
<template v-if="job.scheduled_date">
|
|
<span class="material-icons" style="font-size:12px;vertical-align:-2px">event</span>
|
|
{{ fmtDate(job.scheduled_date) }}{{ job.scheduled_time ? ' · ' + fmtTime(job.scheduled_time) : '' }}
|
|
</template>
|
|
<template v-else>Non planifié</template>
|
|
</div>
|
|
</div>
|
|
<button class="tg-btn is-primary is-compact" :disabled="claimingJob === job.name" @click="claimJob(job)">
|
|
<span v-if="claimingJob === job.name" class="material-icons spinning" style="font-size:16px">autorenew</span>
|
|
<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>
|
|
|
|
<!-- Peek sheet: job detail -->
|
|
<transition name="fade">
|
|
<div v-if="sheetOpen" class="tg-peek-backdrop is-open" @click="sheetOpen = false" />
|
|
</transition>
|
|
<div class="tg-peek" :class="{ 'is-open': sheetOpen }" v-if="sheetJob">
|
|
<div class="tg-peek-handle" />
|
|
<div style="padding: 8px 20px 20px">
|
|
<!-- Title row -->
|
|
<div class="peek-title-row">
|
|
<div>
|
|
<div class="t-caption" style="color:var(--tg-700);font-weight:700">{{ sheetJob.name }}</div>
|
|
<div class="t-h2" style="margin-top:2px">{{ sheetJob.subject || 'Sans titre' }}</div>
|
|
</div>
|
|
<button class="peek-close" @click="sheetOpen = false" aria-label="Fermer">
|
|
<span class="material-icons">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 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 class="t-caption">Heure</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>
|
|
|
|
<!-- Description -->
|
|
<div v-if="sheetJob.description" class="info-row-stack">
|
|
<div class="t-caption">Notes</div>
|
|
<div class="t-body-sm" style="white-space:pre-wrap">{{ sheetJob.description }}</div>
|
|
</div>
|
|
|
|
<!-- Primary action (contextual to status) -->
|
|
<div style="display:flex;flex-direction:column;gap:10px;margin-top:20px">
|
|
<button v-if="isScheduled(sheetJob)"
|
|
class="tg-btn is-primary is-block"
|
|
@click="doStatus(sheetJob, 'In Progress')" :disabled="saving">
|
|
<span class="material-icons">directions_car</span>
|
|
En route
|
|
</button>
|
|
<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>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { Notify } from 'quasar'
|
|
import { BASE_URL } from 'src/config/erpnext'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
import { fmtClock as fmtTime } from 'src/composables/useFormatters' // « 8:30 »→« 8h30 » (source unique)
|
|
import JobCard from '../components/JobCard.vue'
|
|
|
|
const props = defineProps({
|
|
token: { type: String, default: '' },
|
|
})
|
|
|
|
const router = useRouter()
|
|
const loading = ref(false)
|
|
const jobs = ref([])
|
|
const saving = ref(false)
|
|
const isOnline = ref(navigator.onLine)
|
|
const showCompleted = ref(false)
|
|
const magicTechId = ref('')
|
|
|
|
// 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 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 todayLabel = computed(() =>
|
|
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
|
)
|
|
|
|
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
|
|
|
|
const inProgressJobs = computed(() =>
|
|
jobs.value.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
|
|
)
|
|
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 || ''))
|
|
)
|
|
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) {
|
|
// Raccourci de TEST seulement (dev) — saut direct open→Completed ; jamais livré en prod.
|
|
return import.meta.env.DEV && j.status && j.status !== 'Completed' && j.status !== 'Cancelled'
|
|
}
|
|
|
|
// fmtTime → composables/useFormatters (fmtClock)
|
|
function fmtDate (iso) {
|
|
if (!iso) return ''
|
|
try {
|
|
const d = new Date(iso + 'T00:00:00')
|
|
return d.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
|
|
} catch { return iso }
|
|
}
|
|
|
|
function openSheet (job) { sheetJob.value = job; sheetOpen.value = true }
|
|
|
|
// ── API calls ──────────────────────────────────────────────────────────────
|
|
async function apiFetch (url) {
|
|
const res = await fetch(BASE_URL + url)
|
|
if (!res.ok) throw new Error('API ' + res.status)
|
|
return (await res.json()).data || []
|
|
}
|
|
|
|
/**
|
|
* Update job status through targo-hub (not direct ERPNext 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' },
|
|
body: JSON.stringify({ job: jobName, status }),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.text().catch(() => 'Unknown error')
|
|
throw new Error(`Status update failed (${res.status}): ${err}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
async function verifyMagicToken (token) {
|
|
try {
|
|
const res = await fetch(`${HUB_URL}/magic-link/verify?token=${encodeURIComponent(token)}`)
|
|
if (!res.ok) return null
|
|
const data = await res.json()
|
|
return data.ok ? data.tech_id : null
|
|
} catch { return null }
|
|
}
|
|
|
|
async function loadTasks () {
|
|
loading.value = true
|
|
try {
|
|
let techId = ''
|
|
|
|
if (props.token) {
|
|
techId = await verifyMagicToken(props.token)
|
|
if (!techId) {
|
|
Notify.create({ type: 'negative', message: 'Lien expiré ou invalide. Demandez un nouveau lien par SMS.', timeout: 6000 })
|
|
loading.value = false
|
|
return
|
|
}
|
|
magicTechId.value = techId
|
|
try {
|
|
const techDoc = await apiFetch('/api/resource/Dispatch Technician/' + encodeURIComponent(techId) + '?fields=["name","full_name"]')
|
|
if (techDoc && techDoc.full_name) techName.value = techDoc.full_name
|
|
else techName.value = techId
|
|
} catch { techName.value = techId }
|
|
} else {
|
|
// Authentik session fallback
|
|
try {
|
|
const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user')
|
|
if (me.ok) {
|
|
const u = await me.json()
|
|
const userName = u.message || ''
|
|
if (userName && userName !== 'authenticated') {
|
|
try {
|
|
const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1')
|
|
if (techs.length) {
|
|
techId = techs[0].name
|
|
techName.value = techs[0].full_name || prettify(userName)
|
|
} else {
|
|
techName.value = prettify(userName)
|
|
}
|
|
} catch { techName.value = prettify(userName) }
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Filter: open/scheduled/in-progress/completed (everything except 'On Hold' which
|
|
// is the chain-gated pending state — those shouldn't show in the active list)
|
|
const statusFilter = ['open', 'Open', 'Scheduled', 'assigned', 'Assigned', 'In Progress', 'in_progress', 'Completed']
|
|
const filterArr = [
|
|
['scheduled_date', '=', today],
|
|
['status', 'in', statusFilter],
|
|
]
|
|
if (techId) filterArr.push(['assigned_tech', '=', techId])
|
|
|
|
const params = new URLSearchParams({
|
|
fields: JSON.stringify(['name', 'subject', 'status', 'customer', 'customer_name',
|
|
'service_location', 'service_location_name', 'scheduled_time', 'description',
|
|
'job_type', 'duration_h', 'priority', 'depends_on', 'step_order']),
|
|
filters: JSON.stringify(filterArr),
|
|
limit_page_length: 50,
|
|
order_by: 'scheduled_time asc',
|
|
})
|
|
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) {
|
|
Notify.create({ type: 'warning', message: 'Erreur chargement: ' + e.message })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
saving.value = true
|
|
try {
|
|
const result = await apiSetStatus(job.name, status)
|
|
job.status = status
|
|
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 })
|
|
if (status === 'Completed') {
|
|
sheetOpen.value = false
|
|
// Reload to pick up unblocked siblings/children
|
|
await loadTasks()
|
|
}
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function openGps (job) {
|
|
const loc = job.service_location_name || ''
|
|
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}`, '_blank')
|
|
}
|
|
|
|
function goScan (job) {
|
|
sheetOpen.value = false
|
|
router.push({ path: '/j/scan', query: {
|
|
job: job.name, customer: job.customer, customer_name: job.customer_name,
|
|
location: job.service_location, location_name: job.service_location_name,
|
|
}})
|
|
}
|
|
|
|
function goDetail (job) {
|
|
sheetOpen.value = false
|
|
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(() => {
|
|
window.addEventListener('online', () => { isOnline.value = true })
|
|
window.addEventListener('offline', () => { isOnline.value = false })
|
|
loadTasks()
|
|
connectSSE()
|
|
})
|
|
onUnmounted(disconnectSSE)
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
// Group subscription card — claimable jobs visually distinct from personal ones
|
|
.group-card {
|
|
margin-top: 10px;
|
|
padding: 12px 14px;
|
|
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);
|
|
}
|
|
.group-card-row {
|
|
display: flex; align-items: center; gap: 12px;
|
|
}
|
|
.empty-state-hint {
|
|
margin-top: 16px;
|
|
text-align: center;
|
|
}
|
|
.tg-chip.is-compact {
|
|
padding: 2px 8px;
|
|
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.
|
|
|
|
// Hero row layouts (the rest is from tech.scss global tokens)
|
|
.hero-top {
|
|
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;
|
|
cursor: pointer;
|
|
transition: background 160ms;
|
|
&:active { background: rgba(255,255,255,0.22); }
|
|
&:disabled { opacity: 0.6; }
|
|
.spinning { animation: spin 0.9s linear infinite; }
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.hero-progress {
|
|
display: flex; align-items: center; gap: 18px;
|
|
}
|
|
.hero-progress-text { flex: 1; min-width: 0; }
|
|
|
|
.arc-numerator {
|
|
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;
|
|
}
|
|
|
|
// Content padding (hero handles top; q-page bottom-reserve handles tab-island)
|
|
.tg-page--content {
|
|
padding: 20px 16px 8px;
|
|
}
|
|
|
|
// Section header
|
|
.section-head {
|
|
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>
|