Add multiple network maps, graph UX improvements, and full backup import.
Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,70 +24,65 @@
|
||||
<input v-model="form.position" class="form-control" placeholder="Директор" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-form-map-fields">
|
||||
<div class="form-group">
|
||||
<label>Сфера жизни</label>
|
||||
<SearchableSelect
|
||||
v-model="form.life_sphere"
|
||||
:options="lifeSpheres"
|
||||
placeholder="Сфера жизни..."
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label>Карты сети</label>
|
||||
<div v-if="!availableMaps.length" class="text-muted" style="font-size:13px;">
|
||||
Нет карт. Создайте карту на странице «Карта сети».
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Круг сети</label>
|
||||
<SearchableSelect
|
||||
v-model="form.network_circle"
|
||||
:options="networkCircles"
|
||||
placeholder="Круг сети..."
|
||||
/>
|
||||
<div v-else class="map-checkboxes">
|
||||
<label v-for="map in availableMaps" :key="map.id" class="checkbox-label">
|
||||
<input
|
||||
v-model="form.mapIds"
|
||||
type="checkbox"
|
||||
:value="String(map.id)"
|
||||
/>
|
||||
{{ map.name }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Важность (1–5)</label>
|
||||
<input v-model.number="form.importance" type="number" min="1" max="5" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group checkbox-row">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.include_on_network_map" type="checkbox" />
|
||||
Показывать на карте сети
|
||||
</label>
|
||||
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на «Карте сети» — только с этой отметкой.</p>
|
||||
<p class="checkbox-hint">На странице «Граф» контакт виден всегда; на выбранных картах — в соответствующих визуализациях.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметки</label>
|
||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<div class="contact-form-footer">
|
||||
<button
|
||||
v-if="showDelete"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
<div class="contact-form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, ref, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import SearchableSelect from './SearchableSelect.vue'
|
||||
import { reactive, watch, computed, onMounted } from 'vue'
|
||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||
|
||||
const FALLBACK_SPHERES = [
|
||||
{ value: 'work', label: 'Работа' },
|
||||
{ value: 'study', label: 'Учёба' },
|
||||
{ value: 'hobby', label: 'Хобби' },
|
||||
{ value: 'family', label: 'Семья' },
|
||||
{ value: 'health', label: 'Здоровье' },
|
||||
{ value: 'other', label: 'Другое' },
|
||||
]
|
||||
const FALLBACK_CIRCLES = [
|
||||
{ value: 'support', label: 'Круг поддержки' },
|
||||
{ value: 'productivity', label: 'Круг продуктивности' },
|
||||
{ value: 'development', label: 'Круг развития' },
|
||||
]
|
||||
const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
initialMapIds: { type: Array, default: null },
|
||||
deletable: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||
|
||||
const props = defineProps({ initial: { type: Object, default: () => ({}) } })
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
const mapsStore = useNetworkMapsStore()
|
||||
|
||||
const lifeSpheres = ref([...FALLBACK_SPHERES])
|
||||
const networkCircles = ref([...FALLBACK_CIRCLES])
|
||||
const showDelete = computed(() => {
|
||||
if (props.deletable) return true
|
||||
const id = props.initial?.id
|
||||
return id !== undefined && id !== null && id !== ''
|
||||
})
|
||||
|
||||
const availableMaps = computed(() => mapsStore.maps)
|
||||
|
||||
const form = reactive({
|
||||
name: props.initial.name || '',
|
||||
@@ -96,13 +91,19 @@ const form = reactive({
|
||||
organization: props.initial.organization || '',
|
||||
position: props.initial.position || '',
|
||||
notes: props.initial.notes || '',
|
||||
life_sphere: props.initial.life_sphere || 'other',
|
||||
network_circle: props.initial.network_circle || 'productivity',
|
||||
importance: props.initial.importance ?? 3,
|
||||
include_on_network_map: Boolean(props.initial.include_on_network_map),
|
||||
mapIds: (props.initialMapIds || []).map(String),
|
||||
})
|
||||
|
||||
watch(() => props.initial, (v) => {
|
||||
async function loadMapIdsForContact(contactId) {
|
||||
if (!contactId) {
|
||||
form.mapIds = []
|
||||
return
|
||||
}
|
||||
const memberships = await listMembershipsByContact(contactId)
|
||||
form.mapIds = memberships.map((m) => String(m.mapId || m.map))
|
||||
}
|
||||
|
||||
watch(() => props.initial, async (v) => {
|
||||
if (!v || !Object.keys(v).length) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
@@ -111,10 +112,7 @@ watch(() => props.initial, (v) => {
|
||||
organization: '',
|
||||
position: '',
|
||||
notes: '',
|
||||
life_sphere: 'other',
|
||||
network_circle: 'productivity',
|
||||
importance: 3,
|
||||
include_on_network_map: false,
|
||||
mapIds: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -125,37 +123,32 @@ watch(() => props.initial, (v) => {
|
||||
organization: v.organization || '',
|
||||
position: v.position || '',
|
||||
notes: v.notes || '',
|
||||
life_sphere: v.life_sphere || 'other',
|
||||
network_circle: v.network_circle || 'productivity',
|
||||
importance: v.importance ?? 3,
|
||||
include_on_network_map: Boolean(v.include_on_network_map),
|
||||
})
|
||||
if (props.initialMapIds) {
|
||||
form.mapIds = props.initialMapIds.map(String)
|
||||
} else if (v.id) {
|
||||
await loadMapIdsForContact(v.id)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/network-map-choices/')
|
||||
if (data.life_spheres?.length) lifeSpheres.value = data.life_spheres
|
||||
if (data.network_circles?.length) networkCircles.value = data.network_circles
|
||||
} catch {
|
||||
/* оставляем FALLBACK_* */
|
||||
await mapsStore.fetchMaps()
|
||||
if (props.initial?.id && !props.initialMapIds) {
|
||||
await loadMapIdsForContact(props.initial.id)
|
||||
}
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', { ...form })
|
||||
const { mapIds, ...contactData } = form
|
||||
emit('submit', contactData, mapIds)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-form-map-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
margin-top: 4px;
|
||||
.map-checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
@@ -170,4 +163,16 @@ function onSubmit() {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.contact-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.contact-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user