Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs

This commit is contained in:
gitrusprus
2026-06-28 22:16:01 +03:00
parent 4eb4c145b6
commit ce4e9ac5e6
46 changed files with 12125 additions and 176 deletions
+223 -38
View File
@@ -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>
+5 -2
View File
@@ -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)
)