- capacityByDay compte les jobs assignés SANS heure (untimed_used_h au prorata AM/PM/Soir)
- /roster/assign-job : garde 409 si le ticket F est déjà à un autre vrai tech (force pour écraser) ; SPA = dialog confirm
- mirrorAssign : start_time depuis F due_time (am/pm) + fix due_date (cur.→r.) + backfill fill-only dans watchLegacy (97% des tickets F = 'day' → le spread capacité est le fix porteur)
- pushAssignments : garde anti-clobber (conflits F skippés, force=1) + compteur conflicts
- ticket-collab : courriels de notification → deep link OPS /#/tickets?open= (fini le desk) ; TicketsPage lit ?open= ; Dashboard ouvre le ticket précis
- DetailModal + SupplierInvoices : lien desk gaté can('manage_settings')
- NOUVEAU JobMediaModule (photos tech géo-vérifiées + journal terrain) monté dans IssueDetail onsite + volet Planif ; hub /roster/job-media + /field/photo-file signé ; JobThread poll silencieux 45s
- legacy-sync emailList : strip caractères invisibles (soft hyphen U+00AD…) — fixait un 417 InvalidEmailAddress en boucle (compte 15988)
Déployé prod 2026-07-18 (hub restart + SPA /opt/ops-app), sondes live OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
118 lines
5.9 KiB
Vue
118 lines
5.9 KiB
Vue
<!--
|
|
JobMediaModule — photos prises par le TECH sur place (app terrain /field/photo, géo-vérifiées vs l'adresse du job)
|
|
+ journal d'activité terrain (completion_notes : arrivées géofence, scans d'appareil, notes). Module RÉUTILISABLE
|
|
(détail ticket « Intervention sur site » + volet job Planification). Autonome : charge via roster.jobMedia(name),
|
|
se rafraîchit seul pendant que le panneau est ouvert (les photos arrivent PENDANT la visite).
|
|
-->
|
|
<template>
|
|
<div v-if="props.name">
|
|
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">
|
|
Photos & activité terrain
|
|
<span v-if="photos.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· {{ photos.length }} photo(s)</span>
|
|
<q-space />
|
|
<q-btn flat dense round size="sm" icon="refresh" :loading="loading" @click="reload(false)"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
|
</div>
|
|
|
|
<!-- Photos : vignettes cliquables ; badge « sur place » (distance GPS ↔ adresse du job) = preuve d'adresse ;
|
|
la légende (note du tech) sert à identifier l'appareil photographié. -->
|
|
<div v-if="photos.length" class="jm-grid">
|
|
<div v-for="p in photos" :key="p.file" class="jm-cell" @click="openPhoto(p)">
|
|
<img :src="p.url" loading="lazy" :alt="p.note || 'photo terrain'" />
|
|
<div class="jm-meta">
|
|
<span v-if="p.onsite" class="jm-badge jm-ok">✓ sur place</span>
|
|
<span v-else-if="p.offsite" class="jm-badge jm-warn">⚠ hors adresse</span>
|
|
<span v-if="p.at" class="jm-ts">{{ fmtDT(p.at) }}</span>
|
|
</div>
|
|
<div v-if="p.note" class="jm-note">{{ p.note }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Journal terrain (plus récent en haut) : arrivé/reparti, scans SN/MAC, photos, hors-zone… -->
|
|
<div v-if="journal.length" class="q-mt-xs">
|
|
<a class="jm-toggle" @click.prevent="showJournal = !showJournal">
|
|
{{ showJournal ? 'Masquer' : 'Voir' }} le journal terrain ({{ journal.length }})
|
|
</a>
|
|
<div v-if="showJournal" class="jm-journal">
|
|
<div v-for="(l, i) in journal" :key="i" class="jm-line">{{ l }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!loading && !photos.length && !journal.length" class="text-grey-5" style="font-size:11px">
|
|
Aucune photo ni activité terrain pour l'instant.
|
|
</div>
|
|
|
|
<!-- Plein écran -->
|
|
<q-dialog v-model="viewer">
|
|
<q-card style="max-width:94vw">
|
|
<q-card-section class="q-pa-xs row items-center">
|
|
<div class="text-caption q-ml-sm">
|
|
<b v-if="current && current.onsite" class="text-positive">✓ sur place</b>
|
|
<b v-else-if="current && current.offsite" class="text-warning">⚠ hors adresse</b>
|
|
<span v-if="current && current.geo" class="text-grey-7 q-ml-xs">{{ current.geo }}</span>
|
|
<span v-if="current && current.at" class="text-grey-6 q-ml-xs">· {{ fmtDT(current.at) }}</span>
|
|
<span v-if="current && current.note" class="q-ml-xs">— {{ current.note }}</span>
|
|
</div>
|
|
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
|
</q-card-section>
|
|
<img v-if="current" :src="current.url" style="max-width:94vw;max-height:82vh;display:block" :alt="current.note || 'photo terrain'" />
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import * as roster from 'src/api/roster'
|
|
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
|
|
|
|
const props = defineProps({
|
|
name: { type: String, default: '' }, // docname du Dispatch Job
|
|
})
|
|
|
|
const loading = ref(false)
|
|
const photos = ref([])
|
|
const journal = ref([])
|
|
const showJournal = ref(false)
|
|
const viewer = ref(false)
|
|
const current = ref(null)
|
|
|
|
function openPhoto (p) { current.value = p; viewer.value = true }
|
|
|
|
async function reload (silent = true) {
|
|
if (!props.name) return
|
|
if (!silent) loading.value = true
|
|
try {
|
|
const r = await roster.jobMedia(props.name)
|
|
photos.value = (r && r.photos) || []
|
|
// Journal = lignes non-vides de completion_notes, PLUS RÉCENT EN HAUT (comme le fil du billet).
|
|
journal.value = String((r && r.notes) || '').split('\n').map(s => s.replace(/^•\s*/, '').trim()).filter(Boolean).reverse().slice(0, 60)
|
|
} catch (e) { /* best-effort : le module ne casse jamais le panneau */ } finally { loading.value = false }
|
|
}
|
|
|
|
let pollTimer = null
|
|
onMounted(() => {
|
|
reload(false)
|
|
// Les photos/checkpoints arrivent pendant la visite → petit poll tant que le panneau est ouvert (silencieux).
|
|
pollTimer = setInterval(() => { if (document.visibilityState === 'visible') reload(true) }, 60000)
|
|
})
|
|
onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) })
|
|
watch(() => props.name, () => { photos.value = []; journal.value = []; reload(false) })
|
|
defineExpose({ reload })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.jm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
|
|
.jm-cell { cursor: pointer; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #fafafa; }
|
|
.jm-cell img { width: 100%; height: 84px; object-fit: cover; display: block; }
|
|
.jm-meta { display: flex; align-items: center; gap: 4px; padding: 2px 5px 0; flex-wrap: wrap; }
|
|
.jm-badge { font-size: 9px; font-weight: 700; border-radius: 4px; padding: 0 4px; }
|
|
.jm-ok { color: #1b5e20; background: #e6f4ec; }
|
|
.jm-warn { color: #8a5a00; background: #fbf0dc; }
|
|
.jm-ts { font-size: 9px; color: #90a4ae; }
|
|
.jm-note { font-size: 10px; color: #455a64; padding: 0 5px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.jm-toggle { font-size: 11px; color: #5e7a76; cursor: pointer; text-decoration: underline dotted; }
|
|
.jm-journal { margin-top: 4px; max-height: 180px; overflow-y: auto; background: #f6f8fb; border-radius: 6px; padding: 6px 8px; }
|
|
.jm-line { font-size: 11px; color: #37474f; white-space: pre-wrap; border-top: 1px dashed #e0e6ef; padding: 2px 0; }
|
|
.jm-line:first-child { border-top: none; }
|
|
</style>
|