feat(service-auto): multi-tenant + tier 3 lookups + D1 partener + AsyncAutoComplete
Refactor izolare multi-tenant:
- Schema Oracle rezolvată din id_firma via CONTAFIN_ORACLE.V_NOM_FIRME (cached 24h)
- server_id propagat din JWT (request.state.server_id) la oracle_pool.get_connection
- Elimină _SCHEMA='MARIUSM_AUTO' și literal 'mariusm_test' din toate query-urile
- Autorizare firmă la router (_company_id): 403 dacă id_firma nu e în JWT companies[]
Tier 3 — lookup endpoints cached 24h:
- GET /asiguratori (DEV_NOM_ASIGURATORI ← NOM_PARTENERI)
- GET /inspectori?id_asigurator=N (DEV_NOM_INSPECTORI per asig)
- GET /operatii (DEV_NOM_NORME)
- GET /parteneri?q=... (typeahead LIKE escape)
- GET /masini/{id}/detalii (VIN, cilindree, putere)
- POST /comenzi: PACK_SERII_NUMERE.aloca_numar + compensating dezaloca;
pc_nr VFP-format prefix+seq/nrinmat; ORA-06512 stripped din detail
D1 PartnerCreateDialog (nou):
- POST /api/service-auto/parteneri → PartnerCreateRequest; 409 pe CUI
duplicat (NOM_PARTENERI fără UNIQUE constraint — check manual);
id_part = MAX+1 cu retry pe ORA-00001 (fără sequence în schema VFP legacy)
- Frontend PartnerCreateDialog.vue — PrimeVue, design tokens, dark-mode safe
- Integrat în ComandaNoua.vue via AutoComplete empty-action hook
Shared AsyncAutoComplete (nou):
- src/shared/components/AsyncAutoComplete.vue — typeahead async debounced
cu emptyAction slot, force-selection, keyboard (Enter/Esc), design tokens
- ComandaNoua.vue refactorizat să folosească shared component
- SupplierDualField (data-entry) skipped — documentat în
docs/service-auto/autocomplete-dual-decision.md (pattern diferit)
Mobile chrome (CLAUDE.md):
- ComandaNoua.vue + ComenziBrowseView.vue: MobileTopBar, BottomSheet
filtre, MobileBottomNav, card list, isMobile resize listener
Migrații grant-uri idempotente:
- ff_2026_04_13_01_AUTO.sql — SELECT/EXECUTE pe tabele Tier 3 + index
IX_NOM_PARTENERI_DEN_UPPER
- ff_2026_04_13_02_AUTO.sql — INSERT pe NOM_PARTENERI pentru D1
Live smoke pe MARIUSM_AUTO: /ping 1ms, /tip-deviz 7, /masini 261,
POST /parteneri id_part=70241, firma neautorizată → 403.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
171
src/shared/components/AsyncAutoComplete.vue
Normal file
171
src/shared/components/AsyncAutoComplete.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<AutoComplete
|
||||
:model-value="modelValue"
|
||||
:suggestions="suggestions"
|
||||
:option-label="optionLabel"
|
||||
:data-key="optionKey"
|
||||
:loading="loading"
|
||||
:min-length="minChars"
|
||||
:delay="debounceMs"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:force-selection="true"
|
||||
:aria-label="ariaLabel"
|
||||
class="w-full async-autocomplete"
|
||||
@complete="onComplete"
|
||||
@update:model-value="onUpdate"
|
||||
@clear="onClear"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="async-ac-option" v-html="highlight(getLabel(slotProps.option))"></span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="async-ac-empty">
|
||||
<div class="async-ac-empty-text">Niciun rezultat găsit.</div>
|
||||
<Button
|
||||
v-if="emptyActionLabel"
|
||||
:label="emptyActionLabel"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
text
|
||||
class="async-ac-empty-action"
|
||||
@click="$emit('emptyAction', lastQuery)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AutoComplete>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, null], default: null },
|
||||
searchFn: { type: Function, required: true },
|
||||
optionLabel: { type: String, default: 'denumire' },
|
||||
optionKey: { type: String, default: 'id' },
|
||||
placeholder: { type: String, default: 'Caută...' },
|
||||
minChars: { type: Number, default: 2 },
|
||||
debounceMs: { type: Number, default: 300 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
emptyActionLabel: { type: String, default: '' },
|
||||
ariaLabel: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'emptyAction'])
|
||||
|
||||
const suggestions = ref([])
|
||||
const loading = ref(false)
|
||||
const lastQuery = ref('')
|
||||
|
||||
function getLabel(item) {
|
||||
if (item == null) return ''
|
||||
return String(item[props.optionLabel] ?? '')
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]))
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function highlight(label) {
|
||||
const safe = escapeHtml(label)
|
||||
const q = lastQuery.value.trim()
|
||||
if (!q) return safe
|
||||
const re = new RegExp(`(${escapeRegExp(escapeHtml(q))})`, 'ig')
|
||||
return safe.replace(re, '<strong>$1</strong>')
|
||||
}
|
||||
|
||||
async function onComplete(event) {
|
||||
const q = (event?.query ?? '').trim()
|
||||
lastQuery.value = q
|
||||
emit('search', q)
|
||||
if (q.length < props.minChars) {
|
||||
suggestions.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await props.searchFn(q)
|
||||
suggestions.value = Array.isArray(result) ? result : []
|
||||
} catch {
|
||||
suggestions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdate(val) {
|
||||
// PrimeVue emits object (after select) or string (during typing with force-selection false).
|
||||
// With force-selection=true, committed value is always an object/null. Pass-through only
|
||||
// objects or null to the parent v-model.
|
||||
if (val && typeof val === 'object') {
|
||||
emit('update:modelValue', val)
|
||||
} else if (val == null || val === '') {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
// During typing (string), do not emit — AutoComplete manages the input text internally.
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
suggestions.value = []
|
||||
lastQuery.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
onClear()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && suggestions.value.length > 0 && !props.modelValue) {
|
||||
event.preventDefault()
|
||||
const first = suggestions.value[0]
|
||||
emit('update:modelValue', first)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.async-autocomplete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.async-ac-option {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.async-ac-option :deep(strong) {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.async-ac-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.async-ac-empty-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.async-ac-empty-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user