gigafibre-fsm/apps/ops/src/components/shared/UnifiedCreateModal.vue
louispaulb 675e1b0bdd ops : types de job = source unique (config/job-types)
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>
2026-07-07 21:27:32 -04:00

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">&#x1F947;</span>
<span v-else-if="idx === 1" style="font-size:18px">&#x1F948;</span>
<span v-else-if="idx === 2" style="font-size:18px">&#x1F949;</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>