fix(ui): fiche client — en-tête ne déborde plus en mobile ; assistant = sélecteur recherchable

- CustomerHeader : sous-ligne (nom/legacy/type/langue) était `no-wrap` → débordait sa colonne et
  chevauchait le bloc statut/liens en mobile. Fix : wrap + colonne `min-width:0` + le bloc statut/ERPNext/Users
  passe pleine largeur en dessous (<sm) via `col-12 col-sm-auto` ; titre en ellipsis.
- Ajouter un assistant : q-select brut → <TechSelect> (recherche typeahead) ; jdTeamOptions = nom · compétences
  (searchable par nom OU compétence), CAPABLES d'abord (possèdent la compétence du job), value = id ; jdAddAssistant résout le tech par id.

Vérifié préview mobile (375px) : sous-ligne wrap, 0 débordement (scrollW==clientW) ; build vert, leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 17:31:44 -04:00
parent 80c8005f0c
commit 112ed0ea2b
2 changed files with 19 additions and 10 deletions

View File

@ -5,12 +5,12 @@
<div class="col-auto">
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
</div>
<div class="col">
<div class="text-h5 text-weight-bold" style="line-height:1.2">
<div class="col" style="min-width:0">
<div class="text-h5 text-weight-bold ellipsis" style="line-height:1.2">
<InlineField :value="customer.customer_name" field="customer_name" doctype="Customer" :docname="customer.name"
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
</div>
<div class="text-caption text-grey-6 row items-center no-wrap q-gutter-x-xs">
<div class="text-caption text-grey-6 row items-center q-gutter-x-xs" style="flex-wrap:wrap">
<span>{{ customer.name }}</span>
<template v-if="customer.legacy_customer_id"><span>&middot; Legacy: {{ customer.legacy_customer_id }}</span></template>
<span>&middot;</span>
@ -27,7 +27,7 @@
</template>
</div>
</div>
<div class="col-auto text-right">
<div class="col-12 col-sm-auto text-right">
<q-select v-model="customerStatus" dense borderless
:options="statusOptions" emit-value map-options
input-class="editable-input text-weight-bold"

View File

@ -1378,7 +1378,7 @@
</div>
<div v-else class="text-caption text-grey-6 q-mb-xs">Aucun assistant. Ajoutez un renfort un bloc <b>hachuré</b> sera réservé dans son horaire (anti doublebooking).</div>
<div class="row items-center no-wrap q-gutter-xs">
<q-select dense outlined options-dense v-model="jobDetail.teamAdd" :options="jdTeamOptions" emit-value map-options label="Ajouter un assistant" style="flex:1" />
<TechSelect v-model="jobDetail.teamAdd" :options="jdTeamOptions" label="Ajouter un assistant (nom · compétence)" style="flex:1;min-width:0" />
<q-btn dense unelevated color="deep-purple" icon="group_add" label="Ajouter" :disable="!jobDetail.teamAdd" @click="jdAddAssistant" no-caps />
</div>
</div>
@ -2186,16 +2186,25 @@ const geoTimeline = computed(() => {
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
})
// Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe.
const jdTeamOptions = computed(() => { const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)]); return (techs.value || []).filter(t => !taken.has(t.id)).map(t => ({ label: t.name, value: { id: t.id, name: t.name } })) })
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
const jdTeamOptions = computed(() => {
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
const req = jobDetail.required_skill || jobDetail.skill || ''
return (techs.value || []).filter(t => !taken.has(t.id))
.map(t => { const sk = (t.skills || []); return { capable: !!(req && sk.includes(req)), label: t.name + (sk.length ? ' · ' + sk.slice(0, 4).join(', ') : ''), value: t.id } })
.sort((a, b) => (b.capable - a.capable) || a.label.localeCompare(b.label))
.map(({ label, value }) => ({ label, value }))
})
async function jdAddAssistant () {
const t = jobDetail.teamAdd; if (!t || !jobDetail.name) return
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
const tech = (techs.value || []).find(t => t.id === id); if (!tech) return
try {
await roster.addAssistant(jobDetail.name, { tech_id: t.id, tech_name: t.name, duration_h: 0, pinned: 1 })
await roster.addAssistant(jobDetail.name, { tech_id: tech.id, tech_name: tech.name, duration_h: 0, pinned: 1 })
jobDetail.teamAdd = null
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
await reloadOccupancy()
$q.notify({ type: 'positive', icon: 'group_add', message: t.name + ' ajouté en renfort · bloc hachuré réservé dans son horaire', timeout: 2600 })
$q.notify({ type: 'positive', icon: 'group_add', message: tech.name + ' ajouté en renfort · bloc hachuré réservé dans son horaire', timeout: 2600 })
} catch (e) { err(e) }
}
async function jdRemoveAssistant (techId) {