- Planning mode toggle: shift availability as background blocks on timeline (week view shows green=available, yellow=on-call; month view per-tech) - On-call/guard shift editor with RRULE recurrence on tech schedules - Uber-style job offer pool: broadcast/targeted/pool modes with pricing, SMS notifications, accept/decline flow, overload detection alerts - Shared resource group presets via ERPNext Dispatch Preset doctype (replaces localStorage, shared between supervisors) - Google Calendar-style RecurrenceSelector component with contextual quick options + custom RRULE editor, integrated in booking overlay and extra shift editor - Remove default "Repos" ghost chips — only visible in planning mode - Clean up debug console.logs across API, store, and page layers - Add extra_shifts Custom Field on Dispatch Technician doctype Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
75 lines
3.1 KiB
JavaScript
75 lines
3.1 KiB
JavaScript
// ── Job Offer API — Uber-style offer/accept for dispatch jobs ────────────────
|
|
// Offers are stored as "Dispatch Offer" docs in ERPNext.
|
|
// Flow: dispatcher/customer creates offer → matching techs notified → tech accepts → job assigned
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
import { BASE_URL } from 'src/config/erpnext'
|
|
import { authFetch } from './auth'
|
|
|
|
async function apiGet (path) {
|
|
const res = await authFetch(BASE_URL + path)
|
|
const data = await res.json()
|
|
if (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 data = await res.json()
|
|
if (data.exc) throw new Error(data.exc)
|
|
return data
|
|
}
|
|
|
|
// ── CRUD ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function fetchOffers (filters = null) {
|
|
let url = '/api/resource/Dispatch%20Offer?fields=["*"]&limit_page_length=200&order_by=creation+desc'
|
|
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
|
|
const data = await apiGet(url)
|
|
return data.data || []
|
|
}
|
|
|
|
export async function fetchActiveOffers () {
|
|
return fetchOffers([['status', 'in', ['open', 'pending']]])
|
|
}
|
|
|
|
export async function createOffer (payload) {
|
|
const res = await authFetch(
|
|
`${BASE_URL}/api/resource/Dispatch%20Offer`,
|
|
{ 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 updateOffer (name, payload) {
|
|
return apiPut('Dispatch Offer', name, payload)
|
|
}
|
|
|
|
// ── Offer actions ────────────────────────────────────────────────────────────
|
|
|
|
export async function acceptOffer (offerName, techId) {
|
|
return updateOffer(offerName, { status: 'accepted', accepted_by: techId, accepted_at: new Date().toISOString() })
|
|
}
|
|
|
|
export async function declineOffer (offerName, techId, reason = '') {
|
|
// Record decline — we don't close the offer, just track who declined
|
|
return updateOffer(offerName, {
|
|
declined_techs: JSON.stringify([
|
|
...JSON.parse((await apiGet(`/api/resource/Dispatch%20Offer/${offerName}`)).data?.declined_techs || '[]'),
|
|
{ techId, reason, at: new Date().toISOString() },
|
|
]),
|
|
})
|
|
}
|
|
|
|
export async function cancelOffer (offerName) {
|
|
return updateOffer(offerName, { status: 'cancelled' })
|
|
}
|
|
|
|
export async function expireOffer (offerName) {
|
|
return updateOffer(offerName, { status: 'expired' })
|
|
}
|