gigafibre-fsm/apps/ops/src/modules/dispatch/components/RightPanel.vue
louispaulb 4af25071be fix(ops/dispatch): /desk/<DocType>/ broken URL → /app/<slug>/ + add /address/validate hub
Two things ride together because the user noticed the URL bug while
testing the work-in-progress address validation:

1. **Broken Frappe URL pattern.** Three places in the dispatch UI
   were generating `/desk/Service Location/<id>` and
   `/desk/Dispatch Technician/<id>` links — both return "Page not
   found" on Frappe v14+ (= our v16) because the modern desk URL
   format is `/app/<slug>/<id>` where slug is lowercase + hyphens.
   Fixed in:
     • RightPanel.vue (Lieu link in the job details panel)
     • DispatchPage.vue (Lieu in the job ctx menu)
     • DispatchPage.vue (Ouvrir dans ERPNext in the tech ctx menu)

2. **`POST /address/validate` endpoint** on the hub. Wraps the
   existing RQA Supabase search (`address-search.js`) with a
   confidence-scored output:
     • exact_match (boolean) — score >= 0.7
     • best (the top RQA candidate with aq_address_id, lat, lng)
     • candidates[] (top 5 ranked)
     • confidence (0..1)
     • recommendation: validated | review | unmatched
   Score combines civic-number exact match, road-name fuzzy overlap,
   FSA+full postal-code bonuses, and city-name bonus. The endpoint
   is called from ops UI when adding/editing a Service Location to
   auto-populate aq_address_id + canonical lat/lng instead of
   trusting human typing or Mapbox geocode.

(Custom Fields aq_address_id, address_validation_status,
address_validated_at, linked_address have been added on Service
Location via the Frappe REST API in a separate operation — not in
this commit since they're DB-only.)
2026-05-08 11:01:32 -04:00

134 lines
7.4 KiB
Vue

<script setup>
import { inject } from 'vue'
import { fmtDur, prioLabel, prioClass, ICON } from 'src/composables/useHelpers'
import TagEditor from 'src/components/shared/TagEditor.vue'
const props = defineProps({
panel: Object, // { mode, data: { job, tech } } or null
})
const emit = defineEmits([
'close', 'edit', 'move', 'geofix', 'unassign',
'set-end-date', 'remove-assistant', 'assign-pending',
'update-tags',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
const onUpdateTag = inject('onUpdateTag')
const onRenameTag = inject('onRenameTag')
const onDeleteTag = inject('onDeleteTag')
</script>
<template>
<transition name="sb-slide-right">
<aside v-if="panel" class="sb-right">
<div class="sb-rp-hdr">
<span class="sb-rp-title">{{ {details:'Détails',pending:'Demande entrante'}[panel.mode] || 'Détails' }}</span>
<button class="sb-rp-close" @click="emit('close')">✕</button>
</div>
<!-- JOB DETAILS -->
<template v-if="panel.mode==='details'">
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div>
<!-- Client + Lieu de service: clickable shortcuts. Customer opens
the ClientDetailPage in the same SPA (Vue Router hash route);
Service Location opens in ERPNext desk in a new tab since the
ops SPA doesn't have a dedicated SL detail page. The address
below is the free-text on the job; the persisted lat/lng
(used by the Mapbox marker) lives on the linked Service
Location — discrepancy = bad geocode at SL creation time. -->
<div v-if="panel.data?.job?.customer" class="sb-rp-field">
<span class="sb-rp-lbl">Client</span>
<a class="sb-rp-link" :href="'#/clients/' + panel.data.job.customer">
{{ panel.data.job.customer }}
<span class="sb-rp-link-icon">↗</span>
</a>
</div>
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
<span class="sb-rp-lbl">Lieu</span>
<!-- Frappe v14+ desk URL: /app/<slug>/<name>, slug = lowercase
with hyphens. The legacy /desk/<DocType>/<name> format with
spaces returns "Page not found" on v16. -->
<a class="sb-rp-link" target="_blank" rel="noopener"
:href="'/app/service-location/' + panel.data.job.serviceLocation">
<code>{{ panel.data.job.serviceLocation }}</code>
<span class="sb-rp-link-icon">↗</span>
</a>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span>
<span :class="prioClass(panel.data?.job?.priority)">{{ prioLabel(panel.data?.job?.priority) }}</span>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Technicien</span>{{ panel.data?.tech?.fullName || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
{{ panel.data?.job?.scheduledDate || '—' }}
<span v-if="panel.data?.job?.endDate"> → {{ panel.data.job.endDate }}</span>
</div>
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
<span class="sb-rp-lbl">Date de fin</span>
<input type="date" class="sb-form-input" :value="panel.data?.job?.endDate || ''"
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Tags</span>
<TagEditor v-if="panel.data?.job"
:model-value="panel.data.job.tags || []"
@update:model-value="v => emit('update-tags', panel.data.job, v)"
:all-tags="store.allTags" :get-color="getTagColor"
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
</div>
<div v-if="panel.data?.job?.assistants?.length" class="sb-rp-field">
<span class="sb-rp-lbl">Assistants</span>
<div v-for="a in panel.data.job.assistants" :key="a.techId" style="display:flex;align-items:center;gap:6px;margin-top:3px">
<span class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]">
{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</span>
<span style="font-size:0.72rem">{{ a.techName }} · {{ fmtDur(a.duration) }}{{ a.note ? ' · '+a.note : '' }}</span>
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem"
@click="emit('remove-assistant', panel.data.job.id, a.techId)">✕</button>
</div>
</div>
</div>
<div class="sb-rp-actions">
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)">✏ Modifier</button>
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)">↔ Déplacer / Réassigner</button>
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)">✕ Désaffecter</button>
</div>
</template>
<!-- PENDING REQUEST -->
<template v-if="panel.mode==='pending'">
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+(panel.data?.urgency==='urgent'?'#ef4444':'#f59e0b')"></div>
<div v-if="panel.data?.urgency==='urgent'" class="sb-rp-urgent-tag">🚨 Urgent</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Client</span><strong>{{ panel.data?.customer_name }}</strong></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Téléphone</span>{{ panel.data?.phone || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Service</span>{{ panel.data?.service_type }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Problème</span>{{ panel.data?.problem_type || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.address }}</div>
<div v-if="panel.data?.budget_label" class="sb-rp-field"><span class="sb-rp-lbl">Budget</span>{{ panel.data?.budget_label }}</div>
<div class="sbf-title" style="margin-top:0.75rem">Assigner à</div>
<div class="sb-assign-grid">
<button v-for="tech in store.technicians" :key="tech.id"
class="sb-assign-btn" :style="'border-color:'+TECH_COLORS[tech.colorIdx]"
@click="emit('assign-pending', tech.id)">
<span class="sb-assign-dot" :style="'background:'+TECH_COLORS[tech.colorIdx]"></span>
{{ tech.fullName }}
</button>
</div>
</div>
</template>
</aside>
</transition>
</template>