gigafibre-fsm/apps/ops/src/modules/tech/pages/TechJobDetailPage.vue
louispaulb 6bb42a7b88 feat: tech mobile view integrated into ops app at /j, unassign confirmation
Tech mobile view (erp.gigafibre.ca/ops/#/j):
- TechLayout with bottom nav tabs (tasks, scanner, diagnostic, more)
- TechTasksPage: rich header with tech name/stats, job cards with
  priority dots, time, location, duration badges, bottom sheet detail
  with En route/Terminer buttons + scanner/detail access
- TechJobDetailPage: editable fields, equipment list, GPS navigation
- TechScanPage: device lookup by SN/MAC, create/link to job
- TechDiagnosticPage: speed test + host reachability checks
- Route /j replaces legacy dispatch-app tech view

Dispatch unassign confirmation:
- Dialog appears when unassigning published or in-progress jobs
- Warns that tech has already received the task
- Cancel/Confirm flow prevents accidental removal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 08:26:26 -04:00

187 lines
8.7 KiB
Vue

<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 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' || job.status === 'assigned'" unelevated color="indigo" icon="directions_car"
label="En route" @click="updateStatus('In Progress')" :loading="saving" class="action-btn" />
<q-btn v-if="job.status === 'In Progress' || 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>
<!-- Content -->
<div class="job-content q-pa-md">
<!-- Info -->
<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-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="job.duration_h" label="Duree (h)" type="number" step="0.5" min="0.5" outlined dense class="col" @blur="saveField('duration_h', parseFloat(job.duration_h) || 1)" />
</div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense autogrow rows="2" @blur="saveField('description', job.description)" />
</q-card-section>
</q-card>
<!-- Location -->
<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="job.service_location_name" class="row items-center q-mb-sm">
<q-icon name="place" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.service_location_name }}</span>
<q-space />
<q-btn flat dense round icon="navigation" color="primary" @click="openGps" />
</div>
</q-card-section>
</q-card>
<!-- Equipment -->
<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">EQUIPEMENTS ({{ equipment.length }})</div>
<q-btn flat dense size="sm" icon="qr_code_scanner" color="primary" label="Scanner" @click="goScan" />
</q-card-section>
<q-list v-if="equipment.length" separator>
<q-item v-for="eq in equipment" :key="eq.name">
<q-item-section avatar>
<q-icon :name="eq.equipment_type === 'ONT' ? 'settings_input_hdmi' : eq.equipment_type === 'Routeur' ? 'wifi' : 'memory'" :color="eq.status === 'Actif' ? 'green' : 'grey'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ eq.equipment_type }} — {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption style="font-family:monospace">{{ eq.serial_number }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section v-else class="text-center text-grey text-caption">Aucun equipement lie</q-card-section>
</q-card>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const saving = ref(false)
const job = ref(null)
const equipment = ref([])
const jobName = computed(() => route.params.name)
const statusColor = computed(() => {
const map = { Scheduled: 'blue', assigned: 'blue', 'In Progress': 'orange', in_progress: 'orange', Completed: 'green' }
return map[job.value?.status] || 'grey'
})
const statusLabel = computed(() => {
const map = { Scheduled: 'Planifie', assigned: 'Assigne', 'In Progress': 'En cours', in_progress: 'En cours', Completed: 'Termine' }
return map[job.value?.status] || job.value?.status || ''
})
async function apiFetch (url) {
const res = await fetch(BASE_URL + url)
if (!res.ok) throw new Error('API ' + res.status)
return (await res.json()).data
}
async function apiUpdate (doctype, name, data) {
await fetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
}
async function loadJob () {
loading.value = true
try {
job.value = await apiFetch('/api/resource/Dispatch Job/' + encodeURIComponent(jobName.value))
if (job.value.service_location) {
const params = new URLSearchParams({ filters: JSON.stringify({ service_location: job.value.service_location }), fields: JSON.stringify(['name','serial_number','equipment_type','brand','model','status']), limit_page_length: 50 })
try { equipment.value = await apiFetch('/api/resource/Service Equipment?' + params) || [] } catch {}
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { loading.value = false }
}
async function saveField (field, value) {
if (!job.value?.name) return
try { await apiUpdate('Dispatch Job', job.value.name, { [field]: value }) } catch {}
}
async function updateStatus (status) {
saving.value = true
try {
await apiUpdate('Dispatch Job', job.value.name, { status })
job.value.status = status
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Termine' : 'En route !' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { saving.value = false }
}
function openGps () {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(job.value.service_location_name || '')}`, '_blank')
}
function openInErp () { window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank') }
function goScan () {
router.push({ path: '/j/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: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%); 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, &.status-in_progress { background: #fff8e1; }
&.status-completed { background: #e8f5e9; }
}
.action-btn { min-width: 140px; font-weight: 600; border-radius: 12px; }
.job-content { flex: 1; overflow-y: auto; padding-bottom: 80px !important; }
.text-overline { font-size: 0.68rem; letter-spacing: 0.08em; }
</style>