feat(planif): native OPS job creation (#17) — Dispatch deprecated, create from Planification

Job/installation creation now lives in Planification (Dispatch board is
being retired). Reuses the shared UnifiedCreateModal (work-order mode:
subject, job_type, address+RQA geocode, duration, skills/tags, tech,
date) so it's the same proven form.

- "Créer un job" button (desktop toolbar + mobile ⋮ menu) → modal →
  createJob → @created refreshes the pool ("À assigner") + occupancy.
- "Créneau" button → SuggestSlotsDialog (P1-C, re-homed from Dispatch) →
  pick tech+date+time → prefills the create modal.
- useUnifiedCreate: work-order flow now carries start_time + prefilled
  coords (from a booked slot) through resetForm → payload.

Sync F transitoire: OPS-native jobs have NO legacy_ticket_id, so the F→
OPS sync and purgeStaleOrphans (which only touch legacy_ticket_id jobs)
never clobber them — they coexist. No F push for now (billing stays via
the sales path).

Verified: modal opens in Planification with all 9 work-order fields;
button in toolbar (gt-sm) + mobile menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 09:05:32 -04:00
parent a7a6baf8b5
commit df23f232b3
2 changed files with 29 additions and 0 deletions

View File

@ -41,6 +41,7 @@ export function useUnifiedCreate (mode, opts = {}) {
address: '', address: '',
assigned_tech: null, assigned_tech: null,
scheduled_date: '', scheduled_date: '',
start_time: '', // heure du créneau (réservation « Trouver un créneau ») — optionnel
depends_on: null, // parent dependency (ticket/task/job name) depends_on: null, // parent dependency (ticket/task/job name)
tags: [], // [{ tag, level?, required? }] or ['label', ...] tags: [], // [{ tag, level?, required? }] or ['label', ...]
_latitude: null, _latitude: null,
@ -217,6 +218,9 @@ export function useUnifiedCreate (mode, opts = {}) {
form.address = ctx.address || '' form.address = ctx.address || ''
form.assigned_tech = ctx.assigned_tech || null form.assigned_tech = ctx.assigned_tech || null
form.scheduled_date = ctx.scheduled_date || '' form.scheduled_date = ctx.scheduled_date || ''
form.start_time = ctx.start_time || ''
form._latitude = ctx._latitude != null ? ctx._latitude : (ctx.latitude != null ? ctx.latitude : null) // coords pré-remplies (réservation par créneau)
form._longitude = ctx._longitude != null ? ctx._longitude : (ctx.longitude != null ? ctx.longitude : null)
form.depends_on = ctx.depends_on || null form.depends_on = ctx.depends_on || null
form.tags = ctx.tags || [] form.tags = ctx.tags || []
form._latitude = ctx.latitude || null form._latitude = ctx.latitude || null
@ -280,6 +284,7 @@ export function useUnifiedCreate (mode, opts = {}) {
service_location: form.service_location || '', service_location: form.service_location || '',
depends_on: form.depends_on || '', depends_on: form.depends_on || '',
scheduled_date: form.scheduled_date || (form.assigned_tech ? nextWeekday() : ''), scheduled_date: form.scheduled_date || (form.assigned_tech ? nextWeekday() : ''),
start_time: form.start_time || '', // heure du créneau réservé (sinon vide = non planifié à l'heure)
notes: form.description || '', notes: form.description || '',
assigned_tech: form.assigned_tech || '', assigned_tech: form.assigned_tech || '',
latitude: form._latitude || '', latitude: form._latitude || '',

View File

@ -19,6 +19,8 @@
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="dashboard" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch</q-item-section></q-item> <q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="dashboard" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch</q-item-section></q-item>
<q-separator /> <q-separator />
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item> <q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
<q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item> <q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item>
<q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item> <q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item>
</q-list> </q-list>
@ -52,6 +54,9 @@
<q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge> <q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge>
<q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip> <q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip>
</q-btn> </q-btn>
<!-- Création de job NATIVE OPS (#17) Dispatch déprécié tout ici. « Créer » = pool · « Créneau » = choisir tech+date+heure d'abord -->
<q-btn dense unelevated no-caps color="teal-7" icon="add_task" label="Créer un job" @click="openCreateJob"><q-tooltip>Créer un job / une installation (apparaît dans « À assigner »)</q-tooltip></q-btn>
<q-btn dense outline no-caps color="teal-8" icon="event_available" label="Créneau" @click="showSuggestSlots = true"><q-tooltip>Trouver le meilleur créneau (tech + date + heure) puis créer le job réservé</q-tooltip></q-btn>
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer --> <!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps> <q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
<q-list dense style="min-width:230px"> <q-list dense style="min-width:230px">
@ -1723,6 +1728,12 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- #17 Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created rafraîchit le pool. -->
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
<!-- « Trouver un créneau » choisit tech+date+heure puis pré-remplit la création -->
<SuggestSlotsDialog v-model="showSuggestSlots" @select="onSlotSelected" />
</q-page> </q-page>
</template> </template>
@ -1764,6 +1775,8 @@ import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue' import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() const router = useRouter()
@ -2011,6 +2024,17 @@ async function fetchKbMatrix (techId) {
let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle
const kbColor = (j) => j.skill ? getTagColor(j.skill) : (j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae')) const kbColor = (j) => j.skill ? getTagColor(j.skill) : (j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae'))
async function reloadPool () { try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { /* non bloquant */ } } async function reloadPool () { try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { /* non bloquant */ } }
// Création de job NATIVE OPS (#17) Dispatch déprécié tout depuis Planification. Réutilise UnifiedCreateModal (work-order).
// Job OPS-natif = PAS de legacy_ticket_id coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
const createJobOpen = ref(false)
const createJobCtx = ref({})
const showSuggestSlots = ref(false)
function openCreateJob () { createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
function onSlotSelected (slot) { // un créneau choisi pré-remplit tech+date+heure+adresse+coords
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude }
createJobOpen.value = true
}
async function onJobCreated () { createJobOpen.value = false; await reloadPool(); await reloadOccupancy(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Job créé — visible dans « À assigner »', timeout: 2600 }) }
// Drop sur la colonne « À assigner » = renvoyer au pool (désassigner, ERPNext only). // Drop sur la colonne « À assigner » = renvoyer au pool (désassigner, ERPNext only).
async function onKanbanUnassign (ev) { async function onKanbanUnassign (ev) {
ev.preventDefault() ev.preventDefault()