Suite de la consolidation des énumérations partagées (après priorités). - config/job-types.js (NOUVEAU) : JOB_TYPE_OPTIONS + JOB_TYPES (Installation/Réparation/ Maintenance/Retrait/Dépannage/Autre) = SOURCE UNIQUE - élimine 3 copies : wizard-constants (ré-exporte désormais), TaskNode, UnifiedCreateModal, flow-editor/kind-catalogs (JOB_TYPES) - LAISSÉS volontairement (vocabulaires DISTINCTS, pas des doublons) : statuts de job (cycle de vie ≠ actions du pool ≠ ticket ≠ campagne ≠ appareil) ; issueTypeOptions ; jobTypes de l'app terrain (valeurs anglaises Repair/Delivery/Other — à réconcilier séparément, risque de données) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
14 KiB
Vue
322 lines
14 KiB
Vue
<template>
|
|
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
|
|
<q-card style="width:580px;max-width:90vw">
|
|
<!-- Header -->
|
|
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
|
|
<div class="col">
|
|
<div class="text-subtitle1 text-weight-bold">{{ title }}</div>
|
|
<div v-if="contextLabel" class="text-caption text-grey-6">{{ contextLabel }}</div>
|
|
</div>
|
|
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
|
|
</q-card-section>
|
|
|
|
<!-- Form -->
|
|
<q-card-section class="q-pt-md q-gutter-sm">
|
|
<q-input v-model="form.subject" label="Sujet" dense outlined autofocus
|
|
:rules="[v => !!v?.trim() || 'Requis']" />
|
|
|
|
<q-select v-model="form.priority" :options="priorityOptions" label="Priorité"
|
|
dense outlined emit-value map-options />
|
|
|
|
<!-- Description / Notes (essentiel — le contenu du ticket/tâche) -->
|
|
<q-input v-model="form.description" :label="mode === 'work-order' ? 'Notes' : 'Description'"
|
|
type="textarea" dense outlined autogrow style="min-height:60px" />
|
|
|
|
<!-- Bascule : n'afficher d'abord que l'essentiel ; révéler le reste à la demande -->
|
|
<q-btn flat dense no-caps size="sm" class="text-grey-7"
|
|
:icon="showAdvanced ? 'expand_less' : 'expand_more'"
|
|
:label="showAdvanced ? 'Moins d\'options' : 'Plus d\'options'"
|
|
@click="showAdvanced = !showAdvanced" />
|
|
|
|
<!-- ══ Champs secondaires — révélés à la demande ══ -->
|
|
<div v-show="showAdvanced" class="q-gutter-sm">
|
|
<div class="row q-gutter-sm">
|
|
<q-select v-if="fields.showJobType" v-model="form.job_type" :options="jobTypeOptions"
|
|
label="Type de travail" dense outlined emit-value map-options class="col" />
|
|
|
|
<q-select v-if="fields.showIssueType" v-model="form.issue_type" :options="issueTypeOptions"
|
|
label="Type" dense outlined emit-value map-options clearable class="col" />
|
|
</div>
|
|
|
|
<!-- Service location (ticket mode, when locations available) -->
|
|
<q-select v-if="fields.showServiceLocation && locationOptions.length"
|
|
v-model="form.service_location" :options="locationOptions"
|
|
label="Adresse" dense outlined emit-value map-options clearable />
|
|
|
|
<!-- Address autocomplete (work-order mode) -->
|
|
<div v-if="fields.showAddress">
|
|
<q-input v-model="form.address" label="Adresse" dense outlined
|
|
@update:model-value="searchAddr" />
|
|
<q-list v-if="addrResults.length" dense bordered class="q-mt-xs" style="border-radius:6px;max-height:160px;overflow-y:auto">
|
|
<q-item v-for="a in addrResults" :key="a.address_full" clickable @click="onSelectAddr(a)">
|
|
<q-item-section>
|
|
<q-item-label class="text-caption">{{ a.address_full }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
|
|
<!-- Duration -->
|
|
<q-input v-if="fields.showDuration" v-model.number="form.duration_h"
|
|
label="Durée (heures)" type="number" dense outlined min="0.25" step="0.5" />
|
|
|
|
<!-- Tags/Skills -->
|
|
<div v-if="fields.showTags">
|
|
<div class="text-caption text-grey-7 q-mb-xs">Tags / Skills</div>
|
|
<TagEditor
|
|
v-model="form.tags"
|
|
:all-tags="allTagsList"
|
|
:get-color="getTagColor"
|
|
:can-create="true"
|
|
:can-edit="true"
|
|
:show-level="mode === 'work-order'"
|
|
:show-required="mode === 'work-order'"
|
|
placeholder="Ajouter un tag…"
|
|
@create="onCreateTag"
|
|
@update-tag="onUpdateTag"
|
|
@rename-tag="onRenameTag"
|
|
@delete-tag="onDeleteTag"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Parent dependency -->
|
|
<q-select v-model="form.depends_on" label="Dépendance parent (optionnel)"
|
|
dense outlined clearable use-input hide-selected fill-input
|
|
:options="parentOpts" emit-value map-options
|
|
@filter="onParentFilter" input-debounce="0">
|
|
<template v-slot:no-option>
|
|
<q-item>
|
|
<q-item-section class="text-grey text-caption">Aucun résultat</q-item-section>
|
|
</q-item>
|
|
</template>
|
|
<template v-slot:option="{ opt, itemProps }">
|
|
<q-item v-bind="itemProps">
|
|
<q-item-section side style="min-width:48px">
|
|
<q-badge :color="opt.type === 'Issue' ? 'green-3' : 'teal-3'" :label="opt.type === 'Issue' ? 'Ticket' : 'Tâche'" />
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label class="text-caption">{{ opt.label }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</template>
|
|
</q-select>
|
|
|
|
<!-- Technician + Optimal (work-order) -->
|
|
<div v-if="fields.showTech && techOptions.length">
|
|
<div class="row items-center q-gutter-sm">
|
|
<q-select v-model="form.assigned_tech" :options="techOptions" label="Technicien"
|
|
dense outlined clearable emit-value map-options class="col" />
|
|
<q-btn unelevated color="deep-purple-6" icon="bolt" label="Optimal"
|
|
:loading="findingBest" @click="findBestTech" dense style="height:40px" />
|
|
</div>
|
|
<!-- Best tech ranking -->
|
|
<div v-if="ranking.length" class="best-tech-ranking q-mt-sm">
|
|
<div class="ranking-header row items-center justify-between q-px-sm q-py-xs">
|
|
<span class="text-caption text-weight-bold">Classement optimal</span>
|
|
<q-btn flat dense round size="xs" icon="close" @click="ranking = []" />
|
|
</div>
|
|
<q-list dense separator>
|
|
<q-item v-for="(r, idx) in ranking" :key="r.techId" clickable
|
|
@click="form.assigned_tech = r.techId; ranking = []"
|
|
:class="{ 'bg-deep-purple-1': form.assigned_tech === r.techId }">
|
|
<q-item-section avatar style="min-width:32px">
|
|
<span v-if="idx === 0" style="font-size:18px">🥇</span>
|
|
<span v-else-if="idx === 1" style="font-size:18px">🥈</span>
|
|
<span v-else-if="idx === 2" style="font-size:18px">🥉</span>
|
|
<span v-else class="text-grey-6 text-caption">#{{ idx + 1 }}</span>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label class="text-weight-medium">{{ r.techName }}</q-item-label>
|
|
<q-item-label caption>{{ r.reasons.join(' · ') }}</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-btn flat dense size="sm" color="deep-purple" label="Choisir"
|
|
@click.stop="form.assigned_tech = r.techId; ranking = []" />
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scheduled date -->
|
|
<q-input v-if="fields.showScheduledDate" v-model="form.scheduled_date"
|
|
label="Date prévue (optionnel)" type="date" dense outlined />
|
|
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<!-- Actions -->
|
|
<q-card-actions align="right" class="q-px-md q-pb-md">
|
|
<q-btn flat label="Annuler" color="grey-7" @click="$emit('update:modelValue', false)" />
|
|
<q-btn unelevated :label="submitLabel" color="primary"
|
|
:loading="submitting" :disable="!form.subject?.trim()" @click="onSubmit" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch, toRef } from 'vue'
|
|
import { Notify } from 'quasar'
|
|
import { useUnifiedCreate } from 'src/composables/useUnifiedCreate'
|
|
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
|
import TagEditor from 'src/components/shared/TagEditor.vue'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Boolean, default: false },
|
|
mode: { type: String, default: 'ticket' }, // 'ticket' | 'task' | 'work-order'
|
|
context: { type: Object, default: () => ({}) }, // pre-fill: customer, subject, service_location, etc.
|
|
technicians: { type: Array, default: () => [] },
|
|
locations: { type: Array, default: () => [] }, // for ticket mode: customer's service locations
|
|
existingJobs: { type: Array, default: () => [] },
|
|
// Tag overrides (if provided, uses these instead of internal fetch)
|
|
externalTags: { type: Array, default: null },
|
|
externalGetColor:{ type: Function, default: null },
|
|
// Custom submit handler — if provided, replaces default submit logic
|
|
// Receives form data object, should return truthy on success
|
|
submitHandler: { type: Function, default: null },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'created'])
|
|
|
|
// ── Composables ─────────────────────────────────────────────────────────────
|
|
const {
|
|
form, fields, submitting, resetForm, submit,
|
|
loadTags, allTagsList, getTagColor: internalGetTagColor,
|
|
onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
|
|
parentOptions, searchParent,
|
|
findingBest, ranking, findBestTech,
|
|
} = useUnifiedCreate(toRef(props, 'mode'), {
|
|
allTags: props.externalTags,
|
|
getTagColor: props.externalGetColor,
|
|
})
|
|
|
|
const { addrResults, searchAddr, selectAddr } = useAddressSearch()
|
|
|
|
// Divulgation progressive : on n'affiche d'abord que l'essentiel (sujet · priorité · description). « Plus d'options » révèle le reste.
|
|
const showAdvanced = ref(false)
|
|
|
|
// ── Computed ────────────────────────────────────────────────────────────────
|
|
const title = computed(() => {
|
|
const m = props.mode
|
|
if (m === 'ticket') return 'Nouveau ticket'
|
|
if (m === 'task') return 'Nouvelle tâche'
|
|
return 'Nouveau travail de dispatch'
|
|
})
|
|
|
|
const submitLabel = computed(() => {
|
|
if (props.mode === 'ticket') return 'Créer ticket'
|
|
if (props.mode === 'task') return 'Créer tâche'
|
|
return 'Créer travail'
|
|
})
|
|
|
|
const contextLabel = computed(() => {
|
|
const c = props.context
|
|
if (c.source_issue) return c.source_issue
|
|
if (c.customer) return c.customer
|
|
if (c.locationLabel) return c.locationLabel
|
|
return ''
|
|
})
|
|
|
|
const allTagsComputed = computed(() => props.externalTags || allTagsList.value)
|
|
const getTagColor = computed(() => props.externalGetColor || internalGetTagColor)
|
|
|
|
// Priorité Dispatch Job — SOURCE UNIQUE (3 niveaux ; « Urgent » = high, le champ Select rejette 'urgent' → 417).
|
|
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
|
|
|
// Types de job — SOURCE UNIQUE (config/job-types).
|
|
import { JOB_TYPE_OPTIONS as jobTypeOptions } from 'src/config/job-types'
|
|
|
|
const issueTypeOptions = [
|
|
{ label: 'Support', value: 'Support' },
|
|
{ label: 'Installation', value: 'Installation' },
|
|
{ label: 'Déménagement', value: 'Demenagement' },
|
|
{ label: 'Facturation', value: 'Facturation' },
|
|
{ label: 'Réseau', value: 'Reseau' },
|
|
{ label: 'Autre', value: 'Autre' },
|
|
]
|
|
|
|
const locationOptions = computed(() =>
|
|
(props.locations || []).map(l => ({
|
|
label: l.address_line || l.location_name || l.name,
|
|
value: l.name,
|
|
}))
|
|
)
|
|
|
|
const techOptions = computed(() =>
|
|
(props.technicians || []).map(t => ({
|
|
label: t.full_name || t.fullName || t.name,
|
|
value: t.technician_id || t.id,
|
|
}))
|
|
)
|
|
|
|
const parentOpts = computed(() => parentOptions.value)
|
|
|
|
// ── Watchers ────────────────────────────────────────────────────────────────
|
|
watch(() => props.modelValue, (open) => {
|
|
if (open) {
|
|
resetForm(props.context)
|
|
// Pre-select location if context provides it
|
|
if (props.context.service_location) {
|
|
form.service_location = props.context.service_location
|
|
}
|
|
// Divulgation progressive : ouvrir les options d'emblée SEULEMENT si nécessaires (intervention = adresse+tech) ou déjà pré-remplies par le contexte.
|
|
showAdvanced.value = props.mode === 'work-order' || !!(props.context.service_location || props.context.issue_type || props.context.job_type || props.context.duration_h || props.context.scheduled_date || props.context.depends_on || (props.context.tags && props.context.tags.length))
|
|
// Load tags if in a mode that uses them
|
|
if (fields.value.showTags && !props.externalTags) {
|
|
loadTags()
|
|
}
|
|
}
|
|
})
|
|
|
|
// ── Methods ─────────────────────────────────────────────────────────────────
|
|
function onSelectAddr (addr) {
|
|
selectAddr(addr, form)
|
|
}
|
|
|
|
function onParentFilter (val, update) {
|
|
searchParent(val, (results) => {
|
|
update(() => {
|
|
parentOptions.value = results
|
|
})
|
|
})
|
|
}
|
|
|
|
async function onSubmit () {
|
|
if (!form.subject?.trim()) return
|
|
if (props.submitHandler) {
|
|
// Custom handler — pass raw form data, let parent manage store + close
|
|
submitting.value = true
|
|
try {
|
|
const result = await props.submitHandler({ ...form, tags: [...form.tags] })
|
|
if (result !== false) {
|
|
emit('created', result)
|
|
emit('update:modelValue', false)
|
|
}
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
return
|
|
}
|
|
const result = await submit()
|
|
if (result) {
|
|
emit('created', result)
|
|
emit('update:modelValue', false)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.best-tech-ranking {
|
|
border: 1px solid var(--ops-border, #e2e8f0);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.ranking-header {
|
|
background: var(--ops-surface, #f8fafc);
|
|
border-bottom: 1px solid var(--ops-border, #e2e8f0);
|
|
}
|
|
</style>
|