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>
184 lines
6.7 KiB
JavaScript
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')
|
|
}
|