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>
This commit is contained in:
louispaulb 2026-04-08 22:44:18 -04:00
parent a9f8d0c7bf
commit 0c77afdb3b
26 changed files with 2575 additions and 135 deletions

View File

@ -14,9 +14,7 @@ export function authFetch (url, opts = {}) {
opts.headers = { ...opts.headers } opts.headers = { ...opts.headers }
} }
return fetch(url, opts).then(res => { return fetch(url, opts).then(res => {
console.log('[authFetch]', opts.method || 'GET', url, '→', res.status, res.type)
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
console.warn('authFetch: session expired, reloading')
window.location.reload() window.location.reload()
return new Response('{}', { status: res.status }) return new Response('{}', { status: res.status })
} }

View File

@ -8,7 +8,10 @@ import { authFetch } from './auth'
async function apiGet (path) { async function apiGet (path) {
const res = await authFetch(BASE_URL + path) const res = await authFetch(BASE_URL + path)
const data = await res.json() 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 return data
} }
@ -43,17 +46,30 @@ async function fetchDoc (doctype, name) {
} }
export async function fetchTechnicians () { export async function fetchTechnicians () {
const names = (await listDocs('Dispatch Technician', '["name"]', null, 100)).map(t => t.name) // Fast: single list call (no child tables = no tags)
if (!names.length) return [] const techs = await listDocs('Dispatch Technician', '["*"]', null, 100)
// All individual fetches in parallel (child tables: tags) return techs // tags loaded lazily via loadTechTags()
return Promise.all(names.map(n => fetchDoc('Dispatch Technician', n)))
} }
// 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) { export async function fetchJobs (filters = null) {
const names = (await listDocs('Dispatch Job', '["name"]', filters, 200)).map(j => j.name) return fetchJobsFast(filters)
if (!names.length) return []
// All individual fetches in parallel (child tables: assistants, tags)
return Promise.all(names.map(n => fetchDoc('Dispatch Job', n)))
} }
export async function updateJob (name, payload) { export async function updateJob (name, payload) {

View File

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

View File

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

View File

@ -84,7 +84,6 @@ function snapshot (field) {
async function save (field) { async function save (field) {
const val = props.customer[field] ?? '' const val = props.customer[field] ?? ''
const prev = snapshots[field] ?? '' const prev = snapshots[field] ?? ''
console.log('[ContactCard] save', field, { val, prev, changed: val !== prev })
if (val === prev) return // nothing changed if (val === prev) return // nothing changed
snapshots[field] = val // update snapshot snapshots[field] = val // update snapshot
saving.value = field saving.value = field

View File

@ -0,0 +1,298 @@
<script setup>
/**
* Google Calendarstyle recurrence selector.
* Generates context-aware quick options based on the reference date,
* plus a "Personnalisé…" mode for full RRULE editing.
*
* Props:
* modelValue RRULE string (or '' / null for no recurrence)
* refDate YYYY-MM-DD reference date (for contextual labels)
* showNone show "Ne se répète pas" option (default true)
*
* Emits:
* update:modelValue new RRULE string (or '' for none)
*/
import { ref, computed, watch } from 'vue'
import { buildRRule, parseRRule } from 'src/composables/useHelpers'
const props = defineProps({
modelValue: { type: String, default: '' },
refDate: { type: String, default: '' },
showNone: { type: Boolean, default: true },
})
const emit = defineEmits(['update:modelValue'])
const DAY_NAMES_FR = { MO: 'lundi', TU: 'mardi', WE: 'mercredi', TH: 'jeudi', FR: 'vendredi', SA: 'samedi', SU: 'dimanche' }
const DAY_KEYS_BY_DOW = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
const DAY_SHORT = [
{ k: 'MO', l: 'L' }, { k: 'TU', l: 'M' }, { k: 'WE', l: 'M' },
{ k: 'TH', l: 'J' }, { k: 'FR', l: 'V' }, { k: 'SA', l: 'S' }, { k: 'SU', l: 'D' },
]
const MONTH_NAMES_FR = ['janvier','février','mars','avril','mai','juin','juillet','août','septembre','octobre','novembre','décembre']
const showCustom = ref(false)
const customForm = ref({ freq: 'WEEKLY', interval: 1, byDay: [], byMonthDay: null })
// Derive context from refDate
const refDateObj = computed(() => props.refDate ? new Date(props.refDate + 'T12:00:00') : new Date())
const refDow = computed(() => DAY_KEYS_BY_DOW[refDateObj.value.getDay()])
const refDowName = computed(() => DAY_NAMES_FR[refDow.value])
const refDayOfMonth = computed(() => refDateObj.value.getDate())
const refMonthName = computed(() => MONTH_NAMES_FR[refDateObj.value.getMonth()])
// Nth weekday of month (e.g., "le 2e mercredi")
const refNthWeekday = computed(() => {
const d = refDateObj.value
const nth = Math.ceil(d.getDate() / 7)
const ordinals = ['', '1er', '2e', '3e', '4e', '5e']
return { nth, ordinal: ordinals[nth] || nth + 'e', dayName: refDowName.value }
})
// Quick options (Google Calendar style)
const quickOptions = computed(() => {
const opts = []
if (props.showNone) opts.push({ label: 'Ne se répète pas', rrule: '' })
opts.push({ label: 'Tous les jours', rrule: 'FREQ=DAILY' })
opts.push({ label: `Toutes les semaines le ${refDowName.value}`, rrule: `FREQ=WEEKLY;BYDAY=${refDow.value}` })
opts.push({ label: `Tous les mois le ${refNthWeekday.value.ordinal} ${refNthWeekday.value.dayName}`, rrule: `FREQ=MONTHLY;BYMONTHDAY=${refDayOfMonth.value}` })
opts.push({ label: `Tous les ans le ${refDayOfMonth.value} ${refMonthName.value}`, rrule: `FREQ=YEARLY;BYMONTHDAY=${refDayOfMonth.value}` })
opts.push({ label: 'Tous les jours de semaine (lunven)', rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' })
// If current RRULE is custom and doesn't match any quick option, show it as selected
return opts
})
// Current selection
const isCustom = computed(() => {
if (!props.modelValue) return false
return !quickOptions.value.some(o => o.rrule === props.modelValue)
})
const displayLabel = computed(() => {
if (!props.modelValue) return props.showNone ? 'Ne se répète pas' : 'Récurrence…'
const match = quickOptions.value.find(o => o.rrule === props.modelValue)
if (match) return match.label
return describeRRule(props.modelValue)
})
// Human-readable RRULE description
function describeRRule (rrule) {
if (!rrule) return 'Ne se répète pas'
const p = parseRRule(rrule)
const intervalStr = p.interval > 1 ? ` ${p.interval} ` : ' '
if (p.freq === 'DAILY') return `Tous les${p.interval > 1 ? ' ' + p.interval : ''} jour${p.interval > 1 ? 's' : ''}`
if (p.freq === 'WEEKLY') {
const days = p.byDay.map(d => DAY_NAMES_FR[d] || d).join(', ')
if (p.interval === 1) return days ? `Chaque semaine le ${days}` : 'Chaque semaine'
return `Toutes les ${p.interval} semaines le ${days}`
}
if (p.freq === 'MONTHLY') {
return `Tous les${intervalStr}mois le ${p.byMonthDay || refDayOfMonth.value}`
}
if (p.freq === 'YEARLY') return `Tous les${intervalStr}an${p.interval > 1 ? 's' : ''}`
return rrule
}
// Dropdown state
const dropdownOpen = ref(false)
function selectOption (opt) {
dropdownOpen.value = false
showCustom.value = false
emit('update:modelValue', opt.rrule)
}
function openCustom () {
dropdownOpen.value = false
const p = parseRRule(props.modelValue || `FREQ=WEEKLY;BYDAY=${refDow.value}`)
customForm.value = { ...p }
if (!customForm.value.byDay?.length) customForm.value.byDay = [refDow.value]
showCustom.value = true
}
function applyCustom () {
const rrule = buildRRule(customForm.value)
showCustom.value = false
emit('update:modelValue', rrule)
}
function toggleDay (day) {
const idx = customForm.value.byDay.indexOf(day)
if (idx >= 0) {
if (customForm.value.byDay.length > 1) customForm.value.byDay.splice(idx, 1)
} else {
customForm.value.byDay.push(day)
}
}
// Close dropdown on outside click
function onClickOutside (e) {
if (!e.target.closest('.rc-sel-wrap')) dropdownOpen.value = false
}
</script>
<template>
<div class="rc-sel-wrap" v-click-outside="() => dropdownOpen = false">
<!-- Selected value display -->
<button class="rc-sel-btn" @click="dropdownOpen = !dropdownOpen" type="button">
<span class="rc-sel-label">{{ displayLabel }}</span>
<span class="rc-sel-arrow">{{ dropdownOpen ? '▲' : '▼' }}</span>
</button>
<!-- Dropdown -->
<transition name="rc-fade">
<div v-if="dropdownOpen" class="rc-dropdown">
<button v-for="opt in quickOptions" :key="opt.rrule"
class="rc-opt" :class="{ 'rc-opt-active': opt.rrule === modelValue }"
@click="selectOption(opt)">
{{ opt.label }}
</button>
<div class="rc-sep"></div>
<button class="rc-opt rc-opt-custom" :class="{ 'rc-opt-active': isCustom }"
@click="openCustom">
Personnalisé
</button>
</div>
</transition>
<!-- Custom editor (inline, below the selector) -->
<transition name="rc-fade">
<div v-if="showCustom" class="rc-custom">
<div class="rc-custom-hdr">
<span>Récurrence personnalisée</span>
<button class="rc-custom-close" @click="showCustom = false"></button>
</div>
<!-- Frequency + Interval -->
<div class="rc-custom-row">
<span class="rc-custom-lbl">Chaque</span>
<input type="number" v-model.number="customForm.interval" min="1" max="99" class="rc-input rc-input-num" />
<select v-model="customForm.freq" class="rc-input rc-input-sel">
<option value="DAILY">jour(s)</option>
<option value="WEEKLY">semaine(s)</option>
<option value="MONTHLY">mois</option>
<option value="YEARLY">an(s)</option>
</select>
</div>
<!-- Day picker (WEEKLY) -->
<div v-if="customForm.freq === 'WEEKLY'" class="rc-custom-row rc-day-row">
<span class="rc-custom-lbl">Le</span>
<button v-for="d in DAY_SHORT" :key="d.k"
class="rc-day-btn" :class="{ active: customForm.byDay.includes(d.k) }"
@click="toggleDay(d.k)">
{{ d.l }}
</button>
</div>
<!-- Day of month (MONTHLY) -->
<div v-if="customForm.freq === 'MONTHLY'" class="rc-custom-row">
<span class="rc-custom-lbl">Le jour</span>
<input type="number" v-model.number="customForm.byMonthDay" min="1" max="31" class="rc-input rc-input-num" />
<span class="rc-custom-hint">du mois</span>
</div>
<!-- Preview -->
<div class="rc-preview">{{ describeRRule(buildRRule(customForm)) }}</div>
<div class="rc-custom-actions">
<button class="rc-btn rc-btn-cancel" @click="showCustom = false">Annuler</button>
<button class="rc-btn rc-btn-apply" @click="applyCustom">Appliquer</button>
</div>
</div>
</transition>
</div>
</template>
<script>
// v-click-outside directive
export default {
directives: {
'click-outside': {
mounted (el, binding) {
el._clickOutside = e => { if (!el.contains(e.target)) binding.value() }
document.addEventListener('click', el._clickOutside)
},
unmounted (el) { document.removeEventListener('click', el._clickOutside) },
},
},
}
</script>
<style lang="scss">
.rc-sel-wrap { position: relative; display: inline-block; width: 100%; }
.rc-sel-btn {
display: flex; align-items: center; justify-content: space-between; width: 100%;
background: var(--sb-bg, #0d1017); border: 1px solid var(--sb-border, rgba(255,255,255,0.08));
border-radius: 6px; color: var(--sb-text, #e2e4ef); font-size: 0.78rem;
padding: 6px 10px; cursor: pointer; transition: border-color 0.12s;
&:hover { border-color: var(--sb-border-acc, rgba(99,102,241,0.4)); }
}
.rc-sel-label { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-sel-arrow { font-size: 0.55rem; margin-left: 6px; opacity: 0.5; }
.rc-dropdown {
position: absolute; z-index: 200; top: calc(100% + 4px); left: 0; right: 0;
background: var(--sb-card, #151820); border: 1px solid var(--sb-border, rgba(255,255,255,0.1));
border-radius: 8px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
max-height: 320px; overflow-y: auto;
}
.rc-opt {
display: block; width: 100%; text-align: left; border: none; background: none;
color: var(--sb-text, #e2e4ef); font-size: 0.78rem; padding: 8px 14px;
cursor: pointer; transition: background 0.1s;
&:hover { background: rgba(255,255,255,0.06); }
&.rc-opt-active { background: rgba(99,102,241,0.15); color: #a5b4fc; }
}
.rc-opt-custom { font-style: italic; color: var(--sb-muted, #9ca3af); }
.rc-sep { height: 1px; background: var(--sb-border, rgba(255,255,255,0.06)); margin: 2px 8px; }
.rc-custom {
margin-top: 8px; padding: 10px 12px;
background: var(--sb-card, #151820); border: 1px solid var(--sb-border, rgba(255,255,255,0.1));
border-radius: 8px;
}
.rc-custom-hdr {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; font-size: 0.78rem; font-weight: 600; color: var(--sb-text, #e2e4ef);
}
.rc-custom-close { background: none; border: none; color: var(--sb-muted); cursor: pointer; font-size: 0.8rem; }
.rc-custom-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.rc-custom-lbl { font-size: 0.72rem; color: var(--sb-muted, #9ca3af); min-width: 38px; }
.rc-custom-hint { font-size: 0.72rem; color: var(--sb-muted); }
.rc-input {
background: var(--sb-bg, #0d1017); border: 1px solid var(--sb-border, rgba(255,255,255,0.08));
border-radius: 4px; color: var(--sb-text, #e2e4ef); font-size: 0.75rem; padding: 4px 6px;
outline: none; transition: border-color 0.12s;
&:focus { border-color: var(--sb-acc, #6366f1); }
}
.rc-input-num { width: 48px; text-align: center; }
.rc-input-sel { width: auto; }
.rc-day-row { gap: 4px; }
.rc-day-btn {
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--sb-border, rgba(255,255,255,0.1));
background: none; color: var(--sb-text, #c8cad6); font-size: 0.68rem; font-weight: 600;
cursor: pointer; transition: all 0.12s; display: flex; align-items: center; justify-content: center;
&:hover { border-color: var(--sb-acc, #6366f1); }
&.active { background: var(--sb-acc, #6366f1); color: #fff; border-color: var(--sb-acc, #6366f1); }
}
.rc-preview {
font-size: 0.72rem; color: var(--sb-acc, #a5b4fc); padding: 4px 0;
border-top: 1px solid var(--sb-border, rgba(255,255,255,0.06));
margin-top: 4px; font-style: italic;
}
.rc-custom-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.rc-btn {
border: none; border-radius: 5px; font-size: 0.72rem; font-weight: 500;
padding: 5px 12px; cursor: pointer; transition: background 0.12s;
}
.rc-btn-cancel { background: rgba(255,255,255,0.06); color: var(--sb-muted); &:hover { background: rgba(255,255,255,0.1); } }
.rc-btn-apply { background: var(--sb-acc, #6366f1); color: #fff; &:hover { filter: brightness(1.1); } }
.rc-fade-enter-active, .rc-fade-leave-active { transition: opacity 0.12s, transform 0.12s; }
.rc-fade-enter-from, .rc-fade-leave-to { opacity: 0; transform: translateY(-4px); }
</style>

View File

@ -26,7 +26,7 @@ export function useAbsenceResize (pxPerHr, H_START) {
} }
const curL = parseFloat(block.style.left) const curL = parseFloat(block.style.left)
const curW = parseFloat(block.style.width) 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 eH = sH + curW / pxPerHr.value
const lbl = block.querySelector('.sb-absence-label') const lbl = block.querySelector('.sb-absence-label')
if (lbl) lbl.textContent = `${hToTime(sH)}${hToTime(eH)}` if (lbl) lbl.textContent = `${hToTime(sH)}${hToTime(eH)}`
@ -37,7 +37,7 @@ export function useAbsenceResize (pxPerHr, H_START) {
document.removeEventListener('mouseup', onUp) document.removeEventListener('mouseup', onUp)
const curL = parseFloat(block.style.left) const curL = parseFloat(block.style.left)
const curW = parseFloat(block.style.width) 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 newEndH = newStartH + curW / pxPerHr.value
const startTime = hToTime(newStartH) const startTime = hToTime(newStartH)
const endTime = hToTime(newEndH) const endTime = hToTime(newEndH)

View File

@ -1,6 +1,6 @@
// ── Bottom panel composable: unassigned jobs table, multi-select, criteria ──── // ── Bottom panel composable: unassigned jobs table, multi-select, criteria ────
import { ref, computed, watch } from 'vue' 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 }) { export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) {
const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false') const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false')
@ -29,8 +29,7 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
currentDate = d currentDate = d
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date' let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
if (d && d !== today) { if (d && d !== today) {
const dt = new Date(d + 'T00:00:00') label = fmtDate(new Date(d + 'T00:00:00'))
label = dt.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
} }
groups.push({ date: d, label, jobs: [] }) groups.push({ date: d, label, jobs: [] })
} }

View File

@ -124,7 +124,7 @@ export function useDragDrop (deps) {
if (dragJob.value.assignedTech === tech.id) { if (dragJob.value.assignedTech === tech.id) {
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
const x = (e.clientX || e.pageX) - rect.left 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) const dayStr = localDateStr(periodStart.value)
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] }) pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
const draggedJob = dragJob.value const draggedJob = dragJob.value
@ -170,8 +170,8 @@ export function useDragDrop (deps) {
if (!moving) return if (!moving) return
ev.preventDefault() ev.preventDefault()
const newLeft = Math.max(0, startLeft + dx) const newLeft = Math.max(0, startLeft + dx)
const newH = snapH(H_START + newLeft / pxPerHr.value) const newH = snapH(H_START.value + newLeft / pxPerHr.value)
block.style.left = ((newH - H_START) * pxPerHr.value) + 'px' block.style.left = ((newH - H_START.value) * pxPerHr.value) + 'px'
const meta = block.querySelector('.sb-block-meta') const meta = block.querySelector('.sb-block-meta')
if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}` if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}`
} }
@ -184,7 +184,7 @@ export function useDragDrop (deps) {
if (!moving) return if (!moving) return
block.style.zIndex = '' block.style.zIndex = ''
const dx = ev.clientX - startX 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) job.startHour = newH; job.startTime = hToTime(newH)
store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH)) store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH))
invalidateRoutes() invalidateRoutes()

View File

@ -1,8 +1,21 @@
// ── Pure utility functions (no Vue dependencies) ───────────────────────────── // ── Pure utility functions (no Vue dependencies) ─────────────────────────────
export function localDateStr (d) { 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')}` 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) { export function startOfWeek (d) {
const r = new Date(d); r.setHours(0,0,0,0) const r = new Date(d); r.setHours(0,0,0,0)
const diff = r.getDay() === 0 ? -6 : 1 - r.getDay() const diff = r.getDay() === 0 ? -6 : 1 - r.getDay()
@ -82,12 +95,19 @@ export function jobColor (job, techColors, store) {
return '#6b7280' return '#6b7280'
} }
export function jobSpansDate (job, ds) { export function jobSpansDate (job, ds, tech) {
const start = job.scheduledDate const start = job.scheduledDate
const end = job.endDate const end = job.endDate
if (!start) return false if (!start) return false
if (!end) return start === ds 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) { export function sortJobsByTime (jobs) {
@ -206,6 +226,77 @@ export function jobTypeIcon (job) {
return ICON.wrench 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 // Priority color
export function prioColor (p) { export function prioColor (p) {
return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0' return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0'

View File

@ -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,
}
}

View File

@ -146,7 +146,8 @@ export function useMap (deps) {
.filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0)) .filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
.filter(j => { .filter(j => {
if (!j.assignedTech) return (j.scheduledDate || null) === dayStr 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 => { .map(job => {
const isUnassigned = !job.assignedTech 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 // Pre-compute: which techs are assistants on which lead tech's jobs today
const groupCounts = {} // leadTechId → total crew size (1 + assistants) const groupCounts = {} // leadTechId → total crew size (1 + assistants)
store.technicians.forEach(tech => { 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() const assistIds = new Set()
todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId))) todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId)))
if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size
@ -182,8 +183,8 @@ export function useMap (deps) {
const color = TECH_COLORS[tech.colorIdx] const color = TECH_COLORS[tech.colorIdx]
// Calculate daily workload + completion // Calculate daily workload + completion
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr)) const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr)) const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr, tech))
const allToday = [...todayJobs, ...todayAssist] const allToday = [...todayJobs, ...todayAssist]
const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0) const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0)
const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed') const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed')
@ -318,7 +319,7 @@ export function useMap (deps) {
if (routeLegs.value[key] !== undefined) return if (routeLegs.value[key] !== undefined) return
const points = [] const points = []
if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`) 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]}`) }) 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) { function setCache (legs, geom) {
routeLegs.value = { ...routeLegs.value, [key]: legs } routeLegs.value = { ...routeLegs.value, [key]: legs }

View File

@ -1,10 +1,19 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { localDateStr, startOfWeek, startOfMonth } from 'src/composables/useHelpers' 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 () { export function usePeriodNavigation () {
const currentView = ref(localStorage.getItem('sbv2-view') || 'week') const currentView = ref(localStorage.getItem('sbv2-view') || 'week')
const savedDate = localStorage.getItem('sbv2-date') 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(currentView, v => localStorage.setItem('sbv2-view', v))
watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d))) watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d)))
@ -15,28 +24,59 @@ export function usePeriodNavigation () {
if (currentView.value === 'week') return startOfWeek(d) if (currentView.value === 'week') return startOfWeek(d)
return startOfMonth(d) return startOfMonth(d)
}) })
// The "core" period length (what the label describes)
const periodDays = computed(() => { const periodDays = computed(() => {
if (currentView.value === 'day') return 1 if (currentView.value === 'day') return 1
if (currentView.value === 'week') return 7 if (currentView.value === 'week') return 7
const s = periodStart.value const s = periodStart.value
return new Date(s.getFullYear(), s.getMonth()+1, 0).getDate() 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 dayColumns = computed(() => {
const cols = [] const cols = []
for (let i = 0; i < periodDays.value; i++) { const base = renderedStart.value
const d = new Date(periodStart.value); d.setDate(d.getDate() + i); cols.push(d) 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 return cols
}) })
function safeFmt (d, opts) {
try { return d.toLocaleDateString('fr-CA', opts) } catch { return localDateStr(d) }
}
const periodLabel = computed(() => { const periodLabel = computed(() => {
const s = periodStart.value const s = periodStart.value
if (!s || isNaN(s.getTime())) return '—'
if (currentView.value === 'day') 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') { if (currentView.value === 'week') {
const e = new Date(s); e.setDate(e.getDate() + 6) 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()) const todayStr = localDateStr(new Date())
@ -59,6 +99,7 @@ export function usePeriodNavigation () {
return { return {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr, currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
bufferDaysBefore, renderedDays,
prevPeriod, nextPeriod, goToToday, goToDay, prevPeriod, nextPeriod, goToToday, goToDay,
} }
} }

View File

@ -1,6 +1,6 @@
import { ref, computed, watch } from 'vue' 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 selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]'))
const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '') const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '')
const filterGroup = ref(localStorage.getItem('sbv2-filterGroup') || '') const filterGroup = ref(localStorage.getItem('sbv2-filterGroup') || '')
@ -12,6 +12,7 @@ export function useResourceFilter (store) {
const resSelectorOpen = ref(false) const resSelectorOpen = ref(false)
const tempSelectedIds = ref([]) const tempSelectedIds = ref([])
const dragReorderTech = ref(null) 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(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true })
watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v)) 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 (filterGroup.value) list = list.filter(t => t.group === filterGroup.value)
if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id)) 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))) 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 // Sort: humans first, then material; within each, apply chosen sort
list = [...list].sort((a, b) => { list = [...list].sort((a, b) => {
const aType = a.resourceType === 'material' ? 1 : 0 const aType = a.resourceType === 'material' ? 1 : 0
const bType = b.resourceType === 'material' ? 1 : 0 const bType = b.resourceType === 'material' ? 1 : 0
if (aType !== bType) return aType - bType 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 === '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) { if (techSort.value === 'manual' && manualOrder.value.length) {
const order = manualOrder.value 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)) 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) const idx = tempSelectedIds.value.indexOf(id)
if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(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) // Count of inactive techs (for UI indicator)
const inactiveCount = computed(() => store.technicians.filter(t => !t.active).length) const inactiveCount = computed(() => store.technicians.filter(t => !t.active).length)
return { return {
selectedResIds, filterStatus, filterGroup, filterResourceType, filterTags, searchQuery, techSort, manualOrder, 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, filteredResources, groupedResources, availableGroups, resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters, openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop, onTechReorderStart, onTechReorderDrop,

View File

@ -1,16 +1,46 @@
// ── Scheduling logic: timeline computation, route cache, job placement ─────── // ── Scheduling logic: timeline computation, route cache, job placement ───────
import { ref, computed } from 'vue' 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' import { ABSENCE_REASONS } from './useTechManagement'
export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) { export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) {
const H_START = 7 // Day view: 6AM6PM. Week view: 7AM8PM.
const H_END = 20 const H_START = computed(() => currentView.value === 'day' ? 6 : 7)
const H_END = computed(() => currentView.value === 'day' ? 18 : 20)
// ── Route cache ──────────────────────────────────────────────────────────── // ── Route cache ────────────────────────────────────────────────────────────
const routeLegs = ref({}) const routeLegs = ref({})
const routeGeometry = 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 ──────────────────────────────────────────── // ── Parent start position cache ────────────────────────────────────────────
let _parentStartCache = {} let _parentStartCache = {}
@ -46,9 +76,9 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
// ── All jobs for a tech on a date (primary + assists) ────────────────────── // ── All jobs for a tech on a date (primary + assists) ──────────────────────
function techAllJobsForDate (tech, dateStr) { function techAllJobsForDate (tech, dateStr) {
_parentStartCache = {} _parentStartCache = {}
const primary = tech.queue.filter(j => jobSpansDate(j, dateStr)) const primary = tech.queue.filter(j => jobSpansDate(j, dateStr, tech))
const assists = (tech.assistJobs || []) const assists = (tech.assistJobs || [])
.filter(j => jobSpansDate(j, dateStr)) .filter(j => jobSpansDate(j, dateStr, tech))
.map(j => { .map(j => {
const a = j.assistants.find(x => x.techId === tech.id) const a = j.assistants.find(x => x.techId === tech.id)
const parentH = getParentStartH(j) const parentH = getParentStartH(j)
@ -63,7 +93,8 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
_parentJob: j, _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 ─────────────── // ── 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 from = tech.absenceFrom
const until = tech.absenceUntil || from const until = tech.absenceUntil || from
if (dateStr >= from && dateStr <= until) { if (dateStr >= from && dateStr <= until) {
const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START.value
const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END.value
const reasonObj = ABSENCE_REASONS.find(r => r.value === tech.absenceReason) || { label: 'Absent', icon: '⏸' } 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 const width = (endH - startH) * pxPerHr.value
segs.push({ segs.push({
type: 'absence', startH, endH, 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) // 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) const daySched = techDaySchedule(tech, dateStr)
if (!daySched) { if (!daySched) {
const left = 0 const left = 0
const width = (H_END - H_START) * pxPerHr.value const width = (H_END.value - H_START.value) * pxPerHr.value
segs.push({ 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: '📅', reason: 'day_off', reasonLabel: 'Jour de repos', reasonIcon: '📅',
_isDayOff: true,
from: null, until: null, techId: tech.id, from: null, until: null, techId: tech.id,
style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' }, style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
job: { id: `schedoff-${tech.id}-${dateStr}` }, 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) placed.sort((a, b) => a.startH - b.startH)
const result = [] 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 prevEndH = null
let legCounter = 0 let legCounter = 0
placed.forEach((p) => { placed.forEach((p) => {
@ -191,7 +252,7 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
const fromRoute = routeMin != null const fromRoute = routeMin != null
result.push({ result.push({
type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false, 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), color: jobColorFn(realJob),
}) })
legCounter++ legCounter++
@ -202,13 +263,14 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
result.push(_absSeg) result.push(_absSeg)
} else { } else {
const realJob = isAssist ? job._parentJob : job 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) const jWidth = Math.max(18, dur * pxPerHr.value)
result.push({ result.push({
type: isAssist ? 'assist' : 'job', job: realJob, type: isAssist ? 'assist' : 'job', job: realJob,
pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist, pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null, assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : 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' }, 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({ result.push({
type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true, type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true,
assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id, 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) { function techBookingsByDay (tech) {
return dayColumns.value.map(d => { return dayColumns.value.map(d => {
const ds = localDateStr(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 || []) 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 })) .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) 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 // Invalidate caches when period/view changes
let _lastCacheKey = '' let _lastCacheKey = ''
function _checkCacheInvalidation () { 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) { if (key !== _lastCacheKey) {
_lastCacheKey = key _lastCacheKey = key
_periodLoadCache.clear() _periodLoadCache.clear()
@ -306,20 +369,20 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
function techsActiveOnDay (dateStr, resources) { function techsActiveOnDay (dateStr, resources) {
return resources.filter(tech => return resources.filter(tech =>
tech.queue.some(j => jobSpansDate(j, dateStr)) || tech.queue.some(j => jobSpansDate(j, dateStr, tech)) ||
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned) (tech.assistJobs || []).some(j => jobSpansDate(j, dateStr, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned)
) )
} }
function dayJobCount (dateStr, resources) { function dayJobCount (dateStr, resources) {
const jobIds = new Set() 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 jobIds.size
} }
return { return {
H_START, H_END, routeLegs, routeGeometry, H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate,
techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH, techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH,
techsActiveOnDay, dayJobCount, techsActiveOnDay, dayJobCount,
} }

View File

@ -0,0 +1,246 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { PRICING_PRESETS } from 'src/composables/useJobOffers'
const props = defineProps({
modelValue: Boolean,
technicians: { type: Array, default: () => [] },
allTags: { type: Array, default: () => [] },
prefill: { type: Object, default: null }, // Pre-fill from existing job
})
const emit = defineEmits(['update:modelValue', 'create'])
const form = ref(resetForm())
const notifySms = ref(true)
function resetForm () {
return {
subject: '',
address: '',
customer: '',
scheduledDate: '',
startTime: '',
duration: 1,
priority: 'medium',
pricingPreset: 'standard',
offerMode: 'broadcast',
targetTechs: [],
requiredTags: [],
expiresAt: '',
isCustomerRequest: false,
salesOrder: '',
}
}
watch(() => props.modelValue, v => {
if (v) {
form.value = resetForm()
if (props.prefill) {
form.value.subject = props.prefill.subject || ''
form.value.address = props.prefill.address || ''
form.value.customer = props.prefill.customer || ''
form.value.scheduledDate = props.prefill.scheduledDate || ''
form.value.startTime = props.prefill.startTime || ''
form.value.duration = props.prefill.duration || 1
form.value.priority = props.prefill.priority || 'medium'
form.value.jobName = props.prefill.id || props.prefill.name || ''
}
}
})
const pricing = computed(() => PRICING_PRESETS[form.value.pricingPreset] || PRICING_PRESETS.standard)
const estimatedTotal = computed(() => pricing.value.displacement + Math.round(form.value.duration * pricing.value.hourlyRate))
const pricingOptions = Object.entries(PRICING_PRESETS).map(([k, v]) => ({
value: k, label: v.label, description: v.description,
}))
const priorityOptions = [
{ value: 'low', label: 'Basse', color: '#6b7280' },
{ value: 'medium', label: 'Moyenne', color: '#3b82f6' },
{ value: 'high', label: 'Haute', color: '#ef4444' },
]
const modeOptions = [
{ value: 'broadcast', label: '📡 Diffusion', hint: 'Tous les techs disponibles' },
{ value: 'targeted', label: '🎯 Ciblé', hint: 'Techs spécifiques' },
{ value: 'pool', label: '📋 File d\'attente', hint: 'Premier arrivé, premier servi' },
]
function submit () {
emit('create', { ...form.value }, notifySms.value)
emit('update:modelValue', false)
}
</script>
<template>
<q-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" persistent>
<q-card class="create-offer-card" dark>
<q-card-section class="offer-modal-header">
<div class="offer-modal-title">
<span>📡</span> Créer une offre de travail
</div>
<q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section>
<q-separator dark />
<q-card-section class="offer-modal-body">
<!-- Subject -->
<q-input v-model="form.subject" label="Sujet / Description" dark filled dense class="offer-field" />
<!-- Address + Customer -->
<div class="offer-row">
<q-input v-model="form.address" label="Adresse" dark filled dense class="offer-field" style="flex:2" />
<q-input v-model="form.customer" label="Client" dark filled dense class="offer-field" style="flex:1" />
</div>
<!-- Date + Time + Duration -->
<div class="offer-row">
<q-input v-model="form.scheduledDate" label="Date" type="date" dark filled dense class="offer-field" />
<q-input v-model="form.startTime" label="Heure" type="time" dark filled dense class="offer-field" />
<q-input v-model.number="form.duration" label="Durée (h)" type="number" step="0.5" min="0.5" max="24" dark filled dense class="offer-field" />
</div>
<!-- Priority -->
<div class="offer-row">
<div class="offer-field-group">
<label class="offer-label">Priorité</label>
<div class="offer-priority-row">
<button v-for="p in priorityOptions" :key="p.value"
class="offer-priority-btn" :class="{ active: form.priority === p.value }"
:style="form.priority === p.value ? 'background:'+p.color+';color:#fff' : ''"
@click="form.priority = p.value">
{{ p.label }}
</button>
</div>
</div>
</div>
<!-- Offer mode -->
<div class="offer-field-group">
<label class="offer-label">Mode de diffusion</label>
<div class="offer-mode-row">
<button v-for="m in modeOptions" :key="m.value"
class="offer-mode-btn" :class="{ active: form.offerMode === m.value }"
@click="form.offerMode = m.value">
<span class="offer-mode-icon">{{ m.label.slice(0,2) }}</span>
<span>{{ m.label.slice(2) }}</span>
<small>{{ m.hint }}</small>
</button>
</div>
</div>
<!-- Targeted techs (if targeted mode) -->
<div v-if="form.offerMode === 'targeted'" class="offer-field-group">
<label class="offer-label">Techniciens ciblés</label>
<q-select v-model="form.targetTechs" :options="technicians.map(t => ({ label: t.fullName, value: t.id }))"
multiple emit-value map-options use-chips dark filled dense
option-value="value" option-label="label"
class="offer-field" />
</div>
<!-- Required tags -->
<q-select v-model="form.requiredTags" :options="allTags.map(t => t.label || t.name || t)"
multiple use-chips dark filled dense label="Tags / Compétences requises" class="offer-field" />
<q-separator dark class="q-my-sm" />
<!-- Pricing -->
<div class="offer-field-group">
<label class="offer-label">💰 Tarification</label>
<div class="offer-pricing-row">
<button v-for="p in pricingOptions" :key="p.value"
class="offer-pricing-btn" :class="{ active: form.pricingPreset === p.value }"
@click="form.pricingPreset = p.value">
<span class="offer-pricing-name">{{ p.label }}</span>
<small>{{ p.description }}</small>
</button>
</div>
</div>
<!-- Cost estimate -->
<div v-if="pricing.displacement > 0 || pricing.hourlyRate > 0" class="offer-cost-estimate">
<div class="offer-cost-line">
<span>Déplacement</span>
<span>{{ pricing.displacement }}$</span>
</div>
<div class="offer-cost-line">
<span>Main-d'œuvre ({{ form.duration }}h × {{ pricing.hourlyRate }}$/h)</span>
<span>{{ Math.round(form.duration * pricing.hourlyRate) }}$</span>
</div>
<div class="offer-cost-line offer-cost-total">
<span>Total estimé</span>
<span>{{ estimatedTotal }}$ {{ pricing.currency }}</span>
</div>
</div>
<!-- Customer request toggle -->
<q-toggle v-model="form.isCustomerRequest" label="Demande client (checkout portail)" dark dense class="q-mt-xs" />
<!-- Sales order link -->
<q-input v-if="form.isCustomerRequest" v-model="form.salesOrder" label="Bon de commande (Sales Order)" dark filled dense class="offer-field" />
<!-- Expiry -->
<q-input v-model="form.expiresAt" label="Expiration (optionnel)" type="datetime-local" dark filled dense class="offer-field" />
<!-- SMS notification toggle -->
<q-toggle v-model="notifySms" label="Notifier par SMS" dark dense class="q-mt-xs" />
</q-card-section>
<q-separator dark />
<q-card-actions align="right" class="offer-modal-footer">
<q-btn flat label="Annuler" dark @click="emit('update:modelValue', false)" />
<q-btn unelevated color="primary" label="📡 Diffuser l'offre" :disable="!form.subject" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style lang="scss">
.create-offer-card {
width: 560px; max-width: 95vw; background: #12151e !important;
}
.offer-modal-header {
display: flex; align-items: center; justify-content: space-between;
.offer-modal-title { font-size: 1rem; font-weight: 600; display: flex; align-items: center; gap: 6px; }
}
.offer-modal-body { display: flex; flex-direction: column; gap: 8px; }
.offer-row { display: flex; gap: 8px; }
.offer-field { flex: 1; }
.offer-field-group { margin: 4px 0; }
.offer-label { font-size: 0.72rem; color: #9ca3af; font-weight: 500; margin-bottom: 4px; display: block; }
.offer-priority-row, .offer-mode-row, .offer-pricing-row {
display: flex; gap: 6px;
}
.offer-priority-btn, .offer-mode-btn, .offer-pricing-btn {
border: 1px solid rgba(255,255,255,0.08); border-radius: 6px;
background: rgba(255,255,255,0.04); color: #c8cad6;
cursor: pointer; transition: all 0.15s; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
&.active { border-color: #3b82f6; background: rgba(59,130,246,0.12); }
}
.offer-priority-btn { padding: 5px 14px; font-size: 0.78rem; font-weight: 500; }
.offer-mode-btn {
padding: 8px 12px; flex: 1; display: flex; flex-direction: column; gap: 2px;
font-size: 0.78rem;
small { font-size: 0.65rem; color: #6b7280; }
}
.offer-pricing-btn {
padding: 8px 12px; flex: 1; display: flex; flex-direction: column; gap: 2px;
font-size: 0.78rem;
.offer-pricing-name { font-weight: 600; }
small { font-size: 0.65rem; color: #6b7280; }
}
.offer-cost-estimate {
background: rgba(250,204,21,0.06); border: 1px solid rgba(250,204,21,0.12);
border-radius: 6px; padding: 8px 12px;
}
.offer-cost-line {
display: flex; justify-content: space-between; font-size: 0.78rem; color: #d4d4d8;
padding: 2px 0;
&.offer-cost-total { font-weight: 700; color: #fbbf24; border-top: 1px solid rgba(250,204,21,0.15); margin-top: 4px; padding-top: 6px; }
}
.offer-modal-footer { padding: 8px 16px; }
</style>

View File

@ -1,16 +1,19 @@
<script setup> <script setup>
import { inject, computed } from 'vue' import { inject, computed, ref } from 'vue'
import { localDateStr, startOfWeek, jobSpansDate, techDaySchedule, techDayCapacityH, fmtDur } from 'src/composables/useHelpers' import { localDateStr, startOfWeek, jobSpansDate, techDaySchedule, techDayCapacityH, fmtDur, expandRRule } from 'src/composables/useHelpers'
import { ABSENCE_REASONS } from 'src/composables/useTechManagement'
const props = defineProps({ const props = defineProps({
anchorDate: Date, anchorDate: Date,
filteredResources: Array, filteredResources: Array,
todayStr: String, todayStr: String,
selectedTechId: { type: String, default: null },
}) })
const emit = defineEmits(['go-to-day', 'select-tech']) const emit = defineEmits(['go-to-day', 'select-tech', 'open-schedule'])
const TECH_COLORS = inject('TECH_COLORS') const TECH_COLORS = inject('TECH_COLORS')
const planningMode = inject('planningMode', ref(false))
function isDayToday (d) { return localDateStr(d) === props.todayStr } function isDayToday (d) { return localDateStr(d) === props.todayStr }
@ -32,14 +35,14 @@ const monthWeeks = computed(() => {
function techsActiveOnDay (dateStr) { function techsActiveOnDay (dateStr) {
return props.filteredResources.filter(tech => return props.filteredResources.filter(tech =>
tech.queue.some(j => jobSpansDate(j, dateStr)) || tech.queue.some(j => jobSpansDate(j, dateStr, tech)) ||
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned) (tech.assistJobs || []).some(j => jobSpansDate(j, dateStr, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned)
) )
} }
function dayJobCount (dateStr) { function dayJobCount (dateStr) {
const jobIds = new Set() const jobIds = new Set()
props.filteredResources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id))) props.filteredResources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr, t)).forEach(j => jobIds.add(j.id)))
return jobIds.size return jobIds.size
} }
@ -55,13 +58,45 @@ function isTechAvailableOnDay (tech, dateStr) {
return true return true
} }
// Selected tech availability for planning mode
const selectedTech = computed(() => props.selectedTechId ? props.filteredResources.find(t => t.id === props.selectedTechId) : null)
function selectedTechDayInfo (dateStr) {
const tech = selectedTech.value
if (!tech) return null
// Absence check
const isExplicitAbsent = tech.status === 'off' && tech.absenceFrom && dateStr >= tech.absenceFrom && dateStr <= (tech.absenceUntil || tech.absenceFrom)
const sched = techDaySchedule(tech, dateStr)
const isScheduleOff = !sched
if (isExplicitAbsent) {
const r = ABSENCE_REASONS.find(x => x.value === tech.absenceReason)
return { type: 'absence', label: r ? r.label : 'Absent', icon: r ? r.icon : '⏸', color: 'var(--sb-sched-absence, #ef444466)' }
}
if (isScheduleOff) {
return { type: 'dayoff', label: 'Repos', icon: '📅', color: 'var(--sb-sched-dayoff, #6b728044)' }
}
// On-call / extra shifts
const extras = (tech.extraShifts || []).filter(s => {
if (!s.rrule || !s.startTime || !s.endTime) return false
const dates = expandRRule(s.rrule, s.from || dateStr, dateStr, dateStr, [])
return dates.includes(dateStr)
})
if (extras.length) {
const ex = extras[0]
return { type: 'oncall', label: `${ex.label || 'Garde'} ${ex.startTime}${ex.endTime}`, icon: '🔔', color: 'var(--sb-sched-oncall, #f59e0b44)' }
}
// Available
return { type: 'available', label: `${sched.start} ${sched.end}`, icon: '✓', color: 'var(--sb-sched-avail, #4ade8033)' }
}
function daySummary (dateStr) { function daySummary (dateStr) {
let present = 0, absent = 0, loadH = 0, capH = 0 let present = 0, absent = 0, loadH = 0, capH = 0
props.filteredResources.forEach(tech => { props.filteredResources.forEach(tech => {
if (isTechAvailableOnDay(tech, dateStr)) { if (isTechAvailableOnDay(tech, dateStr)) {
present++ present++
capH += techDayCapacityH(tech, dateStr) capH += techDayCapacityH(tech, dateStr)
loadH += tech.queue.filter(j => jobSpansDate(j, dateStr)).reduce((s, j) => s + (parseFloat(j.duration) || 0), 0) loadH += tech.queue.filter(j => jobSpansDate(j, dateStr, tech)).reduce((s, j) => s + (parseFloat(j.duration) || 0), 0)
} else { } else {
absent++ absent++
} }
@ -81,6 +116,15 @@ function daySummary (dateStr) {
:class="{ 'sb-month-day-out': day.getMonth() !== anchorDate.getMonth(), 'sb-month-day-today': isDayToday(day) }" :class="{ 'sb-month-day-out': day.getMonth() !== anchorDate.getMonth(), 'sb-month-day-today': isDayToday(day) }"
@click="emit('go-to-day', day)"> @click="emit('go-to-day', day)">
<div class="sb-month-day-num">{{ day.getDate() }}</div> <div class="sb-month-day-num">{{ day.getDate() }}</div>
<!-- Planning mode: selected tech availability -->
<div v-if="planningMode && selectedTech && selectedTechDayInfo(localDateStr(day))"
class="sb-month-avail" :class="'sb-month-avail-' + selectedTechDayInfo(localDateStr(day)).type"
:style="'background:' + selectedTechDayInfo(localDateStr(day)).color"
@click.stop="emit('open-schedule', selectedTech)"
:title="selectedTech.fullName + ': ' + selectedTechDayInfo(localDateStr(day)).label + ' — cliquer pour modifier'">
<span class="sb-month-avail-icon">{{ selectedTechDayInfo(localDateStr(day)).icon }}</span>
<span class="sb-month-avail-label">{{ selectedTechDayInfo(localDateStr(day)).label }}</span>
</div>
<div class="sb-month-stats"> <div class="sb-month-stats">
<span class="sb-month-stat sb-month-stat-present" :title="daySummary(localDateStr(day)).present + ' tech(s) disponible(s)'"> <span class="sb-month-stat sb-month-stat-present" :title="daySummary(localDateStr(day)).present + ' tech(s) disponible(s)'">
👷 {{ daySummary(localDateStr(day)).present }} 👷 {{ daySummary(localDateStr(day)).present }}
@ -92,7 +136,7 @@ function daySummary (dateStr) {
<div class="sb-month-avatars"> <div class="sb-month-avatars">
<div v-for="tech in techsActiveOnDay(localDateStr(day))" :key="tech.id" <div v-for="tech in techsActiveOnDay(localDateStr(day))" :key="tech.id"
class="sb-month-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]" class="sb-month-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]"
:title="tech.fullName + ' — ' + tech.queue.filter(j=>jobSpansDate(j,localDateStr(day))).length + ' job(s)'" :title="tech.fullName + ' — ' + tech.queue.filter(j=>jobSpansDate(j,localDateStr(day),tech)).length + ' job(s)'"
@click.stop="emit('select-tech', tech)"> @click.stop="emit('select-tech', tech)">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }} {{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div> </div>

View File

@ -0,0 +1,204 @@
<script setup>
import { inject, computed } from 'vue'
import { fmtDur, shortAddr } from 'src/composables/useHelpers'
import { PRICING_PRESETS } from 'src/composables/useJobOffers'
const props = defineProps({
offers: Array,
loading: Boolean,
})
const emit = defineEmits(['accept', 'decline', 'cancel', 'offer-job', 'refresh', 'close'])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
function techName (id) {
const t = store.technicians.find(t => t.id === id)
return t ? t.fullName : id
}
function statusColor (status) {
return { open: '#4ade80', pending: '#facc15', accepted: '#60a5fa', expired: '#6b7280', cancelled: '#ef4444' }[status] || '#6b7280'
}
function statusLabel (status) {
return { open: 'Ouverte', pending: 'En attente', accepted: 'Acceptée', expired: 'Expirée', cancelled: 'Annulée' }[status] || status
}
const activeOffers = computed(() => props.offers.filter(o => o.status === 'open' || o.status === 'pending'))
const pastOffers = computed(() => props.offers.filter(o => o.status !== 'open' && o.status !== 'pending'))
</script>
<template>
<div class="offer-pool-panel">
<div class="offer-pool-header">
<h3>
<span class="offer-icon">📡</span> Offres de travail
<span v-if="activeOffers.length" class="offer-badge">{{ activeOffers.length }}</span>
</h3>
<div class="offer-pool-actions">
<button class="offer-btn offer-btn-sm" @click="emit('refresh')" title="Rafraîchir">🔄</button>
<button class="offer-btn offer-btn-sm" @click="emit('close')" title="Fermer"></button>
</div>
</div>
<div v-if="loading" class="offer-loading">Chargement des offres</div>
<!-- Active offers -->
<div v-if="activeOffers.length" class="offer-section">
<div class="offer-section-label">Actives</div>
<div v-for="offer in activeOffers" :key="offer.id" class="offer-card" :class="'offer-card-' + offer.priority">
<div class="offer-card-header">
<span class="offer-status-dot" :style="'background:'+statusColor(offer.status)"></span>
<span class="offer-subject">{{ offer.subject }}</span>
<span v-if="offer.priority === 'high'" class="offer-urgent">URGENT</span>
<span class="offer-mode-tag">{{ offer.offerMode === 'broadcast' ? '📡' : offer.offerMode === 'targeted' ? '🎯' : '📋' }}</span>
</div>
<div class="offer-card-details">
<div v-if="offer.address" class="offer-detail">📍 {{ shortAddr(offer.address) }}</div>
<div v-if="offer.scheduledDate" class="offer-detail">📅 {{ offer.scheduledDate }}{{ offer.startTime ? ' à ' + offer.startTime : '' }}</div>
<div class="offer-detail"> {{ fmtDur(offer.duration) }}</div>
<div v-if="offer.customer" class="offer-detail">👤 {{ offer.customer }}</div>
</div>
<!-- Pricing -->
<div v-if="offer.displacement > 0 || offer.hourlyRate > 0" class="offer-pricing">
<span class="offer-price-tag">💰</span>
<span v-if="offer.displacement > 0">{{ offer.displacement }}$ déplacement</span>
<span v-if="offer.displacement > 0 && offer.hourlyRate > 0"> + </span>
<span v-if="offer.hourlyRate > 0">{{ offer.hourlyRate }}$/h</span>
<span class="offer-total">= {{ offer.displacement + Math.round(offer.duration * offer.hourlyRate) }}$</span>
</div>
<!-- Target techs -->
<div v-if="offer.targetTechs.length" class="offer-targets">
<span class="offer-targets-label">Ciblés:</span>
<span v-for="tid in offer.targetTechs" :key="tid" class="offer-tech-chip"
:class="{ 'offer-tech-declined': offer.declinedTechs.some(d => d.techId === tid) }">
{{ techName(tid) }}
<span v-if="offer.declinedTechs.some(d => d.techId === tid)" class="offer-declined-x"></span>
</span>
</div>
<!-- Declined list -->
<div v-if="offer.declinedTechs.length" class="offer-declined-info">
{{ offer.declinedTechs.length }} décliné{{ offer.declinedTechs.length > 1 ? 's' : '' }}
</div>
<!-- Actions -->
<div class="offer-card-actions">
<button class="offer-btn offer-btn-accept" @click="emit('accept', offer)" title="Assigner manuellement"> Assigner</button>
<button class="offer-btn offer-btn-cancel" @click="emit('cancel', offer)" title="Annuler l'offre"> Annuler</button>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="!loading && !activeOffers.length" class="offer-empty">
<div class="offer-empty-icon">📡</div>
<div>Aucune offre active</div>
<div class="offer-empty-hint">Créez une offre depuis un travail non-assigné ou le bouton ci-dessous</div>
<button class="offer-btn offer-btn-primary" @click="emit('offer-job')">+ Nouvelle offre</button>
</div>
<!-- Past offers (collapsed) -->
<details v-if="pastOffers.length" class="offer-section offer-past">
<summary class="offer-section-label">Historique ({{ pastOffers.length }})</summary>
<div v-for="offer in pastOffers.slice(0, 20)" :key="offer.id" class="offer-card offer-card-past">
<div class="offer-card-header">
<span class="offer-status-dot" :style="'background:'+statusColor(offer.status)"></span>
<span class="offer-subject">{{ offer.subject }}</span>
<span class="offer-status-label">{{ statusLabel(offer.status) }}</span>
<span v-if="offer.acceptedBy" class="offer-accepted-by"> {{ techName(offer.acceptedBy) }}</span>
</div>
</div>
</details>
</div>
</template>
<style lang="scss">
.offer-pool-panel {
display: flex; flex-direction: column; gap: 0;
height: 100%; overflow-y: auto;
background: var(--sb-panel-bg, #0e1117);
color: #e2e4ef;
font-size: 0.82rem;
}
.offer-pool-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px 8px; border-bottom: 1px solid rgba(255,255,255,0.06);
h3 { margin: 0; font-size: 0.95rem; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.offer-icon { font-size: 1.1rem; }
.offer-badge {
background: #4ade80; color: #000; font-size: 0.65rem; font-weight: 700;
padding: 1px 6px; border-radius: 10px; min-width: 18px; text-align: center;
}
}
.offer-pool-actions { display: flex; gap: 4px; }
.offer-btn {
border: none; border-radius: 6px; cursor: pointer; font-size: 0.78rem;
padding: 5px 10px; transition: all 0.15s; font-weight: 500;
background: rgba(255,255,255,0.06); color: #c8cad6;
&:hover { background: rgba(255,255,255,0.12); }
}
.offer-btn-sm { padding: 3px 7px; font-size: 0.72rem; }
.offer-btn-primary { background: #3b82f6; color: #fff; &:hover { background: #2563eb; } }
.offer-btn-accept { background: rgba(74,222,128,0.15); color: #4ade80; &:hover { background: rgba(74,222,128,0.25); } }
.offer-btn-cancel { background: rgba(239,68,68,0.1); color: #f87171; &:hover { background: rgba(239,68,68,0.2); } }
.offer-loading { padding: 24px; text-align: center; color: #6b7280; }
.offer-section { padding: 8px 14px; }
.offer-section-label {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
color: #6b7280; margin-bottom: 6px; font-weight: 600;
}
.offer-card {
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px; padding: 10px 12px; margin-bottom: 8px;
border-left: 3px solid #3b82f6;
&.offer-card-high { border-left-color: #ef4444; }
&.offer-card-past { opacity: 0.55; border-left-color: #4b5563; }
}
.offer-card-header {
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
}
.offer-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.offer-subject { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.offer-urgent {
font-size: 0.6rem; font-weight: 700; color: #ef4444;
background: rgba(239,68,68,0.15); padding: 1px 5px; border-radius: 3px;
}
.offer-mode-tag { font-size: 0.85rem; }
.offer-card-details {
display: flex; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 6px;
.offer-detail { font-size: 0.75rem; color: #9ca3af; white-space: nowrap; }
}
.offer-pricing {
background: rgba(250,204,21,0.08); border: 1px solid rgba(250,204,21,0.15);
border-radius: 5px; padding: 4px 8px; margin-bottom: 6px;
font-size: 0.75rem; color: #fbbf24; display: flex; align-items: center; gap: 4px;
.offer-total { margin-left: auto; font-weight: 700; color: #fcd34d; }
}
.offer-targets {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-bottom: 6px;
.offer-targets-label { font-size: 0.7rem; color: #6b7280; }
}
.offer-tech-chip {
font-size: 0.7rem; padding: 1px 6px; border-radius: 4px;
background: rgba(59,130,246,0.15); color: #93c5fd;
&.offer-tech-declined { background: rgba(239,68,68,0.1); color: #f87171; text-decoration: line-through; }
.offer-declined-x { color: #ef4444; margin-left: 2px; }
}
.offer-declined-info { font-size: 0.7rem; color: #f87171; margin-bottom: 4px; }
.offer-card-actions { display: flex; gap: 6px; justify-content: flex-end; }
.offer-status-label { font-size: 0.68rem; color: #6b7280; }
.offer-accepted-by { font-size: 0.72rem; color: #4ade80; }
.offer-empty {
padding: 32px 20px; text-align: center; color: #6b7280;
.offer-empty-icon { font-size: 2rem; margin-bottom: 8px; opacity: 0.4; }
.offer-empty-hint { font-size: 0.72rem; margin: 6px 0 14px; color: #4b5563; }
}
.offer-past { summary { cursor: pointer; } }
</style>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { inject } from 'vue' import { inject, ref } from 'vue'
import { ICON, fmtDur, shortAddr, jobStatusIcon, dayLoadColor, stOf } from 'src/composables/useHelpers' import { ICON, fmtDur, shortAddr, jobStatusIcon, dayLoadColor, stOf } from 'src/composables/useHelpers'
const props = defineProps({ const props = defineProps({
@ -24,6 +24,8 @@ const emit = defineEmits([
'block-move', 'block-resize', 'block-move', 'block-resize',
'open-absence', 'end-absence', 'open-absence', 'end-absence',
'absence-resize', 'absence-resize',
'ghost-click', 'ghost-materialize',
'open-schedule',
]) ])
const TECH_COLORS = inject('TECH_COLORS') const TECH_COLORS = inject('TECH_COLORS')
@ -35,6 +37,7 @@ const techPeriodCapacityH = inject('techPeriodCapacityH')
const techDayEndH = inject('techDayEndH') const techDayEndH = inject('techDayEndH')
const getTagColor = inject('getTagColor') const getTagColor = inject('getTagColor')
const isJobMultiSelected = inject('isJobMultiSelected') const isJobMultiSelected = inject('isJobMultiSelected')
const planningMode = inject('planningMode', ref(false))
</script> </script>
<template> <template>
@ -88,7 +91,15 @@ const isJobMultiSelected = inject('isJobMultiSelected')
<div class="sb-capacity-line" :style="'left:'+((techDayEndH(tech) - hStart) * pxPerHr)+'px'" :title="fmtDur(techPeriodCapacityH(tech))"></div> <div class="sb-capacity-line" :style="'left:'+((techDayEndH(tech) - hStart) * pxPerHr)+'px'" :title="fmtDur(techPeriodCapacityH(tech))"></div>
<div v-if="dropGhostX!=null" class="sb-drop-line" :style="'left:'+dropGhostX+'px'"></div> <div v-if="dropGhostX!=null" class="sb-drop-line" :style="'left:'+dropGhostX+'px'"></div>
<template v-for="seg in segments" :key="seg.type+'-'+seg.job.id+(seg.isAssist?'-a':'')+(seg.type==='travel'?'-t':'')"> <!-- Shift availability background blocks (only in planning mode) -->
<template v-if="planningMode" v-for="seg in segments.filter(s => s.type === 'shift')" :key="'shift-'+seg.startH+(seg.isOnCall?'-oncall':'')">
<div class="sb-block-shift" :class="{ 'sb-block-shift-oncall': seg.isOnCall }" :style="seg.style" :title="seg.label + ' — cliquer pour modifier'"
style="pointer-events:auto;cursor:pointer" @click.stop="emit('open-schedule', tech)">
<span class="sb-shift-label">{{ seg.label }}</span>
</div>
</template>
<template v-for="seg in segments.filter(s => s.type !== 'shift' && !(s._isDayOff && !planningMode))" :key="seg.type+'-'+(seg.job?.id||'x')+(seg.isAssist?'-a':'')+(seg.type==='travel'?'-t':'')">
<!-- Absence block --> <!-- Absence block -->
<div v-if="seg.type==='absence'" class="sb-block sb-block-absence" :style="seg.style" <div v-if="seg.type==='absence'" class="sb-block sb-block-absence" :style="seg.style"
:title="`${seg.reasonIcon} ${seg.reasonLabel}${seg.until && seg.until !== seg.from ? ' ('+seg.from+' → '+seg.until+')' : ''}`"> :title="`${seg.reasonIcon} ${seg.reasonLabel}${seg.until && seg.until !== seg.from ? ' ('+seg.from+' → '+seg.until+')' : ''}`">
@ -124,6 +135,18 @@ const isJobMultiSelected = inject('isJobMultiSelected')
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'assist',seg.assistTechId)"></div> <div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'assist',seg.assistTechId)"></div>
</div> </div>
<!-- Job block --> <!-- Job block -->
<!-- Ghost (recurring) block -->
<div v-else-if="seg._isGhost" class="sb-block sb-block-ghost"
:style="{ ...seg.style, background:jobColor(seg.job)+'44', borderColor:jobColor(seg.job)+'88' }"
@click.stop="emit('ghost-click',seg._templateJob,seg.job.scheduledDate,tech.id)"
@dblclick.stop="emit('ghost-materialize',seg._templateJob,seg.job.scheduledDate,tech.id)">
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)+'88'"></div>
<div class="sb-block-inner">
<div class="sb-block-title"><span class="sb-ghost-icon">🔄</span> {{ seg.job.subject }}</div>
<div class="sb-block-meta">{{ fmtDur(seg.job.duration) }} · récurrent</div>
</div>
</div>
<!-- Regular job block -->
<div v-else class="sb-block" <div v-else class="sb-block"
:class="{ 'sb-block-done':seg.job.status==='completed', 'sb-block-draft':!seg.job.published, 'sb-block-sel':selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist, 'sb-block-multi':isJobMultiSelected(seg.job.id), 'sb-block-linked':selectedJob?.job?.id===seg.job.id&&selectedJob?.isAssist, 'sb-block-team':seg.job.assistants?.length }" :class="{ 'sb-block-done':seg.job.status==='completed', 'sb-block-draft':!seg.job.published, 'sb-block-sel':selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist, 'sb-block-multi':isJobMultiSelected(seg.job.id), 'sb-block-linked':selectedJob?.job?.id===seg.job.id&&selectedJob?.isAssist, 'sb-block-team':seg.job.assistants?.length }"
:style="{ ...seg.style, background:jobColor(seg.job)+'dd' }" :style="{ ...seg.style, background:jobColor(seg.job)+'dd' }"

View File

@ -1,8 +1,8 @@
<script setup> <script setup>
import { inject } from 'vue' import { inject, ref } from 'vue'
import { import {
localDateStr, fmtDur, shortAddr, dayLoadColor, stOf, localDateStr, fmtDate, fmtDur, shortAddr, dayLoadColor, stOf,
ICON, jobSpansDate, techDayCapacityH, techDaySchedule, timeToH, ICON, jobSpansDate, techDayCapacityH, techDaySchedule, timeToH, expandRRule,
} from 'src/composables/useHelpers' } from 'src/composables/useHelpers'
import { ABSENCE_REASONS } from 'src/composables/useTechManagement' import { ABSENCE_REASONS } from 'src/composables/useTechManagement'
@ -12,6 +12,7 @@ const props = defineProps({
selectedTechId: String, selectedTechId: String,
dropGhost: Object, dropGhost: Object,
todayStr: String, todayStr: String,
colW: { type: Number, default: 0 }, // fixed column width (px), 0 = flex
}) })
const emit = defineEmits([ const emit = defineEmits([
@ -20,6 +21,7 @@ const emit = defineEmits([
'cal-drop', 'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx', 'cal-drop', 'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
'clear-filters', 'clear-filters',
'open-absence', 'end-absence', 'open-schedule', 'open-absence', 'end-absence', 'open-schedule',
'ghost-click', 'ghost-materialize',
]) ])
const store = inject('store') const store = inject('store')
@ -27,7 +29,9 @@ const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor') const jobColor = inject('jobColor')
const selectedJob = inject('selectedJob') const selectedJob = inject('selectedJob')
const isJobMultiSelected = inject('isJobMultiSelected') const isJobMultiSelected = inject('isJobMultiSelected')
const ghostOccurrencesForDate = inject('ghostOccurrencesForDate', () => () => [])
const getTagColor = inject('getTagColor') const getTagColor = inject('getTagColor')
const planningMode = inject('planningMode', ref(false))
function isDayToday (d) { return localDateStr(d) === props.todayStr } function isDayToday (d) { return localDateStr(d) === props.todayStr }
@ -76,19 +80,45 @@ function absenceInfo (tech, d) {
return { icon, label, isFullDay, hours: absHours, timeRange, remainH } return { icon, label, isFullDay, hours: absHours, timeRange, remainH }
} }
// Planning mode: schedule availability info per tech/day
function scheduleInfoForDay (tech, d) {
const ds = localDateStr(d)
const sched = techDaySchedule(tech, ds)
const info = { available: false, label: '', start: '', end: '', isOnCall: false, onCallLabel: '' }
if (sched) {
info.available = true
info.start = sched.start
info.end = sched.end
info.label = `${sched.start} ${sched.end}`
}
// Check for on-call / extra shifts
const extras = (tech.extraShifts || []).filter(s => {
if (!s.rrule || !s.startTime || !s.endTime) return false
const dates = expandRRule(s.rrule, s.from || ds, ds, ds, [])
return dates.includes(ds)
})
if (extras.length) {
const ex = extras[0]
info.isOnCall = true
info.onCallLabel = `${ex.label || 'Garde'}: ${ex.startTime} ${ex.endTime}`
}
return info
}
defineExpose({ isDayToday }) defineExpose({ isDayToday })
</script> </script>
<template> <template>
<div class="sb-grid sb-grid-cal"> <div class="sb-grid sb-grid-cal" :style="colW ? 'min-width:'+(200 + colW * dayColumns.length)+'px' : ''">
<!-- Header --> <!-- Header -->
<div class="sb-grid-hdr"> <div class="sb-grid-hdr">
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div> <div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
<div class="sb-cal-hdr"> <div class="sb-cal-hdr" :style="colW ? 'flex:none;width:'+(colW * dayColumns.length)+'px' : ''">
<div v-for="d in dayColumns" :key="'ch-'+localDateStr(d)" <div v-for="d in dayColumns" :key="'ch-'+localDateStr(d)"
class="sb-cal-hdr-cell" :class="{ 'sb-col-today': isDayToday(d) }" class="sb-cal-hdr-cell" :class="{ 'sb-col-today': isDayToday(d) }"
:style="colW ? 'flex:none;width:'+colW+'px' : ''"
style="cursor:pointer" @click="emit('go-to-day', d)"> style="cursor:pointer" @click="emit('go-to-day', d)">
<span class="sb-cal-wd">{{ d.toLocaleDateString('fr-CA',{weekday:'short'}) }}</span> <span class="sb-cal-wd">{{ fmtDate(d, {weekday:'short'}) }}</span>
<span class="sb-cal-dn" :class="{ 'sb-today-bubble': isDayToday(d) }">{{ d.getDate() }}</span> <span class="sb-cal-dn" :class="{ 'sb-today-bubble': isDayToday(d) }">{{ d.getDate() }}</span>
</div> </div>
</div> </div>
@ -125,16 +155,30 @@ defineExpose({ isDayToday })
</div> </div>
</div> </div>
</div> </div>
<div class="sb-cal-row"> <div class="sb-cal-row" :style="colW ? 'flex:none;width:'+(colW * dayColumns.length)+'px' : ''">
<div v-for="d in dayColumns" :key="localDateStr(d)" <div v-for="d in dayColumns" :key="localDateStr(d)"
class="sb-cal-cell" :class="{ 'sb-bg-today': isDayToday(d), 'sb-bg-alt': dayColumns.indexOf(d)%2===1, 'sb-cal-absent': isAbsentOnDay(tech, d) && absenceInfo(tech, d).isFullDay }" class="sb-cal-cell" :class="{ 'sb-bg-today': isDayToday(d), 'sb-bg-alt': dayColumns.indexOf(d)%2===1, 'sb-cal-absent': isExplicitAbsent(tech, d) || (planningMode && isScheduleOff(tech, d) && !isExplicitAbsent(tech, d)) }"
:style="colW ? 'flex:none;width:'+colW+'px' : ''"
:data-date-str="localDateStr(d)" :data-date-str="localDateStr(d)"
@dblclick="emit('go-to-day', d)" @dblclick="emit('go-to-day', d)"
@dragover.prevent="()=>{}" @dragleave="()=>{}" @dragover.prevent="()=>{}" @dragleave="()=>{}"
@drop.prevent="emit('cal-drop', $event, tech, localDateStr(d))"> @drop.prevent="emit('cal-drop', $event, tech, localDateStr(d))">
<div v-if="dropGhost?.techId===tech.id && dropGhost.dateStr===localDateStr(d)" class="sb-cal-drop"></div> <div v-if="dropGhost?.techId===tech.id && dropGhost.dateStr===localDateStr(d)" class="sb-cal-drop"></div>
<div v-if="isAbsentOnDay(tech, d)" class="sb-chip sb-chip-absence" <!-- Planning mode: availability band -->
:class="{ 'sb-chip-dayoff': isScheduleOff(tech, d) && !isExplicitAbsent(tech, d), 'sb-chip-absence-full': absenceInfo(tech, d).isFullDay }" <div v-if="planningMode && !isAbsentOnDay(tech, d) && scheduleInfoForDay(tech, d).available"
class="sb-sched-band sb-sched-available"
@click.stop="emit('open-schedule', tech)"
:title="'Disponible: ' + scheduleInfoForDay(tech, d).label + ' — cliquer pour modifier'">
<span class="sb-sched-time">{{ scheduleInfoForDay(tech, d).label }}</span>
</div>
<div v-if="planningMode && scheduleInfoForDay(tech, d).isOnCall"
class="sb-sched-band sb-sched-oncall"
@click.stop="emit('open-schedule', tech)"
:title="scheduleInfoForDay(tech, d).onCallLabel + ' — cliquer pour modifier'">
<span class="sb-sched-time">🔔 {{ scheduleInfoForDay(tech, d).onCallLabel }}</span>
</div>
<!-- Explicit absences always shown; schedule off-days only in planning mode -->
<div v-if="isExplicitAbsent(tech, d)" class="sb-chip sb-chip-absence sb-chip-absence-full"
:title="absenceInfo(tech, d).label + (absenceInfo(tech, d).timeRange ? ' ' + absenceInfo(tech, d).timeRange : '')"> :title="absenceInfo(tech, d).label + (absenceInfo(tech, d).timeRange ? ' ' + absenceInfo(tech, d).timeRange : '')">
<div class="sb-chip-line1">{{ absenceInfo(tech, d).icon }} {{ absenceInfo(tech, d).label }}</div> <div class="sb-chip-line1">{{ absenceInfo(tech, d).icon }} {{ absenceInfo(tech, d).label }}</div>
<div v-if="absenceInfo(tech, d).isFullDay" class="sb-chip-line2 sb-absence-detail">Journée complète</div> <div v-if="absenceInfo(tech, d).isFullDay" class="sb-chip-line2 sb-absence-detail">Journée complète</div>
@ -142,8 +186,15 @@ defineExpose({ isDayToday })
{{ absenceInfo(tech, d).timeRange }} · reste {{ fmtDur(absenceInfo(tech, d).remainH) }} {{ absenceInfo(tech, d).timeRange }} · reste {{ fmtDur(absenceInfo(tech, d).remainH) }}
</div> </div>
</div> </div>
<template v-for="job in [...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))), ...(tech.assistJobs||[]).filter(j=>jobSpansDate(j,localDateStr(d))&&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}))]" :key="job.id+(job._isAssistChip?'-a':'')"> <template v-for="job in [...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)), ...(tech.assistJobs||[]).filter(j=>jobSpansDate(j,localDateStr(d),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})), ...ghostOccurrencesForDate(tech, localDateStr(d))]" :key="job.id+(job._isAssistChip?'-a':'')">
<div class="sb-chip" <div v-if="job._isGhost" class="sb-chip sb-chip-ghost"
:style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'33;color:#ffffffaa;border-style:dashed'"
@click.stop="emit('ghost-click', job._templateJob||job, localDateStr(d), tech.id)"
@dblclick.stop="emit('ghost-materialize', job._templateJob||job, localDateStr(d), tech.id)">
<div class="sb-chip-line1">🔄 {{ job.subject }}</div>
<div class="sb-chip-line2">{{ fmtDur(job.duration) }} · récurrent</div>
</div>
<div v-else class="sb-chip"
:class="{ 'sb-chip-sel': selectedJob?.job?.id===job.id, 'sb-chip-multi': isJobMultiSelected(job.id), 'sb-chip-assist': job._isAssistChip }" :class="{ 'sb-chip-sel': selectedJob?.job?.id===job.id, 'sb-chip-multi': isJobMultiSelected(job.id), 'sb-chip-assist': job._isAssistChip }"
:data-job-id="job.id" :data-job-id="job.id"
:style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'cc;color:#fff'" :style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'cc;color:#fff'"
@ -161,11 +212,11 @@ defineExpose({ isDayToday })
</div> </div>
</template> </template>
<!-- Day load bar --> <!-- Day load bar -->
<div v-if="[...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d)))].length" class="sb-day-load"> <div v-if="[...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech))].length" class="sb-day-load">
<div class="sb-day-load-track"> <div class="sb-day-load-track">
<div class="sb-day-load-fill" :style="{ width: Math.min(100, tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/(techDayCapacityH(tech,localDateStr(d))||8)*100)+'%', background: dayLoadColor(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/(techDayCapacityH(tech,localDateStr(d))||8)) }"></div> <div class="sb-day-load-fill" :style="{ width: Math.min(100, tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/(techDayCapacityH(tech,localDateStr(d))||8)*100)+'%', background: dayLoadColor(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/(techDayCapacityH(tech,localDateStr(d))||8)) }"></div>
</div> </div>
<span class="sb-day-load-label">{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/{{ fmtDur(techDayCapacityH(tech,localDateStr(d))||8) }}</span> <span class="sb-day-load-label">{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/{{ fmtDur(techDayCapacityH(tech,localDateStr(d))||8) }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
import { Notify } from 'quasar'
import { useDispatchStore } from 'src/stores/dispatch' import { useDispatchStore } from 'src/stores/dispatch'
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'src/stores/auth'
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext' import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
@ -20,10 +21,11 @@ import SbModal from 'src/modules/dispatch/components/SbModal.vue'
import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue' import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue'
import { import {
localDateStr, timeToH, hToTime, fmtDur, localDateStr, fmtDate, timeToH, hToTime, fmtDur,
SVC_COLORS, prioLabel, prioClass, serializeAssistants, SVC_COLORS, prioLabel, prioClass, serializeAssistants,
jobColor as _jobColorBase, ICON, prioColor, jobColor as _jobColorBase, ICON, prioColor,
WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS, WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS,
buildRRule,
} from 'src/composables/useHelpers' } from 'src/composables/useHelpers'
import { useScheduler } from 'src/composables/useScheduler' import { useScheduler } from 'src/composables/useScheduler'
import { useUndo } from 'src/composables/useUndo' import { useUndo } from 'src/composables/useUndo'
@ -39,24 +41,44 @@ import { useContextMenus } from 'src/composables/useContextMenus'
import { useTechManagement } from 'src/composables/useTechManagement' import { useTechManagement } from 'src/composables/useTechManagement'
import { useAddressSearch } from 'src/composables/useAddressSearch' import { useAddressSearch } from 'src/composables/useAddressSearch'
import { useAbsenceResize } from 'src/composables/useAbsenceResize' import { useAbsenceResize } from 'src/composables/useAbsenceResize'
import { useJobOffers } from 'src/composables/useJobOffers'
import { fetchPresets, createPreset as apiCreatePreset, deletePreset as apiDeletePreset } from 'src/api/presets'
import OfferPoolPanel from 'src/modules/dispatch/components/OfferPoolPanel.vue'
import CreateOfferModal from 'src/modules/dispatch/components/CreateOfferModal.vue'
import RecurrenceSelector from 'src/components/shared/RecurrenceSelector.vue'
const store = useDispatchStore() const store = useDispatchStore()
const auth = useAuthStore() const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin const erpUrl = BASE_URL || window.location.origin
// Offer pool (Uber-style job offers)
const {
offers, loadingOffers, showOfferPool, activeOfferCount,
loadOffers, broadcastOffer, handleAccept, handleDecline, handleCancel,
matchingTechs, estimateCost, offerExistingJob,
} = useJobOffers(store)
const createOfferModal = ref(false)
const createOfferPrefill = ref(null)
const { const {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr, currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
bufferDaysBefore, renderedDays,
prevPeriod, nextPeriod, goToToday, goToDay, prevPeriod, nextPeriod, goToToday, goToDay,
} = usePeriodNavigation() } = usePeriodNavigation()
// Mutable opts populated after useScheduler is initialized (callbacks capture by ref)
const _resFilterOpts = {
isAbsentOnDay: null,
getLoadH: null,
}
const { const {
selectedResIds, filterStatus, filterTags, filterResourceType, searchQuery, techSort, manualOrder, selectedResIds, filterStatus, filterTags, filterResourceType, searchQuery, techSort, manualOrder,
filteredResources, groupedResources, availableGroups, filterGroup, filteredResources, groupedResources, availableGroups, filterGroup,
showInactive, inactiveCount, humanCount, materialCount, availableCategories, showInactive, hideAbsent, inactiveCount, humanCount, materialCount, availableCategories,
resSelectorOpen, tempSelectedIds, dragReorderTech, resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters, openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop, onTechReorderStart, onTechReorderDrop,
} = useResourceFilter(store) } = useResourceFilter(store, _resFilterOpts)
const techTagModal = ref(null) const techTagModal = ref(null)
const { const {
@ -71,8 +93,15 @@ const setEndDate = (job, endDate) => {
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {}) updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
} }
const toggleContinuous = (job, val) => {
job.continuous = !!val
store.jobVersion++
updateJob(job.name || job.id, { continuous: val ? 1 : 0 }).catch(() => {})
}
const filterPanelOpen = ref(false) const filterPanelOpen = ref(false)
const projectsPanelOpen = ref(false) const projectsPanelOpen = ref(false)
const planningMode = ref(false) // Toggle: show shift availability blocks on timeline
const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true') const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true')
const rightPanel = ref(null) const rightPanel = ref(null)
@ -129,24 +158,41 @@ const jobColor = job => _jobColorBase(job, TECH_COLORS, store)
const PX_PER_HR = ref(80) const PX_PER_HR = ref(80)
const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value) const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value) const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END.value - H_START.value) * pxPerHr.value)
const totalW = computed(() => dayW.value * periodDays.value) const totalW = computed(() => dayW.value * dayColumns.value.length)
const viewportW = computed(() => dayW.value * periodDays.value)
// Week calendar: fixed col width = available board width / 7 visible days
const calColW = ref(0)
function measureCalColW () {
const el = boardScroll.value
if (!el) return
const available = el.clientWidth - 200 // minus resource column
calColW.value = Math.floor(available / periodDays.value)
}
const { const {
H_START, H_END, routeLegs, routeGeometry, H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel, techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate,
periodLoadH, techPeriodCapacityH, techDayEndH, periodLoadH, techPeriodCapacityH, techDayEndH,
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor) } = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
// Wire up resource filter opts now that scheduler is available
_resFilterOpts.isAbsentOnDay = (tech) => {
const dayStr = localDateStr(periodStart.value)
return absenceSegmentsForDate(tech, dayStr).length > 0
}
_resFilterOpts.getLoadH = (tech) => periodLoadH(tech)
const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START) const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START)
const hourTicks = computed(() => { const hourTicks = computed(() => {
if (currentView.value === 'month') return [] if (currentView.value === 'month') return []
const ticks = [] const ticks = []
dayColumns.value.forEach((day, di) => { dayColumns.value.forEach((day, di) => {
for (let h = H_START; h <= H_END; h++) { for (let h = H_START.value; h <= H_END.value; h++) {
const x = di * dayW.value + (h - H_START) * pxPerHr.value const x = di * dayW.value + (h - H_START.value) * pxPerHr.value
ticks.push({ x, label: h===H_START||h%2===0 ? h+':00' : null, isMajor: true, isDay: h===H_START, day, h }) ticks.push({ x, label: h===H_START.value||h%2===0 ? h+':00' : null, isMajor: true, isDay: h===H_START.value, day, h })
} }
}) })
return ticks return ticks
@ -172,6 +218,28 @@ const capMap = computed(() => {
return map return map
}) })
// Resource utilization alerts
const overloadedTechs = computed(() => {
return filteredResources.value.filter(tech => {
const load = loadMap.value[tech.id] || 0
const cap = capMap.value[tech.id] || 8
return load > cap
}).map(tech => ({
tech,
load: loadMap.value[tech.id] || 0,
cap: capMap.value[tech.id] || 8,
pct: Math.round(((loadMap.value[tech.id] || 0) / (capMap.value[tech.id] || 8)) * 100),
}))
})
const underutilizedTechs = computed(() => {
return filteredResources.value.filter(tech => {
if (tech.status === 'off' || tech.status === 'inactive') return false
const load = loadMap.value[tech.id] || 0
const cap = capMap.value[tech.id] || 8
return cap > 0 && load < cap * 0.3
})
})
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes) const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr) const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
@ -274,10 +342,10 @@ const publishModalOpen = ref(false)
const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length) const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length)
const periodEndStr = computed(() => { const periodEndStr = computed(() => {
const ps = periodStart.value const ps = periodStart.value
if (!ps) return '' if (!ps || isNaN(ps.getTime())) return ''
const d = new Date(ps + 'T12:00:00') const d = new Date(ps)
d.setDate(d.getDate() + (periodDays.value || 7) - 1) d.setDate(d.getDate() + (periodDays.value || 7) - 1)
return d.toISOString().slice(0, 10) return localDateStr(d)
}) })
const onPublished = jobNames => store.publishJobsLocal(jobNames) const onPublished = jobNames => store.publishJobsLocal(jobNames)
const gpsSettingsOpen = ref(false) const gpsSettingsOpen = ref(false)
@ -299,6 +367,8 @@ const newTechGroup = ref('')
const scheduleModalTech = ref(null) const scheduleModalTech = ref(null)
const scheduleForm = ref({}) const scheduleForm = ref({})
const extraShiftsForm = ref([]) // On-call / garde shifts
function openScheduleModal (tech) { function openScheduleModal (tech) {
scheduleModalTech.value = tech scheduleModalTech.value = tech
scheduleForm.value = {} scheduleForm.value = {}
@ -306,6 +376,8 @@ function openScheduleModal (tech) {
const day = tech.weeklySchedule?.[d] const day = tech.weeklySchedule?.[d]
scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' } scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' }
}) })
// Load existing extra shifts with UI-friendly _pattern/_interval
extraShiftsForm.value = (tech.extraShifts || []).map(s => _enrichShift({ ...s }))
} }
const applySchedulePreset = preset => { const applySchedulePreset = preset => {
WEEK_DAYS.forEach(d => { WEEK_DAYS.forEach(d => {
@ -313,6 +385,40 @@ const applySchedulePreset = preset => {
scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' } scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
}) })
} }
function _parseShiftPattern (rrule) {
if (!rrule) return { pattern: 'weekend', interval: 1 }
if (rrule.includes('BYDAY=MO,TU,WE,TH,FR')) return { pattern: 'weeknight', interval: 1 }
if (rrule.includes('FREQ=DAILY')) return { pattern: 'daily', interval: 1 }
const m = rrule.match(/INTERVAL=(\d+)/)
return { pattern: 'weekend', interval: m ? parseInt(m[1]) : 1 }
}
function _enrichShift (s) {
const p = _parseShiftPattern(s.rrule)
s._pattern = p.pattern
s._interval = p.interval
return s
}
function updateShiftRrule (shift) {
if (shift._pattern === 'weekend') {
shift.rrule = `FREQ=WEEKLY;INTERVAL=${shift._interval || 1};BYDAY=SA,SU`
} else if (shift._pattern === 'weeknight') {
shift.rrule = 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR'
} else if (shift._pattern === 'daily') {
shift.rrule = 'FREQ=DAILY;INTERVAL=1'
}
}
function addExtraShift () {
extraShiftsForm.value.push(_enrichShift({
label: 'Garde',
startTime: '08:00',
endTime: '16:00',
rrule: 'FREQ=WEEKLY;INTERVAL=4;BYDAY=SA,SU',
from: todayStr.value || localDateStr(new Date()),
}))
}
function removeExtraShift (idx) {
extraShiftsForm.value.splice(idx, 1)
}
function confirmSchedule () { function confirmSchedule () {
const sched = {} const sched = {}
WEEK_DAYS.forEach(d => { WEEK_DAYS.forEach(d => {
@ -320,33 +426,145 @@ function confirmSchedule () {
sched[d] = f.on ? { start: f.start, end: f.end } : null sched[d] = f.on ? { start: f.start, end: f.end } : null
}) })
saveWeeklySchedule(scheduleModalTech.value, sched) saveWeeklySchedule(scheduleModalTech.value, sched)
// Save extra shifts (strip transient UI fields)
const tech = scheduleModalTech.value
tech.extraShifts = extraShiftsForm.value
.filter(s => s.startTime && s.endTime && s.rrule)
.map(({ _pattern, _interval, ...rest }) => rest)
updateTech(tech.name || tech.id, { extra_shifts: JSON.stringify(tech.extraShifts) }).catch(() => {})
store.jobVersion++
scheduleModalTech.value = null scheduleModalTech.value = null
} }
const resSelectorGroupFilter = ref('') const resSelectorGroupFilter = ref('')
const resSelectorSearch = ref('') const resSelectorSearch = ref('')
const savedPresets = ref(JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')) const savedPresets = ref([])
const presetsLoaded = ref(false)
const presetNameInput = ref('') const presetNameInput = ref('')
const showPresetSave = ref(false) const showPresetSave = ref(false)
function savePreset () { // Migrate localStorage presets to ERPNext (one-time)
async function _migrateLocalPresets () {
const local = JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')
if (!local.length) return
for (const p of local) {
if (savedPresets.value.some(sp => sp.name === p.name)) continue
try {
await apiCreatePreset({
preset_name: p.name,
preset_type: p.type || 'selection',
group_name: p.group || '',
tech_ids: JSON.stringify(p.ids || []),
is_shared: 1,
})
} catch {}
}
localStorage.removeItem('sbv2-resPresets')
}
async function loadPresets () {
try {
const raw = await fetchPresets()
savedPresets.value = raw.map(p => ({
name: p.preset_name || p.name,
docName: p.name,
type: p.preset_type || 'selection',
group: p.group_name || '',
ids: p.tech_ids ? JSON.parse(p.tech_ids) : [],
shared: !!p.is_shared,
createdBy: p.created_by_user || p.owner || '',
}))
presetsLoaded.value = true
// One-time migration
if (localStorage.getItem('sbv2-resPresets')) _migrateLocalPresets().then(loadPresets)
} catch (e) {
console.warn('[presets] load failed:', e.message)
// Fallback to localStorage
savedPresets.value = JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')
}
}
async function savePreset () {
const name = presetNameInput.value.trim() const name = presetNameInput.value.trim()
if (!name || !tempSelectedIds.value.length) return if (!name || !tempSelectedIds.value.length) return
const existing = savedPresets.value.findIndex(p => p.name === name) const existing = savedPresets.value.find(p => p.name === name)
const preset = { name, ids: [...tempSelectedIds.value], created: new Date().toISOString() } try {
if (existing >= 0) savedPresets.value.splice(existing, 1, preset) if (existing?.docName) {
else savedPresets.value.push(preset) await import('src/api/presets').then(m => m.updatePreset(existing.docName, { tech_ids: JSON.stringify(tempSelectedIds.value) }))
localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value)) } else {
await apiCreatePreset({
preset_name: name,
preset_type: 'selection',
tech_ids: JSON.stringify([...tempSelectedIds.value]),
is_shared: 1,
})
}
await loadPresets()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
presetNameInput.value = '' presetNameInput.value = ''
showPresetSave.value = false showPresetSave.value = false
} }
const loadPreset = preset => { tempSelectedIds.value = [...preset.ids] } async function saveGroupAsPreset (groupName) {
function deletePreset (idx) { if (!groupName) return
const techsInGroup = store.technicians.filter(t => t.group === groupName && t.status !== 'inactive')
if (!techsInGroup.length) return
try {
const existing = savedPresets.value.find(p => p.name === groupName && p.type === 'group')
if (existing?.docName) {
await import('src/api/presets').then(m => m.updatePreset(existing.docName, { tech_ids: JSON.stringify(techsInGroup.map(t => t.id)) }))
} else {
await apiCreatePreset({
preset_name: groupName,
preset_type: 'group',
group_name: groupName,
tech_ids: JSON.stringify(techsInGroup.map(t => t.id)),
is_shared: 1,
})
}
await loadPresets()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
function loadPreset (preset) {
if (preset.type === 'group' && preset.group) {
const techsInGroup = store.technicians.filter(t => t.group === preset.group && t.status !== 'inactive')
tempSelectedIds.value = techsInGroup.map(t => t.id)
} else {
tempSelectedIds.value = [...(preset.ids || [])]
}
}
async function deletePreset (idx) {
const preset = savedPresets.value[idx]
if (preset?.docName) {
try { await apiDeletePreset(preset.docName) } catch {}
}
savedPresets.value.splice(idx, 1) savedPresets.value.splice(idx, 1)
localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value)) }
function quickLoadPreset (preset) {
if (activePresetName.value === preset.name) {
selectedResIds.value = []
filterGroup.value = ''
return
}
if (preset.type === 'group' && preset.group) {
filterGroup.value = preset.group
selectedResIds.value = []
} else {
filterGroup.value = ''
selectedResIds.value = [...(preset.ids || [])]
}
} }
const activePresetName = computed(() => { const activePresetName = computed(() => {
// Check group presets via filterGroup
if (filterGroup.value) {
const gp = savedPresets.value.find(p => p.type === 'group' && p.group === filterGroup.value)
if (gp) return gp.name
}
// Check ID-based presets
if (!selectedResIds.value.length) return null if (!selectedResIds.value.length) return null
const ids = selectedResIds.value const ids = selectedResIds.value
return savedPresets.value.find(p => p.ids.length === ids.length && p.ids.every(id => ids.includes(id)))?.name || null return savedPresets.value.find(p => p.ids.length === ids.length && p.ids.every(id => ids.includes(id)))?.name || null
@ -435,6 +653,123 @@ function optimizeRoute () {
_optimizeRoute(tech) _optimizeRoute(tech)
} }
// Offer handlers
async function onOfferAccept (offer) {
// Show a quick tech picker for manual assignment from dispatcher side
const techs = matchingTechs(offer)
if (!techs.length) {
Notify.create({ type: 'warning', message: 'Aucun tech disponible pour cette offre', timeout: 3000 })
return
}
// For now, auto-assign to the first/best matching tech
// TODO: show a picker modal for dispatcher to choose
try {
await handleAccept(offer.id, techs[0].id)
Notify.create({ type: 'positive', message: `Offre acceptée → ${techs[0].fullName}`, timeout: 3000 })
invalidateRoutes()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
}
function offerUnassignedJob (job) {
createOfferPrefill.value = job
createOfferModal.value = true
}
async function onCreateOffer (formData, sms) {
try {
await broadcastOffer(formData, sms)
Notify.create({ type: 'positive', message: 'Offre diffusée' + (sms ? ' (SMS envoyés)' : ''), timeout: 3000 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
}
// Ghost / Recurring handlers
function onGhostClick (templateJob, dateStr, techId) {
const tech = store.technicians.find(t => t.id === techId)
bookingOverlay.value = { job: templateJob, tech, ghostDate: dateStr }
}
async function materializeGhost (templateJob, dateStr, techId) {
const newJob = await store.createJob({
subject: templateJob.subject,
address: templateJob.address,
longitude: templateJob.coords?.[0],
latitude: templateJob.coords?.[1],
duration_h: templateJob.duration,
priority: templateJob.priority,
assigned_tech: techId,
scheduled_date: dateStr,
start_time: templateJob.startTime || '',
customer: templateJob.customer || '',
service_location: templateJob.serviceLocation || '',
template_id: templateJob.id,
})
store.jobVersion++
invalidateRoutes()
Notify.create({ type: 'positive', message: `Job matérialisé pour le ${dateStr}`, timeout: 2000 })
}
function toggleRecurring (job) {
job.isRecurring = !job.isRecurring
if (job.isRecurring && !job.recurrenceRule) job.recurrenceRule = 'FREQ=WEEKLY;BYDAY=MO'
store.jobVersion++
updateJob(job.name || job.id, {
is_recurring: job.isRecurring ? 1 : 0,
recurrence_rule: job.recurrenceRule || '',
}).catch(() => {})
}
function updateRecurrence (job, rrule) {
job.recurrenceRule = rrule
store.jobVersion++
updateJob(job.name || job.id, { recurrence_rule: rrule }).catch(() => {})
}
function updateRecurrenceEnd (job, endDate) {
job.recurrenceEnd = endDate || null
store.jobVersion++
updateJob(job.name || job.id, { recurrence_end: endDate || '' }).catch(() => {})
}
function addPausePeriod (job) {
const pauses = [...(job.pausePeriods || []), { from: localDateStr(new Date()), until: '' }]
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
function updatePausePeriod (job, idx, field, val) {
const pauses = [...(job.pausePeriods || [])]
pauses[idx] = { ...pauses[idx], [field]: val }
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
function removePausePeriod (job, idx) {
const pauses = [...(job.pausePeriods || [])]
pauses.splice(idx, 1)
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
async function copyIcalUrl (tech) {
const hubBase = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
try {
const r = await fetch(`${hubBase}/dispatch/ical-token/${tech.id}`)
const data = await r.json()
const url = `${hubBase}/dispatch/calendar/${tech.id}.ics?token=${data.token}`
await navigator.clipboard.writeText(url)
Notify.create({ type: 'positive', message: `Lien iCal copié pour ${tech.fullName}`, caption: 'Collez dans Google Calendar ou iPhone', timeout: 3500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
const critDragIdx = ref(null) const critDragIdx = ref(null)
const critDragOver = ref(null) const critDragOver = ref(null)
function dropCriterion (toIdx) { function dropCriterion (toIdx) {
@ -492,6 +827,12 @@ function onKeyDown (e) {
} }
} }
function _dateRange () {
const start = localDateStr(periodStart.value)
const end = localDateStr(new Date(periodStart.value.getTime() + (periodDays.value - 1) * 86400000))
return [start, end]
}
async function refreshData () { async function refreshData () {
const prevTechId = selectedTechId.value const prevTechId = selectedTechId.value
await store.loadAll() await store.loadAll()
@ -522,6 +863,8 @@ provide('periodLoadH', (tech) => loadMap.value[tech.id] ?? 0)
provide('techPeriodCapacityH', (tech) => capMap.value[tech.id] ?? 8) provide('techPeriodCapacityH', (tech) => capMap.value[tech.id] ?? 8)
provide('techDayEndH', techDayEndH) provide('techDayEndH', techDayEndH)
provide('isJobMultiSelected', isJobMultiSelected) provide('isJobMultiSelected', isJobMultiSelected)
provide('ghostOccurrencesForDate', ghostOccurrencesForDate)
provide('planningMode', planningMode)
provide('btColW', btColW) provide('btColW', btColW)
provide('startColResize', startColResize) provide('startColResize', startColResize)
provide('searchAddr', searchAddr) provide('searchAddr', searchAddr)
@ -556,6 +899,52 @@ function connectDispatchSSE () {
}) })
} }
// Reload jobs when period changes (navigating weeks/days)
watch([periodStart, () => periodDays.value], () => {
if (store.technicians.length) refreshData()
nextTick(() => scrollToCenter())
})
// Infinite scroll: native horizontal scroll through 3 rendered periods
// When scroll reaches a full period past center, shift anchor and re-center silently.
let _recentering = false
// Per-day pixel width depends on current view mode
function _colPx () {
return isCalView.value ? calColW.value : dayW.value
}
function scrollToCenter () {
const el = boardScroll.value
if (!el || currentView.value === 'month' || currentView.value === 'day') return
_recentering = true
el.scrollLeft = bufferDaysBefore.value * _colPx()
requestAnimationFrame(() => { _recentering = false })
}
function onBoardScroll () {
if (_recentering || currentView.value === 'month' || currentView.value === 'day') return
const el = boardScroll.value
if (!el) return
const cpx = _colPx()
if (!cpx) return
const periodW = periodDays.value * cpx
const centerScroll = bufferDaysBefore.value * cpx // scrollLeft where current period starts
// Scrolled 1 full period past current advance anchor, re-center
// Trigger when entering the last buffer week (not at the very edge)
if (el.scrollLeft > centerScroll + periodW - cpx) {
nextPeriod()
nextTick(() => scrollToCenter())
}
// Scrolled 1 full period before current go back, re-center
else if (el.scrollLeft < cpx) {
prevPeriod()
nextTick(() => scrollToCenter())
}
}
onMounted(async () => { onMounted(async () => {
if (!store.technicians.length) await store.loadAll() if (!store.technicians.length) await store.loadAll()
const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}') const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
@ -563,7 +952,10 @@ onMounted(async () => {
routeLegs.value = {}; routeGeometry.value = {} routeLegs.value = {}; routeGeometry.value = {}
const _ds = localDateStr(periodStart.value) const _ds = localDateStr(periodStart.value)
filteredResources.value.forEach(tech => computeDayRoute(tech, _ds)) filteredResources.value.forEach(tech => computeDayRoute(tech, _ds))
await loadPendingReqs() // Non-blocking: don't wait for pending requests before rendering
loadPendingReqs()
loadOffers()
loadPresets()
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
document.addEventListener('click', () => { closeCtxMenu(); assistCtx.value = null; techCtx.value = null }) document.addEventListener('click', () => { closeCtxMenu(); assistCtx.value = null; techCtx.value = null })
if (!document.getElementById('mapbox-css')) { if (!document.getElementById('mapbox-css')) {
@ -572,8 +964,22 @@ onMounted(async () => {
} }
store.startGpsTracking() store.startGpsTracking()
connectDispatchSSE() connectDispatchSSE()
// Measure column width for week calendar, scroll to center
measureCalColW()
nextTick(() => scrollToCenter())
if (boardScroll.value) boardScroll.value.addEventListener('scroll', onBoardScroll, { passive: true })
window.addEventListener('resize', measureCalColW)
})
// Re-measure column widths when switching views (dayweek changes periodDays 17)
watch([currentView, periodDays], () => nextTick(() => { measureCalColW(); scrollToCenter() }))
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('click', closeCtxMenu)
if (boardScroll.value) boardScroll.value.removeEventListener('scroll', onBoardScroll)
window.removeEventListener('resize', measureCalColW)
destroyMap(); store.stopGpsTracking(); if (dispatchSse) dispatchSse.close()
}) })
onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking(); if (dispatchSse) dispatchSse.close() })
</script> </script>
<template> <template>
@ -588,6 +994,15 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<span v-else-if="selectedResIds.length" class="sb-search-chip sb-search-chip-count" @click.stop="selectedResIds=[]">{{ selectedResIds.length }} ressource{{ selectedResIds.length>1?'s':'' }} </span> <span v-else-if="selectedResIds.length" class="sb-search-chip sb-search-chip-count" @click.stop="selectedResIds=[]">{{ selectedResIds.length }} ressource{{ selectedResIds.length>1?'s':'' }} </span>
<span v-if="!filterGroup && !selectedResIds.length" class="sb-search-placeholder">Ressources</span> <span v-if="!filterGroup && !selectedResIds.length" class="sb-search-placeholder">Ressources</span>
</div> </div>
<!-- Quick saved group access -->
<div v-if="savedPresets.length" class="sb-quick-presets">
<button v-for="p in savedPresets" :key="p.name"
class="sb-quick-preset" :class="{ active: activePresetName === p.name }"
@click.stop="quickLoadPreset(p)"
:title="(p.type === 'group' ? 'Groupe: ' : 'Sélection: ') + p.name">
<span v-if="p.type === 'group'" class="sb-qp-icon">👥</span>{{ p.name }}
</button>
</div>
<div v-if="materialCount > 0" class="sb-res-type-toggle"> <div v-if="materialCount > 0" class="sb-res-type-toggle">
<button :class="{ active: !filterResourceType }" @click="filterResourceType=''">Tous <span class="sbf-count">{{ humanCount + materialCount }}</span></button> <button :class="{ active: !filterResourceType }" @click="filterResourceType=''">Tous <span class="sbf-count">{{ humanCount + materialCount }}</span></button>
<button :class="{ active: filterResourceType==='human' }" @click="filterResourceType='human'">👤 <span class="sbf-count">{{ humanCount }}</span></button> <button :class="{ active: filterResourceType==='human' }" @click="filterResourceType='human'">👤 <span class="sbf-count">{{ humanCount }}</span></button>
@ -599,7 +1014,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</div> </div>
<button class="sb-icon-btn" :class="{ active: filterPanelOpen }" @click="filterPanelOpen=!filterPanelOpen" title="Filtres & Ressources"> <button class="sb-icon-btn" :class="{ active: filterPanelOpen }" @click="filterPanelOpen=!filterPanelOpen" title="Filtres & Ressources">
<span v-html="ICON.wrench"></span> <span v-html="ICON.wrench"></span>
<span v-if="filterStatus||filterGroup||selectedResIds.length||filterTags.length" class="sbs-badge" style="position:relative;top:-4px;right:2px"></span> <span v-if="filterStatus||filterGroup||selectedResIds.length||filterTags.length||hideAbsent" class="sbs-badge" style="position:relative;top:-4px;right:2px"></span>
</button> </button>
<button v-if="teamJobs.length" class="sb-icon-btn" :class="{ active: projectsPanelOpen }" @click="projectsPanelOpen=!projectsPanelOpen" title="Projets"> <button v-if="teamJobs.length" class="sb-icon-btn" :class="{ active: projectsPanelOpen }" @click="projectsPanelOpen=!projectsPanelOpen" title="Projets">
👥 <span class="sbs-count" style="position:relative;top:-2px;right:auto">{{ teamJobs.length }}</span> 👥 <span class="sbs-count" style="position:relative;top:-2px;right:auto">{{ teamJobs.length }}</span>
@ -613,11 +1028,22 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<div class="sb-view-sw"> <div class="sb-view-sw">
<button v-for="v in [['day','Jour'],['week','Semaine'],['month','Mois']]" :key="v[0]" :class="{ active: currentView===v[0] }" @click="currentView=v[0]">{{ v[1] }}</button> <button v-for="v in [['day','Jour'],['week','Semaine'],['month','Mois']]" :key="v[0]" :class="{ active: currentView===v[0] }" @click="currentView=v[0]">{{ v[1] }}</button>
</div> </div>
<button class="sb-icon-btn sb-planning-toggle" :class="{ active: planningMode }" @click="planningMode=!planningMode" title="Mode planification — afficher les disponibilités">
🗓 <span v-if="currentView!=='month'" style="font-size:0.72rem">Planning</span>
</button>
</div> </div>
<div class="sb-header-right"> <div class="sb-header-right">
<!-- Overload alert -->
<span v-if="overloadedTechs.length" class="sb-overload-alert" :title="overloadedTechs.map(o => o.tech.fullName + ' ' + o.pct + '%').join(', ')">
{{ overloadedTechs.length }} surchargé{{ overloadedTechs.length > 1 ? 's' : '' }}
</span>
<button class="sb-icon-btn" :class="{ active: bottomPanelOpen }" @click="bottomPanelOpen=!bottomPanelOpen" title="Jobs non assignées"> <button class="sb-icon-btn" :class="{ active: bottomPanelOpen }" @click="bottomPanelOpen=!bottomPanelOpen" title="Jobs non assignées">
📋 <span v-if="unscheduledJobs.length" class="sbs-count" style="position:relative;top:-2px;right:auto">{{ unscheduledJobs.length }}</span> 📋 <span v-if="unscheduledJobs.length" class="sbs-count" style="position:relative;top:-2px;right:auto">{{ unscheduledJobs.length }}</span>
</button> </button>
<!-- Offer pool -->
<button class="sb-icon-btn" :class="{ active: showOfferPool }" @click="showOfferPool=!showOfferPool; if(showOfferPool) loadOffers()" title="Offres de travail">
📡 <span v-if="activeOfferCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#4ade80;color:#000">{{ activeOfferCount }}</span>
</button>
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button> <button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button>
<button class="sb-icon-btn" @click="refreshData()" title="Actualiser"></button> <button class="sb-icon-btn" @click="refreshData()" title="Actualiser"></button>
<button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button> <button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button>
@ -640,7 +1066,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<button class="sbf-primary-btn" style="margin-top:0.3rem" @click="openResSelectorFull">Sélectionner les ressources</button> <button class="sbf-primary-btn" style="margin-top:0.3rem" @click="openResSelectorFull">Sélectionner les ressources</button>
<label class="sbf-lbl" style="margin-top:0.3rem">Tri</label> <label class="sbf-lbl" style="margin-top:0.3rem">Tri</label>
<select class="sbf-select" v-model="techSort"> <select class="sbf-select" v-model="techSort">
<option value="default">Par défaut</option><option value="alpha">Alphabétique (nom)</option><option value="manual">Manuel (drag)</option> <option value="default">Par défaut</option><option value="alpha">Alphabétique (nom)</option><option value="load">Moins chargés d'abord</option><option value="manual">Manuel (drag)</option>
</select> </select>
<div v-if="selectedResIds.length" class="sbf-chip">{{ selectedResIds.length }} sélectionnée{{ selectedResIds.length>1?'s':'' }}<button @click="selectedResIds=[]"></button></div> <div v-if="selectedResIds.length" class="sbf-chip">{{ selectedResIds.length }} sélectionnée{{ selectedResIds.length>1?'s':'' }}<button @click="selectedResIds=[]"></button></div>
</div> </div>
@ -655,6 +1081,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<select class="sbf-select" v-model="filterStatus"> <select class="sbf-select" v-model="filterStatus">
<option value="">Tous (actifs)</option><option value="available">Disponible</option><option value="en-route">En route</option><option value="busy">En cours</option><option value="off">Hors shift</option><option value="inactive">Inactifs</option> <option value="">Tous (actifs)</option><option value="available">Disponible</option><option value="en-route">En route</option><option value="busy">En cours</option><option value="off">Hors shift</option><option value="inactive">Inactifs</option>
</select> </select>
<label class="sbf-lbl" style="margin-top:0.4rem">
<input type="checkbox" v-model="hideAbsent" style="margin-right:4px;vertical-align:middle" />
Disponibles seulement
</label>
<label class="sbf-lbl">Tags</label> <label class="sbf-lbl">Tags</label>
<TagEditor :model-value="filterTags" @update:model-value="v => { filterTags = v; localStorage.setItem('sbv2-filterTags', JSON.stringify(v)) }" <TagEditor :model-value="filterTags" @update:model-value="v => { filterTags = v; localStorage.setItem('sbv2-filterTags', JSON.stringify(v)) }"
:all-tags="store.allTags" :get-color="getTagColor" :can-create="false" :can-edit="false" placeholder="Filtrer par tag…" /> :all-tags="store.allTags" :get-color="getTagColor" :can-create="false" :can-edit="false" placeholder="Filtrer par tag…" />
@ -671,7 +1101,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</div> </div>
</div> </div>
<div style="display:flex;align-items:flex-end;margin-left:auto"> <div style="display:flex;align-items:flex-end;margin-left:auto">
<button v-if="filterStatus||filterGroup||selectedResIds.length||searchQuery||filterTags.length" class="sbf-clear-btn" style="width:auto;padding:0.22rem 0.6rem" @click="clearFilters"> Réinitialiser</button> <button v-if="filterStatus||filterGroup||selectedResIds.length||searchQuery||filterTags.length||hideAbsent" class="sbf-clear-btn" style="width:auto;padding:0.22rem 0.6rem" @click="clearFilters"> Réinitialiser</button>
<button class="sb-icon-btn" style="margin-left:0.3rem" @click="filterPanelOpen=false" title="Fermer"></button> <button class="sb-icon-btn" style="margin-left:0.3rem" @click="filterPanelOpen=false" title="Fermer"></button>
</div> </div>
</div> </div>
@ -726,6 +1156,12 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<span class="sb-rp-lbl">Date de fin</span> <span class="sb-rp-lbl">Date de fin</span>
<input type="date" class="sb-form-input" :value="bookingOverlay.job?.endDate || ''" @change="setEndDate(bookingOverlay.job, $event.target.value)" style="margin-top:2px" /> <input type="date" class="sb-form-input" :value="bookingOverlay.job?.endDate || ''" @change="setEndDate(bookingOverlay.job, $event.target.value)" style="margin-top:2px" />
</div> </div>
<div v-if="bookingOverlay.job?.endDate" class="sb-rp-field" style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:0.72rem;cursor:pointer">
<input type="checkbox" :checked="bookingOverlay.job?.continuous" @change="toggleContinuous(bookingOverlay.job, $event.target.checked)" />
Urgence / continu (inclure fins de semaine)
</label>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Tags</span> <div class="sb-rp-field"><span class="sb-rp-lbl">Tags</span>
<TagEditor v-if="bookingOverlay.job" :model-value="bookingOverlay.job.tagsWithLevel || bookingOverlay.job.tags || []" <TagEditor v-if="bookingOverlay.job" :model-value="bookingOverlay.job.tagsWithLevel || bookingOverlay.job.tags || []"
@update:model-value="v => { bookingOverlay.job.tagsWithLevel = v; bookingOverlay.job.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistJobTags(bookingOverlay.job) }" @update:model-value="v => { bookingOverlay.job.tagsWithLevel = v; bookingOverlay.job.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistJobTags(bookingOverlay.job) }"
@ -741,9 +1177,41 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="store.removeAssistant(bookingOverlay.job.id, a.techId); invalidateRoutes()"></button> <button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="store.removeAssistant(bookingOverlay.job.id, a.techId); invalidateRoutes()"></button>
</div> </div>
</div> </div>
<!-- Recurrence -->
<div class="sb-rp-field" style="border-top:1px solid rgba(255,255,255,0.08);padding-top:8px;margin-top:4px">
<label style="display:flex;align-items:center;gap:4px;font-size:0.72rem;cursor:pointer">
<input type="checkbox" :checked="bookingOverlay.job?.isRecurring" @change="toggleRecurring(bookingOverlay.job)" />
🔄 Récurrence
</label>
</div>
<template v-if="bookingOverlay.job?.isRecurring">
<div class="sb-rp-field">
<RecurrenceSelector
:model-value="bookingOverlay.job.recurrenceRule || ''"
:ref-date="bookingOverlay.job.scheduledDate || todayStr"
:show-none="false"
@update:model-value="rrule => updateRecurrence(bookingOverlay.job, rrule)"
/>
</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Fin de récurrence</span>
<input type="date" class="sb-form-input" :value="bookingOverlay.job?.recurrenceEnd || ''" @change="updateRecurrenceEnd(bookingOverlay.job, $event.target.value)" />
</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Pauses (ex: hiver)</span>
<div v-for="(p, idx) in (bookingOverlay.job?.pausePeriods||[])" :key="idx" style="display:flex;gap:4px;align-items:center;margin-top:3px">
<input type="date" class="sb-form-input" style="width:120px" :value="p.from" @change="updatePausePeriod(bookingOverlay.job, idx, 'from', $event.target.value)" />
<span style="font-size:0.7rem;color:#9ca3af"></span>
<input type="date" class="sb-form-input" style="width:120px" :value="p.until" @change="updatePausePeriod(bookingOverlay.job, idx, 'until', $event.target.value)" />
<button style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="removePausePeriod(bookingOverlay.job, idx)"></button>
</div>
<button class="sb-rp-btn" style="margin-top:4px;font-size:0.68rem;padding:2px 8px" @click="addPausePeriod(bookingOverlay.job)">+ Ajouter une pause</button>
</div>
</template>
</div> </div>
<div class="sb-rp-actions"> <div class="sb-rp-actions">
<button class="sb-rp-primary" @click="openMoveModal(bookingOverlay.job, bookingOverlay.tech?.id)"> Déplacer / Réassigner</button> <button v-if="bookingOverlay.ghostDate" class="sb-rp-primary" @click="materializeGhost(bookingOverlay.job, bookingOverlay.ghostDate, bookingOverlay.tech?.id); bookingOverlay=null"> Matérialiser pour le {{ bookingOverlay.ghostDate }}</button>
<button v-else class="sb-rp-primary" @click="openMoveModal(bookingOverlay.job, bookingOverlay.tech?.id)"> Déplacer / Réassigner</button>
<button class="sb-rp-btn" @click="startGeoFix(bookingOverlay.job)">📍 Géofixer sur la carte</button> <button class="sb-rp-btn" @click="startGeoFix(bookingOverlay.job)">📍 Géofixer sur la carte</button>
<button class="sb-rp-btn" @click="ctxMenu={job:bookingOverlay.job,techId:bookingOverlay.tech?.id};ctxUnschedule();bookingOverlay=null"> Désaffecter</button> <button class="sb-rp-btn" @click="ctxMenu={job:bookingOverlay.job,techId:bookingOverlay.tech?.id};ctxUnschedule();bookingOverlay=null"> Désaffecter</button>
</div> </div>
@ -757,24 +1225,30 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<WeekCalendar v-if="isCalView" <WeekCalendar v-if="isCalView"
:filtered-resources="filteredResources" :day-columns="dayColumns" :filtered-resources="filteredResources" :day-columns="dayColumns"
:selected-tech-id="selectedTechId" :drop-ghost="dropGhost" :today-str="todayStr" :selected-tech-id="selectedTechId" :drop-ghost="dropGhost" :today-str="todayStr"
:col-w="calColW"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" @ctx-tech="openTechCtx" @go-to-day="goToDay" @select-tech="selectTechOnBoard" @ctx-tech="openTechCtx"
@tech-reorder-start="onTechReorderStart" @tech-reorder-drop="onTechReorderDrop" @tech-reorder-start="onTechReorderStart" @tech-reorder-drop="onTechReorderDrop"
@cal-drop="onCalDrop" @job-dragstart="onJobDragStart" @cal-drop="onCalDrop" @job-dragstart="onJobDragStart"
@job-click="selectJob" @job-dblclick="openEditModal" @job-ctx="openCtxMenu" @job-click="selectJob" @job-dblclick="openEditModal" @job-ctx="openCtxMenu"
@clear-filters="clearFilters" @clear-filters="clearFilters"
@ghost-click="onGhostClick" @ghost-materialize="materializeGhost"
@open-absence="openAbsenceModal" @end-absence="endAbsence" @open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" /> @open-schedule="openScheduleModal" />
<MonthCalendar v-else-if="currentView==='month'" <MonthCalendar v-else-if="currentView==='month'"
:anchor-date="anchorDate" :filtered-resources="filteredResources" :today-str="todayStr" :anchor-date="anchorDate" :filtered-resources="filteredResources" :today-str="todayStr"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" /> :selected-tech-id="selectedTechId"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" @open-schedule="openScheduleModal" />
<div v-else class="sb-grid" :style="'min-width:'+(220+totalW)+'px'"> <div v-else class="sb-grid" :style="'min-width:'+(220+totalW)+'px'">
<div class="sb-grid-hdr"> <div class="sb-grid-hdr">
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div> <div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
<div class="sb-time-hdr-wrap" :style="'width:'+totalW+'px;position:relative;height:100%'"> <div class="sb-time-hdr-wrap" :style="'width:'+totalW+'px;position:relative;height:100%'">
<div v-for="tick in hourTicks" :key="'dht-'+tick.x" class="sb-htick" :style="'left:'+tick.x+'px'"> <div v-for="tick in hourTicks" :key="'dht-'+tick.x" class="sb-htick" :class="{ 'sb-day-boundary': tick.isDay }" :style="'left:'+tick.x+'px'">
<span v-if="tick.label" class="sb-htick-lbl">{{ tick.label }}</span> <span v-if="tick.isDay && dayColumns.length > 1" class="sb-day-lbl" :class="{ 'sb-day-today': localDateStr(tick.day) === todayStr }">
{{ fmtDate(tick.day) }}
</span>
<span v-else-if="tick.label" class="sb-htick-lbl">{{ tick.label }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -799,6 +1273,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
@hover-job="id => hoveredJobId=id" @unhover-job="hoveredJobId=null" @hover-job="id => hoveredJobId=id" @unhover-job="hoveredJobId=null"
@block-move="startBlockMove" @block-resize="startResize" @block-move="startBlockMove" @block-resize="startResize"
@absence-resize="startAbsenceResize" @absence-resize="startAbsenceResize"
@ghost-click="onGhostClick" @ghost-materialize="materializeGhost"
@open-absence="openAbsenceModal" @end-absence="endAbsence" @open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" /> @open-schedule="openScheduleModal" />
</div> </div>
@ -854,6 +1329,20 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
@assign-pending="() => rightPanel=null" @assign-pending="() => rightPanel=null"
@update-tags="(job, v) => { job.tags = v; persistJobTags(job) }" /> @update-tags="(job, v) => { job.tags = v; persistJobTags(job) }" />
<!-- Offer pool slide-in panel -->
<transition name="sb-slide-right">
<div v-if="showOfferPool" class="sb-offer-pool-col">
<OfferPoolPanel
:offers="offers" :loading="loadingOffers"
@refresh="loadOffers"
@close="showOfferPool=false"
@offer-job="createOfferPrefill=null; createOfferModal=true"
@accept="o => onOfferAccept(o)"
@cancel="o => handleCancel(o.id)"
/>
</div>
</transition>
</div> </div>
<!-- Context menus --> <!-- Context menus -->
@ -862,6 +1351,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<button class="sb-ctx-item" @click="ctxMove()"> Déplacer / Réassigner</button> <button class="sb-ctx-item" @click="ctxMove()"> Déplacer / Réassigner</button>
<button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button> <button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button>
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button> <button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
<button class="sb-ctx-item" @click="offerUnassignedJob(ctxMenu.job); closeCtxMenu()">📡 Offrir aux ressources</button>
<div class="sb-ctx-sep"></div> <div class="sb-ctx-sep"></div>
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()"> Désaffecter</button> <button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()"> Désaffecter</button>
</SbContextMenu> </SbContextMenu>
@ -870,6 +1360,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button> <button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button>
<button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button> <button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button>
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button> <button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
<button class="sb-ctx-item" @click="copyIcalUrl(techCtx.tech); techCtx=null">📅 Copier le lien iCal</button>
<div class="sb-ctx-sep"></div> <div class="sb-ctx-sep"></div>
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null"> Ouvrir dans ERPNext</button> <button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null"> Ouvrir dans ERPNext</button>
</SbContextMenu> </SbContextMenu>
@ -953,10 +1444,11 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<div v-if="savedPresets.length" class="sb-rsel-groups"> <div v-if="savedPresets.length" class="sb-rsel-groups">
<div class="sb-rsel-section-title">Sélections sauvegardées</div> <div class="sb-rsel-section-title">Sélections sauvegardées</div>
<div class="sb-rsel-chips"> <div class="sb-rsel-chips">
<button v-for="(p, idx) in savedPresets" :key="p.name" class="sb-rsel-chip sb-rsel-preset" <button v-for="(p, idx) in savedPresets" :key="p.name+'-'+idx" class="sb-rsel-chip sb-rsel-preset"
:class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)) }" :class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)), 'sb-rsel-preset-group': p.type === 'group' }"
@click="loadPreset(p)"> @click="loadPreset(p)">
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.ids.length }}</span> <span v-if="p.type === 'group'" class="sb-rsel-preset-icon">👥</span>
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.type === 'group' ? store.technicians.filter(t => t.group === p.group && t.status !== 'inactive').length : p.ids.length }}</span>
<span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer"></span> <span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer"></span>
</button> </button>
</div> </div>
@ -965,8 +1457,14 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<div class="sb-rsel-section-title">Groupes</div> <div class="sb-rsel-section-title">Groupes</div>
<div class="sb-rsel-chips"> <div class="sb-rsel-chips">
<button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button> <button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button>
<button v-for="g in availableGroups" :key="g" class="sb-rsel-chip" <span v-for="g in availableGroups" :key="g" class="sb-rsel-group-wrap">
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}</button> <button class="sb-rsel-chip"
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}
<span class="sb-rsel-group-count">{{ store.technicians.filter(t => t.group === g && t.status !== 'inactive').length }}</span>
</button>
<button v-if="!savedPresets.some(p => p.type === 'group' && p.group === g)"
class="sb-rsel-save-group" @click.stop="saveGroupAsPreset(g)" title="Sauvegarder ce groupe">💾</button>
</span>
</div> </div>
<div class="sb-rsel-group-actions"> <div class="sb-rsel-group-actions">
<button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter"> <button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter">
@ -1221,6 +1719,33 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<span v-else class="sb-schedule-off-label">Repos</span> <span v-else class="sb-schedule-off-label">Repos</span>
</div> </div>
</div> </div>
<!-- On-call / garde shifts -->
<div class="sb-extra-shifts-section">
<div class="sb-extra-shifts-hdr">
<span>🔔 Shifts de garde / urgence</span>
<button class="sb-rp-btn sb-rp-btn-sm" @click="addExtraShift">+ Ajouter</button>
</div>
<div v-if="!extraShiftsForm.length" class="sb-extra-shifts-empty">
Aucun shift de garde. Ajoutez-en pour planifier les disponibilités hors-horaire.
</div>
<div v-for="(shift, idx) in extraShiftsForm" :key="idx" class="sb-extra-shift-row">
<input v-model="shift.label" class="sb-extra-shift-label" placeholder="Label (ex: Garde)" />
<input type="time" v-model="shift.startTime" class="sb-schedule-time" />
<span class="sb-schedule-sep"></span>
<input type="time" v-model="shift.endTime" class="sb-schedule-time" />
<div class="sb-extra-shift-recurrence">
<RecurrenceSelector
:model-value="shift.rrule || ''"
:ref-date="shift.from || todayStr"
:show-none="false"
@update:model-value="rrule => { shift.rrule = rrule; const p = _parseShiftPattern(rrule); shift._pattern = p.pattern; shift._interval = p.interval }"
/>
</div>
<input type="date" v-model="shift.from" class="sb-schedule-time" title="Début de la récurrence" />
<button class="sb-extra-shift-del" @click="removeExtraShift(idx)" title="Supprimer"></button>
</div>
</div>
<div class="sb-modal-footer"> <div class="sb-modal-footer">
<button class="sb-rp-btn" @click="scheduleModalTech = null">Annuler</button> <button class="sb-rp-btn" @click="scheduleModalTech = null">Annuler</button>
<button class="sb-rp-btn sb-rp-primary" @click="confirmSchedule">Enregistrer</button> <button class="sb-rp-btn sb-rp-primary" @click="confirmSchedule">Enregistrer</button>
@ -1228,6 +1753,15 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</div> </div>
</div> </div>
<!-- Create Offer Modal -->
<CreateOfferModal
v-model="createOfferModal"
:technicians="store.technicians"
:all-tags="store.allTags"
:prefill="createOfferPrefill"
@create="onCreateOffer"
/>
</div> </div>
</template> </template>

View File

@ -29,6 +29,11 @@
.sb-view-sw { display:flex; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; overflow:hidden; } .sb-view-sw { display:flex; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; overflow:hidden; }
.sb-view-sw button { background:none; border:none; color:var(--sb-muted); font-size:0.68rem; font-weight:700; padding:0.22rem 0.6rem; cursor:pointer; transition:color 0.12s, background 0.12s; } .sb-view-sw button { background:none; border:none; color:var(--sb-muted); font-size:0.68rem; font-weight:700; padding:0.22rem 0.6rem; cursor:pointer; transition:color 0.12s, background 0.12s; }
.sb-view-sw button.active { background:none; color:var(--sb-acc); box-shadow:inset 0 0 0 1.5px var(--sb-acc); } .sb-view-sw button.active { background:none; color:var(--sb-acc); box-shadow:inset 0 0 0 1.5px var(--sb-acc); }
.sb-planning-toggle {
margin-left: 6px; border-radius: 6px; font-size: 0.72rem;
border: 1px solid rgba(100,180,255,0.15); transition: all 0.15s;
&.active { background: rgba(100,180,255,0.15); border-color: rgba(100,180,255,0.4); color: #93c5fd; }
}
.sb-search { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.22rem 0.55rem; width:160px; outline:none; } .sb-search { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.22rem 0.55rem; width:160px; outline:none; }
.sb-search::placeholder { color:var(--sb-muted); } .sb-search::placeholder { color:var(--sb-muted); }
.sb-search:focus { border-color:var(--sb-border-acc); } .sb-search:focus { border-color:var(--sb-border-acc); }
@ -39,6 +44,34 @@
.sb-search-chip { display:inline-flex; align-items:center; gap:3px; background:rgba(99,102,241,0.2); color:#a5b4fc; border:1px solid rgba(99,102,241,0.3); border-radius:4px; font-size:0.65rem; font-weight:600; padding:1px 6px; white-space:nowrap; cursor:pointer; transition:background 0.1s; } .sb-search-chip { display:inline-flex; align-items:center; gap:3px; background:rgba(99,102,241,0.2); color:#a5b4fc; border:1px solid rgba(99,102,241,0.3); border-radius:4px; font-size:0.65rem; font-weight:600; padding:1px 6px; white-space:nowrap; cursor:pointer; transition:background 0.1s; }
.sb-search-chip:hover { background:rgba(99,102,241,0.35); } .sb-search-chip:hover { background:rgba(99,102,241,0.35); }
.sb-search-chip-count { background:rgba(99,102,241,0.3); } .sb-search-chip-count { background:rgba(99,102,241,0.3); }
// Quick preset bar (saved groups in header)
.sb-quick-presets {
display: flex; gap: 3px; align-items: center; flex-wrap: wrap;
}
.sb-quick-preset {
background: var(--sb-card); border: 1px solid var(--sb-border); border-radius: 5px;
color: var(--sb-muted); font-size: 0.62rem; font-weight: 600;
padding: 2px 7px; cursor: pointer; white-space: nowrap;
transition: all 0.12s; display: inline-flex; align-items: center; gap: 2px;
&:hover { border-color: var(--sb-border-acc); color: var(--sb-text); }
&.active { border-color: var(--sb-acc); color: var(--sb-acc); background: rgba(99,102,241,0.1); }
.sb-qp-icon { font-size: 0.7rem; }
}
// Group save button in selector
.sb-rsel-group-wrap {
display: inline-flex; align-items: center; gap: 0;
}
.sb-rsel-save-group {
background: none; border: none; cursor: pointer; font-size: 0.65rem;
padding: 2px 3px; opacity: 0.35; transition: opacity 0.12s;
&:hover { opacity: 1; }
}
.sb-rsel-group-count {
font-size: 0.6rem; opacity: 0.6; margin-left: 3px;
}
.sb-rsel-preset-group { border-color: rgba(74, 222, 128, 0.3); }
.sb-rsel-preset-group.active { border-color: #4ade80; background: rgba(74, 222, 128, 0.12); }
.sb-rsel-preset-icon { font-size: 0.7rem; margin-right: 2px; }
.sb-icon-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.68rem; font-weight:600; padding:0.22rem 0.55rem; cursor:pointer; white-space:nowrap; transition:color 0.12s, border-color 0.12s; } .sb-icon-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.68rem; font-weight:600; padding:0.22rem 0.55rem; cursor:pointer; white-space:nowrap; transition:color 0.12s, border-color 0.12s; }
.sb-icon-btn:hover, .sb-icon-btn.active { color:var(--sb-text); border-color:var(--sb-border-acc); background:var(--sb-card); } .sb-icon-btn:hover, .sb-icon-btn.active { color:var(--sb-text); border-color:var(--sb-border-acc); background:var(--sb-card); }
.sb-user-menu { display:flex; align-items:center; gap:6px; } .sb-user-menu { display:flex; align-items:center; gap:6px; }
@ -127,6 +160,9 @@
.sb-today-bubble { background:var(--sb-acc); color:#fff !important; border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; } .sb-today-bubble { background:var(--sb-acc); color:#fff !important; border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; }
.sb-htick { position:absolute; top:0; height:100%; border-left:1px solid var(--sb-border); } .sb-htick { position:absolute; top:0; height:100%; border-left:1px solid var(--sb-border); }
.sb-htick-lbl { position:absolute; top:6px; left:4px; font-size:0.58rem; color:var(--sb-muted); white-space:nowrap; font-weight:600; } .sb-htick-lbl { position:absolute; top:6px; left:4px; font-size:0.58rem; color:var(--sb-muted); white-space:nowrap; font-weight:600; }
.sb-day-lbl { position:absolute; top:4px; left:6px; font-size:0.65rem; font-weight:700; color:var(--sb-text); white-space:nowrap; letter-spacing:0.02em; text-transform:capitalize; }
.sb-day-today { color:var(--sb-acc); }
.sb-htick.sb-day-boundary { border-left:2px solid var(--sb-border); }
/* ── Rows ── */ /* ── Rows ── */
.sb-row { display:flex; border-bottom:1px solid var(--sb-border); transition:background 0.12s; } .sb-row { display:flex; border-bottom:1px solid var(--sb-border); transition:background 0.12s; }
@ -221,6 +257,95 @@
border: 1px dashed rgba(255, 255, 255, 0.2); border: 1px dashed rgba(255, 255, 255, 0.2);
} }
// Day picker buttons (recurrence)
.sb-day-btn {
width: 24px; height: 24px; border-radius: 50%; border: 1px solid #4b5563;
background: transparent; color: #9ca3af; font-size: 0.62rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0;
&:hover { border-color: #818cf8; color: #c4c8e4; }
&.sb-day-btn-active { background: #6366f1; border-color: #6366f1; color: #fff; }
}
// Ghost (recurring) block
.sb-block-ghost {
opacity: 0.55;
border: 2px dashed rgba(255, 255, 255, 0.3) !important;
cursor: pointer;
transition: opacity 0.15s;
&:hover { opacity: 0.85; }
.sb-ghost-icon { font-size: 0.65rem; margin-right: 2px; }
}
.sb-chip-ghost {
opacity: 0.55;
border: 1px dashed rgba(255, 255, 255, 0.4) !important;
cursor: pointer;
&:hover { opacity: 0.85; }
}
// Shift availability background blocks
.sb-block-shift {
position: absolute;
z-index: 0;
border-radius: 6px;
background: rgba(100, 180, 255, 0.08);
border: 1px solid rgba(100, 180, 255, 0.15);
pointer-events: none;
.sb-shift-label {
position: absolute;
top: 2px; left: 6px;
font-size: 0.55rem;
color: rgba(140, 190, 255, 0.5);
white-space: nowrap;
letter-spacing: 0.02em;
user-select: none;
}
}
.sb-block-shift-oncall {
background: repeating-linear-gradient(
-45deg,
rgba(255, 180, 60, 0.06),
rgba(255, 180, 60, 0.06) 5px,
rgba(255, 180, 60, 0.02) 5px,
rgba(255, 180, 60, 0.02) 10px
);
border-color: rgba(255, 180, 60, 0.2);
.sb-shift-label { color: rgba(255, 190, 80, 0.5); }
}
// Week calendar: schedule availability bands (planning mode)
.sb-sched-band {
border-radius: 4px; padding: 2px 5px; cursor: pointer;
font-size: 0.62rem; white-space: nowrap; overflow: hidden;
transition: opacity 0.12s;
&:hover { opacity: 0.9; filter: brightness(1.15); }
.sb-sched-time { opacity: 0.85; }
}
.sb-sched-available {
background: rgba(74, 222, 128, 0.12);
border: 1px solid rgba(74, 222, 128, 0.25);
color: #86efac;
}
.sb-sched-oncall {
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
color: #fcd34d;
}
// Month calendar: selected tech availability blocks
.sb-month-avail {
border-radius: 4px; padding: 2px 5px; margin: 1px 0;
font-size: 0.62rem; cursor: pointer;
display: flex; align-items: center; gap: 3px;
transition: filter 0.12s;
&:hover { filter: brightness(1.2); }
.sb-month-avail-icon { font-size: 0.7rem; flex-shrink: 0; }
.sb-month-avail-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.sb-month-avail-available { color: #86efac; }
.sb-month-avail-oncall { color: #fcd34d; }
.sb-month-avail-absence { color: #fca5a5; }
.sb-month-avail-dayoff { color: #9ca3af; opacity: 0.6; }
// Absence block (grayed-out timeline overlay) // Absence block (grayed-out timeline overlay)
.sb-block-absence { .sb-block-absence {
background: repeating-linear-gradient( background: repeating-linear-gradient(
@ -547,6 +672,27 @@
.sb-slide-left-enter-from, .sb-slide-left-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; } .sb-slide-left-enter-from, .sb-slide-left-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; }
.sb-slide-right-enter-active, .sb-slide-right-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; } .sb-slide-right-enter-active, .sb-slide-right-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; }
.sb-slide-right-enter-from, .sb-slide-right-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; } .sb-slide-right-enter-from, .sb-slide-right-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; }
// Offer pool sidebar
.sb-offer-pool-col {
width: 320px; min-width: 320px; height: 100%;
border-left: 1px solid var(--sb-border);
overflow: hidden; flex-shrink: 0;
}
// Overload alert badge
.sb-overload-alert {
font-size: 0.68rem; font-weight: 600;
color: #fbbf24; background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 5px; padding: 2px 8px;
cursor: default; white-space: nowrap;
animation: sb-pulse-warn 2s infinite;
}
@keyframes sb-pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* GPS Settings Modal */ /* GPS Settings Modal */
.sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; display:flex; align-items:center; justify-content:center; } .sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; display:flex; align-items:center; justify-content:center; }
.sb-gps-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:12px; width:700px; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; } .sb-gps-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:12px; width:700px; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; }
@ -628,7 +774,7 @@
.sb-login-error { color:var(--sb-red); font-size:12px; margin:0; text-align:center; } .sb-login-error { color:var(--sb-red); font-size:12px; margin:0; text-align:center; }
/* ── Schedule editor modal ── */ /* ── Schedule editor modal ── */
.sb-schedule-modal { width:420px; max-width:95vw; } .sb-schedule-modal { width:520px; max-width:95vw; }
.sb-schedule-presets { display:flex; gap:6px; padding:0.5rem 1rem; flex-wrap:wrap; } .sb-schedule-presets { display:flex; gap:6px; padding:0.5rem 1rem; flex-wrap:wrap; }
.sb-preset-btn { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:0.68rem; padding:4px 10px; cursor:pointer; transition:border-color 0.15s, background 0.15s; } .sb-preset-btn { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:0.68rem; padding:4px 10px; cursor:pointer; transition:border-color 0.15s, background 0.15s; }
.sb-preset-btn:hover { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); } .sb-preset-btn:hover { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); }
@ -644,3 +790,39 @@
.sb-schedule-sep { color:var(--sb-muted); font-size:0.72rem; } .sb-schedule-sep { color:var(--sb-muted); font-size:0.72rem; }
.sb-schedule-hours { color:var(--sb-muted); font-size:0.68rem; min-width:30px; text-align:right; } .sb-schedule-hours { color:var(--sb-muted); font-size:0.68rem; min-width:30px; text-align:right; }
.sb-schedule-off-label { color:var(--sb-muted); font-size:0.72rem; font-style:italic; } .sb-schedule-off-label { color:var(--sb-muted); font-size:0.72rem; font-style:italic; }
// Extra shifts (on-call / garde) editor
.sb-extra-shifts-section { padding: 0.5rem 1rem; border-top: 1px solid var(--sb-border); }
.sb-extra-shifts-hdr {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;
span { font-size: 0.78rem; font-weight: 600; color: var(--sb-text); }
}
.sb-rp-btn-sm { font-size: 0.68rem !important; padding: 2px 8px !important; }
.sb-extra-shifts-empty { font-size: 0.72rem; color: var(--sb-muted); font-style: italic; padding: 4px 0; }
.sb-extra-shift-row {
display: flex; align-items: center; gap: 6px; padding: 5px 8px; margin-bottom: 4px;
border-radius: 6px; background: rgba(251, 191, 36, 0.06); border: 1px solid rgba(251, 191, 36, 0.12);
}
.sb-extra-shift-label {
background: var(--sb-bg); border: 1px solid var(--sb-border); border-radius: 4px;
color: var(--sb-text); font-size: 0.72rem; padding: 3px 6px; width: 70px; outline: none;
&:focus { border-color: #f59e0b; }
}
.sb-extra-shift-recurrence {
display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0;
}
.sb-extra-shift-pattern {
background: var(--sb-bg); border: 1px solid var(--sb-border); border-radius: 4px;
color: var(--sb-text); font-size: 0.68rem; padding: 3px 4px; min-width: 0; outline: none;
&:focus { border-color: #f59e0b; }
}
.sb-extra-shift-sep { color: var(--sb-muted); font-size: 0.68rem; white-space: nowrap; }
.sb-extra-shift-interval {
background: var(--sb-bg); border: 1px solid var(--sb-border); border-radius: 4px;
color: var(--sb-text); font-size: 0.72rem; padding: 3px 4px; width: 42px; text-align: center; outline: none;
&:focus { border-color: #f59e0b; }
}
.sb-extra-shift-del {
background: none; border: none; color: #f87171; cursor: pointer; font-size: 0.85rem;
padding: 2px 4px; border-radius: 3px; transition: background 0.12s;
&:hover { background: rgba(248, 113, 113, 0.15); }
}

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch' import { fetchTechnicians, loadTechTags, fetchJobsFast, fetchJobFull, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
import { TECH_COLORS } from 'src/config/erpnext' import { TECH_COLORS } from 'src/config/erpnext'
import { serializeAssistants, normalizeStatus, parseWeeklySchedule } from 'src/composables/useHelpers' import { serializeAssistants, normalizeStatus, parseWeeklySchedule } from 'src/composables/useHelpers'
import { useGpsTracking } from 'src/composables/useGpsTracking' import { useGpsTracking } from 'src/composables/useGpsTracking'
@ -19,6 +19,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
const allTags = ref([]) const allTags = ref([])
const loading = ref(false) const loading = ref(false)
const erpStatus = ref('pending') const erpStatus = ref('pending')
const jobVersion = ref(0) // Incremented on any job/queue mutation to bust caches
const { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } = useGpsTracking(technicians) const { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } = useGpsTracking(technicians)
@ -54,6 +55,13 @@ export const useDispatchStore = defineStore('dispatch', () => {
salesOrder: j.sales_order || null, salesOrder: j.sales_order || null,
orderSource: j.order_source || 'Manual', orderSource: j.order_source || 'Manual',
published: j.published === undefined ? true : !!j.published, published: j.published === undefined ? true : !!j.published,
continuous: !!j.continuous, // Emergency: span weekends/off-days
// Recurrence fields
isRecurring: !!j.is_recurring,
recurrenceRule: j.recurrence_rule || null, // RRULE string
recurrenceEnd: j.recurrence_end || null, // YYYY-MM-DD or null = indefinite
pausePeriods: j.pause_periods ? (typeof j.pause_periods === 'string' ? JSON.parse(j.pause_periods) : j.pause_periods) : [],
templateId: j.template_id || null, // For materialized instances → parent template
} }
} }
@ -87,37 +95,69 @@ export const useDispatchStore = defineStore('dispatch', () => {
queue: [], queue: [],
tags: (t.tags || []).map(tg => tg.tag), tags: (t.tags || []).map(tg => tg.tag),
tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })), tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })),
extraShifts: t.extra_shifts ? (typeof t.extra_shifts === 'string' ? JSON.parse(t.extra_shifts) : t.extra_shifts) : [],
} }
} }
async function loadAll () { function _rebuildQueues () {
technicians.value.forEach(tech => {
tech.queue = jobs.value.filter(j => j.assignedTech === tech.id).sort((a, b) => a.routeOrder - b.routeOrder)
tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
})
}
async function loadAll (dateRange = null) {
loading.value = true loading.value = true
erpStatus.value = 'pending' erpStatus.value = 'pending'
const t0 = performance.now()
try { try {
// Load techs and tags first (fast), then jobs (slow) — progressive render // All 3 fetches in parallel — techs, tags, jobs
const [rawTechs, rawTags] = await Promise.all([fetchTechnicians(), fetchTags()]) const [rawTechs, rawTags, rawJobs] = await Promise.all([
fetchTechnicians(),
fetchTags(),
fetchJobsFast([['status', 'in', ['open', 'assigned', 'in_progress']]]),
])
allTags.value = rawTags allTags.value = rawTags
technicians.value = rawTechs.map(_mapTech) technicians.value = rawTechs.map(_mapTech)
loading.value = false // show technicians immediately
const rawJobs = await fetchJobs()
jobs.value = rawJobs.map(_mapJob) jobs.value = rawJobs.map(_mapJob)
technicians.value.forEach(tech => { _rebuildQueues()
tech.queue = jobs.value.filter(j => j.assignedTech === tech.id).sort((a, b) => a.routeOrder - b.routeOrder)
tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
})
erpStatus.value = 'ok' erpStatus.value = 'ok'
// Background: load tech tags (child tables) without blocking render
loadTechTags(rawTechs.map(t => t.name)).then(fullDocs => {
for (const doc of fullDocs) {
const tech = technicians.value.find(t => t.name === doc.name)
if (tech && doc.tags?.length) {
tech.tags = doc.tags.map(tg => tg.tag)
tech.tagsWithLevel = doc.tags.map(tg => ({ tag: tg.tag, level: tg.level || 0 }))
}
}
// Tech tags loaded in background
}).catch(() => {})
} catch (e) { } catch (e) {
console.error('[dispatch] loadAll failed:', e)
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error' erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// Load full doc with child tables for a specific job (on demand)
async function loadJobDetails (jobId) {
const job = jobs.value.find(j => j.id === jobId || j.name === jobId)
if (!job) return
try {
const full = await fetchJobFull(job.name)
const mapped = _mapJob(full)
Object.assign(job, mapped)
_rebuildQueues()
} catch {}
}
async function loadJobsForTech (techId) { async function loadJobsForTech (techId) {
loading.value = true loading.value = true
try { try {
const raw = await fetchJobs([['assigned_tech', '=', techId]]) const raw = await fetchJobsFast([['assigned_tech', '=', techId]])
jobs.value = raw.map(_mapJob) jobs.value = raw.map(_mapJob)
} finally { } finally {
loading.value = false loading.value = false
@ -158,6 +198,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) { async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) }) technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
@ -176,6 +217,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
async function unassignJob (jobId) { async function unassignJob (jobId) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) }) technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
@ -232,6 +274,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
async function setJobSchedule (jobId, scheduledDate, startTime) { async function setJobSchedule (jobId, scheduledDate, startTime) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
job.scheduledDate = scheduledDate || null job.scheduledDate = scheduledDate || null
@ -279,6 +322,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
function smartAssign (jobId, newTechId, dateStr) { function smartAssign (jobId, newTechId, dateStr) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
if (job.assistants.some(a => a.techId === newTechId)) { if (job.assistants.some(a => a.techId === newTechId)) {
@ -290,6 +334,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
function fullUnassign (jobId) { function fullUnassign (jobId) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) } if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) }
@ -324,8 +369,8 @@ export const useDispatchStore = defineStore('dispatch', () => {
const stopGpsPolling = stopGpsTracking const stopGpsPolling = stopGpsTracking
return { return {
technicians, jobs, allTags, loading, erpStatus, traccarDevices, technicians, jobs, allTags, loading, erpStatus, jobVersion, traccarDevices,
loadAll, loadJobsForTech, loadAll, loadJobsForTech, loadJobDetails,
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
smartAssign, fullUnassign, publishJobsLocal, smartAssign, fullUnassign, publishJobsLocal,
pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling, pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling,

View File

@ -482,6 +482,8 @@ def _extend_dispatch_job():
"label": "Catégorie ressource", "label": "Catégorie ressource",
"options": "\nVéhicule\nOutil\nSalle\nÉquipement\nNacelle\nGrue\nFusionneuse\nOTDR", "options": "\nVéhicule\nOutil\nSalle\nÉquipement\nNacelle\nGrue\nFusionneuse\nOTDR",
"insert_after": "resource_type"}, "insert_after": "resource_type"},
{"dt": "Dispatch Technician", "fieldname": "extra_shifts", "fieldtype": "Small Text",
"label": "Extra Shifts (JSON)", "insert_after": "weekly_schedule", "hidden": 1},
] ]
for field_def in tech_fields: for field_def in tech_fields:
fieldname = field_def["fieldname"] fieldname = field_def["fieldname"]

View File

@ -0,0 +1,166 @@
'use strict'
const crypto = require('crypto')
const cfg = require('./config')
const { log, json, erpFetch } = require('./helpers')
// ── Token generation/validation (HMAC-SHA256, no DB needed) ─────────────────
const ICAL_SECRET = cfg.ICAL_SECRET || cfg.INTERNAL_TOKEN || 'gigafibre-ical-2026'
function generateToken (techId) {
return crypto.createHmac('sha256', ICAL_SECRET).update(techId).digest('hex').slice(0, 16)
}
function validateToken (techId, token) {
return token === generateToken(techId)
}
// ── iCal date format: 20260408T080000 ───────────────────────────────────────
function icalDate (dateStr, timeStr) {
const [y, m, d] = dateStr.split('-')
if (!timeStr) return `${y}${m}${d}`
const [hh, mm] = timeStr.split(':')
return `${y}${m}${d}T${hh}${mm || '00'}00`
}
function icalNow () {
const d = new Date()
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z/, 'Z')
}
// Escape iCal text values (fold long lines handled by client)
function esc (s) {
if (!s) return ''
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
}
// ── Build VCALENDAR for a tech's schedule ───────────────────────────────────
function buildICal (techName, jobs, techId) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Gigafibre//Dispatch//FR',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${esc(techName)} — Dispatch`,
'X-WR-TIMEZONE:America/Toronto',
// Timezone definition for EST/EDT
'BEGIN:VTIMEZONE',
'TZID:America/Toronto',
'BEGIN:DAYLIGHT',
'DTSTART:19700308T020000',
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU',
'TZOFFSETFROM:-0500',
'TZOFFSETTO:-0400',
'TZNAME:EDT',
'END:DAYLIGHT',
'BEGIN:STANDARD',
'DTSTART:19701101T020000',
'RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU',
'TZOFFSETFROM:-0400',
'TZOFFSETTO:-0500',
'TZNAME:EST',
'END:STANDARD',
'END:VTIMEZONE',
]
for (const job of jobs) {
if (!job.scheduled_date) continue
const startTime = job.start_time || '08:00'
const dur = parseFloat(job.duration_h) || 1
const endMin = Math.round((parseFloat(startTime.split(':')[0]) + parseFloat(startTime.split(':')[1] || 0) / 60 + dur) * 60)
const endHH = String(Math.floor(endMin / 60)).padStart(2, '0')
const endMM = String(endMin % 60).padStart(2, '0')
const endTime = `${endHH}:${endMM}`
const dtStart = icalDate(job.scheduled_date, startTime)
const dtEnd = icalDate(job.scheduled_date, endTime)
const uid = `${job.name || job.ticket_id}@dispatch.gigafibre.ca`
const summary = job.subject || 'Dispatch Job'
const location = job.address || ''
const desc = [
job.customer ? `Client: ${job.customer}` : '',
job.priority ? `Priorité: ${job.priority}` : '',
`Durée: ${dur}h`,
job.status ? `Statut: ${job.status}` : '',
job.notes ? `\nNotes: ${job.notes}` : '',
].filter(Boolean).join('\\n')
// Status mapping
let icalStatus = 'CONFIRMED'
const st = (job.status || '').toLowerCase()
if (st === 'open') icalStatus = 'TENTATIVE'
if (st === 'cancelled') icalStatus = 'CANCELLED'
if (st === 'completed') icalStatus = 'CANCELLED' // don't show completed
lines.push(
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${icalNow()}`,
`DTSTART;TZID=America/Toronto:${dtStart}`,
`DTEND;TZID=America/Toronto:${dtEnd}`,
`SUMMARY:${esc(summary)}`,
location ? `LOCATION:${esc(location)}` : null,
`DESCRIPTION:${desc}`,
`STATUS:${icalStatus}`,
// Recurrence rule (if recurring template)
job.is_recurring && job.recurrence_rule ? `RRULE:${job.recurrence_rule}${job.recurrence_end ? ';UNTIL=' + job.recurrence_end.replace(/-/g, '') + 'T235959' : ''}` : null,
// Color hint (non-standard but supported by some clients)
job.priority === 'high' ? 'PRIORITY:1' : job.priority === 'medium' ? 'PRIORITY:5' : 'PRIORITY:9',
'END:VEVENT',
)
}
lines.push('END:VCALENDAR')
return lines.filter(Boolean).join('\r\n')
}
// ── HTTP handler: GET /dispatch/calendar/:techId.ics?token=xxx ──────────────
async function handleCalendar (req, res, techId, query) {
try {
// Validate token (HMAC-based, no DB)
const token = query?.get?.('token') || ''
if (!validateToken(techId, token)) {
return json(res, 403, { error: 'Invalid or missing token. Get the link from the dispatch app.' })
}
// Fetch tech info
const techRes = await erpFetch(`/api/resource/Dispatch Technician?filters=${encodeURIComponent(JSON.stringify({ technician_id: techId }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name']))}&limit_page_length=1`)
const techs = techRes.data?.data || []
if (!techs.length) return json(res, 404, { error: 'Tech not found' })
const tech = techs[0]
// Fetch jobs for this tech: past 7 days + future 60 days
const now = new Date()
const past = new Date(now); past.setDate(past.getDate() - 7)
const future = new Date(now); future.setDate(future.getDate() + 60)
const fromStr = past.toISOString().slice(0, 10)
const toStr = future.toISOString().slice(0, 10)
const jobRes = await erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
['assigned_tech', '=', techId],
['scheduled_date', '>=', fromStr],
['scheduled_date', '<=', toStr],
['status', '!=', 'cancelled'],
]))}&fields=${encodeURIComponent(JSON.stringify([
'name', 'ticket_id', 'subject', 'address', 'scheduled_date', 'start_time',
'duration_h', 'priority', 'status', 'customer', 'notes',
'is_recurring', 'recurrence_rule', 'recurrence_end',
]))}&limit_page_length=500&order_by=${encodeURIComponent('scheduled_date asc, start_time asc')}`)
const jobs = jobRes.data?.data || []
const ical = buildICal(tech.full_name, jobs, techId)
res.writeHead(200, {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `inline; filename="${techId}.ics"`,
'Cache-Control': 'no-cache, max-age=0',
})
res.end(ical)
} catch (e) {
log('iCal error:', e.message)
json(res, 500, { error: e.message })
}
}
module.exports = { handleCalendar, buildICal, generateToken }

View File

@ -13,6 +13,7 @@ const auth = require('./lib/auth')
const conversation = require('./lib/conversation') const conversation = require('./lib/conversation')
const traccar = require('./lib/traccar') const traccar = require('./lib/traccar')
const dispatch = require('./lib/dispatch') const dispatch = require('./lib/dispatch')
const ical = require('./lib/ical')
const vision = require('./lib/vision') const vision = require('./lib/vision')
let voiceAgent let voiceAgent
try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) } try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) }
@ -73,6 +74,16 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path) if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path) if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)
if (path.startsWith('/api/catalog') || path.startsWith('/api/checkout') || path.startsWith('/api/accept-for-client') || path.startsWith('/api/order') || path.startsWith('/api/address') || path.startsWith('/api/otp')) return require('./lib/checkout').handle(req, res, method, path) if (path.startsWith('/api/catalog') || path.startsWith('/api/checkout') || path.startsWith('/api/accept-for-client') || path.startsWith('/api/order') || path.startsWith('/api/address') || path.startsWith('/api/otp')) return require('./lib/checkout').handle(req, res, method, path)
// iCal token: /dispatch/ical-token/TECH-001 (auth required — returns token for building URL)
const icalTokenMatch = path.match(/^\/dispatch\/ical-token\/(.+)$/)
if (icalTokenMatch && method === 'GET') {
const techId = icalTokenMatch[1]
const token = ical.generateToken(techId)
return json(res, 200, { techId, token, url: `/dispatch/calendar/${techId}.ics?token=${token}` })
}
// iCal feed: /dispatch/calendar/TECH-001.ics?token=xxx (token auth, no SSO)
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path) if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res) if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res) if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res)