feat: tech mobile view integrated into ops app at /j, unassign confirmation

Tech mobile view (erp.gigafibre.ca/ops/#/j):
- TechLayout with bottom nav tabs (tasks, scanner, diagnostic, more)
- TechTasksPage: rich header with tech name/stats, job cards with
  priority dots, time, location, duration badges, bottom sheet detail
  with En route/Terminer buttons + scanner/detail access
- TechJobDetailPage: editable fields, equipment list, GPS navigation
- TechScanPage: device lookup by SN/MAC, create/link to job
- TechDiagnosticPage: speed test + host reachability checks
- Route /j replaces legacy dispatch-app tech view

Dispatch unassign confirmation:
- Dialog appears when unassigning published or in-progress jobs
- Warns that tech has already received the task
- Cancel/Confirm flow prevents accidental removal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-09 08:26:26 -04:00
parent 8fc722acdf
commit 73691668d3
10 changed files with 1423 additions and 90 deletions

View File

@ -1,149 +1,521 @@
<template>
<q-page padding>
<!-- Date header -->
<div class="row items-center q-mb-md">
<div class="text-h6">{{ todayLabel }}</div>
<q-space />
<q-btn flat dense icon="refresh" :loading="loading" @click="loadTasks" />
<q-page class="tasks-page">
<!-- Tech header -->
<div class="tech-header">
<div class="tech-header-row">
<div class="tech-header-left">
<div class="tech-date">{{ todayLabel }}</div>
<div class="tech-name">{{ techName }}</div>
</div>
<div class="tech-header-right">
<q-badge :color="offline.online ? 'green' : 'grey'" :label="offline.online ? 'En ligne' : 'Hors ligne'" class="tech-status-badge" />
<q-btn flat dense round icon="swap_horiz" color="white" size="sm" @click="loadTasks" :loading="loading" />
<q-avatar size="36px" color="indigo-8" text-color="white" class="tech-avatar">{{ initials }}</q-avatar>
</div>
</div>
<!-- Stats cards -->
<div class="stats-row">
<div class="stat-card" @click="filter = 'all'">
<div class="stat-value">{{ jobs.length }}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card" @click="filter = 'todo'">
<div class="stat-value">{{ todoCount }}</div>
<div class="stat-label">A faire</div>
</div>
<div class="stat-card stat-done" @click="filter = 'done'">
<div class="stat-value">{{ doneCount }}</div>
<div class="stat-label">Faits</div>
</div>
</div>
</div>
<!-- Filter tabs -->
<q-tabs v-model="filter" dense no-caps active-color="primary" class="q-mb-md">
<q-tab name="jobs" :label="'Jobs (' + jobs.length + ')'" />
<q-tab name="tickets" :label="'Tickets (' + tickets.length + ')'" />
</q-tabs>
<!-- Jobs list -->
<template v-if="filter === 'jobs'">
<q-card v-for="job in jobs" :key="job.name" class="q-mb-sm" clickable
@click="$router.push({ name: 'job-detail', params: { name: job.name } })">
<q-card-section class="q-py-sm">
<div class="row items-center no-wrap">
<q-badge :color="statusColor(job.status)" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle2 ellipsis">{{ job.subject || job.name }}</div>
<div class="text-caption text-grey">
{{ job.customer_name || job.customer || '' }}
<span v-if="job.scheduled_time"> &middot; {{ job.scheduled_time?.slice(0, 5) }}</span>
</div>
<div v-if="job.service_location_name" class="text-caption text-blue-grey">
<q-icon name="place" size="xs" /> {{ job.service_location_name }}
</div>
</div>
<div class="column items-end q-gutter-xs">
<q-btn v-if="job.status === 'Scheduled'" dense size="sm" unelevated color="blue" icon="directions_car"
@click.stop="updateJobStatus(job, 'In Progress')" class="q-px-sm" />
<q-btn v-if="job.status === 'In Progress'" dense size="sm" unelevated color="positive" icon="check"
@click.stop="updateJobStatus(job, 'Completed')" class="q-px-sm" />
<q-icon name="chevron_right" color="grey-5" />
</div>
<!-- Job list -->
<div class="jobs-list q-pa-md">
<!-- Upcoming section -->
<div v-if="upcomingJobs.length" class="section-label">A VENIR ({{ upcomingJobs.length }})</div>
<div v-for="(job, idx) in upcomingJobs" :key="job.name" class="job-card" :class="{ 'job-card-urgent': job.priority === 'urgent' || job.priority === 'high' }" @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>
</q-card-section>
</q-card>
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
<div v-if="job.service_location_name" class="job-card-location">
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
</div>
<div class="job-card-badges">
<span v-if="job.duration_h" class="job-badge">
<q-icon name="schedule" size="12px" /> {{ job.duration_h }}h
</span>
</div>
</div>
<!-- In progress section -->
<div v-if="inProgressJobs.length" class="section-label q-mt-md">EN COURS ({{ inProgressJobs.length }})</div>
<div v-for="job in inProgressJobs" :key="job.name" class="job-card job-card-progress" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<q-spinner-dots size="14px" color="orange" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div>
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
<div v-if="job.service_location_name" class="job-card-location">
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
</div>
</div>
<!-- Completed section -->
<div v-if="completedJobs.length && (filter === 'all' || filter === 'done')" class="section-label q-mt-md">TERMINÉS ({{ completedJobs.length }})</div>
<div v-for="job in (filter === 'all' || filter === 'done' ? completedJobs : [])" :key="job.name" class="job-card job-card-done" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<q-icon name="check_circle" size="16px" color="green" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div>
<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>
</template>
</div>
<!-- Tickets list -->
<template v-if="filter === 'tickets'">
<q-card v-for="t in tickets" :key="t.name" class="q-mb-sm">
<q-card-section class="q-py-sm">
<div class="row items-center no-wrap">
<q-badge :color="t.status === 'Open' ? 'orange' : t.status === 'Closed' ? 'grey' : 'blue'" class="q-mr-sm" />
<!-- 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="text-subtitle2 ellipsis">{{ t.subject }}</div>
<div class="text-caption text-grey">
{{ t.customer_name || '' }} &middot; {{ formatDate(t.creation) }}
<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 + travel -->
<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 class="sheet-info-label">Durée estimée</div>
<div class="sheet-info-value">{{ sheetJob.duration_h }}h</div>
</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" />
<div>
<div class="sheet-info-label">Client</div>
<div class="sheet-info-value">{{ sheetJob.customer_name }}</div>
</div>
</div>
</div>
</q-card-section>
<!-- Description -->
<q-card-section v-if="sheetJob.description" class="q-py-sm">
<div class="sheet-info-label q-mb-xs">Notes</div>
<div class="text-body2" v-html="sheetJob.description" />
</q-card-section>
<!-- Action buttons -->
<q-card-section class="q-pt-sm">
<div class="row q-gutter-sm">
<q-btn v-if="sheetJob.status === 'Scheduled'" unelevated color="indigo" icon="directions_car"
label="En route" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
<q-btn v-if="sheetJob.status === 'In Progress'" unelevated color="positive" icon="check_circle"
label="Terminer" class="col action-btn" @click="doStatus(sheetJob, 'Completed')" :loading="saving" />
<q-btn v-if="sheetJob.status === 'Completed'" unelevated color="blue-grey" icon="replay"
label="Rouvrir" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
</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="Détails" class="col"
@click="goDetail(sheetJob)" />
</div>
</q-card-section>
</q-card>
<div v-if="!loading && tickets.length === 0" class="text-center text-grey q-mt-xl">
Aucun ticket assigné
</div>
</template>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { listDocs, updateDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { useAuthStore } from 'src/stores/auth'
import { Notify } from 'quasar'
const router = useRouter()
const loading = ref(false)
const filter = ref('jobs')
const filter = ref('all')
const jobs = ref([])
const tickets = ref([])
const offline = useOfflineStore()
const auth = useAuthStore()
const saving = ref(false)
// Bottom sheet
const sheetOpen = ref(false)
const sheetJob = ref(null)
const today = new Date().toISOString().slice(0, 10)
const todayLabel = computed(() =>
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
)
function formatDate (d) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' })
const techName = computed(() => {
const u = auth.user || ''
// Authentik gives full name or email parse for display
if (u.includes('@')) return u.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
if (u === 'authenticated') return 'Technicien'
return u
})
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 === 'Scheduled' || j.status === 'In Progress').length)
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
const upcomingJobs = computed(() => {
const filtered = jobs.value.filter(j => j.status === 'Scheduled')
if (filter.value === 'done') return []
return filtered.sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || ''))
})
const inProgressJobs = computed(() => {
if (filter.value === 'done') return []
return jobs.value.filter(j => j.status === 'In Progress')
})
const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed'))
function fmtTime (t) {
if (!t) return ''
const [h, m] = t.split(':')
return `${h}h${m}`
}
function statusColor (s) {
const map = { Scheduled: 'blue', 'In Progress': 'orange', Completed: 'green', Cancelled: 'grey' }
return map[s] || 'grey'
function openSheet (job) {
sheetJob.value = job
sheetOpen.value = true
}
async function loadTasks () {
loading.value = true
try {
const [j, t] = await Promise.all([
listDocs('Dispatch Job', {
filters: { scheduled_date: today },
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
'service_location_name', 'scheduled_time', 'description', 'job_type'],
limit: 50,
orderBy: 'scheduled_time asc',
}),
listDocs('Issue', {
filters: { status: ['in', ['Open', 'Replied']] },
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'creation', 'priority'],
limit: 30,
orderBy: 'creation desc',
}),
])
const j = await listDocs('Dispatch Job', {
filters: { scheduled_date: today },
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
'service_location_name', 'scheduled_time', 'description', 'job_type', 'duration_h', 'priority'],
limit: 50,
orderBy: 'scheduled_time asc',
})
jobs.value = j
tickets.value = t
// Cache for offline
offline.cacheData('tasks-jobs', j)
offline.cacheData('tasks-tickets', t)
} catch {
// Try cached data
const cj = await offline.getCached('tasks-jobs')
const ct = await offline.getCached('tasks-tickets')
if (cj) jobs.value = cj
if (ct) tickets.value = ct
const cached = await offline.getCached('tasks-jobs')
if (cached) jobs.value = cached
Notify.create({ type: 'warning', message: 'Mode hors ligne — données en cache' })
} finally {
loading.value = false
}
}
async function updateJobStatus (job, status) {
async function doStatus (job, status) {
saving.value = true
try {
if (offline.online) {
await updateDoc('Dispatch Job', job.name, { status })
job.status = status
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Job terminé' : 'Job démarré' })
} else {
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.name, data: { status } })
job.status = status
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
}
job.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé ✓' }
Notify.create({ type: 'positive', message: msgs[status] || status })
if (status === 'Completed') sheetOpen.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
function openGps (job) {
const loc = job.service_location_name || ''
if (job.latitude && job.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${job.latitude},${job.longitude}`, '_blank')
} else {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}`, '_blank')
}
}
function goScan (job) {
sheetOpen.value = false
router.push({ name: '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({ name: 'job-detail', params: { name: job.name } })
}
onMounted(loadTasks)
</script>
<style lang="scss" scoped>
.tasks-page {
padding: 0 !important;
background: #eef1f5;
min-height: 100vh;
}
/* ── Tech header ── */
.tech-header {
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%);
color: white;
padding: 16px 16px 0;
border-radius: 0 0 20px 20px;
}
.tech-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.tech-date {
font-size: 0.78rem;
opacity: 0.7;
text-transform: capitalize;
}
.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 ── */
.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; }
.stat-value {
font-size: 1.5rem;
font-weight: 800;
line-height: 1.2;
}
.stat-label {
font-size: 0.7rem;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Jobs list ── */
.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 ── */
.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; }
.job-card-progress {
border-left-color: #f59e0b;
background: #fffbeb;
}
.job-card-done {
border-left-color: #22c55e;
opacity: 0.7;
}
.job-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.job-card-left {
display: flex;
align-items: center;
gap: 6px;
}
.job-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #5c59a8;
color: white;
font-size: 0.7rem;
font-weight: 700;
}
.job-id {
font-size: 0.78rem;
font-weight: 600;
color: #5c59a8;
}
.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;
}
.job-card-location {
font-size: 0.78rem;
color: #6b7280;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 2px;
}
.job-card-badges {
display: flex;
gap: 8px;
}
.job-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: #f3f4f6;
border-radius: 8px;
padding: 2px 8px;
font-size: 0.72rem;
font-weight: 600;
color: #6b7280;
}
/* ── Bottom sheet ── */
.bottom-sheet {
border-radius: 20px 20px 0 0;
max-height: 70vh;
width: 100%;
}
.sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: #d1d5db;
margin: 10px auto 4px;
}
.sheet-job-id {
font-size: 0.82rem;
font-weight: 700;
color: #5c59a8;
}
.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;
}
.sheet-info-value {
font-size: 0.95rem;
font-weight: 600;
color: #1f2937;
}
.action-btn {
font-weight: 700;
border-radius: 12px;
min-height: 48px;
font-size: 0.9rem;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<q-layout view="hHh lpR fFf">
<q-page-container>
<router-view />
</q-page-container>
<!-- Bottom navigation -->
<q-footer class="tech-footer" bordered>
<transition name="slide-down">
<div v-if="!isOnline" class="offline-banner">
<q-icon name="wifi_off" size="14px" class="q-mr-xs" /> Hors ligne
</div>
</transition>
<q-tabs v-model="tab" dense no-caps active-color="primary" indicator-color="primary" class="tech-tabs">
<q-route-tab name="tasks" icon="assignment" label="Taches" to="/j" exact />
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/j/scan" />
<q-route-tab name="diag" icon="speed" label="Diagnostic" to="/j/diagnostic" />
<q-route-tab name="more" icon="more_horiz" label="Plus" to="/j/more" />
</q-tabs>
</q-footer>
</q-layout>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const tab = ref('tasks')
const isOnline = ref(navigator.onLine)
function onOnline () { isOnline.value = true }
function onOffline () { isOnline.value = false }
onMounted(() => {
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
})
onUnmounted(() => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
})
</script>
<style lang="scss" scoped>
.tech-footer {
background: white;
}
.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>

View File

@ -0,0 +1,77 @@
<template>
<q-page padding>
<div class="text-h6 q-mb-md">Diagnostic reseau</div>
<!-- Speed test -->
<q-card flat bordered class="q-mb-md">
<q-card-section>
<div class="text-subtitle2 q-mb-sm">Test de vitesse</div>
<div v-if="speed !== null" class="text-center q-py-md">
<div class="text-h3 text-primary text-weight-bold">{{ speed }}</div>
<div class="text-caption text-grey">Mbps download</div>
<div v-if="latency !== null" class="text-caption">Latence: {{ latency }}ms</div>
</div>
<q-btn unelevated color="primary" label="Lancer le test" icon="speed" class="full-width" @click="runSpeed" :loading="testing" />
</q-card-section>
</q-card>
<!-- Host checks -->
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle2 q-mb-sm">Verification des hotes</div>
<div v-for="h in hosts" :key="h.host" class="row items-center q-mb-xs">
<q-icon :name="h.status === 'ok' ? 'check_circle' : h.status === 'fail' ? 'cancel' : 'radio_button_unchecked'"
:color="h.status === 'ok' ? 'positive' : h.status === 'fail' ? 'negative' : 'grey'" class="q-mr-sm" />
<span class="text-body2">{{ h.host }}</span>
<q-space />
<span v-if="h.ms" class="text-caption text-grey">{{ h.ms }}ms</span>
</div>
<q-btn flat color="primary" label="Verifier" icon="refresh" class="q-mt-sm" @click="checkHosts" :loading="checking" />
</q-card-section>
</q-card>
</q-page>
</template>
<script setup>
import { ref } from 'vue'
const testing = ref(false)
const speed = ref(null)
const latency = ref(null)
const checking = ref(false)
const hosts = ref([
{ host: 'google.ca', status: null, ms: null },
{ host: 'erp.gigafibre.ca', status: null, ms: null },
{ host: 'cloudflare.com', status: null, ms: null },
])
async function runSpeed () {
testing.value = true; speed.value = null; latency.value = null
try {
const url = 'https://speed.cloudflare.com/__down?bytes=10000000'
const t0 = performance.now()
const res = await fetch(url, { mode: 'no-cors' })
const t1 = performance.now()
const ms = t1 - t0
latency.value = Math.round(ms)
// Approximate: 10MB / time = speed
speed.value = Math.round((10 * 8) / (ms / 1000))
} catch {
speed.value = 0
} finally { testing.value = false }
}
async function checkHosts () {
checking.value = true
for (const h of hosts.value) {
try {
const t0 = performance.now()
await fetch('https://' + h.host, { mode: 'no-cors', signal: AbortSignal.timeout(5000) })
h.ms = Math.round(performance.now() - t0)
h.status = 'ok'
} catch { h.status = 'fail'; h.ms = null }
}
checking.value = false
}
</script>

View File

@ -0,0 +1,186 @@
<template>
<q-page class="job-detail-page">
<!-- Top bar -->
<div class="job-topbar">
<q-btn flat dense icon="arrow_back" @click="$router.back()" color="white" />
<div class="col text-center">
<div class="text-subtitle2">{{ job?.subject || job?.name || 'Job' }}</div>
<div class="text-caption text-grey-4">{{ job?.name }}</div>
</div>
<q-btn flat dense icon="more_vert" color="white">
<q-menu>
<q-list dense>
<q-item clickable v-close-popup @click="openInErp">
<q-item-section avatar><q-icon name="open_in_new" size="xs" /></q-item-section>
<q-item-section>Ouvrir dans ERPNext</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<q-spinner v-if="loading" size="lg" color="primary" class="block q-mx-auto q-mt-xl" />
<template v-if="job && !loading">
<!-- Status hero -->
<div class="job-status-hero" :class="'status-' + (job.status || '').toLowerCase().replace(/\s/g, '-')">
<q-badge :color="statusColor" :label="statusLabel" class="text-body2 q-px-md q-py-xs" />
<div class="row q-mt-md q-gutter-sm justify-center">
<q-btn v-if="job.status === 'Scheduled' || job.status === 'assigned'" unelevated color="indigo" icon="directions_car"
label="En route" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" />
<q-btn v-if="job.status === 'In Progress' || job.status === 'in_progress'" unelevated color="positive" icon="check_circle"
label="Terminer" @click="updateStatus('Completed')" :loading="saving" class="action-btn" />
<q-btn v-if="job.status === 'Completed'" flat color="grey" icon="replay"
label="Rouvrir" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" />
</div>
</div>
<!-- Content -->
<div class="job-content q-pa-md">
<!-- Info -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-pb-none"><div class="text-overline text-grey-6">INFORMATIONS</div></q-card-section>
<q-card-section>
<q-input v-model="job.subject" label="Sujet" outlined dense class="q-mb-sm" @blur="saveField('subject', job.subject)" />
<div class="row q-gutter-sm q-mb-sm">
<q-input v-model="job.scheduled_time" label="Heure" type="time" outlined dense class="col" @blur="saveField('scheduled_time', job.scheduled_time)" />
<q-input v-model="job.duration_h" label="Duree (h)" type="number" step="0.5" min="0.5" outlined dense class="col" @blur="saveField('duration_h', parseFloat(job.duration_h) || 1)" />
</div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense autogrow rows="2" @blur="saveField('description', job.description)" />
</q-card-section>
</q-card>
<!-- Location -->
<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="job.service_location_name" class="row items-center q-mb-sm">
<q-icon name="place" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.service_location_name }}</span>
<q-space />
<q-btn flat dense round icon="navigation" color="primary" @click="openGps" />
</div>
</q-card-section>
</q-card>
<!-- Equipment -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-pb-none row items-center">
<div class="text-overline text-grey-6 col">EQUIPEMENTS ({{ equipment.length }})</div>
<q-btn flat dense size="sm" icon="qr_code_scanner" color="primary" label="Scanner" @click="goScan" />
</q-card-section>
<q-list v-if="equipment.length" separator>
<q-item v-for="eq in equipment" :key="eq.name">
<q-item-section avatar>
<q-icon :name="eq.equipment_type === 'ONT' ? 'settings_input_hdmi' : eq.equipment_type === 'Routeur' ? 'wifi' : 'memory'" :color="eq.status === 'Actif' ? 'green' : 'grey'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption style="font-family:monospace">{{ eq.serial_number }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section v-else class="text-center text-grey text-caption">Aucun equipement lie</q-card-section>
</q-card>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const saving = ref(false)
const job = ref(null)
const equipment = ref([])
const jobName = computed(() => route.params.name)
const statusColor = computed(() => {
const map = { Scheduled: 'blue', assigned: 'blue', 'In Progress': 'orange', in_progress: 'orange', Completed: 'green' }
return map[job.value?.status] || 'grey'
})
const statusLabel = computed(() => {
const map = { Scheduled: 'Planifie', assigned: 'Assigne', 'In Progress': 'En cours', in_progress: 'En cours', Completed: 'Termine' }
return map[job.value?.status] || job.value?.status || ''
})
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
}
async function apiUpdate (doctype, name, data) {
await fetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
}
async function loadJob () {
loading.value = true
try {
job.value = await apiFetch('/api/resource/Dispatch Job/' + encodeURIComponent(jobName.value))
if (job.value.service_location) {
const params = new URLSearchParams({ filters: JSON.stringify({ service_location: job.value.service_location }), fields: JSON.stringify(['name','serial_number','equipment_type','brand','model','status']), limit_page_length: 50 })
try { equipment.value = await apiFetch('/api/resource/Service Equipment?' + params) || [] } catch {}
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { loading.value = false }
}
async function saveField (field, value) {
if (!job.value?.name) return
try { await apiUpdate('Dispatch Job', job.value.name, { [field]: value }) } catch {}
}
async function updateStatus (status) {
saving.value = true
try {
await apiUpdate('Dispatch Job', job.value.name, { status })
job.value.status = status
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Termine' : 'En route !' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { saving.value = false }
}
function openGps () {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(job.value.service_location_name || '')}`, '_blank')
}
function openInErp () { window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank') }
function goScan () {
router.push({ path: '/j/scan', query: { job: job.value.name, customer: job.value.customer, customer_name: job.value.customer_name, location: job.value.service_location, location_name: job.value.service_location_name } })
}
onMounted(loadJob)
</script>
<style lang="scss" scoped>
.job-detail-page { padding: 0 !important; display: flex; flex-direction: column; min-height: 100%; }
.job-topbar {
display: flex; align-items: center; padding: 8px 8px 8px 4px;
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%); color: white;
position: sticky; top: 0; z-index: 10;
}
.job-status-hero {
text-align: center; padding: 20px 16px; background: #f5f7fa;
border-bottom: 1px solid rgba(0,0,0,0.06);
&.status-in-progress, &.status-in_progress { background: #fff8e1; }
&.status-completed { background: #e8f5e9; }
}
.action-btn { min-width: 140px; font-weight: 600; border-radius: 12px; }
.job-content { flex: 1; overflow-y: auto; padding-bottom: 80px !important; }
.text-overline { font-size: 0.68rem; letter-spacing: 0.08em; }
</style>

View File

@ -0,0 +1,33 @@
<template>
<q-page padding>
<div class="text-h6 q-mb-md">Plus</div>
<q-list>
<q-item>
<q-item-section avatar>
<q-icon :name="isOnline ? 'wifi' : 'wifi_off'" :color="isOnline ? 'positive' : 'negative'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ isOnline ? 'En ligne' : 'Hors ligne' }}</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="logout">
<q-item-section avatar><q-icon name="logout" color="negative" /></q-item-section>
<q-item-section><q-item-label class="text-negative">Deconnexion</q-item-label></q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey text-center q-mt-xl">Targo Ops v2.0 Vue technicien</div>
</q-page>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const isOnline = ref(navigator.onLine)
function onOnline () { isOnline.value = true }
function onOffline () { isOnline.value = false }
onMounted(() => { 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/' }
</script>

View File

@ -0,0 +1,138 @@
<template>
<q-page padding class="scan-page">
<!-- Job context banner -->
<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-icon name="work" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle2">{{ jobContext.customer_name || jobContext.customer }}</div>
<div class="text-caption text-grey" v-if="jobContext.location_name">
<q-icon name="place" size="xs" /> {{ jobContext.location_name }}
</div>
</div>
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
</q-card-section>
</q-card>
<!-- Manual entry -->
<q-input v-model="manualCode" label="Numero de serie / MAC" outlined dense class="q-mb-md" @keyup.enter="lookupDevice">
<template v-slot:append>
<q-btn flat dense icon="search" @click="lookupDevice" :disable="!manualCode.trim()" :loading="lookingUp" />
</template>
</q-input>
<!-- Results -->
<q-card v-if="result" class="q-mb-md">
<q-card-section v-if="result.found">
<div class="row items-center q-mb-sm">
<q-badge color="green" label="Trouve" class="q-mr-sm" />
<span class="text-subtitle2">{{ result.eq.equipment_type }} {{ result.eq.brand }} {{ result.eq.model }}</span>
</div>
<div class="text-caption" style="font-family:monospace">SN: {{ result.eq.serial_number }}</div>
<div v-if="result.eq.customer_name" class="text-caption">Client: {{ result.eq.customer_name }}</div>
<div v-if="!result.eq.service_location && jobContext" class="q-mt-sm">
<q-btn unelevated size="sm" color="primary" label="Lier a ce job" icon="link" @click="linkToJob(result.eq)" :loading="linking" />
</div>
</q-card-section>
<q-card-section v-else>
<q-badge color="orange" label="Non trouve" class="q-mb-sm" />
<div class="text-caption">Aucun equipement avec ce code.</div>
<q-btn flat size="sm" color="primary" label="Creer un equipement" icon="add" @click="createDialog = true" class="q-mt-xs" />
</q-card-section>
</q-card>
<!-- Create dialog -->
<q-dialog v-model="createDialog">
<q-card style="min-width: 320px">
<q-card-section class="text-h6">Nouvel equipement</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numero de serie" outlined dense class="q-mb-sm" />
<q-select v-model="newEquip.equipment_type" :options="eqTypes" label="Type" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.brand" label="Marque" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.model" label="Modele" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Creer" @click="createEquipment" :loading="creating" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
const route = useRoute()
const manualCode = ref('')
const lookingUp = ref(false)
const result = ref(null)
const linking = ref(false)
const createDialog = ref(false)
const creating = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Decodeur TV', 'VoIP', 'Amplificateur', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const jobContext = ref(route.query.job ? {
job: route.query.job, customer: route.query.customer, customer_name: route.query.customer_name,
location: route.query.location, location_name: route.query.location_name,
} : null)
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 || []
}
async function lookupDevice () {
const code = manualCode.value.trim()
if (!code) return
lookingUp.value = true; result.value = null
try {
let docs = await apiFetch('/api/resource/Service Equipment?filters=' + encodeURIComponent(JSON.stringify({ serial_number: code })) + '&fields=["name","serial_number","equipment_type","brand","model","customer","customer_name","service_location","status","mac_address"]&limit_page_length=1')
if (!docs.length) {
const norm = code.replace(/[:\-\.]/g, '').toUpperCase()
if (norm.length >= 6) {
docs = await apiFetch('/api/resource/Service Equipment?filters=' + encodeURIComponent(JSON.stringify({ mac_address: ['like', '%' + norm.slice(-6) + '%'] })) + '&fields=["name","serial_number","equipment_type","brand","model","customer","customer_name","service_location","status","mac_address"]&limit_page_length=1')
}
}
result.value = docs.length ? { found: true, eq: docs[0] } : { found: false }
} catch { result.value = { found: false } }
finally { lookingUp.value = false }
}
async function linkToJob (eq) {
if (!jobContext.value) return
linking.value = true
try {
const updates = {}
if (jobContext.value.customer) updates.customer = jobContext.value.customer
if (jobContext.value.location) updates.service_location = jobContext.value.location
await fetch(BASE_URL + '/api/resource/Service Equipment/' + encodeURIComponent(eq.name), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates),
})
eq.customer = jobContext.value.customer
eq.service_location = jobContext.value.location
Notify.create({ type: 'positive', message: 'Lie au job', icon: 'link' })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
finally { linking.value = false }
}
async function createEquipment () {
creating.value = true
try {
const data = { ...newEquip.value, status: 'Actif', customer: jobContext.value?.customer || '', service_location: jobContext.value?.location || '' }
const res = await fetch(BASE_URL + '/api/resource/Service Equipment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
if (!res.ok) throw new Error('Create failed')
const doc = (await res.json()).data
result.value = { found: true, eq: doc }
createDialog.value = false
Notify.create({ type: 'positive', message: 'Equipement cree' })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
finally { creating.value = false }
}
</script>

View File

@ -0,0 +1,386 @@
<template>
<q-page class="tasks-page">
<!-- Tech header -->
<div class="tech-header">
<div class="tech-header-row">
<div class="tech-header-left">
<div class="tech-date">{{ todayLabel }}</div>
<div class="tech-name">{{ techName }}</div>
</div>
<div class="tech-header-right">
<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" />
<q-avatar size="36px" color="indigo-8" text-color="white" class="tech-avatar">{{ initials }}</q-avatar>
</div>
</div>
<!-- Stats cards -->
<div class="stats-row">
<div class="stat-card" @click="statFilter = 'all'">
<div class="stat-value">{{ jobs.length }}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card" @click="statFilter = 'todo'">
<div class="stat-value">{{ todoCount }}</div>
<div class="stat-label">A faire</div>
</div>
<div class="stat-card stat-done" @click="statFilter = 'done'">
<div class="stat-value">{{ doneCount }}</div>
<div class="stat-label">Faits</div>
</div>
</div>
</div>
<!-- Job list -->
<div class="jobs-list q-pa-md">
<!-- Upcoming section -->
<div v-if="upcomingJobs.length" class="section-label">A VENIR ({{ upcomingJobs.length }})</div>
<div v-for="(job, idx) in upcomingJobs" :key="job.name" class="job-card"
:class="{ 'job-card-urgent': job.priority === 'urgent' || job.priority === 'high' }"
@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 class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
<div v-if="job.service_location_name" class="job-card-location">
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
</div>
<div class="job-card-badges">
<span v-if="job.duration_h" class="job-badge">
<q-icon name="schedule" size="12px" /> {{ job.duration_h }}h
</span>
</div>
</div>
<!-- In progress section -->
<div v-if="inProgressJobs.length" class="section-label q-mt-md">EN COURS ({{ inProgressJobs.length }})</div>
<div v-for="job in inProgressJobs" :key="job.name" class="job-card job-card-progress" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<q-spinner-dots size="14px" color="orange" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div>
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
</div>
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
<div v-if="job.service_location_name" class="job-card-location">
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
</div>
</div>
<!-- Completed section -->
<div v-if="completedJobs.length && (statFilter === 'all' || statFilter === 'done')" class="section-label q-mt-md">TERMINES ({{ completedJobs.length }})</div>
<div v-for="job in (statFilter === 'all' || statFilter === 'done' ? completedJobs : [])" :key="job.name"
class="job-card job-card-done" @click="openSheet(job)">
<div class="job-card-header">
<div class="job-card-left">
<q-icon name="check_circle" size="16px" color="green" class="q-mr-xs" />
<span class="job-id">{{ job.name }}</span>
</div>
<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 class="sheet-info-label">Duree estimee</div>
<div class="sheet-info-value">{{ sheetJob.duration_h }}h</div>
</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" />
<div>
<div class="sheet-info-label">Client</div>
<div class="sheet-info-value">{{ sheetJob.customer_name }}</div>
</div>
</div>
</div>
</q-card-section>
<!-- Description -->
<q-card-section v-if="sheetJob.description" class="q-py-sm">
<div class="sheet-info-label q-mb-xs">Notes</div>
<div class="text-body2" v-html="sheetJob.description" />
</q-card-section>
<!-- Action buttons -->
<q-card-section class="q-pt-sm">
<div class="row q-gutter-sm">
<q-btn v-if="sheetJob.status === 'Scheduled' || sheetJob.status === 'assigned'" unelevated color="indigo" icon="directions_car"
label="En route" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
<q-btn v-if="sheetJob.status === 'In Progress' || sheetJob.status === 'in_progress'" unelevated color="positive" icon="check_circle"
label="Terminer" class="col action-btn" @click="doStatus(sheetJob, 'Completed')" :loading="saving" />
<q-btn v-if="sheetJob.status === 'Completed'" unelevated color="blue-grey" icon="replay"
label="Rouvrir" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
</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>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
const router = useRouter()
const loading = ref(false)
const statFilter = ref('all')
const jobs = ref([])
const saving = ref(false)
const isOnline = ref(navigator.onLine)
// Bottom sheet
const sheetOpen = ref(false)
const sheetJob = ref(null)
const today = new Date().toISOString().slice(0, 10)
const todayLabel = computed(() =>
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 upcomingJobs = computed(() => {
if (statFilter.value === 'done') return []
return jobs.value.filter(j => j.status === 'Scheduled' || j.status === 'assigned' || j.status === 'open')
.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'))
function fmtTime (t) {
if (!t) return ''
const [h, m] = t.split(':')
return `${h}h${m}`
}
function openSheet (job) {
sheetJob.value = job
sheetOpen.value = true
}
// Use the same auth pattern as ops app nginx injects token
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 || []
}
async function apiUpdate (doctype, name, data) {
const res = await fetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Update failed')
}
async function loadTasks () {
loading.value = true
try {
// Load tech profile to get name
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 || ''
// Try to get full name from Dispatch Technician linked to user
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 && techs[0].full_name) techName.value = techs[0].full_name
else techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
} catch { techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
}
}
} catch {}
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']),
filters: JSON.stringify({ scheduled_date: today }),
limit_page_length: 50,
order_by: 'scheduled_time asc',
})
jobs.value = await apiFetch('/api/resource/Dispatch Job?' + params)
} catch (e) {
Notify.create({ type: 'warning', message: 'Erreur chargement: ' + e.message })
} finally {
loading.value = false
}
}
async function doStatus (job, status) {
saving.value = true
try {
await apiUpdate('Dispatch Job', job.name, { status })
job.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job termine' }
Notify.create({ type: 'positive', message: msgs[status] || status })
if (status === 'Completed') sheetOpen.value = false
} 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 })
}
onMounted(() => {
window.addEventListener('online', () => { isOnline.value = true })
window.addEventListener('offline', () => { isOnline.value = false })
loadTasks()
})
</script>
<style lang="scss" scoped>
.tasks-page { padding: 0 !important; background: #eef1f5; min-height: 100vh; }
.tech-header {
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%);
color: white; padding: 16px 16px 0; border-radius: 0 0 20px 20px;
}
.tech-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.tech-date { font-size: 0.78rem; opacity: 0.7; text-transform: capitalize; }
.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; }
.stat-value { font-size: 1.5rem; font-weight: 800; line-height: 1.2; }
.stat-label { font-size: 0.7rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; }
.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; }
.job-card-progress { border-left-color: #f59e0b; background: #fffbeb; }
.job-card-done { border-left-color: #22c55e; opacity: 0.7; }
.job-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.job-card-left { display: flex; align-items: center; gap: 6px; }
.job-order {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: #5c59a8; color: white; font-size: 0.7rem; font-weight: 700;
}
.job-id { font-size: 0.78rem; font-weight: 600; color: #5c59a8; }
.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; }
.job-card-location { font-size: 0.78rem; color: #6b7280; margin-bottom: 6px; display: flex; align-items: center; gap: 2px; }
.job-card-badges { display: flex; gap: 8px; }
.job-badge {
display: inline-flex; align-items: center; gap: 3px;
background: #f3f4f6; border-radius: 8px; padding: 2px 8px;
font-size: 0.72rem; font-weight: 600; color: #6b7280;
}
.bottom-sheet { border-radius: 20px 20px 0 0; max-height: 70vh; width: 100%; }
.sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 10px auto 4px; }
.sheet-job-id { font-size: 0.82rem; font-weight: 700; color: #5c59a8; }
.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; }
.sheet-info-value { font-size: 0.95rem; font-weight: 600; color: #1f2937; }
.action-btn { font-weight: 700; border-radius: 12px; min-height: 48px; font-size: 0.9rem; }
</style>

View File

@ -243,13 +243,38 @@ const underutilizedTechs = computed(() => {
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
function fullUnassign (job) {
// Confirmation state for unassign
const confirmUnassignDialog = ref(false)
const pendingUnassignJob = ref(null)
function _doUnassign (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
store.fullUnassign(job.id)
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
invalidateRoutes()
}
function fullUnassign (job) {
// Require confirmation for published or in-progress jobs
if (job.published || job.status === 'in_progress' || job.status === 'In Progress' || job.status === 'assigned') {
pendingUnassignJob.value = job
confirmUnassignDialog.value = true
return
}
_doUnassign(job)
}
function confirmUnassign () {
if (pendingUnassignJob.value) _doUnassign(pendingUnassignJob.value)
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
function cancelUnassign () {
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
@ -1753,6 +1778,25 @@ onUnmounted(() => {
</div>
</div>
<!-- Confirm Unassign Dialog -->
<div v-if="confirmUnassignDialog" class="sb-modal-overlay" @click.self="cancelUnassign">
<div class="sb-confirm-dialog">
<div class="sb-confirm-icon"></div>
<div class="sb-confirm-title">Désaffecter ce job ?</div>
<div class="sb-confirm-body">
<strong>{{ pendingUnassignJob?.subject || pendingUnassignJob?.id }}</strong><br>
<span v-if="pendingUnassignJob?.published" class="sb-confirm-tag sb-confirm-tag-pub">Publié</span>
<span v-if="pendingUnassignJob?.status === 'in_progress' || pendingUnassignJob?.status === 'In Progress'" class="sb-confirm-tag sb-confirm-tag-ip">En cours</span>
<span v-if="pendingUnassignJob?.status === 'assigned'" class="sb-confirm-tag sb-confirm-tag-asg">Assigné</span>
<br><span class="sb-confirm-warn">Le technicien a déjà reçu cette tâche. Désaffecter la remettra dans le pool non-assigné.</span>
</div>
<div class="sb-confirm-actions">
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
</div>
</div>
</div>
<!-- Create Offer Modal -->
<CreateOfferModal
v-model="createOfferModal"

View File

@ -826,3 +826,27 @@
padding: 2px 4px; border-radius: 3px; transition: background 0.12s;
&:hover { background: rgba(248, 113, 113, 0.15); }
}
/* ── Confirm unassign dialog ── */
.sb-confirm-dialog {
background: var(--sb-card); border-radius: 14px; padding: 24px;
max-width: 380px; width: 90%; text-align: center;
border: 1px solid var(--sb-border-acc);
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.sb-confirm-icon { font-size: 2rem; margin-bottom: 8px; }
.sb-confirm-title { font-size: 1rem; font-weight: 700; color: var(--sb-text); margin-bottom: 10px; }
.sb-confirm-body { font-size: 0.82rem; color: var(--sb-muted); line-height: 1.5; margin-bottom: 16px; }
.sb-confirm-tag {
display: inline-block; font-size: 0.68rem; font-weight: 600; padding: 1px 8px;
border-radius: 4px; margin: 4px 2px;
}
.sb-confirm-tag-pub { background: rgba(99,102,241,0.2); color: #818cf8; }
.sb-confirm-tag-ip { background: rgba(251,191,36,0.2); color: #fbbf24; }
.sb-confirm-tag-asg { background: rgba(59,130,246,0.2); color: #60a5fa; }
.sb-confirm-warn { font-size: 0.75rem; color: #f59e0b; font-style: italic; }
.sb-confirm-actions { display: flex; gap: 8px; justify-content: center; }
.sb-confirm-danger {
background: #dc2626 !important; color: white !important;
&:hover { background: #b91c1c !important; }
}

View File

@ -2,6 +2,19 @@ import { route } from 'quasar/wrappers'
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
// Tech mobile view (sent via SMS to field techs)
{
path: '/j',
component: () => import('src/layouts/TechLayout.vue'),
children: [
{ path: '', name: 'tech-tasks', component: () => import('src/modules/tech/pages/TechTasksPage.vue') },
{ path: 'job/:name', name: 'tech-job', component: () => import('src/modules/tech/pages/TechJobDetailPage.vue'), props: true },
{ path: 'scan', name: 'tech-scan', component: () => import('src/modules/tech/pages/TechScanPage.vue') },
{ path: 'diagnostic', name: 'tech-diag', component: () => import('src/modules/tech/pages/TechDiagnosticPage.vue') },
{ path: 'more', name: 'tech-more', component: () => import('src/modules/tech/pages/TechMorePage.vue') },
],
},
// Ops staff desktop view
{
path: '/',
component: () => import('src/layouts/MainLayout.vue'),