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>
163 lines
6.0 KiB
Vue
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"> · {{ 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 || '' }} · {{ 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>
|