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>
This commit is contained in:
parent
8fc722acdf
commit
73691668d3
|
|
@ -1,149 +1,521 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page class="tasks-page">
|
||||||
<!-- Date header -->
|
<!-- Tech header -->
|
||||||
<div class="row items-center q-mb-md">
|
<div class="tech-header">
|
||||||
<div class="text-h6">{{ todayLabel }}</div>
|
<div class="tech-header-row">
|
||||||
<q-space />
|
<div class="tech-header-left">
|
||||||
<q-btn flat dense icon="refresh" :loading="loading" @click="loadTasks" />
|
<div class="tech-date">{{ todayLabel }}</div>
|
||||||
|
<div class="tech-name">{{ techName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="tech-header-right">
|
||||||
|
<q-badge :color="offline.online ? 'green' : 'grey'" :label="offline.online ? 'En ligne' : 'Hors ligne'" class="tech-status-badge" />
|
||||||
|
<q-btn flat dense round icon="swap_horiz" color="white" size="sm" @click="loadTasks" :loading="loading" />
|
||||||
|
<q-avatar size="36px" color="indigo-8" text-color="white" class="tech-avatar">{{ initials }}</q-avatar>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter tabs -->
|
<!-- Stats cards -->
|
||||||
<q-tabs v-model="filter" dense no-caps active-color="primary" class="q-mb-md">
|
<div class="stats-row">
|
||||||
<q-tab name="jobs" :label="'Jobs (' + jobs.length + ')'" />
|
<div class="stat-card" @click="filter = 'all'">
|
||||||
<q-tab name="tickets" :label="'Tickets (' + tickets.length + ')'" />
|
<div class="stat-value">{{ jobs.length }}</div>
|
||||||
</q-tabs>
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" @click="filter = 'todo'">
|
||||||
|
<div class="stat-value">{{ todoCount }}</div>
|
||||||
|
<div class="stat-label">A faire</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-done" @click="filter = 'done'">
|
||||||
|
<div class="stat-value">{{ doneCount }}</div>
|
||||||
|
<div class="stat-label">Faits</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Jobs list -->
|
<!-- Job list -->
|
||||||
<template v-if="filter === 'jobs'">
|
<div class="jobs-list q-pa-md">
|
||||||
<q-card v-for="job in jobs" :key="job.name" class="q-mb-sm" clickable
|
<!-- Upcoming section -->
|
||||||
@click="$router.push({ name: 'job-detail', params: { name: job.name } })">
|
<div v-if="upcomingJobs.length" class="section-label">A VENIR ({{ upcomingJobs.length }})</div>
|
||||||
<q-card-section class="q-py-sm">
|
<div v-for="(job, idx) in upcomingJobs" :key="job.name" class="job-card" :class="{ 'job-card-urgent': job.priority === 'urgent' || job.priority === 'high' }" @click="openSheet(job)">
|
||||||
<div class="row items-center no-wrap">
|
<div class="job-card-header">
|
||||||
<q-badge :color="statusColor(job.status)" class="q-mr-sm" />
|
<div class="job-card-left">
|
||||||
<div class="col">
|
<span class="job-order">{{ idx + 1 }}</span>
|
||||||
<div class="text-subtitle2 ellipsis">{{ job.subject || job.name }}</div>
|
<span class="job-id">{{ job.name }}</span>
|
||||||
<div class="text-caption text-grey">
|
<span v-if="job.priority === 'urgent' || job.priority === 'high'" class="job-priority-dot" />
|
||||||
{{ job.customer_name || job.customer || '' }}
|
|
||||||
<span v-if="job.scheduled_time"> · {{ job.scheduled_time?.slice(0, 5) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="job.service_location_name" class="text-caption text-blue-grey">
|
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
|
||||||
<q-icon name="place" size="xs" /> {{ job.service_location_name }}
|
</div>
|
||||||
|
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
|
||||||
|
<div v-if="job.service_location_name" class="job-card-location">
|
||||||
|
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
|
||||||
|
</div>
|
||||||
|
<div class="job-card-badges">
|
||||||
|
<span v-if="job.duration_h" class="job-badge">
|
||||||
|
<q-icon name="schedule" size="12px" /> {{ job.duration_h }}h
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
<!-- In progress section -->
|
||||||
@click.stop="updateJobStatus(job, 'In Progress')" class="q-px-sm" />
|
<div v-if="inProgressJobs.length" class="section-label q-mt-md">EN COURS ({{ inProgressJobs.length }})</div>
|
||||||
<q-btn v-if="job.status === 'In Progress'" dense size="sm" unelevated color="positive" icon="check"
|
<div v-for="job in inProgressJobs" :key="job.name" class="job-card job-card-progress" @click="openSheet(job)">
|
||||||
@click.stop="updateJobStatus(job, 'Completed')" class="q-px-sm" />
|
<div class="job-card-header">
|
||||||
<q-icon name="chevron_right" color="grey-5" />
|
<div class="job-card-left">
|
||||||
|
<q-spinner-dots size="14px" color="orange" class="q-mr-xs" />
|
||||||
|
<span class="job-id">{{ job.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
|
||||||
|
<div v-if="job.service_location_name" class="job-card-location">
|
||||||
|
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
<!-- Completed section -->
|
||||||
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
|
<div v-if="completedJobs.length && (filter === 'all' || filter === 'done')" class="section-label q-mt-md">TERMINÉS ({{ completedJobs.length }})</div>
|
||||||
|
<div v-for="job in (filter === 'all' || filter === 'done' ? completedJobs : [])" :key="job.name" class="job-card job-card-done" @click="openSheet(job)">
|
||||||
|
<div class="job-card-header">
|
||||||
|
<div class="job-card-left">
|
||||||
|
<q-icon name="check_circle" size="16px" color="green" class="q-mr-xs" />
|
||||||
|
<span class="job-id">{{ job.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-time text-grey">{{ fmtTime(job.scheduled_time) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-card-title text-grey">{{ job.subject || 'Sans titre' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl q-pa-lg">
|
||||||
|
<q-icon name="event_available" size="48px" color="grey-4" class="q-mb-md" /><br>
|
||||||
Aucun job aujourd'hui
|
Aucun job aujourd'hui
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<!-- Tickets list -->
|
<!-- Job detail bottom sheet -->
|
||||||
<template v-if="filter === 'tickets'">
|
<q-dialog v-model="sheetOpen" position="bottom" seamless>
|
||||||
<q-card v-for="t in tickets" :key="t.name" class="q-mb-sm">
|
<q-card class="bottom-sheet" v-if="sheetJob">
|
||||||
<q-card-section class="q-py-sm">
|
<div class="sheet-handle" />
|
||||||
<div class="row items-center no-wrap">
|
|
||||||
<q-badge :color="t.status === 'Open' ? 'orange' : t.status === 'Closed' ? 'grey' : 'blue'" class="q-mr-sm" />
|
<q-card-section class="q-pb-sm">
|
||||||
|
<div class="row items-start no-wrap">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-subtitle2 ellipsis">{{ t.subject }}</div>
|
<div class="row items-center q-gutter-xs q-mb-xs">
|
||||||
<div class="text-caption text-grey">
|
<span class="sheet-job-id">{{ sheetJob.name }}</span>
|
||||||
{{ t.customer_name || '' }} · {{ formatDate(t.creation) }}
|
<span v-if="sheetJob.priority === 'urgent' || sheetJob.priority === 'high'" class="job-priority-dot" />
|
||||||
|
<q-badge v-if="sheetJob.priority === 'urgent'" color="red" label="Urgent" />
|
||||||
|
<q-badge v-else-if="sheetJob.priority === 'high'" color="orange" label="Haute" />
|
||||||
|
</div>
|
||||||
|
<div class="sheet-title">{{ sheetJob.subject || 'Sans titre' }}</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense round icon="close" @click="sheetOpen = false" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<q-card-section v-if="sheetJob.service_location_name" class="q-py-sm">
|
||||||
|
<div class="sheet-info-row">
|
||||||
|
<q-icon name="place" size="20px" color="red-5" class="q-mr-sm" />
|
||||||
|
<div class="col">
|
||||||
|
<div class="sheet-info-label">Adresse</div>
|
||||||
|
<div class="sheet-info-value">{{ sheetJob.service_location_name }}</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense color="primary" label="Carte" icon="navigation" @click="openGps(sheetJob)" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Duration + travel -->
|
||||||
|
<q-card-section class="q-py-sm">
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<div v-if="sheetJob.duration_h" class="sheet-info-row col">
|
||||||
|
<q-icon name="schedule" size="20px" color="grey-6" class="q-mr-sm" />
|
||||||
|
<div>
|
||||||
|
<div class="sheet-info-label">Durée estimée</div>
|
||||||
|
<div class="sheet-info-value">{{ sheetJob.duration_h }}h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="sheetJob.customer_name" class="sheet-info-row col">
|
||||||
|
<q-icon name="person" size="20px" color="grey-6" class="q-mr-sm" />
|
||||||
|
<div>
|
||||||
|
<div class="sheet-info-label">Client</div>
|
||||||
|
<div class="sheet-info-value">{{ sheetJob.customer_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
|
||||||
<div v-if="!loading && tickets.length === 0" class="text-center text-grey q-mt-xl">
|
<!-- Description -->
|
||||||
Aucun ticket assigné
|
<q-card-section v-if="sheetJob.description" class="q-py-sm">
|
||||||
|
<div class="sheet-info-label q-mb-xs">Notes</div>
|
||||||
|
<div class="text-body2" v-html="sheetJob.description" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<q-card-section class="q-pt-sm">
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn v-if="sheetJob.status === 'Scheduled'" unelevated color="indigo" icon="directions_car"
|
||||||
|
label="En route" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
|
||||||
|
<q-btn v-if="sheetJob.status === 'In Progress'" unelevated color="positive" icon="check_circle"
|
||||||
|
label="Terminer" class="col action-btn" @click="doStatus(sheetJob, 'Completed')" :loading="saving" />
|
||||||
|
<q-btn v-if="sheetJob.status === 'Completed'" unelevated color="blue-grey" icon="replay"
|
||||||
|
label="Rouvrir" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="row q-gutter-sm q-mt-xs">
|
||||||
|
<q-btn outline color="primary" icon="qr_code_scanner" label="Scanner" class="col"
|
||||||
|
@click="goScan(sheetJob)" />
|
||||||
|
<q-btn outline color="grey-8" icon="open_in_full" label="Détails" class="col"
|
||||||
|
@click="goDetail(sheetJob)" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { listDocs, updateDoc } from 'src/api/erp'
|
import { listDocs, updateDoc } from 'src/api/erp'
|
||||||
import { useOfflineStore } from 'src/stores/offline'
|
import { useOfflineStore } from 'src/stores/offline'
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
import { Notify } from 'quasar'
|
import { Notify } from 'quasar'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const filter = ref('jobs')
|
const filter = ref('all')
|
||||||
const jobs = ref([])
|
const jobs = ref([])
|
||||||
const tickets = ref([])
|
|
||||||
const offline = useOfflineStore()
|
const offline = useOfflineStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Bottom sheet
|
||||||
|
const sheetOpen = ref(false)
|
||||||
|
const sheetJob = ref(null)
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const todayLabel = computed(() =>
|
const todayLabel = computed(() =>
|
||||||
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
)
|
)
|
||||||
|
|
||||||
function formatDate (d) {
|
const techName = computed(() => {
|
||||||
if (!d) return ''
|
const u = auth.user || ''
|
||||||
return new Date(d).toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' })
|
// Authentik gives full name or email — parse for display
|
||||||
|
if (u.includes('@')) return u.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
if (u === 'authenticated') return 'Technicien'
|
||||||
|
return u
|
||||||
|
})
|
||||||
|
const initials = computed(() => {
|
||||||
|
const parts = techName.value.split(' ')
|
||||||
|
return parts.length >= 2 ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() : techName.value.slice(0, 2).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const todoCount = computed(() => jobs.value.filter(j => j.status === 'Scheduled' || j.status === 'In Progress').length)
|
||||||
|
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
|
||||||
|
|
||||||
|
const upcomingJobs = computed(() => {
|
||||||
|
const filtered = jobs.value.filter(j => j.status === 'Scheduled')
|
||||||
|
if (filter.value === 'done') return []
|
||||||
|
return filtered.sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || ''))
|
||||||
|
})
|
||||||
|
const inProgressJobs = computed(() => {
|
||||||
|
if (filter.value === 'done') return []
|
||||||
|
return jobs.value.filter(j => j.status === 'In Progress')
|
||||||
|
})
|
||||||
|
const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed'))
|
||||||
|
|
||||||
|
function fmtTime (t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const [h, m] = t.split(':')
|
||||||
|
return `${h}h${m}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor (s) {
|
function openSheet (job) {
|
||||||
const map = { Scheduled: 'blue', 'In Progress': 'orange', Completed: 'green', Cancelled: 'grey' }
|
sheetJob.value = job
|
||||||
return map[s] || 'grey'
|
sheetOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks () {
|
async function loadTasks () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [j, t] = await Promise.all([
|
const j = await listDocs('Dispatch Job', {
|
||||||
listDocs('Dispatch Job', {
|
|
||||||
filters: { scheduled_date: today },
|
filters: { scheduled_date: today },
|
||||||
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
|
fields: ['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
|
||||||
'service_location_name', 'scheduled_time', 'description', 'job_type'],
|
'service_location_name', 'scheduled_time', 'description', 'job_type', 'duration_h', 'priority'],
|
||||||
limit: 50,
|
limit: 50,
|
||||||
orderBy: 'scheduled_time asc',
|
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
|
jobs.value = j
|
||||||
tickets.value = t
|
|
||||||
// Cache for offline
|
|
||||||
offline.cacheData('tasks-jobs', j)
|
offline.cacheData('tasks-jobs', j)
|
||||||
offline.cacheData('tasks-tickets', t)
|
|
||||||
} catch {
|
} catch {
|
||||||
// Try cached data
|
const cached = await offline.getCached('tasks-jobs')
|
||||||
const cj = await offline.getCached('tasks-jobs')
|
if (cached) jobs.value = cached
|
||||||
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' })
|
Notify.create({ type: 'warning', message: 'Mode hors ligne — données en cache' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateJobStatus (job, status) {
|
async function doStatus (job, status) {
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (offline.online) {
|
if (offline.online) {
|
||||||
await updateDoc('Dispatch Job', job.name, { status })
|
await updateDoc('Dispatch Job', job.name, { status })
|
||||||
job.status = status
|
|
||||||
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Job terminé' : 'Job démarré' })
|
|
||||||
} else {
|
} else {
|
||||||
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.name, data: { status } })
|
await offline.enqueue({ type: 'update', doctype: 'Dispatch Job', name: job.name, data: { status } })
|
||||||
job.status = status
|
|
||||||
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
|
|
||||||
}
|
}
|
||||||
|
job.status = status
|
||||||
|
const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé ✓' }
|
||||||
|
Notify.create({ type: 'positive', message: msgs[status] || status })
|
||||||
|
if (status === 'Completed') sheetOpen.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openGps (job) {
|
||||||
|
const loc = job.service_location_name || ''
|
||||||
|
if (job.latitude && job.longitude) {
|
||||||
|
window.open(`https://www.google.com/maps/dir/?api=1&destination=${job.latitude},${job.longitude}`, '_blank')
|
||||||
|
} else {
|
||||||
|
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}`, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goScan (job) {
|
||||||
|
sheetOpen.value = false
|
||||||
|
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,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail (job) {
|
||||||
|
sheetOpen.value = false
|
||||||
|
router.push({ name: 'job-detail', params: { name: job.name } })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadTasks)
|
onMounted(loadTasks)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tasks-page {
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #eef1f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tech header ── */
|
||||||
|
.tech-header {
|
||||||
|
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
border-radius: 0 0 20px 20px;
|
||||||
|
}
|
||||||
|
.tech-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.tech-date {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.tech-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tech-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tech-status-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.tech-avatar {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats ── */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 -4px;
|
||||||
|
transform: translateY(24px);
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: #4a4880;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:active { background: #5a589a; }
|
||||||
|
}
|
||||||
|
.stat-card.stat-done .stat-value { color: #ff6b6b; }
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Jobs list ── */
|
||||||
|
.jobs-list {
|
||||||
|
padding-top: 36px !important;
|
||||||
|
}
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #8b8fa3;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Job card ── */
|
||||||
|
.job-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 4px solid #5c59a8;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
}
|
||||||
|
.job-card-urgent { border-left-color: #ef4444; }
|
||||||
|
.job-card-progress {
|
||||||
|
border-left-color: #f59e0b;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
.job-card-done {
|
||||||
|
border-left-color: #22c55e;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.job-card-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.job-order {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #5c59a8;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.job-id {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5c59a8;
|
||||||
|
}
|
||||||
|
.job-priority-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ef4444;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.job-time {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.job-card-location {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.job-card-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.job-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom sheet ── */
|
||||||
|
.bottom-sheet {
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #d1d5db;
|
||||||
|
margin: 10px auto 4px;
|
||||||
|
}
|
||||||
|
.sheet-job-id {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5c59a8;
|
||||||
|
}
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.sheet-info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sheet-info-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.sheet-info-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
60
apps/ops/src/layouts/TechLayout.vue
Normal file
60
apps/ops/src/layouts/TechLayout.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<q-layout view="hHh lpR fFf">
|
||||||
|
<q-page-container>
|
||||||
|
<router-view />
|
||||||
|
</q-page-container>
|
||||||
|
|
||||||
|
<!-- Bottom navigation -->
|
||||||
|
<q-footer class="tech-footer" bordered>
|
||||||
|
<transition name="slide-down">
|
||||||
|
<div v-if="!isOnline" class="offline-banner">
|
||||||
|
<q-icon name="wifi_off" size="14px" class="q-mr-xs" /> Hors ligne
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
<q-tabs v-model="tab" dense no-caps active-color="primary" indicator-color="primary" class="tech-tabs">
|
||||||
|
<q-route-tab name="tasks" icon="assignment" label="Taches" to="/j" exact />
|
||||||
|
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/j/scan" />
|
||||||
|
<q-route-tab name="diag" icon="speed" label="Diagnostic" to="/j/diagnostic" />
|
||||||
|
<q-route-tab name="more" icon="more_horiz" label="Plus" to="/j/more" />
|
||||||
|
</q-tabs>
|
||||||
|
</q-footer>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const tab = ref('tasks')
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
|
||||||
|
function onOnline () { isOnline.value = true }
|
||||||
|
function onOffline () { isOnline.value = false }
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
window.addEventListener('offline', onOffline)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.removeEventListener('offline', onOffline)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tech-footer {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.tech-tabs {
|
||||||
|
:deep(.q-tab) {
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offline-banner {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #c62828; color: white; font-size: 13px; font-weight: 500; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.slide-down-enter-active, .slide-down-leave-active { transition: max-height 0.3s ease, opacity 0.3s ease; overflow: hidden; }
|
||||||
|
.slide-down-enter-from, .slide-down-leave-to { max-height: 0; opacity: 0; }
|
||||||
|
.slide-down-enter-to, .slide-down-leave-from { max-height: 30px; opacity: 1; }
|
||||||
|
</style>
|
||||||
77
apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue
Normal file
77
apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="text-h6 q-mb-md">Diagnostic reseau</div>
|
||||||
|
|
||||||
|
<!-- Speed test -->
|
||||||
|
<q-card flat bordered class="q-mb-md">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-subtitle2 q-mb-sm">Test de vitesse</div>
|
||||||
|
<div v-if="speed !== null" class="text-center q-py-md">
|
||||||
|
<div class="text-h3 text-primary text-weight-bold">{{ speed }}</div>
|
||||||
|
<div class="text-caption text-grey">Mbps download</div>
|
||||||
|
<div v-if="latency !== null" class="text-caption">Latence: {{ latency }}ms</div>
|
||||||
|
</div>
|
||||||
|
<q-btn unelevated color="primary" label="Lancer le test" icon="speed" class="full-width" @click="runSpeed" :loading="testing" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Host checks -->
|
||||||
|
<q-card flat bordered>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-subtitle2 q-mb-sm">Verification des hotes</div>
|
||||||
|
<div v-for="h in hosts" :key="h.host" class="row items-center q-mb-xs">
|
||||||
|
<q-icon :name="h.status === 'ok' ? 'check_circle' : h.status === 'fail' ? 'cancel' : 'radio_button_unchecked'"
|
||||||
|
:color="h.status === 'ok' ? 'positive' : h.status === 'fail' ? 'negative' : 'grey'" class="q-mr-sm" />
|
||||||
|
<span class="text-body2">{{ h.host }}</span>
|
||||||
|
<q-space />
|
||||||
|
<span v-if="h.ms" class="text-caption text-grey">{{ h.ms }}ms</span>
|
||||||
|
</div>
|
||||||
|
<q-btn flat color="primary" label="Verifier" icon="refresh" class="q-mt-sm" @click="checkHosts" :loading="checking" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const testing = ref(false)
|
||||||
|
const speed = ref(null)
|
||||||
|
const latency = ref(null)
|
||||||
|
const checking = ref(false)
|
||||||
|
|
||||||
|
const hosts = ref([
|
||||||
|
{ host: 'google.ca', status: null, ms: null },
|
||||||
|
{ host: 'erp.gigafibre.ca', status: null, ms: null },
|
||||||
|
{ host: 'cloudflare.com', status: null, ms: null },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function runSpeed () {
|
||||||
|
testing.value = true; speed.value = null; latency.value = null
|
||||||
|
try {
|
||||||
|
const url = 'https://speed.cloudflare.com/__down?bytes=10000000'
|
||||||
|
const t0 = performance.now()
|
||||||
|
const res = await fetch(url, { mode: 'no-cors' })
|
||||||
|
const t1 = performance.now()
|
||||||
|
const ms = t1 - t0
|
||||||
|
latency.value = Math.round(ms)
|
||||||
|
// Approximate: 10MB / time = speed
|
||||||
|
speed.value = Math.round((10 * 8) / (ms / 1000))
|
||||||
|
} catch {
|
||||||
|
speed.value = 0
|
||||||
|
} finally { testing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHosts () {
|
||||||
|
checking.value = true
|
||||||
|
for (const h of hosts.value) {
|
||||||
|
try {
|
||||||
|
const t0 = performance.now()
|
||||||
|
await fetch('https://' + h.host, { mode: 'no-cors', signal: AbortSignal.timeout(5000) })
|
||||||
|
h.ms = Math.round(performance.now() - t0)
|
||||||
|
h.status = 'ok'
|
||||||
|
} catch { h.status = 'fail'; h.ms = null }
|
||||||
|
}
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
186
apps/ops/src/modules/tech/pages/TechJobDetailPage.vue
Normal file
186
apps/ops/src/modules/tech/pages/TechJobDetailPage.vue
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<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>
|
||||||
33
apps/ops/src/modules/tech/pages/TechMorePage.vue
Normal file
33
apps/ops/src/modules/tech/pages/TechMorePage.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="text-h6 q-mb-md">Plus</div>
|
||||||
|
<q-list>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="isOnline ? 'wifi' : 'wifi_off'" :color="isOnline ? 'positive' : 'negative'" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ isOnline ? 'En ligne' : 'Hors ligne' }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
<q-item clickable @click="logout">
|
||||||
|
<q-item-section avatar><q-icon name="logout" color="negative" /></q-item-section>
|
||||||
|
<q-item-section><q-item-label class="text-negative">Deconnexion</q-item-label></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<div class="text-caption text-grey text-center q-mt-xl">Targo Ops v2.0 — Vue technicien</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
function onOnline () { isOnline.value = true }
|
||||||
|
function onOffline () { isOnline.value = false }
|
||||||
|
onMounted(() => { window.addEventListener('online', onOnline); window.addEventListener('offline', onOffline) })
|
||||||
|
onUnmounted(() => { window.removeEventListener('online', onOnline); window.removeEventListener('offline', onOffline) })
|
||||||
|
|
||||||
|
function logout () { window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/' }
|
||||||
|
</script>
|
||||||
138
apps/ops/src/modules/tech/pages/TechScanPage.vue
Normal file
138
apps/ops/src/modules/tech/pages/TechScanPage.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding class="scan-page">
|
||||||
|
<!-- Job context banner -->
|
||||||
|
<q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1">
|
||||||
|
<q-card-section class="q-py-sm row items-center no-wrap">
|
||||||
|
<q-icon name="work" color="primary" class="q-mr-sm" />
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-subtitle2">{{ jobContext.customer_name || jobContext.customer }}</div>
|
||||||
|
<div class="text-caption text-grey" v-if="jobContext.location_name">
|
||||||
|
<q-icon name="place" size="xs" /> {{ jobContext.location_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Manual entry -->
|
||||||
|
<q-input v-model="manualCode" label="Numero de serie / MAC" outlined dense class="q-mb-md" @keyup.enter="lookupDevice">
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-btn flat dense icon="search" @click="lookupDevice" :disable="!manualCode.trim()" :loading="lookingUp" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<q-card v-if="result" class="q-mb-md">
|
||||||
|
<q-card-section v-if="result.found">
|
||||||
|
<div class="row items-center q-mb-sm">
|
||||||
|
<q-badge color="green" label="Trouve" class="q-mr-sm" />
|
||||||
|
<span class="text-subtitle2">{{ result.eq.equipment_type }} — {{ result.eq.brand }} {{ result.eq.model }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption" style="font-family:monospace">SN: {{ result.eq.serial_number }}</div>
|
||||||
|
<div v-if="result.eq.customer_name" class="text-caption">Client: {{ result.eq.customer_name }}</div>
|
||||||
|
<div v-if="!result.eq.service_location && jobContext" class="q-mt-sm">
|
||||||
|
<q-btn unelevated size="sm" color="primary" label="Lier a ce job" icon="link" @click="linkToJob(result.eq)" :loading="linking" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-else>
|
||||||
|
<q-badge color="orange" label="Non trouve" class="q-mb-sm" />
|
||||||
|
<div class="text-caption">Aucun equipement avec ce code.</div>
|
||||||
|
<q-btn flat size="sm" color="primary" label="Creer un equipement" icon="add" @click="createDialog = true" class="q-mt-xs" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Create dialog -->
|
||||||
|
<q-dialog v-model="createDialog">
|
||||||
|
<q-card style="min-width: 320px">
|
||||||
|
<q-card-section class="text-h6">Nouvel equipement</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="newEquip.serial_number" label="Numero de serie" 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="Modele" outlined dense class="q-mb-sm" />
|
||||||
|
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
|
<q-btn color="primary" label="Creer" @click="createEquipment" :loading="creating" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const manualCode = ref('')
|
||||||
|
const lookingUp = ref(false)
|
||||||
|
const result = ref(null)
|
||||||
|
const linking = ref(false)
|
||||||
|
const createDialog = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Decodeur TV', 'VoIP', 'Amplificateur', 'Autre']
|
||||||
|
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
|
||||||
|
|
||||||
|
const jobContext = ref(route.query.job ? {
|
||||||
|
job: route.query.job, customer: route.query.customer, customer_name: route.query.customer_name,
|
||||||
|
location: route.query.location, location_name: route.query.location_name,
|
||||||
|
} : null)
|
||||||
|
|
||||||
|
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 lookupDevice () {
|
||||||
|
const code = manualCode.value.trim()
|
||||||
|
if (!code) return
|
||||||
|
lookingUp.value = true; result.value = null
|
||||||
|
try {
|
||||||
|
let docs = await apiFetch('/api/resource/Service Equipment?filters=' + encodeURIComponent(JSON.stringify({ serial_number: code })) + '&fields=["name","serial_number","equipment_type","brand","model","customer","customer_name","service_location","status","mac_address"]&limit_page_length=1')
|
||||||
|
if (!docs.length) {
|
||||||
|
const norm = code.replace(/[:\-\.]/g, '').toUpperCase()
|
||||||
|
if (norm.length >= 6) {
|
||||||
|
docs = await apiFetch('/api/resource/Service Equipment?filters=' + encodeURIComponent(JSON.stringify({ mac_address: ['like', '%' + norm.slice(-6) + '%'] })) + '&fields=["name","serial_number","equipment_type","brand","model","customer","customer_name","service_location","status","mac_address"]&limit_page_length=1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.value = docs.length ? { found: true, eq: docs[0] } : { found: false }
|
||||||
|
} catch { result.value = { found: false } }
|
||||||
|
finally { lookingUp.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkToJob (eq) {
|
||||||
|
if (!jobContext.value) return
|
||||||
|
linking.value = true
|
||||||
|
try {
|
||||||
|
const updates = {}
|
||||||
|
if (jobContext.value.customer) updates.customer = jobContext.value.customer
|
||||||
|
if (jobContext.value.location) updates.service_location = jobContext.value.location
|
||||||
|
await fetch(BASE_URL + '/api/resource/Service Equipment/' + encodeURIComponent(eq.name), {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates),
|
||||||
|
})
|
||||||
|
eq.customer = jobContext.value.customer
|
||||||
|
eq.service_location = jobContext.value.location
|
||||||
|
Notify.create({ type: 'positive', message: 'Lie au job', icon: 'link' })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
|
||||||
|
finally { linking.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEquipment () {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...newEquip.value, status: 'Actif', customer: jobContext.value?.customer || '', service_location: jobContext.value?.location || '' }
|
||||||
|
const res = await fetch(BASE_URL + '/api/resource/Service Equipment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||||||
|
if (!res.ok) throw new Error('Create failed')
|
||||||
|
const doc = (await res.json()).data
|
||||||
|
result.value = { found: true, eq: doc }
|
||||||
|
createDialog.value = false
|
||||||
|
Notify.create({ type: 'positive', message: 'Equipement cree' })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
|
||||||
|
finally { creating.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
386
apps/ops/src/modules/tech/pages/TechTasksPage.vue
Normal file
386
apps/ops/src/modules/tech/pages/TechTasksPage.vue
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
<template>
|
||||||
|
<q-page class="tasks-page">
|
||||||
|
<!-- Tech header -->
|
||||||
|
<div class="tech-header">
|
||||||
|
<div class="tech-header-row">
|
||||||
|
<div class="tech-header-left">
|
||||||
|
<div class="tech-date">{{ todayLabel }}</div>
|
||||||
|
<div class="tech-name">{{ techName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="tech-header-right">
|
||||||
|
<q-badge :color="isOnline ? 'green' : 'grey'" :label="isOnline ? 'En ligne' : 'Hors ligne'" class="tech-status-badge" />
|
||||||
|
<q-btn flat dense round icon="swap_horiz" color="white" size="sm" @click="loadTasks" :loading="loading" />
|
||||||
|
<q-avatar size="36px" color="indigo-8" text-color="white" class="tech-avatar">{{ initials }}</q-avatar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats cards -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card" @click="statFilter = 'all'">
|
||||||
|
<div class="stat-value">{{ jobs.length }}</div>
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" @click="statFilter = 'todo'">
|
||||||
|
<div class="stat-value">{{ todoCount }}</div>
|
||||||
|
<div class="stat-label">A faire</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-done" @click="statFilter = 'done'">
|
||||||
|
<div class="stat-value">{{ doneCount }}</div>
|
||||||
|
<div class="stat-label">Faits</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job list -->
|
||||||
|
<div class="jobs-list q-pa-md">
|
||||||
|
<!-- Upcoming section -->
|
||||||
|
<div v-if="upcomingJobs.length" class="section-label">A VENIR ({{ upcomingJobs.length }})</div>
|
||||||
|
<div v-for="(job, idx) in upcomingJobs" :key="job.name" class="job-card"
|
||||||
|
:class="{ 'job-card-urgent': job.priority === 'urgent' || job.priority === 'high' }"
|
||||||
|
@click="openSheet(job)">
|
||||||
|
<div class="job-card-header">
|
||||||
|
<div class="job-card-left">
|
||||||
|
<span class="job-order">{{ idx + 1 }}</span>
|
||||||
|
<span class="job-id">{{ job.name }}</span>
|
||||||
|
<span v-if="job.priority === 'urgent' || job.priority === 'high'" class="job-priority-dot" />
|
||||||
|
</div>
|
||||||
|
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
|
||||||
|
<div v-if="job.service_location_name" class="job-card-location">
|
||||||
|
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
|
||||||
|
</div>
|
||||||
|
<div class="job-card-badges">
|
||||||
|
<span v-if="job.duration_h" class="job-badge">
|
||||||
|
<q-icon name="schedule" size="12px" /> {{ job.duration_h }}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- In progress section -->
|
||||||
|
<div v-if="inProgressJobs.length" class="section-label q-mt-md">EN COURS ({{ inProgressJobs.length }})</div>
|
||||||
|
<div v-for="job in inProgressJobs" :key="job.name" class="job-card job-card-progress" @click="openSheet(job)">
|
||||||
|
<div class="job-card-header">
|
||||||
|
<div class="job-card-left">
|
||||||
|
<q-spinner-dots size="14px" color="orange" class="q-mr-xs" />
|
||||||
|
<span class="job-id">{{ job.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-time">{{ fmtTime(job.scheduled_time) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-card-title">{{ job.subject || 'Sans titre' }}</div>
|
||||||
|
<div v-if="job.service_location_name" class="job-card-location">
|
||||||
|
<q-icon name="place" size="14px" color="grey-6" /> {{ job.service_location_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completed section -->
|
||||||
|
<div v-if="completedJobs.length && (statFilter === 'all' || statFilter === 'done')" class="section-label q-mt-md">TERMINES ({{ completedJobs.length }})</div>
|
||||||
|
<div v-for="job in (statFilter === 'all' || statFilter === 'done' ? completedJobs : [])" :key="job.name"
|
||||||
|
class="job-card job-card-done" @click="openSheet(job)">
|
||||||
|
<div class="job-card-header">
|
||||||
|
<div class="job-card-left">
|
||||||
|
<q-icon name="check_circle" size="16px" color="green" class="q-mr-xs" />
|
||||||
|
<span class="job-id">{{ job.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-time text-grey">{{ fmtTime(job.scheduled_time) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-card-title text-grey">{{ job.subject || 'Sans titre' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl q-pa-lg">
|
||||||
|
<q-icon name="event_available" size="48px" color="grey-4" class="q-mb-md" /><br>
|
||||||
|
Aucun job aujourd'hui
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job detail bottom sheet -->
|
||||||
|
<q-dialog v-model="sheetOpen" position="bottom" seamless>
|
||||||
|
<q-card class="bottom-sheet" v-if="sheetJob">
|
||||||
|
<div class="sheet-handle" />
|
||||||
|
|
||||||
|
<q-card-section class="q-pb-sm">
|
||||||
|
<div class="row items-start no-wrap">
|
||||||
|
<div class="col">
|
||||||
|
<div class="row items-center q-gutter-xs q-mb-xs">
|
||||||
|
<span class="sheet-job-id">{{ sheetJob.name }}</span>
|
||||||
|
<span v-if="sheetJob.priority === 'urgent' || sheetJob.priority === 'high'" class="job-priority-dot" />
|
||||||
|
<q-badge v-if="sheetJob.priority === 'urgent'" color="red" label="Urgent" />
|
||||||
|
<q-badge v-else-if="sheetJob.priority === 'high'" color="orange" label="Haute" />
|
||||||
|
</div>
|
||||||
|
<div class="sheet-title">{{ sheetJob.subject || 'Sans titre' }}</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense round icon="close" @click="sheetOpen = false" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<q-card-section v-if="sheetJob.service_location_name" class="q-py-sm">
|
||||||
|
<div class="sheet-info-row">
|
||||||
|
<q-icon name="place" size="20px" color="red-5" class="q-mr-sm" />
|
||||||
|
<div class="col">
|
||||||
|
<div class="sheet-info-label">Adresse</div>
|
||||||
|
<div class="sheet-info-value">{{ sheetJob.service_location_name }}</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense color="primary" label="Carte" icon="navigation" @click="openGps(sheetJob)" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Duration + client -->
|
||||||
|
<q-card-section class="q-py-sm">
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<div v-if="sheetJob.duration_h" class="sheet-info-row col">
|
||||||
|
<q-icon name="schedule" size="20px" color="grey-6" class="q-mr-sm" />
|
||||||
|
<div>
|
||||||
|
<div class="sheet-info-label">Duree estimee</div>
|
||||||
|
<div class="sheet-info-value">{{ sheetJob.duration_h }}h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="sheetJob.customer_name" class="sheet-info-row col">
|
||||||
|
<q-icon name="person" size="20px" color="grey-6" class="q-mr-sm" />
|
||||||
|
<div>
|
||||||
|
<div class="sheet-info-label">Client</div>
|
||||||
|
<div class="sheet-info-value">{{ sheetJob.customer_name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<q-card-section v-if="sheetJob.description" class="q-py-sm">
|
||||||
|
<div class="sheet-info-label q-mb-xs">Notes</div>
|
||||||
|
<div class="text-body2" v-html="sheetJob.description" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<q-card-section class="q-pt-sm">
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn v-if="sheetJob.status === 'Scheduled' || sheetJob.status === 'assigned'" unelevated color="indigo" icon="directions_car"
|
||||||
|
label="En route" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
|
||||||
|
<q-btn v-if="sheetJob.status === 'In Progress' || sheetJob.status === 'in_progress'" unelevated color="positive" icon="check_circle"
|
||||||
|
label="Terminer" class="col action-btn" @click="doStatus(sheetJob, 'Completed')" :loading="saving" />
|
||||||
|
<q-btn v-if="sheetJob.status === 'Completed'" unelevated color="blue-grey" icon="replay"
|
||||||
|
label="Rouvrir" class="col action-btn" @click="doStatus(sheetJob, 'In Progress')" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
<div class="row q-gutter-sm q-mt-xs">
|
||||||
|
<q-btn outline color="primary" icon="qr_code_scanner" label="Scanner" class="col"
|
||||||
|
@click="goScan(sheetJob)" />
|
||||||
|
<q-btn outline color="grey-8" icon="open_in_full" label="Details" class="col"
|
||||||
|
@click="goDetail(sheetJob)" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const statFilter = ref('all')
|
||||||
|
const jobs = ref([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
|
||||||
|
// Bottom sheet
|
||||||
|
const sheetOpen = ref(false)
|
||||||
|
const sheetJob = ref(null)
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const todayLabel = computed(() =>
|
||||||
|
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get tech name from Authentik headers (X-Authentik-Name) or fallback
|
||||||
|
const techName = ref('Technicien')
|
||||||
|
const initials = computed(() => {
|
||||||
|
const parts = techName.value.split(' ')
|
||||||
|
return parts.length >= 2 ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() : techName.value.slice(0, 2).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const todoCount = computed(() => jobs.value.filter(j => j.status !== 'Completed' && j.status !== 'Cancelled').length)
|
||||||
|
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
|
||||||
|
|
||||||
|
const upcomingJobs = computed(() => {
|
||||||
|
if (statFilter.value === 'done') return []
|
||||||
|
return jobs.value.filter(j => j.status === 'Scheduled' || j.status === 'assigned' || j.status === 'open')
|
||||||
|
.sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || ''))
|
||||||
|
})
|
||||||
|
const inProgressJobs = computed(() => {
|
||||||
|
if (statFilter.value === 'done') return []
|
||||||
|
return jobs.value.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
|
||||||
|
})
|
||||||
|
const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed'))
|
||||||
|
|
||||||
|
function fmtTime (t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const [h, m] = t.split(':')
|
||||||
|
return `${h}h${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSheet (job) {
|
||||||
|
sheetJob.value = job
|
||||||
|
sheetOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the same auth pattern as ops app — nginx injects token
|
||||||
|
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) {
|
||||||
|
const res = await fetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Update failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks () {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Load tech profile to get name
|
||||||
|
try {
|
||||||
|
const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user')
|
||||||
|
if (me.ok) {
|
||||||
|
const u = await me.json()
|
||||||
|
const userName = u.message || ''
|
||||||
|
// Try to get full name from Dispatch Technician linked to user
|
||||||
|
if (userName && userName !== 'authenticated') {
|
||||||
|
try {
|
||||||
|
const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1')
|
||||||
|
if (techs.length && techs[0].full_name) techName.value = techs[0].full_name
|
||||||
|
else techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
} catch { techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fields: JSON.stringify(['name', 'subject', 'status', 'customer', 'customer_name', 'service_location',
|
||||||
|
'service_location_name', 'scheduled_time', 'description', 'job_type', 'duration_h', 'priority']),
|
||||||
|
filters: JSON.stringify({ scheduled_date: today }),
|
||||||
|
limit_page_length: 50,
|
||||||
|
order_by: 'scheduled_time asc',
|
||||||
|
})
|
||||||
|
jobs.value = await apiFetch('/api/resource/Dispatch Job?' + params)
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'warning', message: 'Erreur chargement: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doStatus (job, status) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await apiUpdate('Dispatch Job', job.name, { status })
|
||||||
|
job.status = status
|
||||||
|
const msgs = { 'In Progress': 'En route !', Completed: 'Job termine' }
|
||||||
|
Notify.create({ type: 'positive', message: msgs[status] || status })
|
||||||
|
if (status === 'Completed') sheetOpen.value = false
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGps (job) {
|
||||||
|
const loc = job.service_location_name || ''
|
||||||
|
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goScan (job) {
|
||||||
|
sheetOpen.value = false
|
||||||
|
router.push({ path: '/j/scan', query: {
|
||||||
|
job: job.name, customer: job.customer, customer_name: job.customer_name,
|
||||||
|
location: job.service_location, location_name: job.service_location_name,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail (job) {
|
||||||
|
sheetOpen.value = false
|
||||||
|
router.push({ path: '/j/job/' + job.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('online', () => { isOnline.value = true })
|
||||||
|
window.addEventListener('offline', () => { isOnline.value = false })
|
||||||
|
loadTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tasks-page { padding: 0 !important; background: #eef1f5; min-height: 100vh; }
|
||||||
|
|
||||||
|
.tech-header {
|
||||||
|
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%);
|
||||||
|
color: white; padding: 16px 16px 0; border-radius: 0 0 20px 20px;
|
||||||
|
}
|
||||||
|
.tech-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.tech-date { font-size: 0.78rem; opacity: 0.7; text-transform: capitalize; }
|
||||||
|
.tech-name { font-size: 1.25rem; font-weight: 700; }
|
||||||
|
.tech-header-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.tech-status-badge { font-size: 0.65rem; padding: 3px 8px; border-radius: 10px; }
|
||||||
|
.tech-avatar { font-weight: 700; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.stats-row { display: flex; gap: 10px; margin: 0 -4px; transform: translateY(24px); }
|
||||||
|
.stat-card {
|
||||||
|
flex: 1; background: #4a4880; border-radius: 12px; padding: 12px 8px;
|
||||||
|
text-align: center; cursor: pointer; transition: background 0.15s;
|
||||||
|
&:active { background: #5a589a; }
|
||||||
|
}
|
||||||
|
.stat-card.stat-done .stat-value { color: #ff6b6b; }
|
||||||
|
.stat-value { font-size: 1.5rem; font-weight: 800; line-height: 1.2; }
|
||||||
|
.stat-label { font-size: 0.7rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
|
.jobs-list { padding-top: 36px !important; }
|
||||||
|
.section-label { font-size: 0.72rem; font-weight: 700; color: #8b8fa3; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.job-card {
|
||||||
|
background: white; border-radius: 14px; padding: 14px 16px; margin-bottom: 10px;
|
||||||
|
cursor: pointer; border-left: 4px solid #5c59a8;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.05); transition: transform 0.1s;
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
}
|
||||||
|
.job-card-urgent { border-left-color: #ef4444; }
|
||||||
|
.job-card-progress { border-left-color: #f59e0b; background: #fffbeb; }
|
||||||
|
.job-card-done { border-left-color: #22c55e; opacity: 0.7; }
|
||||||
|
|
||||||
|
.job-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||||
|
.job-card-left { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.job-order {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
background: #5c59a8; color: white; font-size: 0.7rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.job-id { font-size: 0.78rem; font-weight: 600; color: #5c59a8; }
|
||||||
|
.job-priority-dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; display: inline-block; }
|
||||||
|
.job-time { font-size: 0.82rem; font-weight: 600; color: #374151; }
|
||||||
|
|
||||||
|
.job-card-title { font-size: 0.95rem; font-weight: 600; color: #1f2937; margin-bottom: 4px; }
|
||||||
|
.job-card-location { font-size: 0.78rem; color: #6b7280; margin-bottom: 6px; display: flex; align-items: center; gap: 2px; }
|
||||||
|
.job-card-badges { display: flex; gap: 8px; }
|
||||||
|
.job-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 3px;
|
||||||
|
background: #f3f4f6; border-radius: 8px; padding: 2px 8px;
|
||||||
|
font-size: 0.72rem; font-weight: 600; color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet { border-radius: 20px 20px 0 0; max-height: 70vh; width: 100%; }
|
||||||
|
.sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 10px auto 4px; }
|
||||||
|
.sheet-job-id { font-size: 0.82rem; font-weight: 700; color: #5c59a8; }
|
||||||
|
.sheet-title { font-size: 1.15rem; font-weight: 700; color: #1f2937; }
|
||||||
|
.sheet-info-row { display: flex; align-items: center; }
|
||||||
|
.sheet-info-label { font-size: 0.72rem; color: #9ca3af; }
|
||||||
|
.sheet-info-value { font-size: 0.95rem; font-weight: 600; color: #1f2937; }
|
||||||
|
.action-btn { font-weight: 700; border-radius: 12px; min-height: 48px; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
|
@ -243,13 +243,38 @@ const underutilizedTechs = computed(() => {
|
||||||
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
|
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
|
||||||
|
|
||||||
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
|
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
|
||||||
function fullUnassign (job) {
|
|
||||||
|
// Confirmation state for unassign
|
||||||
|
const confirmUnassignDialog = ref(false)
|
||||||
|
const pendingUnassignJob = ref(null)
|
||||||
|
|
||||||
|
function _doUnassign (job) {
|
||||||
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
|
||||||
store.fullUnassign(job.id)
|
store.fullUnassign(job.id)
|
||||||
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
|
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
|
||||||
invalidateRoutes()
|
invalidateRoutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fullUnassign (job) {
|
||||||
|
// Require confirmation for published or in-progress jobs
|
||||||
|
if (job.published || job.status === 'in_progress' || job.status === 'In Progress' || job.status === 'assigned') {
|
||||||
|
pendingUnassignJob.value = job
|
||||||
|
confirmUnassignDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_doUnassign(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmUnassign () {
|
||||||
|
if (pendingUnassignJob.value) _doUnassign(pendingUnassignJob.value)
|
||||||
|
pendingUnassignJob.value = null
|
||||||
|
confirmUnassignDialog.value = false
|
||||||
|
}
|
||||||
|
function cancelUnassign () {
|
||||||
|
pendingUnassignJob.value = null
|
||||||
|
confirmUnassignDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ctxMenu, techCtx, assistCtx, assistNoteModal,
|
ctxMenu, techCtx, assistCtx, assistNoteModal,
|
||||||
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
|
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
|
||||||
|
|
@ -1753,6 +1778,25 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Unassign Dialog -->
|
||||||
|
<div v-if="confirmUnassignDialog" class="sb-modal-overlay" @click.self="cancelUnassign">
|
||||||
|
<div class="sb-confirm-dialog">
|
||||||
|
<div class="sb-confirm-icon">⚠️</div>
|
||||||
|
<div class="sb-confirm-title">Désaffecter ce job ?</div>
|
||||||
|
<div class="sb-confirm-body">
|
||||||
|
<strong>{{ pendingUnassignJob?.subject || pendingUnassignJob?.id }}</strong><br>
|
||||||
|
<span v-if="pendingUnassignJob?.published" class="sb-confirm-tag sb-confirm-tag-pub">Publié</span>
|
||||||
|
<span v-if="pendingUnassignJob?.status === 'in_progress' || pendingUnassignJob?.status === 'In Progress'" class="sb-confirm-tag sb-confirm-tag-ip">En cours</span>
|
||||||
|
<span v-if="pendingUnassignJob?.status === 'assigned'" class="sb-confirm-tag sb-confirm-tag-asg">Assigné</span>
|
||||||
|
<br><span class="sb-confirm-warn">Le technicien a déjà reçu cette tâche. Désaffecter la remettra dans le pool non-assigné.</span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-confirm-actions">
|
||||||
|
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
|
||||||
|
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create Offer Modal -->
|
<!-- Create Offer Modal -->
|
||||||
<CreateOfferModal
|
<CreateOfferModal
|
||||||
v-model="createOfferModal"
|
v-model="createOfferModal"
|
||||||
|
|
|
||||||
|
|
@ -826,3 +826,27 @@
|
||||||
padding: 2px 4px; border-radius: 3px; transition: background 0.12s;
|
padding: 2px 4px; border-radius: 3px; transition: background 0.12s;
|
||||||
&:hover { background: rgba(248, 113, 113, 0.15); }
|
&:hover { background: rgba(248, 113, 113, 0.15); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Confirm unassign dialog ── */
|
||||||
|
.sb-confirm-dialog {
|
||||||
|
background: var(--sb-card); border-radius: 14px; padding: 24px;
|
||||||
|
max-width: 380px; width: 90%; text-align: center;
|
||||||
|
border: 1px solid var(--sb-border-acc);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.sb-confirm-icon { font-size: 2rem; margin-bottom: 8px; }
|
||||||
|
.sb-confirm-title { font-size: 1rem; font-weight: 700; color: var(--sb-text); margin-bottom: 10px; }
|
||||||
|
.sb-confirm-body { font-size: 0.82rem; color: var(--sb-muted); line-height: 1.5; margin-bottom: 16px; }
|
||||||
|
.sb-confirm-tag {
|
||||||
|
display: inline-block; font-size: 0.68rem; font-weight: 600; padding: 1px 8px;
|
||||||
|
border-radius: 4px; margin: 4px 2px;
|
||||||
|
}
|
||||||
|
.sb-confirm-tag-pub { background: rgba(99,102,241,0.2); color: #818cf8; }
|
||||||
|
.sb-confirm-tag-ip { background: rgba(251,191,36,0.2); color: #fbbf24; }
|
||||||
|
.sb-confirm-tag-asg { background: rgba(59,130,246,0.2); color: #60a5fa; }
|
||||||
|
.sb-confirm-warn { font-size: 0.75rem; color: #f59e0b; font-style: italic; }
|
||||||
|
.sb-confirm-actions { display: flex; gap: 8px; justify-content: center; }
|
||||||
|
.sb-confirm-danger {
|
||||||
|
background: #dc2626 !important; color: white !important;
|
||||||
|
&:hover { background: #b91c1c !important; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,19 @@ import { route } from 'quasar/wrappers'
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
// Tech mobile view (sent via SMS to field techs)
|
||||||
|
{
|
||||||
|
path: '/j',
|
||||||
|
component: () => import('src/layouts/TechLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'tech-tasks', component: () => import('src/modules/tech/pages/TechTasksPage.vue') },
|
||||||
|
{ path: 'job/:name', name: 'tech-job', component: () => import('src/modules/tech/pages/TechJobDetailPage.vue'), props: true },
|
||||||
|
{ path: 'scan', name: 'tech-scan', component: () => import('src/modules/tech/pages/TechScanPage.vue') },
|
||||||
|
{ path: 'diagnostic', name: 'tech-diag', component: () => import('src/modules/tech/pages/TechDiagnosticPage.vue') },
|
||||||
|
{ path: 'more', name: 'tech-more', component: () => import('src/modules/tech/pages/TechMorePage.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Ops staff desktop view
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('src/layouts/MainLayout.vue'),
|
component: () => import('src/layouts/MainLayout.vue'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user