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

This commit is contained in:
2025-06-24 16:45:50 +03:00
parent 45cde76e85
commit 261a4ecb2a
17 changed files with 1209 additions and 155 deletions

View File

@@ -4,17 +4,52 @@
<h2>Контакты</h2>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div class="filters-panel">
<input v-model="filterName" placeholder="Имя" />
<input v-model="filterEmail" placeholder="Email" />
<input v-model="filterTelegram" placeholder="Telegram" />
<input v-model="filterWallet" placeholder="Кошелек" />
<input v-model="filterDateFrom" type="date" placeholder="Дата от" />
<input v-model="filterDateTo" type="date" placeholder="Дата до" />
<label class="checkbox-label">
<input type="checkbox" v-model="filterOnlyNewMessages" /> Только с новыми сообщениями
</label>
</div>
<el-form :inline="true" class="filters-form" label-position="top">
<el-form-item label="Поиск">
<el-input v-model="filterSearch" placeholder="Поиск по имени, email, telegram, кошельку" clearable @input="onAnyFilterChange" />
</el-form-item>
<el-form-item label="Тип контакта">
<el-select v-model="filterContactType" placeholder="Все" style="min-width:120px;" @change="onAnyFilterChange">
<el-option label="Все" value="all" />
<el-option label="Email" value="email" />
<el-option label="Telegram" value="telegram" />
<el-option label="Кошелек" value="wallet" />
</el-select>
</el-form-item>
<el-form-item label="Дата от">
<el-date-picker v-model="filterDateFrom" type="date" placeholder="Дата от" clearable style="width: 100%;" @change="onAnyFilterChange" />
</el-form-item>
<el-form-item label="Дата до">
<el-date-picker v-model="filterDateTo" type="date" placeholder="Дата до" clearable style="width: 100%;" @change="onAnyFilterChange" />
</el-form-item>
<el-form-item label="Только с новыми сообщениями">
<el-select v-model="filterNewMessages" placeholder="Нет" style="min-width:110px;" @change="onAnyFilterChange">
<el-option label="Нет" :value="''" />
<el-option label="Да" value="yes" />
</el-select>
</el-form-item>
<el-form-item label="Теги">
<el-select
v-model="selectedTagIds"
multiple
filterable
collapse-tags
placeholder="Выберите теги"
style="min-width:180px;"
@change="onAnyFilterChange"
>
<el-option
v-for="tag in allTags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="resetFilters" type="default">Сбросить фильтры</el-button>
</el-form-item>
</el-form>
<table class="contact-table">
<thead>
<tr>
@@ -27,7 +62,7 @@
</tr>
</thead>
<tbody>
<tr v-for="contact in filteredContactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
<td>{{ contact.name || '-' }}</td>
<td>{{ contact.email || '-' }}</td>
<td>{{ contact.telegram || '-' }}</td>
@@ -44,8 +79,9 @@
</template>
<script setup>
import { defineProps, computed, ref } from 'vue';
import { defineProps, computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton } from 'element-plus';
const props = defineProps({
contacts: { type: Array, default: () => [] },
newContacts: { type: Array, default: () => [] },
@@ -53,38 +89,79 @@ const props = defineProps({
markMessagesAsReadForUser: { type: Function, default: null },
markContactAsRead: { type: Function, default: null }
});
const contactsArray = computed(() => Array.from(props.contacts || []));
const contactsArray = ref([]); // теперь управляем вручную
const newIds = computed(() => props.newContacts.map(c => c.id));
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
const router = useRouter();
// Фильтры
const filterName = ref('');
const filterEmail = ref('');
const filterTelegram = ref('');
const filterWallet = ref('');
const filterSearch = ref('');
const filterContactType = ref('all');
const filterDateFrom = ref('');
const filterDateTo = ref('');
const filterOnlyNewMessages = ref(false);
const filterNewMessages = ref('');
const filteredContactsArray = computed(() => {
return contactsArray.value.filter(contact => {
const nameMatch = !filterName.value || (contact.name || '').toLowerCase().includes(filterName.value.toLowerCase());
const emailMatch = !filterEmail.value || (contact.email || '').toLowerCase().includes(filterEmail.value.toLowerCase());
const telegramMatch = !filterTelegram.value || (contact.telegram || '').toLowerCase().includes(filterTelegram.value.toLowerCase());
const walletMatch = !filterWallet.value || (contact.wallet || '').toLowerCase().includes(filterWallet.value.toLowerCase());
let dateFromMatch = true, dateToMatch = true;
if (filterDateFrom.value && contact.created_at) {
dateFromMatch = new Date(contact.created_at) >= new Date(filterDateFrom.value);
}
if (filterDateTo.value && contact.created_at) {
dateToMatch = new Date(contact.created_at) <= new Date(filterDateTo.value);
}
const newMsgMatch = !filterOnlyNewMessages.value || newMsgUserIds.value.includes(String(contact.id));
return nameMatch && emailMatch && telegramMatch && walletMatch && dateFromMatch && dateToMatch && newMsgMatch;
});
// Теги
const allTags = ref([]);
const selectedTagIds = ref([]);
onMounted(async () => {
await loadTags();
await fetchContacts();
});
async function loadTags() {
const res = await fetch('/api/tags');
allTags.value = await res.json();
}
function buildQuery() {
const params = new URLSearchParams();
if (selectedTagIds.value.length > 0) params.append('tagIds', selectedTagIds.value.join(','));
if (filterDateFrom.value) params.append('dateFrom', formatDateOnly(filterDateFrom.value));
if (filterDateTo.value) params.append('dateTo', formatDateOnly(filterDateTo.value));
if (filterContactType.value && filterContactType.value !== 'all') params.append('contactType', filterContactType.value);
if (filterSearch.value) params.append('search', filterSearch.value);
if (filterNewMessages.value) params.append('newMessages', filterNewMessages.value);
return params.toString();
}
async function fetchContacts() {
let url = '/api/users';
const query = buildQuery();
if (query) url += '?' + query;
const res = await fetch(url);
const data = await res.json();
contactsArray.value = data.contacts || [];
}
function onTagsFilterChange() {
onAnyFilterChange();
}
function onAnyFilterChange() {
fetchContacts();
}
function resetFilters() {
filterSearch.value = '';
filterContactType.value = 'all';
filterDateFrom.value = '';
filterDateTo.value = '';
filterNewMessages.value = '';
selectedTagIds.value = [];
fetchContacts();
}
function formatDateOnly(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
@@ -201,27 +278,21 @@ async function showDetails(contact) {
background: #e6ffe6 !important;
transition: background 0.3s;
}
.filters-panel {
.filters-form {
display: flex;
gap: 10px;
margin-bottom: 18px;
align-items: center;
flex-wrap: wrap;
gap: 1.2em 1.5em;
align-items: flex-end;
background: #f7f9fa;
border-radius: 12px;
padding: 1.2em 1em 0.7em 1em;
margin-bottom: 1.2em;
}
.filters-panel input {
padding: 6px 10px;
border: 1px solid #d0d7de;
border-radius: 6px;
font-size: 1em;
min-width: 110px;
}
.filters-panel input[type="checkbox"] {
margin-right: 4px;
}
.checkbox-label {
display: flex;
align-items: center;
font-size: 0.98em;
user-select: none;
@media (max-width: 900px) {
.filters-form {
flex-direction: column;
gap: 0.7em 0;
}
}
.new-msg-icon {
color: #ff9800;

View File

@@ -1,5 +1,21 @@
<template>
<td>
<template v-if="column.type === 'tags'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view">
<span v-if="selectedTagNames.length">{{ selectedTagNames.join(', ') }}</span>
<span v-else style="color:#bbb"></span>
</div>
<div v-else class="tags-cell-edit">
<div class="tags-multiselect">
<div v-for="tag in allTags" :key="tag.id" class="tag-option">
<input type="checkbox" :id="'cell-tag-' + tag.id + '-' + rowId" :value="tag.id" v-model="editTagIds" />
<label :for="'cell-tag-' + tag.id + '-' + rowId">{{ tag.name }}</label>
</div>
</div>
<button class="save-btn" @click="saveTags">Сохранить</button>
<button class="cancel-btn" @click="cancelTags">Отмена</button>
</div>
</template>
<template v-else>
<input
v-model="localValue"
@blur="save"
@@ -7,26 +23,77 @@
:placeholder="column.name"
class="cell-input"
/>
</td>
</template>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ref, watch, onMounted } from 'vue';
const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']);
const localValue = ref('');
const editing = ref(false);
const allTags = ref([]); // Все теги из /api/tags
const editTagIds = ref([]); // id выбранных тегов в режиме редактирования
// Для отображения выбранных тегов
const selectedTagNames = ref([]);
onMounted(async () => {
if (props.column.type === 'tags') {
await loadTags();
updateSelectedTagNames();
}
});
async function loadTags() {
const res = await fetch('/api/tags');
allTags.value = await res.json();
}
watch(
() => [props.rowId, props.column.id, props.cellValues],
() => {
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
localValue.value = cell ? cell.value : '';
if (props.column.type === 'tags') {
// Значение ячейки — строка с JSON-массивом id тегов
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
let ids = [];
if (cell && cell.value) {
try {
ids = JSON.parse(cell.value);
} catch {}
}
editTagIds.value = Array.isArray(ids) ? ids : [];
updateSelectedTagNames();
} else {
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
localValue.value = cell ? cell.value : '';
}
},
{ immediate: true }
);
function updateSelectedTagNames() {
if (props.column.type === 'tags') {
selectedTagNames.value = allTags.value
.filter(tag => editTagIds.value.includes(tag.id))
.map(tag => tag.name);
}
}
function saveTags() {
emit('update', JSON.stringify(editTagIds.value));
editing.value = false;
}
function cancelTags() {
editing.value = false;
updateSelectedTagNames();
}
function save() {
emit('update', localValue.value);
}
@@ -46,4 +113,52 @@ function save() {
border: 1.5px solid #2ecc40;
outline: none;
}
.tags-cell-view {
min-height: 1.7em;
cursor: pointer;
padding: 0.2em 0.1em;
}
.tags-cell-edit {
background: #f8f8f8;
border-radius: 6px;
padding: 0.3em 0.2em 0.5em 0.2em;
}
.tags-multiselect {
display: flex;
flex-wrap: wrap;
gap: 0.5em 1.2em;
margin-bottom: 0.7em;
}
.tag-option {
display: flex;
align-items: center;
gap: 0.3em;
}
.save-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 600;
cursor: pointer;
margin-right: 0.7em;
transition: background 0.2s;
}
.save-btn:hover {
background: #27ae38;
}
.cancel-btn {
background: #eaeaea;
color: #333;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.cancel-btn:hover {
background: #d5d5d5;
}
</style>

View File

@@ -7,32 +7,38 @@
<table class="notion-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.id" @dblclick="editColumn(col)">
<th v-for="col in columns" :key="col.id" @dblclick="editColumn(col)" class="th-col">
<span v-if="!editingCol || editingCol.id !== col.id">{{ col.name }}</span>
<input v-else v-model="colEditValue" @blur="saveColEdit(col)" @keyup.enter="saveColEdit(col)" @keyup.esc="cancelColEdit" class="notion-input" />
<button class="col-menu" @click.stop="openColMenu(col)"></button>
<button class="col-menu" @click.stop="openColMenu(col, $event)"></button>
<!-- Меню столбца -->
<div v-if="openedColMenuId === col.id" class="context-menu" :style="colMenuStyle">
<button class="menu-item" @click="startRenameCol(col)">Переименовать</button>
<button class="menu-item" @click="startChangeTypeCol(col)">Изменить тип</button>
<button class="menu-item danger" @click="deleteColumn(col)">Удалить</button>
</div>
</th>
<th>
<button class="add-col" @click="addColumn">+</button>
<button class="add-col" @click="showAddColModal = true">+</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in columns" :key="col.id" @click="startEdit(row, col)">
<span v-if="!isEditing(row, col)">{{ getCellValue(row, col) || '' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEdit(row, col)"
@keyup.enter="saveEdit(row, col)"
@keyup.esc="cancelEdit"
class="notion-input"
autofocus
<td v-for="col in columns" :key="col.id">
<TableCell
:rowId="row.id"
:column="col"
:cellValues="cellValues"
@update="val => saveCellValue(row.id, col.id, val)"
/>
</td>
<td>
<button class="row-menu" @click.stop="openRowMenu(row)"></button>
<button class="row-menu" @click.stop="openRowMenu(row, $event)"></button>
<!-- Меню строки -->
<div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle">
<button class="menu-item danger" @click="deleteRow(row)">Удалить</button>
</div>
</td>
</tr>
<tr>
@@ -42,13 +48,55 @@
</tr>
</tbody>
</table>
<!-- Модалки и меню можно реализовать через отдельные компоненты или простые div -->
<!-- Оверлей для закрытия меню по клику вне -->
<div v-if="openedColMenuId || openedRowMenuId" class="menu-overlay" @click="closeMenus"></div>
<!-- Модалка добавления столбца -->
<div v-if="showAddColModal" class="modal-backdrop">
<div class="modal add-col-modal">
<h4>Добавить столбец</h4>
<label>Название</label>
<input v-model="newColName" class="notion-input" placeholder="Название столбца" />
<label>Тип</label>
<select v-model="newColType" class="notion-input">
<option value="text">Текст</option>
<option value="number">Число</option>
<option value="tags">Теги</option>
</select>
<label>Назначение столбца</label>
<select v-model="newColPurpose" class="notion-input">
<option value=""> Не выбрано </option>
<option value="question">Это столбец с вопросами</option>
<option value="answer">Это столбец с ответами</option>
<option value="clarifyingAnswer">Ответ с уточняющим вопросом</option>
<option value="objectionAnswer">Ответ на возражение</option>
<option value="userTags">Это столбец с тегами пользователей</option>
<option value="context">Это столбец с дополнительным контекстом</option>
<option value="product">Это столбец с продуктом/услугой</option>
<option value="priority">Это столбец с приоритетом</option>
<option value="date">Это столбец с датой</option>
</select>
<div v-if="newColType === 'tags'">
<label>Выберите теги</label>
<div class="tags-multiselect">
<div v-for="tag in tags" :key="tag.id" class="tag-option">
<input type="checkbox" :id="'tag-' + tag.id" :value="tag.id" v-model="selectedTagIds" />
<label :for="'tag-' + tag.id">{{ tag.name }}</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="save-btn" @click="handleAddColumn">Добавить</button>
<button class="cancel-btn" @click="closeAddColModal">Отмена</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import tablesService from '../../services/tablesService';
import TableCell from './TableCell.vue';
const props = defineProps({ tableId: Number });
const columns = ref([]);
@@ -56,6 +104,52 @@ const rows = ref([]);
const cellValues = ref([]);
const tableMeta = ref(null);
// Для модалки добавления столбца
const showAddColModal = ref(false);
const newColName = ref('');
const newColType = ref('text');
const tags = ref([]);
const selectedTagIds = ref([]);
const newColPurpose = ref("");
// Меню столбца
const openedColMenuId = ref(null);
const openedRowMenuId = ref(null);
const colMenuStyle = ref('');
const rowMenuStyle = ref('');
function closeAddColModal() {
showAddColModal.value = false;
newColName.value = '';
newColType.value = 'text';
selectedTagIds.value = [];
newColPurpose.value = '';
}
async function handleAddColumn() {
if (!newColName.value) return;
const data = { name: newColName.value, type: newColType.value };
if (newColType.value === 'tags') {
data.tagIds = selectedTagIds.value;
}
if (newColPurpose.value) {
data.purpose = newColPurpose.value;
}
await tablesService.addColumn(props.tableId, data);
closeAddColModal();
fetchTable();
}
async function loadTags() {
const res = await fetch('/api/tags');
tags.value = await res.json();
}
onMounted(() => {
fetchTable();
loadTags();
});
// Для редактирования ячеек
const editing = ref({ rowId: null, colId: null });
const editValue = ref('');
@@ -95,13 +189,40 @@ function cancelColEdit() {
// Добавление/удаление
function addColumn() {
tablesService.addColumn(props.tableId, { name: 'Новый столбец', type: 'text' }).then(fetchTable);
showAddColModal.value = true;
}
function addRow() {
tablesService.addRow(props.tableId).then(fetchTable);
}
function openColMenu(col) { /* TODO: контекстное меню */ }
function openRowMenu(row) { /* TODO: контекстное меню */ }
function openColMenu(col, event) {
openedColMenuId.value = col.id;
openedRowMenuId.value = null;
setMenuPosition(event, colMenuStyle);
}
function openRowMenu(row, event) {
openedRowMenuId.value = row.id;
openedColMenuId.value = null;
setMenuPosition(event, rowMenuStyle);
}
function closeMenus() {
openedColMenuId.value = null;
openedRowMenuId.value = null;
}
function setMenuPosition(event, styleRef) {
// Позиционируем меню под кнопкой
const rect = event.target.getBoundingClientRect();
styleRef.value = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:2000;`;
}
// Действия меню столбца
function startRenameCol(col) {
closeMenus();
editColumn(col);
}
function startChangeTypeCol(col) {
closeMenus();
// TODO: реализовать смену типа столбца (можно открыть модалку выбора типа)
alert('Изменение типа столбца пока не реализовано');
}
// Загрузка данных
async function fetchTable() {
@@ -111,7 +232,21 @@ async function fetchTable() {
cellValues.value = data.cellValues;
tableMeta.value = { name: data.name, description: data.description };
}
fetchTable();
function saveCellValue(rowId, columnId, value) {
tablesService.saveCell({ row_id: rowId, column_id: columnId, value }).then(fetchTable);
}
function deleteRow(row) {
if (confirm('Удалить эту строку?')) {
tablesService.deleteRow(row.id).then(fetchTable);
}
}
function deleteColumn(col) {
if (confirm('Удалить этот столбец?')) {
tablesService.deleteColumn(col.id).then(fetchTable);
}
}
</script>
<style scoped>
@@ -174,4 +309,138 @@ fetchTable();
color: #888;
font-size: 1em;
}
.modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.18);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
padding: 2em 2em 1.5em 2em;
min-width: 320px;
max-width: 95vw;
}
.add-col-modal label {
font-weight: 500;
margin-top: 0.7em;
display: block;
}
.add-col-modal input,
.add-col-modal select {
width: 100%;
border: 1px solid #ececec;
border-radius: 7px;
padding: 0.5em 0.8em;
font-size: 1em;
background: #fafbfc;
margin-bottom: 0.7em;
}
.tags-multiselect {
display: flex;
flex-wrap: wrap;
gap: 0.5em 1.2em;
margin-bottom: 1em;
}
.tag-option {
display: flex;
align-items: center;
gap: 0.3em;
}
.save-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 600;
cursor: pointer;
margin-right: 0.7em;
transition: background 0.2s;
}
.save-btn:hover {
background: #27ae38;
}
.cancel-btn {
background: #eaeaea;
color: #333;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.cancel-btn:hover {
background: #d5d5d5;
}
.th-col {
position: relative;
}
.delete-col-btn {
position: absolute;
top: 6px;
right: 6px;
background: none;
border: none;
color: #ff4d4f;
font-size: 1.1em;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.th-col:hover .delete-col-btn {
opacity: 1;
}
.delete-row-btn {
background: none;
border: none;
color: #ff4d4f;
font-size: 1.1em;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
tr:hover .delete-row-btn {
opacity: 1;
}
.context-menu {
background: #fff;
border: 1px solid #ececec;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.13);
min-width: 150px;
padding: 0.3em 0.2em;
display: flex;
flex-direction: column;
position: fixed;
z-index: 2001;
}
.menu-item {
background: none;
border: none;
text-align: left;
padding: 0.6em 1.1em;
font-size: 1em;
color: #222;
cursor: pointer;
border-radius: 6px;
transition: background 0.18s;
}
.menu-item:hover {
background: #f2f8f4;
}
.menu-item.danger {
color: #ff4d4f;
}
.menu-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1999;
}
</style>