Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs
This commit is contained in:
@@ -1,72 +1,257 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Добавить на карту</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Контакт</label>
|
||||
<SearchableSelect
|
||||
v-model="selectedContactId"
|
||||
:options="contactOptions"
|
||||
placeholder="Выберите контакт..."
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedContactId"
|
||||
@click="onAdd"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-overlay add-contact-modal" @click.self="onClose">
|
||||
<div class="modal" role="dialog" aria-labelledby="add-contact-title">
|
||||
<div class="modal-header">
|
||||
<h3 id="add-contact-title">Добавить на карту</h3>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="onClose">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="add-contact-search">Поиск контакта</label>
|
||||
<input
|
||||
id="add-contact-search"
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="form-control"
|
||||
placeholder="Введите имя..."
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="selectFirstResult"
|
||||
/>
|
||||
<p v-if="!searchQuery.trim()" class="field-hint text-muted">
|
||||
Начните вводить имя — появится список контактов.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="searchQuery.trim()" class="contact-results">
|
||||
<p v-if="searching" class="text-muted results-status">Поиск…</p>
|
||||
<p v-else-if="!searchResults.length" class="text-muted results-status">Ничего не найдено</p>
|
||||
<button
|
||||
v-for="contact in searchResults"
|
||||
:key="contact.id"
|
||||
type="button"
|
||||
class="contact-result"
|
||||
:class="{ 'is-selected': String(selectedContactId) === String(contact.id) }"
|
||||
@click="selectContact(contact)"
|
||||
>
|
||||
<span class="contact-result__name">{{ contact.name }}</span>
|
||||
<span v-if="contact.organization" class="contact-result__meta">{{ contact.organization }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedContact" class="selected-summary">
|
||||
Выбран: <strong>{{ selectedContact.name }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="onClose">Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedContactId || saving"
|
||||
@click="submit"
|
||||
>
|
||||
{{ saving ? 'Добавление…' : 'Добавить' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { listContacts } from '../application/usecases/contacts'
|
||||
import { normalizeApiError } from '../lib/api/errors'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
contacts: { type: Array, default: () => [] },
|
||||
memberContactIds: { type: Array, default: () => [] },
|
||||
onAdd: { type: Function, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close', 'add'])
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const selectedContactId = ref('')
|
||||
const searching = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
let searchTimer = null
|
||||
let searchRequestId = 0
|
||||
|
||||
const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
|
||||
|
||||
const contactOptions = computed(() =>
|
||||
props.contacts
|
||||
.filter((c) => !memberSet.value.has(String(c.id)))
|
||||
.map((c) => ({ value: String(c.id), label: c.name }))
|
||||
const selectedContact = computed(() =>
|
||||
searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
|
||||
)
|
||||
|
||||
function resetState() {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
selectedContactId.value = ''
|
||||
searching.value = false
|
||||
saving.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function selectContact(contact) {
|
||||
selectedContactId.value = String(contact.id)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function selectFirstResult() {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectContact(first)
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
const requestId = ++searchRequestId
|
||||
searching.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const rows = await listContacts(query)
|
||||
if (requestId !== searchRequestId) return
|
||||
searchResults.value = rows.filter((c) => !memberSet.value.has(String(c.id)))
|
||||
if (
|
||||
selectedContactId.value
|
||||
&& !searchResults.value.some((c) => String(c.id) === String(selectedContactId.value))
|
||||
) {
|
||||
selectedContactId.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== searchRequestId) return
|
||||
searchResults.value = []
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) selectedContactId.value = ''
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
resetState()
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
)
|
||||
|
||||
function onAdd() {
|
||||
if (!selectedContactId.value) return
|
||||
emit('add', selectedContactId.value)
|
||||
watch(searchQuery, (value) => {
|
||||
clearTimeout(searchTimer)
|
||||
selectedContactId.value = ''
|
||||
error.value = ''
|
||||
|
||||
const query = value.trim()
|
||||
if (!query) {
|
||||
searchResults.value = []
|
||||
searching.value = false
|
||||
return
|
||||
}
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
runSearch(query)
|
||||
}, 250)
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!selectedContactId.value || saving.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await props.onAdd(selectedContactId.value)
|
||||
} catch (e) {
|
||||
error.value = normalizeApiError(e).message
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-contact-modal {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.field-hint,
|
||||
.results-status {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contact-results {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.contact-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.contact-result:hover,
|
||||
.contact-result.is-selected {
|
||||
background: rgba(91, 141, 238, 0.12);
|
||||
}
|
||||
|
||||
.contact-result__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contact-result__meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selected-summary {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--red, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-overlay" @click.self="onCancel">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Новый контакт</h3>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="onCancel">✕</button>
|
||||
</div>
|
||||
<ContactForm
|
||||
:initial="{}"
|
||||
:initial-map-ids="initialMapIds"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ContactForm from './ContactForm.vue'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
initialMapIds: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
function onCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSubmit(contactData, mapIds, pluginPayload) {
|
||||
emit('created', contactData, mapIds, pluginPayload)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu-overlay"
|
||||
@click="close"
|
||||
@contextmenu.prevent="close"
|
||||
/>
|
||||
<div
|
||||
v-if="open"
|
||||
class="graph-node-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="menu"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
|
||||
Добавить контакт
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'create-contact'])
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onCreateContact() {
|
||||
emit('create-contact')
|
||||
close()
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === 'Escape' && props.open) close()
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) window.addEventListener('keydown', onKeyDown)
|
||||
else window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) window.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-node-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
.graph-node-menu {
|
||||
position: fixed;
|
||||
z-index: 901;
|
||||
min-width: 180px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.graph-node-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-node-menu__item:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<ul v-if="open && query.trim() && filteredOptions.length" class="searchable-select__list" role="listbox">
|
||||
<li
|
||||
v-for="(opt, index) in filteredOptions"
|
||||
:key="String(opt.value)"
|
||||
@@ -32,6 +32,9 @@
|
||||
{{ opt.label }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="open && !query.trim()" class="searchable-select__empty">
|
||||
Начните вводить имя для поиска
|
||||
</p>
|
||||
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
@@ -62,7 +65,7 @@ const allOptions = computed(() =>
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase('ru')
|
||||
if (!q) return allOptions.value
|
||||
if (!q) return []
|
||||
return allOptions.value.filter((o) =>
|
||||
o.label.toLocaleLowerCase('ru').includes(q)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user