Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
6.5 KiB
Vue
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="indigo-6" 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>
|