gigafibre-fsm/apps/ops/src/components/shared/detail-sections/modules/JobMediaModule.vue
louispaulb cd25fa2504 fix(dispatch): double-assign F + médias terrain dans le ticket + deep links natifs
- 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>
2026-07-18 18:02:37 -04:00

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 &amp; 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>