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:
parent
922572653a
commit
8fc722acdf
526
apps/field/src/pages/JobDetailPage.vue
Normal file
526
apps/field/src/pages/JobDetailPage.vue
Normal 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>
|
||||
|
|
@ -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"> · {{ 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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user