- 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>
299 lines
13 KiB
Vue
299 lines
13 KiB
Vue
<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>
|