gigafibre-fsm/apps/field/src/pages/TasksPage.vue
louispaulb 11cd38f93c feat: add field tech app — barcode scanner, tasks, diagnostics, offline
Mobile-first Quasar PWA for field technicians at erp.gigafibre.ca/field/:
- Multi-barcode scanner (photo + live + manual) with device lookup
- Tasks page: today's Dispatch Jobs + assigned tickets
- Diagnostic: speed test, HTTP resolve, batch service check
- Device detail with customer linking
- Offline support: IndexedDB queue, API cache, auto-sync
- Standalone nginx container with Traefik StripPrefix + Authentik SSO

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:00:44 -04:00

163 lines
6.0 KiB
Vue

<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" />
</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" @click="expandJob(job)">
<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>
<q-icon name="chevron_right" color="grey" />
</div>
<!-- Expanded detail -->
<q-slide-transition>
<div v-if="expanded === job.name" class="q-mt-sm">
<div v-if="job.description" class="text-body2 q-mb-xs" v-html="job.description" />
<div v-if="job.service_location_name" class="text-caption">
<q-icon name="place" size="xs" /> {{ job.service_location_name }}
</div>
<div class="row q-mt-sm q-gutter-sm">
<q-btn size="sm" color="primary" label="Commencer" icon="play_arrow"
v-if="job.status === 'Scheduled'" @click.stop="updateJobStatus(job, 'In Progress')" />
<q-btn size="sm" color="positive" label="Terminer" icon="check"
v-if="job.status === 'In Progress'" @click.stop="updateJobStatus(job, 'Completed')" />
<q-btn size="sm" flat label="Scanner" icon="qr_code_scanner"
@click.stop="$router.push({ name: 'scan', query: { job: job.name, customer: job.customer } })" />
</div>
</div>
</q-slide-transition>
</q-card-section>
</q-card>
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
Aucun job aujourd'hui
</div>
</template>
<!-- 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" />
<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>
</div>
</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-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { listDocs, updateDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { Notify } from 'quasar'
const loading = ref(false)
const filter = ref('jobs')
const jobs = ref([])
const tickets = ref([])
const expanded = ref(null)
const offline = useOfflineStore()
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' })
}
function statusColor (s) {
const map = { Scheduled: 'blue', 'In Progress': 'orange', Completed: 'green', Cancelled: 'grey' }
return map[s] || 'grey'
}
function expandJob (job) {
expanded.value = expanded.value === job.name ? null : job.name
}
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',
}),
])
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
Notify.create({ type: 'warning', message: 'Mode hors ligne — données en cache' })
} finally {
loading.value = false
}
}
async function updateJobStatus (job, status) {
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' })
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
onMounted(loadTasks)
</script>