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 -->
|
<!-- Jobs list -->
|
||||||
<template v-if="filter === 'jobs'">
|
<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">
|
<q-card-section class="q-py-sm">
|
||||||
<div class="row items-center no-wrap">
|
<div class="row items-center no-wrap">
|
||||||
<q-badge :color="statusColor(job.status)" class="q-mr-sm" />
|
<q-badge :color="statusColor(job.status)" class="q-mr-sm" />
|
||||||
|
|
@ -25,33 +26,18 @@
|
||||||
{{ job.customer_name || job.customer || '' }}
|
{{ job.customer_name || job.customer || '' }}
|
||||||
<span v-if="job.scheduled_time"> · {{ job.scheduled_time?.slice(0, 5) }}</span>
|
<span v-if="job.scheduled_time"> · {{ job.scheduled_time?.slice(0, 5) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="job.service_location_name" class="text-caption text-blue-grey">
|
||||||
<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 }}
|
<q-icon name="place" size="xs" /> {{ job.service_location_name }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</q-slide-transition>
|
<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-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
|
<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 filter = ref('jobs')
|
||||||
const jobs = ref([])
|
const jobs = ref([])
|
||||||
const tickets = ref([])
|
const tickets = ref([])
|
||||||
const expanded = ref(null)
|
|
||||||
const offline = useOfflineStore()
|
const offline = useOfflineStore()
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
|
@ -109,10 +94,6 @@ function statusColor (s) {
|
||||||
return map[s] || 'grey'
|
return map[s] || 'grey'
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandJob (job) {
|
|
||||||
expanded.value = expanded.value === job.name ? null : job.name
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTasks () {
|
async function loadTasks () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const routes = [
|
||||||
{ path: 'scan', name: 'scan', component: () => import('src/pages/ScanPage.vue') },
|
{ path: 'scan', name: 'scan', component: () => import('src/pages/ScanPage.vue') },
|
||||||
{ path: 'diagnostic', name: 'diagnostic', component: () => import('src/pages/DiagnosticPage.vue') },
|
{ path: 'diagnostic', name: 'diagnostic', component: () => import('src/pages/DiagnosticPage.vue') },
|
||||||
{ path: 'more', name: 'more', component: () => import('src/pages/MorePage.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 },
|
{ path: 'device/:serial', name: 'device', component: () => import('src/pages/DevicePage.vue'), props: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user