gigafibre-fsm/apps/ops/src/api/offers.js
louispaulb 9b9d307bdf feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- 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>
2026-04-08 22:44:18 -04:00

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' })
}