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.)
134 lines
7.4 KiB
Vue
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>
|