feat(field): job detail page with equipment management and inline editing

- New JobDetailPage: full-screen job view with editable fields (subject,
  type, priority, time, duration, description), status transitions
  (en route / terminer / rouvrir), GPS navigation to service location
- Equipment section: list equipment at location, add via scanner/search/create
- TasksPage: jobs now navigate to detail page instead of inline expand,
  quick status buttons remain accessible from the list
- Offline support: all edits queued when offline, cached job data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-09 07:21:38 -04:00
parent 922572653a
commit 8fc722acdf
3 changed files with 538 additions and 30 deletions

View File

@ -0,0 +1,526 @@
<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 + actions 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'" unelevated color="blue" icon="directions_car"
label="En route" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" />
<q-btn v-if="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>
<!-- Scrollable content -->
<div class="job-content q-pa-md">
<!-- Info card -->
<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-select v-model="job.job_type" :options="jobTypes" label="Type" outlined dense
class="col" emit-value map-options @update:model-value="saveField('job_type', $event)" />
<q-select v-model="job.priority" :options="priorities" label="Priorité" outlined dense
class="col" emit-value map-options @update:model-value="saveField('priority', $event)" />
</div>
<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="displayDuration" label="Durée (h)" type="number" step="0.5" min="0.5" max="12"
outlined dense class="col"
@blur="saveField('duration_h', parseFloat(displayDuration) || 1)" />
</div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense
autogrow rows="2" class="q-mb-sm"
@blur="saveField('description', job.description)" />
</q-card-section>
</q-card>
<!-- Location card -->
<q-card flat bordered class="q-mb-md" v-if="job.service_location_name || job.customer_name">
<q-card-section class="q-pb-none">
<div class="text-overline text-grey-6">CLIENT & ADRESSE</div>
</q-card-section>
<q-card-section>
<div v-if="job.customer_name" class="row items-center q-mb-xs">
<q-icon name="person" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.customer_name }}</span>
</div>
<div v-if="locationAddress" class="row items-center q-mb-sm">
<q-icon name="place" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ locationAddress }}</span>
<q-space />
<q-btn flat dense round icon="navigation" color="primary" @click="openGps" title="Naviguer" />
</div>
<div v-if="locationDetail?.contact_name" class="text-caption text-grey">
Contact: {{ locationDetail.contact_name }}
<span v-if="locationDetail.contact_phone"> {{ locationDetail.contact_phone }}</span>
</div>
</q-card-section>
</q-card>
<!-- Equipment section -->
<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">ÉQUIPEMENTS ({{ equipment.length }})</div>
<q-btn flat dense size="sm" icon="add" color="primary" label="Ajouter" @click="addEquipmentMenu = true" />
</q-card-section>
<q-card-section v-if="loadingEquip" class="text-center">
<q-spinner size="sm" />
</q-card-section>
<q-list v-else-if="equipment.length" separator>
<q-item v-for="eq in equipment" :key="eq.name" clickable
@click="$router.push({ name: 'device', params: { serial: eq.serial_number } })">
<q-item-section avatar>
<q-icon :name="eqIcon(eq.equipment_type)" :color="eqStatusColor(eq.status)" />
</q-item-section>
<q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">{{ eq.serial_number }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge :color="eqStatusColor(eq.status)" :label="eq.status || '—'" />
</q-item-section>
</q-item>
</q-list>
<q-card-section v-else class="text-center text-grey text-caption">
Aucun équipement lié
</q-card-section>
</q-card>
<!-- Add equipment menu -->
<q-dialog v-model="addEquipmentMenu" position="bottom">
<q-card style="width: 100%; max-width: 400px">
<q-card-section class="text-h6">Ajouter un équipement</q-card-section>
<q-list>
<q-item clickable v-close-popup @click="goToScanner">
<q-item-section avatar><q-icon name="qr_code_scanner" color="primary" /></q-item-section>
<q-item-section>
<q-item-label>Scanner un code-barres / QR</q-item-label>
<q-item-label caption>Utiliser la caméra pour détecter un SN ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="searchEquipDialog = true">
<q-item-section avatar><q-icon name="search" color="orange" /></q-item-section>
<q-item-section>
<q-item-label>Rechercher un équipement existant</q-item-label>
<q-item-label caption>Par numéro de série ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="createEquipDialog = true">
<q-item-section avatar><q-icon name="add_circle" color="green" /></q-item-section>
<q-item-section>
<q-item-label>Créer un nouvel équipement</q-item-label>
<q-item-label caption>Saisir manuellement les informations</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</q-dialog>
<!-- Search existing equipment dialog -->
<q-dialog v-model="searchEquipDialog">
<q-card style="min-width: 340px">
<q-card-section class="text-h6">Rechercher un équipement</q-card-section>
<q-card-section>
<q-input v-model="eqSearchText" label="Numéro de série ou MAC" outlined dense autofocus
@keyup.enter="searchEquipment" debounce="400" @update:model-value="searchEquipment">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="eqSearchResults.length" bordered separator class="q-mt-sm" style="max-height: 250px; overflow-y: auto">
<q-item v-for="eq in eqSearchResults" :key="eq.name" clickable @click="linkEquipToJob(eq)">
<q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">SN: {{ eq.serial_number }}</q-item-label>
<q-item-label caption v-if="eq.customer_name">Client: {{ eq.customer_name }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="link" color="primary" />
</q-item-section>
</q-item>
</q-list>
<div v-if="eqSearchText && !eqSearchResults.length && !eqSearching" class="text-caption text-grey q-mt-sm text-center">
Aucun résultat
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Create new equipment dialog -->
<q-dialog v-model="createEquipDialog">
<q-card style="min-width: 340px">
<q-card-section class="text-h6">Nouvel équipement</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense class="q-mb-sm" />
<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="Modèle" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Créer & Lier" :loading="creatingEquip" @click="createAndLinkEquip" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getDoc, updateDoc, listDocs, createDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
const route = useRoute()
const router = useRouter()
const offline = useOfflineStore()
const jobName = computed(() => route.params.name)
const loading = ref(true)
const saving = ref(false)
const job = ref(null)
const locationDetail = ref(null)
const equipment = ref([])
const loadingEquip = ref(false)
// Add equipment
const addEquipmentMenu = ref(false)
const searchEquipDialog = ref(false)
const createEquipDialog = ref(false)
const eqSearchText = ref('')
const eqSearchResults = ref([])
const eqSearching = ref(false)
const creatingEquip = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const displayDuration = computed({
get: () => job.value?.duration_h || 1,
set: v => { if (job.value) job.value.duration_h = v },
})
const jobTypes = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Repair' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Inspection', value: 'Inspection' },
{ label: 'Livraison', value: 'Delivery' },
{ label: 'Autre', value: 'Other' },
]
const priorities = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
{ label: 'Urgente', value: 'urgent' },
]
const statusColor = computed(() => {
const s = job.value?.status
if (s === 'Scheduled') return 'blue'
if (s === 'In Progress') return 'orange'
if (s === 'Completed') return 'green'
if (s === 'Cancelled') return 'grey'
return 'grey'
})
const statusLabel = computed(() => {
const map = { Scheduled: 'Planifié', 'In Progress': 'En cours', Completed: 'Terminé', Cancelled: 'Annulé' }
return map[job.value?.status] || job.value?.status || ''
})
const locationAddress = computed(() => {
const loc = locationDetail.value
if (!loc) return job.value?.service_location_name || ''
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
})
function eqIcon (type) {
const map = { ONT: 'settings_input_hdmi', Modem: 'router', Routeur: 'wifi', 'Décodeur TV': 'tv', VoIP: 'phone' }
return map[type] || 'memory'
}
function eqStatusColor (s) {
if (s === 'Actif') return 'green'
if (s === 'Défectueux' || s === 'Perdu') return 'red'
return 'grey'
}
// --- Load job + related data ---
async function loadJob () {
loading.value = true
try {
job.value = await getDoc('Dispatch Job', jobName.value)
// Load location details
if (job.value.service_location) {
getDoc('Service Location', job.value.service_location)
.then(loc => { locationDetail.value = loc })
.catch(() => {})
}
// Load equipment at this location
loadEquipment()
} catch (e) {
// Try cache
const cached = await offline.getCached('job-' + jobName.value)
if (cached) {
job.value = cached
} else {
Notify.create({ type: 'negative', message: 'Erreur chargement job' })
}
} finally {
loading.value = false
}
}
async function loadEquipment () {
if (!job.value?.service_location && !job.value?.customer) return
loadingEquip.value = true
try {
const filters = job.value.service_location
? { service_location: job.value.service_location }
: { customer: job.value.customer }
equipment.value = await listDocs('Service Equipment', {
filters,
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'status', 'customer_name'],
limit: 50,
})
} catch { equipment.value = [] }
finally { loadingEquip.value = false }
}
// --- Save field ---
async function saveField (field, value) {
if (!job.value?.name) return
try {
if (offline.online) {
await updateDoc('Dispatch Job', job.value.name, { [field]: value })
} else {
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.value.name, data: { [field]: value } })
}
// Cache for offline
offline.cacheData('job-' + job.value.name, { ...job.value })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur sauvegarde: ' + e.message })
}
}
// --- Status update ---
async function updateStatus (status) {
saving.value = true
try {
if (offline.online) {
await updateDoc('Dispatch Job', job.value.name, { status })
} else {
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.value.name, data: { status } })
}
job.value.status = status
const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé', Scheduled: 'Job réouvert' }
Notify.create({ type: 'positive', message: msgs[status] || status, icon: status === 'Completed' ? 'check_circle' : 'directions_car' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
// --- GPS navigation ---
function openGps () {
const loc = locationDetail.value
if (!loc) return
const addr = [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
// Try Google Maps first (most common on Android), fallback to Apple Maps
const encoded = encodeURIComponent(addr)
if (loc.latitude && loc.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${loc.latitude},${loc.longitude}`, '_blank')
} else {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encoded}`, '_blank')
}
}
function openInErp () {
window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank')
}
// --- Equipment search ---
async function searchEquipment () {
const text = eqSearchText.value?.trim()
if (!text || text.length < 2) { eqSearchResults.value = []; return }
eqSearching.value = true
try {
// Search by serial
let results = await listDocs('Service Equipment', {
filters: { serial_number: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
if (!results.length) {
// Search by MAC
results = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
}
eqSearchResults.value = results
} catch { eqSearchResults.value = [] }
finally { eqSearching.value = false }
}
async function linkEquipToJob (eq) {
try {
const updates = {}
if (job.value.customer) updates.customer = job.value.customer
if (job.value.service_location) updates.service_location = job.value.service_location
await updateDoc('Service Equipment', eq.name, updates)
equipment.value.push(eq)
searchEquipDialog.value = false
eqSearchText.value = ''
eqSearchResults.value = []
Notify.create({ type: 'positive', message: `${eq.equipment_type} lié au job`, icon: 'link' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
async function createAndLinkEquip () {
if (!newEquip.value.serial_number?.trim()) {
Notify.create({ type: 'warning', message: 'Numéro de série requis' })
return
}
creatingEquip.value = true
try {
const data = {
...newEquip.value,
status: 'Actif',
customer: job.value.customer || '',
service_location: job.value.service_location || '',
}
if (offline.online) {
const doc = await createDoc('Service Equipment', data)
equipment.value.push(doc)
Notify.create({ type: 'positive', message: 'Équipement créé et lié', icon: 'check_circle' })
} else {
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
}
createEquipDialog.value = false
newEquip.value = { serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creatingEquip.value = false
}
}
function goToScanner () {
router.push({
name: '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: var(--q-primary, #1976d2);
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 { background: #fff8e1; }
&.status-completed { background: #e8f5e9; }
&.status-cancelled { background: #fafafa; }
}
.action-btn {
min-width: 140px;
font-weight: 600;
}
.job-content {
flex: 1;
overflow-y: auto;
padding-bottom: 80px !important; // clear bottom nav
}
.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em;
}
.text-overline {
font-size: 0.68rem;
letter-spacing: 0.08em;
}
</style>

View File

@ -15,7 +15,8 @@
<!-- 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 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" />
@ -25,33 +26,18 @@
{{ 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">
<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 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,
customer_name: job.customer_name,
location: job.service_location,
location_name: job.service_location_name,
} })" />
</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>
</div>
</q-slide-transition>
</q-card-section>
</q-card>
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
@ -91,7 +77,6 @@ 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)
@ -109,10 +94,6 @@ function statusColor (s) {
return map[s] || 'grey'
}
function expandJob (job) {
expanded.value = expanded.value === job.name ? null : job.name
}
async function loadTasks () {
loading.value = true
try {

View File

@ -9,6 +9,7 @@ const routes = [
{ path: 'scan', name: 'scan', component: () => import('src/pages/ScanPage.vue') },
{ path: 'diagnostic', name: 'diagnostic', component: () => import('src/pages/DiagnosticPage.vue') },
{ path: 'more', name: 'more', component: () => import('src/pages/MorePage.vue') },
{ path: 'job/:name', name: 'job-detail', component: () => import('src/pages/JobDetailPage.vue'), props: true },
{ path: 'device/:serial', name: 'device', component: () => import('src/pages/DevicePage.vue'), props: true },
],
},