gigafibre-fsm/apps/ops/src/components/shared/RecurrenceSelector.vue
louispaulb 9b9d307bdf feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- Planning mode toggle: shift availability as background blocks on timeline
  (week view shows green=available, yellow=on-call; month view per-tech)
- On-call/guard shift editor with RRULE recurrence on tech schedules
- Uber-style job offer pool: broadcast/targeted/pool modes with pricing,
  SMS notifications, accept/decline flow, overload detection alerts
- Shared resource group presets via ERPNext Dispatch Preset doctype
  (replaces localStorage, shared between supervisors)
- Google Calendar-style RecurrenceSelector component with contextual
  quick options + custom RRULE editor, integrated in booking overlay
  and extra shift editor
- Remove default "Repos" ghost chips — only visible in planning mode
- Clean up debug console.logs across API, store, and page layers
- Add extra_shifts Custom Field on Dispatch Technician doctype

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:44:18 -04:00

299 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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