Files
roa2web-service-auto/src/shared/components/AsyncAutoComplete.vue
Claude Agent 4397027f36 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>
2026-06-05 09:37:10 +00:00

172 lines
4.3 KiB
Vue

<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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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>