gigafibre-fsm/apps/ops/src/components/shared/InlineField.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

244 lines
6.5 KiB
Vue

<template>
<span
class="inline-field"
:class="{
'inline-field--editing': editing,
'inline-field--saving': saving,
'inline-field--readonly': readonly,
}"
@dblclick.stop="startEdit"
>
<!-- Display mode -->
<template v-if="!editing">
<slot name="display" :value="value" :display-value="displayValue" :start-edit="startEdit">
<span class="inline-field__value" :class="{ 'inline-field__placeholder': !displayValue, 'inline-field__value--multiline': type === 'textarea' }">
{{ displayValue || placeholder }}
</span>
</slot>
<q-spinner v-if="saving" size="12px" color="primary" class="q-ml-xs" />
</template>
<!-- Edit mode: text / number -->
<q-input
v-else-if="type === 'text' || type === 'number'"
ref="inputRef"
v-model="editValue"
:type="type === 'number' ? 'number' : 'text'"
dense
borderless
:input-class="'inline-field__input'"
:autofocus="true"
@keydown.enter.prevent="commitEdit"
@keydown.esc.prevent="cancelEdit"
@blur="onBlur"
/>
<!-- Edit mode: textarea -->
<q-input
v-else-if="type === 'textarea'"
ref="inputRef"
v-model="editValue"
type="textarea"
dense
borderless
autogrow
:input-class="'inline-field__input'"
:autofocus="true"
@keydown.esc.prevent="cancelEdit"
@keydown.ctrl.enter="commitEdit"
@keydown.meta.enter="commitEdit"
@blur="onBlur"
/>
<!-- Edit mode: select -->
<q-select
v-else-if="type === 'select'"
ref="inputRef"
v-model="editValue"
:options="options"
dense
borderless
emit-value
map-options
:input-class="'inline-field__input'"
:autofocus="true"
@update:model-value="commitEdit"
@keydown.esc.prevent="cancelEdit"
/>
<!-- Edit mode: date -->
<q-input
v-else-if="type === 'date'"
ref="inputRef"
v-model="editValue"
type="date"
dense
borderless
:input-class="'inline-field__input'"
:autofocus="true"
@keydown.enter.prevent="commitEdit"
@keydown.esc.prevent="cancelEdit"
@blur="onBlur"
/>
</span>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { useInlineEdit } from 'src/composables/useInlineEdit'
const props = defineProps({
/** Current field value */
value: { type: [String, Number, null], default: '' },
/** Field name in the doctype */
field: { type: String, required: true },
/** ERPNext doctype */
doctype: { type: String, required: true },
/** ERPNext document name */
docname: { type: String, required: true },
/** Input type: text, number, textarea, select, date */
type: { type: String, default: 'text' },
/** Options for select type — array of strings or { label, value } */
options: { type: Array, default: () => [] },
/** Placeholder when value is empty */
placeholder: { type: String, default: '—' },
/** If true, disable editing */
readonly: { type: Boolean, default: false },
/** Custom display formatter */
formatter: { type: Function, default: null },
})
const emit = defineEmits(['saved', 'editing'])
const editing = ref(false)
const editValue = ref('')
const inputRef = ref(null)
const committing = ref(false) // prevents double-fire from blur + enter/select
const { save, saving } = useInlineEdit()
const displayValue = computed(() => {
if (props.formatter) return props.formatter(props.value)
if (props.type === 'select' && props.options.length) {
const opt = props.options.find(o => (typeof o === 'object' ? o.value : o) === props.value)
return opt ? (typeof opt === 'object' ? opt.label : opt) : props.value
}
return props.value != null && props.value !== '' ? String(props.value) : ''
})
function startEdit () {
if (props.readonly || editing.value) return
editValue.value = props.value ?? ''
editing.value = true
committing.value = false
emit('editing', true)
nextTick(() => {
const el = inputRef.value
if (el) {
if (typeof el.focus === 'function') el.focus()
if (props.type === 'select' && typeof el.showPopup === 'function') el.showPopup()
}
})
}
// Blur handler with small delay to let Enter/select fire first
function onBlur () {
// Delay so Enter keydown or select @update fires first and sets committing
setTimeout(() => {
if (!committing.value && editing.value) commitEdit()
}, 80)
}
async function commitEdit () {
if (!editing.value || committing.value) return
committing.value = true
const newVal = props.type === 'number' ? Number(editValue.value) : editValue.value
editing.value = false
emit('editing', false)
// Skip save if value unchanged
if (newVal === props.value) {
committing.value = false
return
}
const ok = await save(props.doctype, props.docname, props.field, newVal)
if (ok) {
emit('saved', { field: props.field, value: newVal, doctype: props.doctype, docname: props.docname })
}
committing.value = false
}
function cancelEdit () {
committing.value = true // prevent blur from firing after cancel
editing.value = false
emit('editing', false)
setTimeout(() => { committing.value = false }, 100)
}
/** Allow parent to trigger edit programmatically */
defineExpose({ startEdit })
</script>
<style lang="scss">
/* Champs multi-lignes (description, notes) : conserver les sauts de ligne en lecture (sinon tout est aplati en un paragraphe) */
.inline-field__value--multiline { white-space: pre-wrap; word-break: break-word; }
.inline-field {
display: inline-flex;
align-items: center;
min-width: 30px;
border-radius: 4px;
transition: background 0.15s;
cursor: default;
&:not(&--readonly):not(&--editing) {
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
&--editing {
background: #e0f2fe;
}
&--saving {
opacity: 0.7;
}
&__value {
padding: 1px 4px;
min-height: 1.4em;
line-height: 1.4;
}
&__placeholder {
color: #9e9e9e;
font-style: italic;
}
&__input {
font-size: inherit !important;
line-height: 1.4 !important;
padding: 1px 4px !important;
min-height: 1.4em !important;
}
// Override Quasar input padding when inline
.q-field__control {
min-height: unset !important;
height: auto !important;
padding: 0 !important;
}
.q-field__native {
padding: 0 !important;
min-height: unset !important;
}
.q-field--dense .q-field__control {
min-height: unset !important;
}
}
</style>