From 0c77afdb3bd31f953004eb50f0dd7ba94831eb49 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 8 Apr 2026 22:44:18 -0400 Subject: [PATCH] feat: dispatch planning mode, offer pool, shared presets, recurrence selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/ops/src/api/auth.js | 2 - apps/ops/src/api/dispatch.js | 34 +- apps/ops/src/api/offers.js | 74 +++ apps/ops/src/api/presets.js | 46 ++ .../src/components/customer/ContactCard.vue | 1 - .../components/shared/RecurrenceSelector.vue | 298 +++++++++ apps/ops/src/composables/useAbsenceResize.js | 4 +- apps/ops/src/composables/useBottomPanel.js | 5 +- apps/ops/src/composables/useDragDrop.js | 8 +- apps/ops/src/composables/useHelpers.js | 95 ++- apps/ops/src/composables/useJobOffers.js | 302 +++++++++ apps/ops/src/composables/useMap.js | 11 +- .../src/composables/usePeriodNavigation.js | 53 +- apps/ops/src/composables/useResourceFilter.js | 10 +- apps/ops/src/composables/useScheduler.js | 107 +++- .../dispatch/components/CreateOfferModal.vue | 246 +++++++ .../dispatch/components/MonthCalendar.vue | 60 +- .../dispatch/components/OfferPoolPanel.vue | 204 ++++++ .../dispatch/components/TimelineRow.vue | 27 +- .../dispatch/components/WeekCalendar.vue | 81 ++- apps/ops/src/pages/DispatchPage.vue | 606 ++++++++++++++++-- apps/ops/src/pages/dispatch-styles.scss | 184 +++++- apps/ops/src/stores/dispatch.js | 73 ++- erpnext/setup_fsm_doctypes.py | 2 + services/targo-hub/lib/ical.js | 166 +++++ services/targo-hub/server.js | 11 + 26 files changed, 2575 insertions(+), 135 deletions(-) create mode 100644 apps/ops/src/api/offers.js create mode 100644 apps/ops/src/api/presets.js create mode 100644 apps/ops/src/components/shared/RecurrenceSelector.vue create mode 100644 apps/ops/src/composables/useJobOffers.js create mode 100644 apps/ops/src/modules/dispatch/components/CreateOfferModal.vue create mode 100644 apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue create mode 100644 services/targo-hub/lib/ical.js diff --git a/apps/ops/src/api/auth.js b/apps/ops/src/api/auth.js index aef4afc..d202d40 100644 --- a/apps/ops/src/api/auth.js +++ b/apps/ops/src/api/auth.js @@ -14,9 +14,7 @@ export function authFetch (url, opts = {}) { opts.headers = { ...opts.headers } } return fetch(url, opts).then(res => { - console.log('[authFetch]', opts.method || 'GET', url, '→', res.status, res.type) if (res.status === 401 || res.status === 403) { - console.warn('authFetch: session expired, reloading') window.location.reload() return new Response('{}', { status: res.status }) } diff --git a/apps/ops/src/api/dispatch.js b/apps/ops/src/api/dispatch.js index 5543fc6..bc64096 100644 --- a/apps/ops/src/api/dispatch.js +++ b/apps/ops/src/api/dispatch.js @@ -8,7 +8,10 @@ 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) + 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 } @@ -43,17 +46,30 @@ async function fetchDoc (doctype, name) { } export async function fetchTechnicians () { - const names = (await listDocs('Dispatch Technician', '["name"]', null, 100)).map(t => t.name) - if (!names.length) return [] - // All individual fetches in parallel (child tables: tags) - return Promise.all(names.map(n => fetchDoc('Dispatch Technician', n))) + // 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) { - const names = (await listDocs('Dispatch Job', '["name"]', filters, 200)).map(j => j.name) - if (!names.length) return [] - // All individual fetches in parallel (child tables: assistants, tags) - return Promise.all(names.map(n => fetchDoc('Dispatch Job', n))) + return fetchJobsFast(filters) } export async function updateJob (name, payload) { diff --git a/apps/ops/src/api/offers.js b/apps/ops/src/api/offers.js new file mode 100644 index 0000000..be44f44 --- /dev/null +++ b/apps/ops/src/api/offers.js @@ -0,0 +1,74 @@ +// ── 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' }) +} diff --git a/apps/ops/src/api/presets.js b/apps/ops/src/api/presets.js new file mode 100644 index 0000000..a96ddc1 --- /dev/null +++ b/apps/ops/src/api/presets.js @@ -0,0 +1,46 @@ +// ── Dispatch Preset CRUD — shared resource group presets ───────────────────── +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 +} + +export async function fetchPresets () { + const data = await apiGet('/api/resource/Dispatch%20Preset?fields=["*"]&limit_page_length=100&order_by=creation+asc') + return data.data || [] +} + +export async function createPreset (payload) { + const res = await authFetch( + `${BASE_URL}/api/resource/Dispatch%20Preset`, + { 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 updatePreset (name, payload) { + const res = await authFetch( + `${BASE_URL}/api/resource/Dispatch%20Preset/${encodeURIComponent(name)}`, + { method: 'PUT', 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 deletePreset (name) { + const res = await authFetch( + `${BASE_URL}/api/resource/Dispatch%20Preset/${encodeURIComponent(name)}`, + { method: 'DELETE' }, + ) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.exception || 'Delete failed') + } +} diff --git a/apps/ops/src/components/customer/ContactCard.vue b/apps/ops/src/components/customer/ContactCard.vue index 8b70038..ee77ca1 100644 --- a/apps/ops/src/components/customer/ContactCard.vue +++ b/apps/ops/src/components/customer/ContactCard.vue @@ -84,7 +84,6 @@ function snapshot (field) { async function save (field) { const val = props.customer[field] ?? '' const prev = snapshots[field] ?? '' - console.log('[ContactCard] save', field, { val, prev, changed: val !== prev }) if (val === prev) return // nothing changed snapshots[field] = val // update snapshot saving.value = field diff --git a/apps/ops/src/components/shared/RecurrenceSelector.vue b/apps/ops/src/components/shared/RecurrenceSelector.vue new file mode 100644 index 0000000..6ef8160 --- /dev/null +++ b/apps/ops/src/components/shared/RecurrenceSelector.vue @@ -0,0 +1,298 @@ + + + + + + + diff --git a/apps/ops/src/composables/useAbsenceResize.js b/apps/ops/src/composables/useAbsenceResize.js index 93863d7..f235c60 100644 --- a/apps/ops/src/composables/useAbsenceResize.js +++ b/apps/ops/src/composables/useAbsenceResize.js @@ -26,7 +26,7 @@ export function useAbsenceResize (pxPerHr, H_START) { } const curL = parseFloat(block.style.left) const curW = parseFloat(block.style.width) - const sH = H_START + curL / pxPerHr.value + const sH = H_START.value + curL / pxPerHr.value const eH = sH + curW / pxPerHr.value const lbl = block.querySelector('.sb-absence-label') if (lbl) lbl.textContent = `${hToTime(sH)} → ${hToTime(eH)}` @@ -37,7 +37,7 @@ export function useAbsenceResize (pxPerHr, H_START) { document.removeEventListener('mouseup', onUp) const curL = parseFloat(block.style.left) const curW = parseFloat(block.style.width) - const newStartH = H_START + curL / pxPerHr.value + const newStartH = H_START.value + curL / pxPerHr.value const newEndH = newStartH + curW / pxPerHr.value const startTime = hToTime(newStartH) const endTime = hToTime(newEndH) diff --git a/apps/ops/src/composables/useBottomPanel.js b/apps/ops/src/composables/useBottomPanel.js index 19ecdcd..3eec073 100644 --- a/apps/ops/src/composables/useBottomPanel.js +++ b/apps/ops/src/composables/useBottomPanel.js @@ -1,6 +1,6 @@ // ── Bottom panel composable: unassigned jobs table, multi-select, criteria ──── import { ref, computed, watch } from 'vue' -import { localDateStr } from './useHelpers' +import { localDateStr, fmtDate } from './useHelpers' export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) { const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false') @@ -29,8 +29,7 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm currentDate = d let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date' if (d && d !== today) { - const dt = new Date(d + 'T00:00:00') - label = dt.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' }) + label = fmtDate(new Date(d + 'T00:00:00')) } groups.push({ date: d, label, jobs: [] }) } diff --git a/apps/ops/src/composables/useDragDrop.js b/apps/ops/src/composables/useDragDrop.js index 0af0b1d..7ba656b 100644 --- a/apps/ops/src/composables/useDragDrop.js +++ b/apps/ops/src/composables/useDragDrop.js @@ -124,7 +124,7 @@ export function useDragDrop (deps) { if (dragJob.value.assignedTech === tech.id) { const rect = e.currentTarget.getBoundingClientRect() const x = (e.clientX || e.pageX) - rect.left - const dropH = H_START + x / pxPerHr.value + const dropH = H_START.value + x / pxPerHr.value const dayStr = localDateStr(periodStart.value) pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] }) const draggedJob = dragJob.value @@ -170,8 +170,8 @@ export function useDragDrop (deps) { if (!moving) return ev.preventDefault() const newLeft = Math.max(0, startLeft + dx) - const newH = snapH(H_START + newLeft / pxPerHr.value) - block.style.left = ((newH - H_START) * pxPerHr.value) + 'px' + const newH = snapH(H_START.value + newLeft / pxPerHr.value) + block.style.left = ((newH - H_START.value) * pxPerHr.value) + 'px' const meta = block.querySelector('.sb-block-meta') if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}` } @@ -184,7 +184,7 @@ export function useDragDrop (deps) { if (!moving) return block.style.zIndex = '' const dx = ev.clientX - startX - const newH = snapH(H_START + Math.max(0, startLeft + dx) / pxPerHr.value) + const newH = snapH(H_START.value + Math.max(0, startLeft + dx) / pxPerHr.value) job.startHour = newH; job.startTime = hToTime(newH) store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH)) invalidateRoutes() diff --git a/apps/ops/src/composables/useHelpers.js b/apps/ops/src/composables/useHelpers.js index f00045b..0f27225 100644 --- a/apps/ops/src/composables/useHelpers.js +++ b/apps/ops/src/composables/useHelpers.js @@ -1,8 +1,21 @@ // ── Pure utility functions (no Vue dependencies) ───────────────────────────── export function localDateStr (d) { + if (!d || !(d instanceof Date) || isNaN(d.getTime())) return '—' return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` } +// Safe date formatter — never throws RangeError +export function fmtDate (d, opts = { weekday:'short', day:'numeric', month:'short' }) { + if (!d || !(d instanceof Date) || isNaN(d.getTime())) { + console.warn('[fmtDate] invalid date:', d, typeof d) + return '—' + } + try { return d.toLocaleDateString('fr-CA', opts) } catch (e) { + console.warn('[fmtDate] toLocaleDateString failed:', d, e.message) + return localDateStr(d) + } +} + export function startOfWeek (d) { const r = new Date(d); r.setHours(0,0,0,0) const diff = r.getDay() === 0 ? -6 : 1 - r.getDay() @@ -82,12 +95,19 @@ export function jobColor (job, techColors, store) { return '#6b7280' } -export function jobSpansDate (job, ds) { +export function jobSpansDate (job, ds, tech) { const start = job.scheduledDate const end = job.endDate if (!start) return false if (!end) return start === ds - return ds >= start && ds <= end + if (ds < start || ds > end) return false + // Multi-day jobs skip the tech's off-days (weekends, custom schedule) + // unless the job is flagged as emergency/continuous + if (tech && !job.continuous) { + const sched = techDaySchedule(tech, ds) + if (!sched) return false + } + return true } export function sortJobsByTime (jobs) { @@ -206,6 +226,77 @@ export function jobTypeIcon (job) { return ICON.wrench } +// ── RRULE expansion (pure JS, no deps) ─────────────────────────────────────── +const _dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 } +const _d = s => new Date(s + 'T12:00:00') +const _fmt = dt => `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}` + +export function expandRRule (rrule, dtStart, rangeStart, rangeEnd, pausePeriods = []) { + if (!rrule || !dtStart) return [] + const params = Object.fromEntries(rrule.split(';').map(p => p.split('='))) + const freq = params.FREQ, interval = parseInt(params.INTERVAL || '1', 10) + const byDay = params.BYDAY ? params.BYDAY.split(',') : null + const byMonthDay = params.BYMONTHDAY ? parseInt(params.BYMONTHDAY, 10) : null + const byMonth = params.BYMONTH ? parseInt(params.BYMONTH, 10) : null + + const end = _d(rangeEnd), rStart = _d(rangeStart), origin = _d(dtStart) + const results = [] + const isPaused = dt => pausePeriods.some(p => dt >= _d(p.from) && dt <= _d(p.until)) + const push = dt => { if (dt >= rStart && dt <= end && !isPaused(dt)) results.push(_fmt(dt)) } + + if (freq === 'DAILY') { + const c = new Date(origin); while (c <= end) { push(c); c.setDate(c.getDate() + interval) } + } else if (freq === 'WEEKLY') { + const daySet = byDay ? new Set(byDay.map(d => _dayMap[d])) : new Set([origin.getDay()]) + const ws = new Date(origin); ws.setDate(ws.getDate() - ws.getDay()) + while (ws <= end) { + for (let dow = 0; dow < 7; dow++) { + if (!daySet.has(dow)) continue + const dt = new Date(ws); dt.setDate(dt.getDate() + dow) + if (dt >= origin && dt <= end) push(dt) + } + ws.setDate(ws.getDate() + 7 * interval) + } + } else if (freq === 'MONTHLY') { + const day = byMonthDay || origin.getDate() + const c = new Date(origin); while (c <= end) { + const dt = new Date(c.getFullYear(), c.getMonth(), day, 12) + if (dt.getMonth() === c.getMonth()) push(dt) + c.setMonth(c.getMonth() + interval) + } + } else if (freq === 'YEARLY') { + const month = byMonth ? byMonth - 1 : origin.getMonth() + const day = byMonthDay || origin.getDate() + let year = origin.getFullYear(); while (year <= end.getFullYear()) { + const dt = new Date(year, month, day, 12) + if (dt.getMonth() === month) push(dt) + year += interval + } + } + return results +} + +// Build RRULE string from UI fields +export function buildRRule ({ freq, interval, byDay, byMonthDay }) { + let rule = `FREQ=${freq}` + if (interval && interval > 1) rule += `;INTERVAL=${interval}` + if (byDay?.length && freq === 'WEEKLY') rule += `;BYDAY=${byDay.join(',')}` + if (byMonthDay && (freq === 'MONTHLY' || freq === 'YEARLY')) rule += `;BYMONTHDAY=${byMonthDay}` + return rule +} + +// Parse RRULE string to UI fields +export function parseRRule (rrule) { + if (!rrule) return { freq: 'WEEKLY', interval: 1, byDay: ['MO'], byMonthDay: null } + const params = Object.fromEntries(rrule.split(';').map(p => p.split('='))) + return { + freq: params.FREQ || 'WEEKLY', + interval: parseInt(params.INTERVAL || '1', 10), + byDay: params.BYDAY ? params.BYDAY.split(',') : [], + byMonthDay: params.BYMONTHDAY ? parseInt(params.BYMONTHDAY, 10) : null, + } +} + // Priority color export function prioColor (p) { return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0' diff --git a/apps/ops/src/composables/useJobOffers.js b/apps/ops/src/composables/useJobOffers.js new file mode 100644 index 0000000..3b5b01b --- /dev/null +++ b/apps/ops/src/composables/useJobOffers.js @@ -0,0 +1,302 @@ +// ── Uber-style job offer pool ──────────────────────────────────────────────── +// Manages the offer lifecycle: create → broadcast → accept/decline → assign +// Supports both push (dispatcher sends to specific techs) and pull (tech picks from pool) +// Pricing: rush/overtime jobs carry displacement fee + hourly rate +// ───────────────────────────────────────────────────────────────────────────── +import { ref, computed } from 'vue' +import { fetchActiveOffers, createOffer, acceptOffer, declineOffer, cancelOffer } from 'src/api/offers' +import { updateJob } from 'src/api/dispatch' +import { sendTestSms } from 'src/api/sms' +import { localDateStr, techDaySchedule, techDayCapacityH, timeToH } from './useHelpers' + +// ── Pricing presets ────────────────────────────────────────────────────────── +export const PRICING_PRESETS = { + rush_weekend: { + label: 'Rush fin de semaine', + displacement: 150, + hourlyRate: 125, + currency: 'CAD', + description: 'Déplacement minimum 150$ + 125$/h', + }, + rush_evening: { + label: 'Rush soirée', + displacement: 100, + hourlyRate: 100, + currency: 'CAD', + description: 'Déplacement minimum 100$ + 100$/h', + }, + rush_holiday: { + label: 'Rush jour férié', + displacement: 200, + hourlyRate: 150, + currency: 'CAD', + description: 'Déplacement minimum 200$ + 150$/h', + }, + standard: { + label: 'Tarif régulier', + displacement: 0, + hourlyRate: 0, + currency: 'CAD', + description: 'Inclus dans le plan de service', + }, +} + +export function useJobOffers (store) { + const offers = ref([]) + const loadingOffers = ref(false) + const showOfferPool = ref(false) + + // ── Map raw ERPNext doc → local offer object ────────────────────────────── + function _mapOffer (o) { + return { + id: o.name, + name: o.name, + jobName: o.job_name || null, // linked Dispatch Job + subject: o.subject || '', + address: o.address || '', + customer: o.customer || '', + scheduledDate: o.scheduled_date || null, + startTime: o.start_time || null, + duration: parseFloat(o.duration_h) || 1, + priority: o.priority || 'medium', + status: o.status || 'open', // open | pending | accepted | expired | cancelled + // Targeting + offerMode: o.offer_mode || 'broadcast', // broadcast | targeted | pool + targetTechs: o.target_techs ? JSON.parse(o.target_techs) : [], // specific tech IDs + requiredTags: o.required_tags ? JSON.parse(o.required_tags) : [], + // Responses + acceptedBy: o.accepted_by || null, + acceptedAt: o.accepted_at || null, + declinedTechs: o.declined_techs ? JSON.parse(o.declined_techs) : [], + // Pricing + pricingPreset: o.pricing_preset || 'standard', + displacement: parseFloat(o.displacement_fee) || 0, + hourlyRate: parseFloat(o.hourly_rate) || 0, + currency: o.currency || 'CAD', + // Customer checkout + isCustomerRequest: !!o.is_customer_request, + salesOrder: o.sales_order || null, + // Timing + expiresAt: o.expires_at || null, + createdAt: o.creation || null, + // Source + orderSource: o.order_source || 'dispatch', // dispatch | portal | api + } + } + + // ── Load active offers ──────────────────────────────────────────────────── + async function loadOffers () { + loadingOffers.value = true + try { + const raw = await fetchActiveOffers() + offers.value = raw.map(_mapOffer) + } catch (e) { + console.warn('[offers] load failed:', e.message) + } finally { + loadingOffers.value = false + } + } + + // ── Active offers count (for badge) ─────────────────────────────────────── + const activeOfferCount = computed(() => offers.value.filter(o => o.status === 'open' || o.status === 'pending').length) + + // ── Find matching techs for an offer ────────────────────────────────────── + function matchingTechs (offer) { + if (!store.technicians?.length) return [] + return store.technicians.filter(tech => { + // Skip inactive / absent + if (tech.status === 'off' || tech.status === 'inactive') return false + // Skip already declined + if (offer.declinedTechs.some(d => d.techId === tech.id)) return false + // Check required tags/skills + if (offer.requiredTags.length) { + const hasAll = offer.requiredTags.every(reqTag => { + const techTag = tech.tagsWithLevel?.find(t => t.tag === reqTag.tag || t.tag === reqTag) + if (!techTag) return false + if (reqTag.level && techTag.level < reqTag.level) return false + return true + }) + if (!hasAll) return false + } + // Check availability on scheduled date + if (offer.scheduledDate) { + const sched = techDaySchedule(tech, offer.scheduledDate) + // For rush/overtime: tech doesn't need to be on schedule + if (!sched && offer.pricingPreset === 'standard') return false + // Check capacity (allow overflow for rush) + if (sched && offer.pricingPreset === 'standard') { + const cap = techDayCapacityH(tech, offer.scheduledDate) + const load = tech.queue + .filter(j => j.scheduledDate === offer.scheduledDate) + .reduce((sum, j) => sum + (parseFloat(j.duration) || 0), 0) + if (load + offer.duration > cap * 1.2) return false // 20% overflow tolerance for standard + } + } + // Targeted mode: only include specified techs + if (offer.offerMode === 'targeted' && offer.targetTechs.length) { + if (!offer.targetTechs.includes(tech.id)) return false + } + return true + }) + } + + // ── Create and broadcast an offer ───────────────────────────────────────── + async function broadcastOffer (offerData, notifyViaSms = false) { + const pricing = PRICING_PRESETS[offerData.pricingPreset] || PRICING_PRESETS.standard + const payload = { + subject: offerData.subject, + address: offerData.address || '', + customer: offerData.customer || '', + scheduled_date: offerData.scheduledDate || '', + start_time: offerData.startTime || '', + duration_h: offerData.duration || 1, + priority: offerData.priority || 'medium', + status: 'open', + offer_mode: offerData.offerMode || 'broadcast', + target_techs: JSON.stringify(offerData.targetTechs || []), + required_tags: JSON.stringify(offerData.requiredTags || []), + pricing_preset: offerData.pricingPreset || 'standard', + displacement_fee: pricing.displacement, + hourly_rate: pricing.hourlyRate, + currency: pricing.currency, + is_customer_request: offerData.isCustomerRequest ? 1 : 0, + sales_order: offerData.salesOrder || '', + order_source: offerData.orderSource || 'dispatch', + expires_at: offerData.expiresAt || '', + job_name: offerData.jobName || '', + } + const doc = await createOffer(payload) + const mapped = _mapOffer(doc) + offers.value.unshift(mapped) + + // Notify matching techs via SMS + if (notifyViaSms) { + const techs = matchingTechs(mapped) + const fmtPrice = pricing.displacement > 0 + ? `💰 ${pricing.displacement}$ déplacement + ${pricing.hourlyRate}$/h` + : '' + for (const tech of techs) { + if (!tech.phone) continue + const msg = [ + `📋 Nouvelle offre de travail:`, + `${mapped.subject}`, + mapped.address ? `📍 ${mapped.address}` : '', + mapped.scheduledDate ? `📅 ${mapped.scheduledDate}${mapped.startTime ? ' à ' + mapped.startTime : ''}` : '', + `⏱ ${mapped.duration}h`, + fmtPrice, + ``, + `Répondez OUI pour accepter ou NON pour décliner.`, + ].filter(Boolean).join('\n') + sendTestSms(tech.phone, msg, mapped.customer, { + reference_doctype: 'Dispatch Offer', + reference_name: mapped.id, + }).catch(err => console.warn(`[offer SMS] ${tech.id}:`, err.message)) + } + } + + return mapped + } + + // ── Accept an offer (tech side) ─────────────────────────────────────────── + async function handleAccept (offerId, techId) { + const offer = offers.value.find(o => o.id === offerId) + if (!offer) throw new Error('Offer not found') + // Update offer status + await acceptOffer(offerId, techId) + offer.status = 'accepted' + offer.acceptedBy = techId + + // Create or assign the dispatch job + if (offer.jobName) { + // Existing job — assign to accepting tech + await store.assignJobToTech(offer.jobName, techId, + store.technicians.find(t => t.id === techId)?.queue.length || 0, + offer.scheduledDate) + } else { + // Create new job from offer + const job = await store.createJob({ + subject: offer.subject, + address: offer.address, + duration_h: offer.duration, + priority: offer.priority, + scheduled_date: offer.scheduledDate, + start_time: offer.startTime, + assigned_tech: techId, + customer: offer.customer, + sales_order: offer.salesOrder, + order_source: offer.orderSource, + }) + // Link job back to offer + if (job?.name) { + await import('src/api/offers').then(m => m.updateOffer(offerId, { job_name: job.name })) + offer.jobName = job.name + } + } + + return offer + } + + // ── Decline an offer (tech side) ────────────────────────────────────────── + async function handleDecline (offerId, techId, reason = '') { + const offer = offers.value.find(o => o.id === offerId) + if (!offer) return + await declineOffer(offerId, techId, reason) + offer.declinedTechs.push({ techId, reason, at: new Date().toISOString() }) + // Check if all targeted techs declined → auto-expire + if (offer.offerMode === 'targeted' && offer.targetTechs.length) { + const allDeclined = offer.targetTechs.every(tid => + offer.declinedTechs.some(d => d.techId === tid) + ) + if (allDeclined) { + await cancelOffer(offerId) + offer.status = 'expired' + } + } + } + + // ── Cancel an offer (dispatcher side) ───────────────────────────────────── + async function handleCancel (offerId) { + const offer = offers.value.find(o => o.id === offerId) + if (!offer) return + await cancelOffer(offerId) + offer.status = 'cancelled' + } + + // ── Estimate cost for a rush job ────────────────────────────────────────── + function estimateCost (preset, durationH) { + const p = PRICING_PRESETS[preset] || PRICING_PRESETS.standard + return { + displacement: p.displacement, + labour: Math.ceil(durationH * p.hourlyRate * 100) / 100, + total: p.displacement + Math.ceil(durationH * p.hourlyRate * 100) / 100, + currency: p.currency, + description: p.description, + } + } + + // ── Create offer from existing unassigned job ───────────────────────────── + async function offerExistingJob (job, opts = {}) { + return broadcastOffer({ + jobName: job.name || job.id, + subject: job.subject, + address: job.address, + customer: job.customer, + scheduledDate: job.scheduledDate, + startTime: job.startTime, + duration: job.duration, + priority: job.priority, + pricingPreset: opts.pricingPreset || 'standard', + offerMode: opts.offerMode || 'broadcast', + targetTechs: opts.targetTechs || [], + requiredTags: opts.requiredTags || [], + orderSource: 'dispatch', + expiresAt: opts.expiresAt || '', + }, opts.sms !== false) + } + + return { + offers, loadingOffers, showOfferPool, activeOfferCount, + loadOffers, broadcastOffer, handleAccept, handleDecline, handleCancel, + matchingTechs, estimateCost, offerExistingJob, + } +} diff --git a/apps/ops/src/composables/useMap.js b/apps/ops/src/composables/useMap.js index 8529f1e..c01abd0 100644 --- a/apps/ops/src/composables/useMap.js +++ b/apps/ops/src/composables/useMap.js @@ -146,7 +146,8 @@ export function useMap (deps) { .filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0)) .filter(j => { if (!j.assignedTech) return (j.scheduledDate || null) === dayStr - return jobSpansDate(j, dayStr) + const tech = store.technicians.find(t => t.id === j.assignedTech) + return jobSpansDate(j, dayStr, tech) }) .map(job => { const isUnassigned = !job.assignedTech @@ -169,7 +170,7 @@ export function useMap (deps) { // Pre-compute: which techs are assistants on which lead tech's jobs today const groupCounts = {} // leadTechId → total crew size (1 + assistants) store.technicians.forEach(tech => { - const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr)) + const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech)) const assistIds = new Set() todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId))) if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size @@ -182,8 +183,8 @@ export function useMap (deps) { const color = TECH_COLORS[tech.colorIdx] // Calculate daily workload + completion - const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr)) - const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr)) + const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech)) + const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr, tech)) const allToday = [...todayJobs, ...todayAssist] const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0) const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed') @@ -318,7 +319,7 @@ export function useMap (deps) { if (routeLegs.value[key] !== undefined) return const points = [] if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`) - const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr))] + const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr, tech)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr, tech))] allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) }) function setCache (legs, geom) { routeLegs.value = { ...routeLegs.value, [key]: legs } diff --git a/apps/ops/src/composables/usePeriodNavigation.js b/apps/ops/src/composables/usePeriodNavigation.js index 8cbe8aa..f8df4c2 100644 --- a/apps/ops/src/composables/usePeriodNavigation.js +++ b/apps/ops/src/composables/usePeriodNavigation.js @@ -1,10 +1,19 @@ import { ref, computed, watch } from 'vue' import { localDateStr, startOfWeek, startOfMonth } from 'src/composables/useHelpers' +// Buffer: 1 period before, 2 after — biased toward the future for natural right-scroll +const BUFFER_BEFORE = 1 +const BUFFER_AFTER = 2 + export function usePeriodNavigation () { const currentView = ref(localStorage.getItem('sbv2-view') || 'week') const savedDate = localStorage.getItem('sbv2-date') - const anchorDate = ref(savedDate ? new Date(savedDate + 'T00:00:00') : new Date()) + let initDate = new Date() + if (savedDate && /^\d{4}-\d{2}-\d{2}$/.test(savedDate)) { + const d = new Date(savedDate + 'T00:00:00') + if (!isNaN(d.getTime())) initDate = d + } + const anchorDate = ref(initDate) watch(currentView, v => localStorage.setItem('sbv2-view', v)) watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d))) @@ -15,28 +24,59 @@ export function usePeriodNavigation () { if (currentView.value === 'week') return startOfWeek(d) return startOfMonth(d) }) + + // The "core" period length (what the label describes) const periodDays = computed(() => { if (currentView.value === 'day') return 1 if (currentView.value === 'week') return 7 const s = periodStart.value return new Date(s.getFullYear(), s.getMonth()+1, 0).getDate() }) + + // Buffer: extra periods before/after for seamless scroll (week only, not day) + const bufferDaysBefore = computed(() => { + if (currentView.value !== 'week') return 0 + return periodDays.value * BUFFER_BEFORE + }) + + const renderedDays = computed(() => { + if (currentView.value !== 'week') return periodDays.value + return periodDays.value * (1 + BUFFER_BEFORE + BUFFER_AFTER) + }) + + // The start date of all rendered columns (buffer included) + const renderedStart = computed(() => { + const ps = periodStart.value + if (!ps || isNaN(ps.getTime())) return new Date() + const d = new Date(ps) + d.setDate(d.getDate() - bufferDaysBefore.value) + return d + }) + + // dayColumns spans the full rendered range (prev + current + next) const dayColumns = computed(() => { const cols = [] - for (let i = 0; i < periodDays.value; i++) { - const d = new Date(periodStart.value); d.setDate(d.getDate() + i); cols.push(d) + const base = renderedStart.value + if (!base || isNaN(base.getTime())) return cols + for (let i = 0; i < renderedDays.value; i++) { + const d = new Date(base); d.setDate(d.getDate() + i); cols.push(d) } return cols }) + + function safeFmt (d, opts) { + try { return d.toLocaleDateString('fr-CA', opts) } catch { return localDateStr(d) } + } const periodLabel = computed(() => { const s = periodStart.value + if (!s || isNaN(s.getTime())) return '—' if (currentView.value === 'day') - return s.toLocaleDateString('fr-CA', { weekday:'long', day:'numeric', month:'long', year:'numeric' }) + return safeFmt(s, { weekday:'long', day:'numeric', month:'long', year:'numeric' }) if (currentView.value === 'week') { const e = new Date(s); e.setDate(e.getDate() + 6) - return `${s.toLocaleDateString('fr-CA',{day:'numeric',month:'short'})} – ${e.toLocaleDateString('fr-CA',{day:'numeric',month:'short',year:'numeric'})}` + return `${safeFmt(s,{day:'numeric',month:'short'})} – ${safeFmt(e,{day:'numeric',month:'short',year:'numeric'})}` } - return s.toLocaleDateString('fr-CA', { month:'long', year:'numeric' }) + return safeFmt(s, { month:'long', year:'numeric' }) }) const todayStr = localDateStr(new Date()) @@ -59,6 +99,7 @@ export function usePeriodNavigation () { return { currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr, + bufferDaysBefore, renderedDays, prevPeriod, nextPeriod, goToToday, goToDay, } } diff --git a/apps/ops/src/composables/useResourceFilter.js b/apps/ops/src/composables/useResourceFilter.js index d2849bc..637f114 100644 --- a/apps/ops/src/composables/useResourceFilter.js +++ b/apps/ops/src/composables/useResourceFilter.js @@ -1,6 +1,6 @@ import { ref, computed, watch } from 'vue' -export function useResourceFilter (store) { +export function useResourceFilter (store, opts = {}) { const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]')) const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '') const filterGroup = ref(localStorage.getItem('sbv2-filterGroup') || '') @@ -12,6 +12,7 @@ export function useResourceFilter (store) { const resSelectorOpen = ref(false) const tempSelectedIds = ref([]) const dragReorderTech = ref(null) + const hideAbsent = ref(false) // Quick toggle: hide techs absent on current day watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true }) watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v)) @@ -54,12 +55,15 @@ export function useResourceFilter (store) { if (filterGroup.value) list = list.filter(t => t.group === filterGroup.value) if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id)) if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft))) + // Quick toggle: hide techs absent on the current viewed day + if (hideAbsent.value && opts.isAbsentOnDay) list = list.filter(t => !opts.isAbsentOnDay(t)) // Sort: humans first, then material; within each, apply chosen sort list = [...list].sort((a, b) => { const aType = a.resourceType === 'material' ? 1 : 0 const bType = b.resourceType === 'material' ? 1 : 0 if (aType !== bType) return aType - bType if (techSort.value === 'alpha') return a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase()) + if (techSort.value === 'load' && opts.getLoadH) return (opts.getLoadH(a) || 0) - (opts.getLoadH(b) || 0) if (techSort.value === 'manual' && manualOrder.value.length) { const order = manualOrder.value return (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id)) @@ -108,14 +112,14 @@ export function useResourceFilter (store) { const idx = tempSelectedIds.value.indexOf(id) if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id) } - function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; filterGroup.value = ''; filterResourceType.value = ''; searchQuery.value = ''; filterTags.value = []; showInactive.value = false; localStorage.removeItem('sbv2-filterTags'); localStorage.removeItem('sbv2-filterGroup'); localStorage.removeItem('sbv2-filterResType') } + function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; filterGroup.value = ''; filterResourceType.value = ''; searchQuery.value = ''; filterTags.value = []; showInactive.value = false; hideAbsent.value = false; localStorage.removeItem('sbv2-filterTags'); localStorage.removeItem('sbv2-filterGroup'); localStorage.removeItem('sbv2-filterResType') } // Count of inactive techs (for UI indicator) const inactiveCount = computed(() => store.technicians.filter(t => !t.active).length) return { selectedResIds, filterStatus, filterGroup, filterResourceType, filterTags, searchQuery, techSort, manualOrder, - showInactive, inactiveCount, humanCount, materialCount, availableCategories, + showInactive, hideAbsent, inactiveCount, humanCount, materialCount, availableCategories, filteredResources, groupedResources, availableGroups, resSelectorOpen, tempSelectedIds, dragReorderTech, openResSelector, applyResSelector, toggleTempRes, clearFilters, onTechReorderStart, onTechReorderDrop, diff --git a/apps/ops/src/composables/useScheduler.js b/apps/ops/src/composables/useScheduler.js index ede924c..aebe341 100644 --- a/apps/ops/src/composables/useScheduler.js +++ b/apps/ops/src/composables/useScheduler.js @@ -1,16 +1,46 @@ // ── Scheduling logic: timeline computation, route cache, job placement ─────── import { ref, computed } from 'vue' -import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate, techDaySchedule, techDayCapacityH } from './useHelpers' +import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate, techDaySchedule, techDayCapacityH, expandRRule } from './useHelpers' import { ABSENCE_REASONS } from './useTechManagement' export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) { - const H_START = 7 - const H_END = 20 + // Day view: 6AM–6PM. Week view: 7AM–8PM. + const H_START = computed(() => currentView.value === 'day' ? 6 : 7) + const H_END = computed(() => currentView.value === 'day' ? 18 : 20) // ── Route cache ──────────────────────────────────────────────────────────── const routeLegs = ref({}) const routeGeometry = ref({}) + // ── Ghost occurrences from recurring templates ───────────────────────────── + function ghostOccurrencesForDate (tech, dateStr) { + const templates = tech.queue.filter(j => j.isRecurring && j.recurrenceRule) + if (!templates.length) return [] + const ghosts = [] + for (const tpl of templates) { + const rangeEnd = tpl.recurrenceEnd || localDateStr((() => { const d = new Date(); d.setDate(d.getDate() + 90); return d })()) + const dates = expandRRule(tpl.recurrenceRule, tpl.scheduledDate, dateStr, dateStr, tpl.pausePeriods || []) + if (!dates.includes(dateStr)) continue + // Skip if dateStr is the template's own scheduledDate (already rendered as real job) + if (dateStr === tpl.scheduledDate) continue + // Skip if a materialized instance already exists for this date + const hasMaterialized = tech.queue.some(j => j.templateId === tpl.id && j.scheduledDate === dateStr) + if (hasMaterialized) continue + // Skip tech off-days (unless continuous) + if (!tpl.continuous && !techDaySchedule(tech, dateStr)) continue + ghosts.push({ + ...tpl, + id: `ghost-${tpl.id}-${dateStr}`, + _realId: tpl.id, + scheduledDate: dateStr, + endDate: null, + _isGhost: true, + _templateJob: tpl, + }) + } + return ghosts + } + // ── Parent start position cache ──────────────────────────────────────────── let _parentStartCache = {} @@ -46,9 +76,9 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo // ── All jobs for a tech on a date (primary + assists) ────────────────────── function techAllJobsForDate (tech, dateStr) { _parentStartCache = {} - const primary = tech.queue.filter(j => jobSpansDate(j, dateStr)) + const primary = tech.queue.filter(j => jobSpansDate(j, dateStr, tech)) const assists = (tech.assistJobs || []) - .filter(j => jobSpansDate(j, dateStr)) + .filter(j => jobSpansDate(j, dateStr, tech)) .map(j => { const a = j.assistants.find(x => x.techId === tech.id) const parentH = getParentStartH(j) @@ -63,7 +93,8 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo _parentJob: j, } }) - return sortJobsByTime([...primary, ...assists]) + const ghosts = ghostOccurrencesForDate(tech, dateStr) + return sortJobsByTime([...primary, ...assists, ...ghosts]) } // ── Absence / schedule-off segments for a tech on a given date ─────────────── @@ -75,10 +106,10 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo const from = tech.absenceFrom const until = tech.absenceUntil || from if (dateStr >= from && dateStr <= until) { - const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START - const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END + const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START.value + const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END.value const reasonObj = ABSENCE_REASONS.find(r => r.value === tech.absenceReason) || { label: 'Absent', icon: '⏸' } - const left = (startH - H_START) * pxPerHr.value + const left = (startH - H_START.value) * pxPerHr.value const width = (endH - startH) * pxPerHr.value segs.push({ type: 'absence', startH, endH, @@ -92,13 +123,15 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo } // 2. Weekly schedule off-day (regular day off like Fridays for 4×10 schedule) + // Marked as _isDayOff so TimelineRow can hide it when planning mode is off const daySched = techDaySchedule(tech, dateStr) if (!daySched) { const left = 0 - const width = (H_END - H_START) * pxPerHr.value + const width = (H_END.value - H_START.value) * pxPerHr.value segs.push({ - type: 'absence', startH: H_START, endH: H_END, + type: 'absence', startH: H_START.value, endH: H_END.value, reason: 'day_off', reasonLabel: 'Jour de repos', reasonIcon: '📅', + _isDayOff: true, from: null, until: null, techId: tech.id, style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' }, job: { id: `schedoff-${tech.id}-${dateStr}` }, @@ -175,6 +208,34 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo placed.sort((a, b) => a.startH - b.startH) const result = [] + + // Shift availability background block (renders behind jobs) + if (daySched) { + const shiftLeft = (daySched.startH - H_START.value) * pxPerHr.value + const shiftWidth = (daySched.endH - daySched.startH) * pxPerHr.value + result.push({ + type: 'shift', startH: daySched.startH, endH: daySched.endH, + label: `${daySched.start} – ${daySched.end}`, + style: { left: shiftLeft + 'px', width: Math.max(18, shiftWidth) + 'px', top: '0', bottom: '0', position: 'absolute' }, + }) + } + + // Extra shifts (on-call, garde) from tech.extraShifts + const extras = (tech.extraShifts || []).filter(s => { + if (!s.rrule || !s.startTime || !s.endTime) return false + const dates = expandRRule(s.rrule, s.from || tech.scheduledDate || dayStr, dayStr, dayStr, []) + return dates.includes(dayStr) + }) + extras.forEach(s => { + const sH = timeToH(s.startTime), eH = timeToH(s.endTime) + result.push({ + type: 'shift', startH: sH, endH: eH, + label: `${s.label || 'Garde'}: ${s.startTime} – ${s.endTime}`, + isOnCall: true, + style: { left: (sH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, (eH - sH) * pxPerHr.value) + 'px', top: '0', bottom: '0', position: 'absolute' }, + }) + }) + let prevEndH = null let legCounter = 0 placed.forEach((p) => { @@ -191,7 +252,7 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo const fromRoute = routeMin != null result.push({ type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false, - style: { left: (travelStart - H_START) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' }, + style: { left: (travelStart - H_START.value) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' }, color: jobColorFn(realJob), }) legCounter++ @@ -202,13 +263,14 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo result.push(_absSeg) } else { const realJob = isAssist ? job._parentJob : job - const jLeft = (startH - H_START) * pxPerHr.value + const jLeft = (startH - H_START.value) * pxPerHr.value const jWidth = Math.max(18, dur * pxPerHr.value) result.push({ type: isAssist ? 'assist' : 'job', job: realJob, pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist, assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null, assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null, + _isGhost: !!job._isGhost, _templateJob: job._templateJob || null, style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' }, }) } @@ -221,7 +283,7 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo result.push({ type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true, assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id, - style: { left: (startH - H_START) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' }, + style: { left: (startH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' }, }) }) @@ -233,12 +295,13 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo function techBookingsByDay (tech) { return dayColumns.value.map(d => { const ds = localDateStr(d) - const primary = tech.queue.filter(j => jobSpansDate(j, ds)) + const primary = tech.queue.filter(j => jobSpansDate(j, ds, tech)) const assists = (tech.assistJobs || []) - .filter(j => jobSpansDate(j, ds) && j.assistants.find(a => a.techId === tech.id)?.pinned) + .filter(j => jobSpansDate(j, ds, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned) .map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration })) + const ghosts = ghostOccurrencesForDate(tech, ds) const absSegs = absenceSegmentsForDate(tech, ds) - return { day: d, dateStr: ds, jobs: [...primary, ...assists], absent: absSegs.length > 0, absenceInfo: absSegs[0] || null } + return { day: d, dateStr: ds, jobs: [...primary, ...assists, ...ghosts], absent: absSegs.length > 0, absenceInfo: absSegs[0] || null } }) } @@ -250,7 +313,7 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo // Invalidate caches when period/view changes let _lastCacheKey = '' function _checkCacheInvalidation () { - const key = `${currentView.value}||${periodStart.value}||${dayColumns.value.length}||${store.jobs.length}` + const key = `${currentView.value}||${periodStart.value}||${dayColumns.value.length}||${store.jobs.length}||${store.jobVersion}` if (key !== _lastCacheKey) { _lastCacheKey = key _periodLoadCache.clear() @@ -306,20 +369,20 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo function techsActiveOnDay (dateStr, resources) { return resources.filter(tech => - tech.queue.some(j => jobSpansDate(j, dateStr)) || - (tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned) + tech.queue.some(j => jobSpansDate(j, dateStr, tech)) || + (tech.assistJobs || []).some(j => jobSpansDate(j, dateStr, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned) ) } function dayJobCount (dateStr, resources) { const jobIds = new Set() - resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id))) + resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr, t)).forEach(j => jobIds.add(j.id))) return jobIds.size } return { H_START, H_END, routeLegs, routeGeometry, - techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, + techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate, techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH, techsActiveOnDay, dayJobCount, } diff --git a/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue b/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue new file mode 100644 index 0000000..21034e6 --- /dev/null +++ b/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/apps/ops/src/modules/dispatch/components/MonthCalendar.vue b/apps/ops/src/modules/dispatch/components/MonthCalendar.vue index 7a20f7f..f423769 100644 --- a/apps/ops/src/modules/dispatch/components/MonthCalendar.vue +++ b/apps/ops/src/modules/dispatch/components/MonthCalendar.vue @@ -1,16 +1,19 @@ + + + + diff --git a/apps/ops/src/modules/dispatch/components/TimelineRow.vue b/apps/ops/src/modules/dispatch/components/TimelineRow.vue index 404cda1..0f34758 100644 --- a/apps/ops/src/modules/dispatch/components/TimelineRow.vue +++ b/apps/ops/src/modules/dispatch/components/TimelineRow.vue @@ -1,5 +1,5 @@