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:
parent
a9f8d0c7bf
commit
0c77afdb3b
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
74
apps/ops/src/api/offers.js
Normal file
74
apps/ops/src/api/offers.js
Normal 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' })
|
||||||
|
}
|
||||||
46
apps/ops/src/api/presets.js
Normal file
46
apps/ops/src/api/presets.js
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
298
apps/ops/src/components/shared/RecurrenceSelector.vue
Normal file
298
apps/ops/src/components/shared/RecurrenceSelector.vue
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Google Calendar–style 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 (lun–ven)', 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>
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: [] })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
302
apps/ops/src/composables/useJobOffers.js
Normal file
302
apps/ops/src/composables/useJobOffers.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: 6AM–6PM. Week view: 7AM–8PM.
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
246
apps/ops/src/modules/dispatch/components/CreateOfferModal.vue
Normal file
246
apps/ops/src/modules/dispatch/components/CreateOfferModal.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
204
apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue
Normal file
204
apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue
Normal 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>
|
||||||
|
|
@ -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' }"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (day→week changes periodDays 1→7)
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
loading.value = true
|
|
||||||
erpStatus.value = 'pending'
|
|
||||||
try {
|
|
||||||
// Load techs and tags first (fast), then jobs (slow) — progressive render
|
|
||||||
const [rawTechs, rawTags] = await Promise.all([fetchTechnicians(), fetchTags()])
|
|
||||||
allTags.value = rawTags
|
|
||||||
technicians.value = rawTechs.map(_mapTech)
|
|
||||||
loading.value = false // show technicians immediately
|
|
||||||
|
|
||||||
const rawJobs = await fetchJobs()
|
|
||||||
jobs.value = rawJobs.map(_mapJob)
|
|
||||||
technicians.value.forEach(tech => {
|
technicians.value.forEach(tech => {
|
||||||
tech.queue = jobs.value.filter(j => j.assignedTech === tech.id).sort((a, b) => a.routeOrder - b.routeOrder)
|
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))
|
tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll (dateRange = null) {
|
||||||
|
loading.value = true
|
||||||
|
erpStatus.value = 'pending'
|
||||||
|
const t0 = performance.now()
|
||||||
|
try {
|
||||||
|
// All 3 fetches in parallel — techs, tags, jobs
|
||||||
|
const [rawTechs, rawTags, rawJobs] = await Promise.all([
|
||||||
|
fetchTechnicians(),
|
||||||
|
fetchTags(),
|
||||||
|
fetchJobsFast([['status', 'in', ['open', 'assigned', 'in_progress']]]),
|
||||||
|
])
|
||||||
|
allTags.value = rawTags
|
||||||
|
technicians.value = rawTechs.map(_mapTech)
|
||||||
|
jobs.value = rawJobs.map(_mapJob)
|
||||||
|
_rebuildQueues()
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
166
services/targo-hub/lib/ical.js
Normal file
166
services/targo-hub/lib/ical.js
Normal 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 }
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user