gigafibre-fsm/apps/ops/src/api/dispatch.js
louispaulb 3599ec995f feat(dispatch): P1-C "Trouver un créneau" — wire suggest-slots to a booking dialog
The hub /dispatch/suggest-slots (gap-finding + travel buffer + proximity
ranking) existed but had NO UI (Gemini claimed SuggestSlotsDialog.vue was
coded — it did not exist). Built it:

- api/dispatch.js: suggestSlots(payload) → POST /dispatch/suggest-slots.
- SuggestSlotsDialog.vue: address (RQA geocode via useAddressSearch) +
  duration + earliest date → ranked slots (tech, date, time, travel min,
  reasons). Selecting a slot emits it.
- DispatchPage: "📅 Créneau" button in the header opens the dialog;
  onSlotSelected prefills the WO create modal (tech + date + address +
  duration) and confirmWo injects the slot's start_time + geocoded coords
  into createJob. Manual "+ WO" clears the booking slot.

Verified: dialog compiles/opens with inputs; suggest-slots returns ranked
slots (e.g. Gilles Drolet 08:15–09:45, journée libre, 15 min). Note: the
address geocode (/api/method/search_address) 403s in the dev preview
because the dev proxy doesn't inject the ERP token that prod nginx adds —
works in prod (same call the create modal already uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 07:30:08 -04:00

184 lines
6.7 KiB
JavaScript

// ── ERPNext Dispatch resource calls ─────────────────────────────────────────
// All ERPNext fetch() calls live here.
// Swap BASE_URL in config/erpnext.js to change the target server.
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { authFetch } from './auth'
// Créneaux disponibles (hub : gap-finding + tampon de route + classement) pour un nouveau job à une adresse.
// payload = { duration_h, latitude, longitude, after_date?, limit? } → [{ tech_id, tech_name, date, start_time, end_time, travel_min, distance_km, reasons[] }]
export async function suggestSlots (payload) {
const res = await fetch(`${HUB_URL}/dispatch/suggest-slots`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}),
})
if (!res.ok) throw new Error('suggest-slots ' + res.status)
const data = await res.json()
return data.slots || data || []
}
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()
if (data.exc) {
console.error('[apiGet] ERPNext error:', path.slice(0, 120), data.exc.slice ? data.exc.slice(0, 200) : data.exc)
throw new Error(data.exc)
}
return data
}
async function apiPut (doctype, name, body) {
const res = await authFetch(
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
const text = await res.text()
if (!res.ok) console.error(`[API] PUT ${doctype}/${name} failed:`, res.status, text)
let data
try { data = JSON.parse(text) } catch { throw new Error(`PUT ${doctype}/${name}: invalid JSON — ${text.slice(0, 200)}`) }
if (data.exc) throw new Error(data.exc)
return data
}
// Single-call fetch with all fields + child tables
async function listDocs (doctype, fields = '["*"]', filters = null, limit = 200) {
let url = `/api/resource/${encodeURIComponent(doctype)}?fields=${fields}&limit_page_length=${limit}`
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
const data = await apiGet(url)
return data.data || []
}
// Fetch single doc (needed for child tables not returned by list)
async function fetchDoc (doctype, name) {
return (await apiGet(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`)).data
}
export async function fetchTechnicians () {
// Fast: single list call (no child tables = no tags)
const techs = await listDocs('Dispatch Technician', '["*"]', null, 100)
return techs // tags loaded lazily via loadTechTags()
}
// Background: fetch individual docs to get child tables (tags)
export async function loadTechTags (techNames) {
const results = await Promise.all(techNames.map(n => fetchDoc('Dispatch Technician', n).catch(() => null)))
return results.filter(Boolean)
}
// Fast: single list call, no child tables (assistants/tags come empty)
export async function fetchJobsFast (filters = null) {
return listDocs('Dispatch Job', '["*"]', filters, 500)
}
// Full: adds child tables (assistants, tags) — use only for jobs that need them
export async function fetchJobFull (name) {
return fetchDoc('Dispatch Job', name)
}
// Legacy: fetches all with child tables (slow — avoid)
export async function fetchJobs (filters = null) {
return fetchJobsFast(filters)
}
export async function updateJob (name, payload) {
return apiPut('Dispatch Job', name, payload)
}
export async function createJob (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Job`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function publishJobs (jobNames) {
const results = await Promise.allSettled(
jobNames.map(name => apiPut('Dispatch Job', name, { published: 1 }))
)
const failed = results.filter(r => r.status === 'rejected')
if (failed.length) console.warn(`[publishJobs] ${failed.length}/${jobNames.length} failed`)
return { published: jobNames.length - failed.length, failed: failed.length }
}
export async function updateTech (name, payload) {
return apiPut('Dispatch Technician', name, payload)
}
export async function createTech (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Technician`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function deleteTech (name) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Technician/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const msg = data._server_messages ? JSON.parse(JSON.parse(data._server_messages)[0]).message : data.exception || 'Delete failed'
throw new Error(msg)
}
}
export async function fetchTags () {
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
return data.data || []
}
export async function createTag (label, category = 'Custom', color = '#6b7280') {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Tag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, category, color }),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function updateTag (name, payload) {
return apiPut('Dispatch Tag', name, payload)
}
export async function renameTag (oldName, newName) {
const res = await authFetch(
`${BASE_URL}/api/method/frappe.client.rename_doc`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Dispatch Tag', old_name: oldName, new_name: newName }),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.message // returns new name
}
export async function deleteTag (name) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Tag/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
if (!res.ok) throw new Error('Delete tag failed')
}