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>
|
||||
<q-page padding>
|
||||
<!-- Date header -->
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h6">{{ todayLabel }}</div>
|
||||
<q-space />
|
||||
<q-btn flat dense icon="refresh" :loading="loading" @click="loadTasks" />
|
||||
<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="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>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<q-tabs v-model="filter" dense no-caps active-color="primary" class="q-mb-md">
|
||||
<q-tab name="jobs" :label="'Jobs (' + jobs.length + ')'" />
|
||||
<q-tab name="tickets" :label="'Tickets (' + tickets.length + ')'" />
|
||||
</q-tabs>
|
||||
<!-- Stats cards -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card" @click="filter = 'all'">
|
||||
<div class="stat-value">{{ jobs.length }}</div>
|
||||
<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 -->
|
||||
<template v-if="filter === 'jobs'">
|
||||
<q-card v-for="job in jobs" :key="job.name" class="q-mb-sm" clickable
|
||||
@click="$router.push({ name: 'job-detail', params: { name: job.name } })">
|
||||
<q-card-section class="q-py-sm">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-badge :color="statusColor(job.status)" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle2 ellipsis">{{ job.subject || job.name }}</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ job.customer_name || job.customer || '' }}
|
||||
<span v-if="job.scheduled_time"> · {{ job.scheduled_time?.slice(0, 5) }}</span>
|
||||
<!-- 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 v-if="job.service_location_name" class="text-caption text-blue-grey">
|
||||
<q-icon name="place" size="xs" /> {{ job.service_location_name }}
|
||||
<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>
|
||||
<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" />
|
||||
|
||||
<!-- 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>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-if="!loading && jobs.length === 0" class="text-center text-grey q-mt-xl">
|
||||
|
||||
<!-- Completed section -->
|
||||
<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
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Tickets list -->
|
||||
<template v-if="filter === 'tickets'">
|
||||
<q-card v-for="t in tickets" :key="t.name" class="q-mb-sm">
|
||||
<q-card-section class="q-py-sm">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-badge :color="t.status === 'Open' ? 'orange' : t.status === 'Closed' ? 'grey' : 'blue'" class="q-mr-sm" />
|
||||
<!-- 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="text-subtitle2 ellipsis">{{ t.subject }}</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ t.customer_name || '' }} · {{ formatDate(t.creation) }}
|
||||
<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 + 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>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-if="!loading && tickets.length === 0" class="text-center text-grey q-mt-xl">
|
||||
Aucun ticket assigné
|
||||
|
||||
<!-- 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'" 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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listDocs, updateDoc } from 'src/api/erp'
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
import { Notify } from 'quasar'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const filter = ref('jobs')
|
||||
const filter = ref('all')
|
||||
const jobs = ref([])
|
||||
const tickets = ref([])
|
||||
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 todayLabel = computed(() =>
|
||||
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
)
|
||||
|
||||
function formatDate (d) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' })
|
||||
const techName = computed(() => {
|
||||
const u = auth.user || ''
|
||||
// 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) {
|
||||
const map = { Scheduled: 'blue', 'In Progress': 'orange', Completed: 'green', Cancelled: 'grey' }
|
||||
return map[s] || 'grey'
|
||||
function openSheet (job) {
|
||||
sheetJob.value = job
|
||||
sheetOpen.value = true
|
||||
}
|
||||
|
||||
async function loadTasks () {
|
||||
loading.value = true
|
||||
try {
|
||||
const [j, t] = await Promise.all([
|
||||
listDocs('Dispatch Job', {
|
||||
const j = await listDocs('Dispatch Job', {
|
||||
filters: { scheduled_date: today },
|
||||
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,
|
||||
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
|
||||
tickets.value = t
|
||||
// Cache for offline
|
||||
offline.cacheData('tasks-jobs', j)
|
||||
offline.cacheData('tasks-tickets', t)
|
||||
} catch {
|
||||
// Try cached data
|
||||
const cj = await offline.getCached('tasks-jobs')
|
||||
const ct = await offline.getCached('tasks-tickets')
|
||||
if (cj) jobs.value = cj
|
||||
if (ct) tickets.value = ct
|
||||
const cached = await offline.getCached('tasks-jobs')
|
||||
if (cached) jobs.value = cached
|
||||
Notify.create({ type: 'warning', message: 'Mode hors ligne — données en cache' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJobStatus (job, status) {
|
||||
async function doStatus (job, status) {
|
||||
saving.value = true
|
||||
try {
|
||||
if (offline.online) {
|
||||
await updateDoc('Dispatch Job', job.name, { status })
|
||||
job.status = status
|
||||
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Job terminé' : 'Job démarré' })
|
||||
} else {
|
||||
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) {
|
||||
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)
|
||||
</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 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] })
|
||||
store.fullUnassign(job.id)
|
||||
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
|
||||
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 {
|
||||
ctxMenu, techCtx, assistCtx, assistNoteModal,
|
||||
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
|
||||
|
|
@ -1753,6 +1778,25 @@ onUnmounted(() => {
|
|||
</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 -->
|
||||
<CreateOfferModal
|
||||
v-model="createOfferModal"
|
||||
|
|
|
|||
|
|
@ -826,3 +826,27 @@
|
|||
padding: 2px 4px; border-radius: 3px; transition: background 0.12s;
|
||||
&: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'
|
||||
|
||||
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: '/',
|
||||
component: () => import('src/layouts/MainLayout.vue'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user