Files
social-graph/frontend/src/views/ContactDetailView.vue
T
gitrusprusandCursor cafc7335ad Add plugin platform with modular backend and frontend registry.
Split graph/import/meta into Django apps, add API v1 with OpenAPI and pytest, and introduce plugin registry with the tags reference plugin on both FE and BE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 09:35:51 +03:00

372 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="page-header">
<div class="page-header__title">
<button class="btn btn-secondary btn-sm" @click="$router.back()"> Назад</button>
<h2>{{ contact?.name || 'Загрузка...' }}</h2>
</div>
</div>
<div class="page-content" v-if="contact">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px;">
<!-- Info card -->
<div class="card">
<div class="card-section-header">
<h3 class="card-section-title">Информация</h3>
<button class="btn btn-primary btn-sm" type="button" @click="editing = true">Редактировать</button>
</div>
<div class="form-group">
<label>Email</label>
<div>{{ contact.email || '—' }}</div>
</div>
<div class="form-group">
<label>Телефон</label>
<div>{{ contact.phone || '—' }}</div>
</div>
<div class="form-group">
<label>Организация</label>
<div>{{ contact.organization || '—' }}</div>
</div>
<div class="form-group">
<label>Должность</label>
<div>{{ contact.position || '—' }}</div>
</div>
<div class="form-group">
<label>Заметки</label>
<div style="white-space:pre-wrap;">{{ contact.notes || '—' }}</div>
</div>
<div class="form-group">
<label>Карты сети</label>
<div v-if="contactMapNames.length">
<RouterLink
v-for="name in contactMapNames"
:key="name.id"
:to="`/map/${name.id}`"
class="map-link"
>
{{ name.label }}
</RouterLink>
</div>
<div v-else>Не участвует ни на одной карте (только в общем графе)</div>
</div>
</div>
<!-- Relations card -->
<div class="card">
<div class="flex justify-between items-center" style="margin-bottom:16px;">
<h3 style="font-size:14px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;">Связи ({{ contactRelations.length }})</h3>
<button class="btn btn-primary btn-sm" @click="showAddRelation = true">+ Добавить</button>
</div>
<div v-if="contactRelations.length === 0" class="empty-state" style="padding:20px 0;">
<p>Нет связей с другими контактами.</p>
</div>
<div v-else class="relations-list">
<div
v-for="rel in contactRelations"
:key="rel.id"
class="relation-row"
:class="{ 'is-selected': String(editRelationTarget?.id) === String(rel.id) }"
@click="openEditRelation(rel)"
>
<div class="relation-row__body">
<div class="relation-row__main">
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
</div>
<div v-if="rel.description" class="relation-row__desc">{{ rel.description }}</div>
</div>
<div class="relation-row__actions" @click.stop>
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
Изменить
</button>
<button class="btn btn-danger btn-sm" type="button" @click="removeRelation(rel.id)"></button>
</div>
</div>
</div>
</div>
</div>
</div>
<EditRelationModal
:open="editRelationOpen"
:relation="editRelationTarget"
@close="closeEditRelation"
@updated="onRelationUpdated"
@deleted="onRelationDeleted"
/>
<!-- Edit modal -->
<div v-if="editing" class="modal-overlay" @click.self="editing = false">
<div class="modal">
<div class="modal-header">
<h3>Редактировать контакт</h3>
<button class="btn btn-secondary btn-sm" @click="editing = false"></button>
</div>
<ContactForm
:initial="contact"
deletable
@submit="onUpdate"
@cancel="editing = false"
@delete="confirmDeleteContact"
/>
</div>
</div>
<!-- Add relation modal -->
<div v-if="showAddRelation" class="modal-overlay" @click.self="showAddRelation = false">
<div class="modal">
<div class="modal-header">
<h3>Добавить связь</h3>
<button class="btn btn-secondary btn-sm" @click="showAddRelation = false"></button>
</div>
<div v-if="relError" class="alert alert-error">{{ relError }}</div>
<div class="form-group">
<label>С кем связать</label>
<SearchableSelect
v-model="newRel.targetId"
:options="contactSelectOptions"
placeholder="Введите имя контакта..."
/>
</div>
<div class="form-group">
<label>Тип связи</label>
<RelationTypeSelect v-model="newRel.type" :options="relationTypes" />
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<InteractionIntensitySelect v-model="newRel.interaction_intensity" />
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="newRel.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
Стрелка на карте сети идёт от вас к выбранному контакту: вы указаны как источник связи.
</p>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddRelation = false">Отмена</button>
<button class="btn btn-primary" :disabled="!newRel.targetId" @click="addRelation">Создать связь</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useContactsStore } from '../stores/contacts'
import { useNetworkMapsStore } from '../stores/networkMaps'
import ContactForm from '../components/ContactForm.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import SearchableSelect from '../components/SearchableSelect.vue'
import InteractionIntensitySelect from '../components/InteractionIntensitySelect.vue'
import RelationTypeSelect from '../components/RelationTypeSelect.vue'
const route = useRoute()
const router = useRouter()
const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const contact = ref(null)
const editing = ref(false)
const showAddRelation = ref(false)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)
const relationTypes = ref([])
const interactionIntensities = ref([])
const relError = ref('')
const newRel = ref({
targetId: '',
type: 'acquaintance',
description: '',
interaction_intensity: 'intense',
})
const contactId = computed(() => route.params.id)
const contactRelations = computed(() =>
store.relations.filter(
(r) => String(r.source) === String(contactId.value) || String(r.target) === String(contactId.value)
)
)
const otherContacts = computed(() =>
store.contacts
.filter((c) => String(c.id) !== String(contactId.value))
.slice()
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
)
const contactSelectOptions = computed(() =>
otherContacts.value.map((c) => ({ value: c.id, label: c.name }))
)
const contactMapNames = computed(() => {
const memberships = mapsStore.contactMemberships || []
return memberships.map((m) => ({
id: m.mapId || m.map,
label: m.map_name || mapsStore.maps.find((map) => String(map.id) === String(m.mapId || m.map))?.name || 'Карта',
}))
})
function relLabel(type) {
return relationTypes.value.find((r) => r.value === type)?.label || type
}
function intensityLabel(v) {
return interactionIntensities.value.find((x) => x.value === v)?.label || v || ''
}
function otherContactName(rel) {
const cid = String(contactId.value)
return String(rel.source) === cid ? rel.target_name : rel.source_name
}
function openEditRelation(rel) {
editRelationTarget.value = rel
editRelationOpen.value = true
}
function closeEditRelation() {
editRelationOpen.value = false
editRelationTarget.value = null
}
function onRelationUpdated() {
closeEditRelation()
}
function onRelationDeleted() {
closeEditRelation()
}
async function loadContact() {
contact.value = await store.fetchContactById(contactId.value)
await mapsStore.fetchContactMemberships(contactId.value)
}
async function onUpdate(data, mapIds, pluginPayload) {
await store.updateContact(contactId.value, data)
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(contactId.value, pluginPayload)
contact.value = { ...contact.value, ...data }
editing.value = false
await mapsStore.fetchContactMemberships(contactId.value)
}
async function addRelation() {
relError.value = ''
try {
await store.createRelation({
source: contactId.value,
target: newRel.value.targetId,
relation_type: newRel.value.type,
description: newRel.value.description,
interaction_intensity: newRel.value.interaction_intensity,
})
showAddRelation.value = false
newRel.value = {
targetId: '',
type: 'acquaintance',
description: '',
interaction_intensity: 'intense',
}
} catch (e) {
const msg = e.response?.data
relError.value = typeof msg === 'object' ? JSON.stringify(msg) : String(msg)
}
}
async function removeRelation(id) {
await store.deleteRelation(id)
}
onMounted(async () => {
await mapsStore.fetchMaps()
await loadContact()
await Promise.all([store.fetchContacts(), store.fetchRelations()])
const [rt, intensities] = await Promise.all([
store.fetchRelationTypes(),
store.fetchNetworkMapChoices(),
])
relationTypes.value = rt
interactionIntensities.value = intensities?.interaction_intensities || []
})
</script>
<style scoped>
.relations-list {
display: flex;
flex-direction: column;
}
.relation-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.relation-row:last-child {
border-bottom: none;
}
.relation-row:hover {
background: var(--surface-alt);
margin: 0 -12px;
padding: 10px 12px;
border-radius: var(--radius);
}
.relation-row.is-selected {
background: var(--accent-dim);
margin: 0 -12px;
padding: 10px 12px;
border-radius: var(--radius);
}
.relation-row__body {
flex: 1;
min-width: 0;
}
.relation-row__main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.relation-row__name {
font-weight: 500;
}
.relation-row__intensity {
font-size: 12px;
color: var(--text-muted);
}
.relation-row__desc {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.relation-row__actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.map-link {
display: inline-block;
margin-right: 8px;
}
</style>