ваше сообщение коммита

This commit is contained in:
2025-06-06 21:56:17 +03:00
parent 7e17aeb4ab
commit 47a6d8593a
24 changed files with 5072 additions and 3832 deletions

View File

@@ -1,323 +0,0 @@
<template>
<div class="contact-details-modal">
<div class="contact-details-header">
<h2>Детали контакта</h2>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div class="contact-info-block">
<div>
<strong>Имя:</strong>
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
<span v-if="isSavingName" class="saving">Сохранение...</span>
</div>
<div><strong>Email:</strong> {{ contact.email || '-' }}</div>
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
<div>
<strong>Язык:</strong>
<div class="multi-select">
<div class="selected-langs">
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
{{ getLanguageLabel(lang) }}
<span class="remove-tag" @click="removeLanguage(lang)">×</span>
</span>
<input
v-model="langInput"
@focus="showLangDropdown = true"
@input="showLangDropdown = true"
@keydown.enter.prevent="addLanguageFromInput"
class="lang-input"
placeholder="Добавить язык..."
/>
</div>
<ul v-if="showLangDropdown" class="lang-dropdown">
<li
v-for="lang in filteredLanguages"
:key="lang.value"
@mousedown.prevent="addLanguage(lang.value)"
:class="{ selected: selectedLanguages.includes(lang.value) }"
>
{{ lang.label }}
</li>
</ul>
</div>
<span v-if="isSavingLangs" class="saving">Сохранение...</span>
</div>
<div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div>
<div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div>
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div>
<div class="messages-block">
<h3>История сообщений</h3>
<div v-if="isLoading" class="loading">Загрузка...</div>
<div v-else-if="messages.length === 0" class="empty">Нет сообщений</div>
<div v-else class="messages-list">
<Message v-for="msg in messages" :key="msg.id" :message="msg" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import Message from './Message.vue';
import messagesService from '../services/messagesService';
import contactsService from '../services/contactsService';
const props = defineProps({
contact: { type: Object, required: true }
});
const emit = defineEmits(['close', 'contact-deleted', 'contact-updated']);
const messages = ref([]);
const isLoading = ref(false);
const lastMessageDate = ref(null);
const editableName = ref(props.contact.name || '');
const isSavingName = ref(false);
const isSavingLangs = ref(false);
// --- Языки ---
const allLanguages = [
{ value: 'ru', label: 'Русский' },
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'zh', label: '中文' },
{ value: 'ar', label: 'العربية' },
{ value: 'pt', label: 'Português' },
{ value: 'it', label: 'Italiano' },
{ value: 'ja', label: '日本語' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'pl', label: 'Polski' },
{ value: 'uk', label: 'Українська' },
{ value: 'other', label: 'Другое' }
];
const selectedLanguages = ref(Array.isArray(props.contact.preferred_language) ? props.contact.preferred_language : (props.contact.preferred_language ? [props.contact.preferred_language] : []));
const langInput = ref('');
const showLangDropdown = ref(false);
const filteredLanguages = computed(() => {
const input = langInput.value.toLowerCase();
return allLanguages.filter(
l => !selectedLanguages.value.includes(l.value) && l.label.toLowerCase().includes(input)
);
});
function getLanguageLabel(val) {
const found = allLanguages.find(l => l.value === val);
return found ? found.label : val;
}
function addLanguage(lang) {
if (!selectedLanguages.value.includes(lang)) {
selectedLanguages.value.push(lang);
saveLanguages();
}
langInput.value = '';
showLangDropdown.value = false;
}
function addLanguageFromInput() {
const found = filteredLanguages.value[0];
if (found) addLanguage(found.value);
}
function removeLanguage(lang) {
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
saveLanguages();
}
function saveLanguages() {
isSavingLangs.value = true;
contactsService.updateContact(props.contact.id, { language: selectedLanguages.value })
.then(() => emit('contact-updated'))
.finally(() => { isSavingLangs.value = false; });
}
// --- Имя ---
function saveName() {
if (editableName.value !== props.contact.name) {
isSavingName.value = true;
contactsService.updateContact(props.contact.id, { name: editableName.value })
.then(() => emit('contact-updated'))
.finally(() => { isSavingName.value = false; });
}
}
// --- Удаление ---
function deleteContact() {
if (confirm('Удалить контакт?')) {
contactsService.deleteContact(props.contact.id)
.then(() => emit('contact-deleted', props.contact.id))
.catch(() => alert('Ошибка удаления контакта'));
}
}
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
}
async function loadMessages() {
if (!props.contact || !props.contact.id) return;
isLoading.value = true;
try {
messages.value = await messagesService.getMessagesByUserId(props.contact.id);
if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else {
lastMessageDate.value = null;
}
} catch (e) {
messages.value = [];
lastMessageDate.value = null;
} finally {
isLoading.value = false;
}
}
onMounted(loadMessages);
watch(() => props.contact, loadMessages);
watch(() => props.contact.preferred_language, (newVal) => {
selectedLanguages.value = Array.isArray(newVal) ? newVal : (newVal ? [newVal] : []);
});
</script>
<style scoped>
.contact-details-modal {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
max-width: 700px;
margin: 40px auto;
position: relative;
overflow-x: auto;
}
.contact-details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #bbb;
transition: color 0.2s;
}
.close-btn:hover {
color: #333;
}
.contact-info-block {
margin-bottom: 18px;
font-size: 1.08rem;
line-height: 1.7;
}
.edit-input {
border: 1px solid #ccc;
border-radius: 6px;
padding: 4px 10px;
font-size: 1rem;
margin-left: 8px;
min-width: 120px;
}
.saving {
color: #17a2b8;
font-size: 0.95rem;
margin-left: 8px;
}
.delete-btn {
background: #dc3545;
color: #fff;
border: none;
border-radius: 6px;
padding: 7px 18px;
cursor: pointer;
font-size: 1rem;
margin-top: 18px;
transition: background 0.2s;
}
.delete-btn:hover {
background: #b52a37;
}
.multi-select {
position: relative;
display: inline-block;
min-width: 220px;
}
.selected-langs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 36px;
background: #f5f7fa;
border-radius: 6px;
padding: 4px 8px;
border: 1px solid #ccc;
}
.lang-tag {
background: #e6f7ff;
color: #138496;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.97rem;
display: flex;
align-items: center;
}
.remove-tag {
margin-left: 4px;
cursor: pointer;
color: #888;
font-weight: bold;
}
.remove-tag:hover {
color: #dc3545;
}
.lang-input {
border: none;
outline: none;
background: transparent;
font-size: 1rem;
min-width: 80px;
margin-left: 4px;
}
.lang-dropdown {
position: absolute;
left: 0;
top: 100%;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
z-index: 10;
min-width: 180px;
max-height: 180px;
overflow-y: auto;
margin-top: 2px;
padding: 0;
list-style: none;
}
.lang-dropdown li {
padding: 7px 14px;
cursor: pointer;
font-size: 1rem;
}
.lang-dropdown li.selected {
background: #e6f7ff;
color: #138496;
}
.lang-dropdown li:hover {
background: #f0f0f0;
}
.messages-block {
background: #f8fafc;
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.messages-list {
max-height: 350px;
overflow-y: auto;
margin-top: 10px;
}
.loading, .empty {
color: #888;
text-align: center;
margin: 20px 0;
}
</style>

View File

@@ -32,17 +32,18 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { defineProps } from 'vue';
import { useRouter } from 'vue-router';
const props = defineProps({
contacts: { type: Array, required: true }
});
const emit = defineEmits(['show-details']);
const router = useRouter();
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
}
function showDetails(contact) {
emit('show-details', contact);
router.push({ name: 'contact-details', params: { id: contact.id } });
}
</script>

View File

@@ -362,7 +362,7 @@ export function useChat(auth) {
}
} catch (error) {
console.error('[useChat] Ошибка связывания гостевых сообщений:', error);
}
}
};
// --- Watchers ---

View File

@@ -5,11 +5,15 @@ import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import axios from 'axios';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
// Настройка axios
// В Docker контейнере localhost:8000 не работает, поэтому используем явное значение
const apiUrl =
window.location.hostname === 'localhost' ? 'http://localhost:8000' : import.meta.env.VITE_API_URL;
window.location.hostname === 'localhost'
? 'http://localhost:8000'
: 'http://dapp-backend:8000'; // имя контейнера backend
axios.defaults.baseURL = apiUrl;
axios.defaults.withCredentials = true;
@@ -17,6 +21,7 @@ axios.defaults.withCredentials = true;
const app = createApp(App);
app.use(router);
app.use(ElementPlus);
// Не используем заглушки, так как сервер работает
// if (import.meta.env.DEV) {

View File

@@ -92,6 +92,18 @@ const routes = [
component: () => import('../views/tables/DeleteTableView.vue'),
props: true
},
{
path: '/contacts/:id',
name: 'contact-details',
component: () => import('../views/contacts/ContactDetailsView.vue'),
props: true
},
{
path: '/contacts/:id/delete',
name: 'contact-delete-confirm',
component: () => import('../views/contacts/ContactDeleteConfirm.vue'),
props: true
},
];
const router = createRouter({

View File

@@ -13,7 +13,20 @@ export default {
return res.data;
},
async deleteContact(id) {
const res = await api.delete(`/api/users/${id}`);
return res.data;
try {
const res = await api.delete(`/api/users/${id}`);
console.log('Ответ на удаление контакта:', res.status, res.data);
return res.data;
} catch (err) {
console.error('Ошибка при удалении контакта:', err.response?.status, err.response?.data, err);
throw err;
}
},
async getContactById(id) {
const res = await api.get(`/api/users/${id}`);
if (res.data && res.data.id) {
return res.data;
}
return null;
}
};

View File

@@ -21,7 +21,6 @@
</button>
</div>
<ContactTable v-if="showContacts" :contacts="contacts" @close="showContacts = false" @show-details="openContactDetails" />
<ContactDetails v-if="showContactDetails" :contact="selectedContact" @close="showContactDetails = false" @contact-deleted="onContactDeleted" />
<div class="crm-tables-block">
<h2>Таблицы</h2>
<button class="btn btn-info" @click="goToTables">
@@ -43,7 +42,6 @@ import dleService from '../services/dleService';
import ContactTable from '../components/ContactTable.vue';
import contactsService from '../services/contactsService.js';
import DleManagement from '../components/DleManagement.vue';
import ContactDetails from '../components/ContactDetails.vue';
// Определяем props
const props = defineProps({
@@ -70,6 +68,33 @@ const selectedContact = ref(null);
const showContactDetails = ref(false);
const showTables = ref(false);
let ws = null;
function connectWebSocket() {
if (ws) ws.close();
ws = new WebSocket('ws://localhost:8000');
ws.onopen = () => {
console.log('[CRM] WebSocket соединение установлено');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'contacts-updated') {
console.log('[CRM] Получено событие contacts-updated, обновляем контакты');
loadContacts();
}
} catch (e) {
console.error('[CRM] Ошибка обработки сообщения WebSocket:', e);
}
};
ws.onclose = () => {
console.log('[CRM] WebSocket соединение закрыто');
};
ws.onerror = (e) => {
console.error('[CRM] WebSocket ошибка:', e);
};
}
// Функция для перехода на домашнюю страницу и открытия боковой панели
const goToHomeAndShowSidebar = () => {
setToStorage('showWalletSidebar', true);
@@ -122,6 +147,8 @@ onMounted(() => {
// Подписка на события авторизации
unsubscribe = eventBus.on('auth-state-changed', handleAuthEvent);
connectWebSocket();
});
onBeforeUnmount(() => {
@@ -129,6 +156,8 @@ onBeforeUnmount(() => {
if (unsubscribe) {
unsubscribe();
}
if (ws) ws.close();
});
async function loadContacts() {

View File

@@ -0,0 +1,128 @@
<template>
<div class="delete-confirm-page">
<h2>Подтверждение удаления контакта</h2>
<div v-if="isLoading">Загрузка...</div>
<div v-else-if="!contact">Контакт не найден</div>
<div v-else class="contact-info">
<p><strong>Имя:</strong> {{ contact.name || '-' }}</p>
<p><strong>Email:</strong> {{ contact.email || '-' }}</p>
<p><strong>Telegram:</strong> {{ contact.telegram || '-' }}</p>
<p><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</p>
<p><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</p>
<div class="confirm-actions">
<button class="delete-btn" @click="deleteContact" :disabled="isDeleting">Удалить</button>
<button class="cancel-btn" @click="cancelDelete" :disabled="isDeleting">Отменить</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import contactsService from '../../services/contactsService.js';
const route = useRoute();
const router = useRouter();
const contact = ref(null);
const isLoading = ref(true);
const isDeleting = ref(false);
const error = ref('');
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
}
async function loadContact() {
isLoading.value = true;
try {
contact.value = await contactsService.getContactById(route.params.id);
} catch (e) {
contact.value = null;
} finally {
isLoading.value = false;
}
}
async function deleteContact() {
if (!contact.value) return;
isDeleting.value = true;
error.value = '';
try {
await contactsService.deleteContact(contact.value.id);
router.push({ name: 'crm' });
} catch (e) {
error.value = 'Ошибка при удалении контакта';
} finally {
isDeleting.value = false;
}
}
function cancelDelete() {
router.push({ name: 'contact-details', params: { id: route.params.id } });
}
onMounted(loadContact);
</script>
<style scoped>
.delete-confirm-page {
max-width: 500px;
margin: 60px auto;
padding: 32px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.contact-info {
margin-top: 18px;
font-size: 1.08rem;
line-height: 1.7;
}
.confirm-actions {
margin-top: 24px;
display: flex;
gap: 18px;
}
.delete-btn {
background: #dc3545;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 22px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.delete-btn:disabled {
background: #e6a6ad;
cursor: not-allowed;
}
.delete-btn:hover:not(:disabled) {
background: #b52a37;
}
.cancel-btn {
background: #f5f5f5;
color: #333;
border: 1px solid #ccc;
border-radius: 6px;
padding: 8px 22px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.cancel-btn:disabled {
background: #eee;
color: #aaa;
cursor: not-allowed;
}
.cancel-btn:hover:not(:disabled) {
background: #e0e0e0;
}
.error {
color: #dc3545;
margin-top: 18px;
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<div class="contact-details-page">
<Header :isSidebarOpen="isSidebarOpen" @toggle-sidebar="toggleSidebar" />
<div v-if="isLoading">Загрузка...</div>
<div v-else-if="!contact">Контакт не найден</div>
<div v-else class="contact-details-content">
<div class="contact-details-header">
<h2>Детали контакта</h2>
<router-link class="back-btn" :to="{ name: 'crm' }"> Назад к списку</router-link>
</div>
<div class="contact-info-block">
<div>
<strong>Имя:</strong>
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
<span v-if="isSavingName" class="saving">Сохранение...</span>
</div>
<div><strong>Email:</strong> {{ contact.email || '-' }}</div>
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
<div>
<strong>Язык:</strong>
<div class="multi-select">
<div class="selected-langs">
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
{{ getLanguageLabel(lang) }}
<span class="remove-tag" @click="removeLanguage(lang)">×</span>
</span>
<input
v-model="langInput"
@focus="showLangDropdown = true"
@input="showLangDropdown = true"
@keydown.enter.prevent="addLanguageFromInput"
class="lang-input"
placeholder="Добавить язык..."
/>
</div>
<ul v-if="showLangDropdown" class="lang-dropdown">
<li
v-for="lang in filteredLanguages"
:key="lang.value"
@mousedown.prevent="addLanguage(lang.value)"
:class="{ selected: selectedLanguages.includes(lang.value) }"
>
{{ lang.label }}
</li>
</ul>
</div>
<span v-if="isSavingLangs" class="saving">Сохранение...</span>
</div>
<div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div>
<div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div>
<div class="user-tags-block">
<strong>Теги пользователя:</strong>
<span v-for="tag in userTags" :key="tag.id" class="user-tag">
{{ tag.name }}
<span class="remove-tag" @click="removeUserTag(tag.id)">×</span>
</span>
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
</div>
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div>
<div class="messages-block">
<h3>История сообщений</h3>
<div v-if="isLoadingMessages" class="loading">Загрузка...</div>
<div v-else-if="messages.length === 0" class="empty">Нет сообщений</div>
<div v-else class="messages-list">
<Message v-for="msg in messages" :key="msg.id" :message="msg" />
</div>
</div>
<el-dialog v-model="showTagModal" title="Добавить тег пользователю">
<div v-if="allTags.length">
<el-select
v-model="selectedTags"
multiple
filterable
placeholder="Выберите теги"
@change="addTagsToUser"
>
<el-option
v-for="tag in allTags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
<div style="margin-top: 1em; color: #888; font-size: 0.95em;">
<strong>Существующие теги:</strong>
<span v-for="tag in allTags" :key="'list-' + tag.id" style="margin-right: 0.7em;">
{{ tag.name }}<span v-if="tag.description"> ({{ tag.description }})</span>
</span>
</div>
</div>
<div style="margin-top: 1em;">
<el-input v-model="newTagName" placeholder="Новый тег" />
<el-input v-model="newTagDescription" placeholder="Описание" />
<el-button type="primary" @click="createTag">Создать тег</el-button>
</div>
</el-dialog>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Header from '../../components/Header.vue';
import Message from '../../components/Message.vue';
import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js';
const route = useRoute();
const router = useRouter();
const userId = computed(() => route.params.id);
const contact = ref(null);
const isLoading = ref(true);
const isLoadingMessages = ref(false);
const lastMessageDate = ref(null);
const editableName = ref('');
const isSavingName = ref(false);
const isSavingLangs = ref(false);
const userTags = ref([]);
const allTags = ref([]);
const selectedTags = ref([]);
const showTagModal = ref(false);
const newTagName = ref('');
const newTagDescription = ref('');
const messages = ref([]);
const isSidebarOpen = ref(false);
function toggleSidebar() {
isSidebarOpen.value = !isSidebarOpen.value;
}
// --- Языки ---
const allLanguages = [
{ value: 'ru', label: 'Русский' },
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'zh', label: '中文' },
{ value: 'ar', label: 'العربية' },
{ value: 'pt', label: 'Português' },
{ value: 'it', label: 'Italiano' },
{ value: 'ja', label: '日本語' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'pl', label: 'Polski' },
{ value: 'uk', label: 'Українська' },
{ value: 'other', label: 'Другое' }
];
const selectedLanguages = ref([]);
const langInput = ref('');
const showLangDropdown = ref(false);
const filteredLanguages = computed(() => {
const input = langInput.value.toLowerCase();
return allLanguages.filter(
l => !selectedLanguages.value.includes(l.value) && l.label.toLowerCase().includes(input)
);
});
function getLanguageLabel(val) {
const found = allLanguages.find(l => l.value === val);
return found ? found.label : val;
}
function addLanguage(lang) {
if (!selectedLanguages.value.includes(lang)) {
selectedLanguages.value.push(lang);
saveLanguages();
}
langInput.value = '';
showLangDropdown.value = false;
}
function addLanguageFromInput() {
const found = filteredLanguages.value[0];
if (found) addLanguage(found.value);
}
function removeLanguage(lang) {
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
saveLanguages();
}
function saveLanguages() {
isSavingLangs.value = true;
contactsService.updateContact(contact.value.id, { language: selectedLanguages.value })
.then(() => reloadContact())
.finally(() => { isSavingLangs.value = false; });
}
// --- Имя ---
function saveName() {
if (editableName.value !== contact.value.name) {
isSavingName.value = true;
contactsService.updateContact(contact.value.id, { name: editableName.value })
.then(() => reloadContact())
.finally(() => { isSavingName.value = false; });
}
}
// --- Удаление ---
function deleteContact() {
router.push({ name: 'contact-delete-confirm', params: { id: contact.value.id } });
}
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
}
async function loadMessages() {
if (!contact.value || !contact.value.id) return;
isLoadingMessages.value = true;
try {
messages.value = await messagesService.getMessagesByUserId(contact.value.id);
if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else {
lastMessageDate.value = null;
}
} catch (e) {
messages.value = [];
lastMessageDate.value = null;
} finally {
isLoadingMessages.value = false;
}
}
async function loadUserTags() {
if (!contact.value) return;
const res = await fetch(`/api/users/${contact.value.id}/tags`);
userTags.value = await res.json();
selectedTags.value = userTags.value.map(t => t.id);
}
async function openTagModal() {
await fetch('/api/tags/init', { method: 'POST' })
const res = await fetch('/api/tags')
allTags.value = await res.json()
await loadUserTags()
showTagModal.value = true
}
async function createTag() {
const res = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.value, description: newTagDescription.value })
});
const newTag = await res.json();
await fetch(`/api/users/${contact.value.id}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_id: newTag.id })
});
const tagsRes = await fetch('/api/tags');
allTags.value = await tagsRes.json();
await loadUserTags();
newTagName.value = '';
newTagDescription.value = '';
}
async function addTagsToUser() {
for (const tagId of selectedTags.value) {
await fetch(`/api/users/${contact.value.id}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_id: tagId })
})
}
await loadUserTags()
}
async function removeUserTag(tagId) {
await fetch(`/api/users/${contact.value.id}/tags/${tagId}`, {
method: 'DELETE'
});
await loadUserTags();
}
async function reloadContact() {
isLoading.value = true;
try {
contact.value = await contactsService.getContactById(userId.value);
editableName.value = contact.value?.name || '';
selectedLanguages.value = Array.isArray(contact.value?.preferred_language)
? contact.value.preferred_language
: (contact.value?.preferred_language ? [contact.value.preferred_language] : []);
} catch (e) {
contact.value = null;
} finally {
isLoading.value = false;
}
}
onMounted(async () => {
await reloadContact();
await loadMessages();
await loadUserTags();
});
watch(userId, async () => {
await reloadContact();
await loadMessages();
await loadUserTags();
});
</script>
<style scoped>
.contact-details-page {
max-width: 900px;
margin: 0 auto;
padding: 32px 0;
}
.contact-details-content {
padding: 32px 24px 24px 24px;
max-width: 700px;
margin: 40px auto;
position: relative;
overflow-x: auto;
background: none;
border-radius: 0;
box-shadow: none;
}
.contact-details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.back-btn {
background: none;
border: none;
color: #17a2b8;
font-size: 1.1rem;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.back-btn:hover {
color: #138496;
}
.contact-info-block {
margin-bottom: 18px;
font-size: 1.08rem;
line-height: 1.7;
}
.edit-input {
border: 1px solid #ccc;
border-radius: 6px;
padding: 4px 10px;
font-size: 1rem;
margin-left: 8px;
min-width: 120px;
}
.saving {
color: #17a2b8;
font-size: 0.95rem;
margin-left: 8px;
}
.delete-btn {
background: #dc3545;
color: #fff;
border: none;
border-radius: 6px;
padding: 7px 18px;
cursor: pointer;
font-size: 1rem;
margin-top: 18px;
transition: background 0.2s;
}
.delete-btn:hover {
background: #b52a37;
}
.multi-select {
position: relative;
display: inline-block;
min-width: 220px;
}
.selected-langs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 36px;
background: #f5f7fa;
border-radius: 6px;
padding: 4px 8px;
border: 1px solid #ccc;
}
.lang-tag {
background: #e6f7ff;
color: #138496;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.97rem;
display: flex;
align-items: center;
}
.remove-tag {
margin-left: 4px;
cursor: pointer;
color: #888;
font-weight: bold;
}
.remove-tag:hover {
color: #dc3545;
}
.lang-input {
border: none;
outline: none;
background: transparent;
font-size: 1rem;
min-width: 80px;
margin-left: 4px;
}
.lang-dropdown {
position: absolute;
left: 0;
top: 100%;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
z-index: 10;
min-width: 180px;
max-height: 180px;
overflow-y: auto;
margin-top: 2px;
padding: 0;
list-style: none;
}
.lang-dropdown li {
padding: 7px 14px;
cursor: pointer;
font-size: 1rem;
}
.lang-dropdown li.selected {
background: #e6f7ff;
color: #138496;
}
.lang-dropdown li:hover {
background: #f0f0f0;
}
.messages-block {
background: #f8fafc;
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.messages-list {
max-height: 350px;
overflow-y: auto;
margin-top: 10px;
}
.loading, .empty {
color: #888;
text-align: center;
margin: 20px 0;
}
.user-tags-block {
margin: 1em 0;
}
.user-tag {
display: inline-block;
background: #e0f7fa;
color: #00796b;
border-radius: 6px;
padding: 0.2em 0.7em;
margin-right: 0.5em;
font-size: 0.95em;
}
.add-tag-btn {
margin-left: 1em;
background: #2ecc40;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.3em 1em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.add-tag-btn:hover {
background: #27ae38;
}
</style>