mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Добавление аналитики
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.isAdmin()) {
|
||||
$(".container").html(
|
||||
'<div class="bg-white rounded-xl shadow-sm p-8 text-center border border-gray-100"><svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg><h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3><p class="text-gray-500 mb-4">Только администраторы могут просматривать аналитику</p><a href="/" class="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">На главную</a></div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let loansChart = null;
|
||||
let returnsChart = null;
|
||||
let currentPeriod = 30;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
$("#period-select").on("change", function () {
|
||||
currentPeriod = parseInt($(this).val());
|
||||
loadAnalytics();
|
||||
});
|
||||
|
||||
$("#refresh-btn").on("click", loadAnalytics);
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
const data = await Api.get(`/api/loans/analytics?days=${currentPeriod}`);
|
||||
renderSummary(data.summary);
|
||||
renderCharts(data);
|
||||
renderTopBooks(data.top_books);
|
||||
} catch (error) {
|
||||
console.error("Failed to load analytics", error);
|
||||
Utils.showToast("Ошибка загрузки аналитики", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
$("#total-loans").text(summary.total_loans || 0);
|
||||
$("#active-loans").text(summary.active_loans || 0);
|
||||
$("#returned-loans").text(summary.returned_loans || 0);
|
||||
$("#overdue-loans").text(summary.overdue_loans || 0);
|
||||
$("#reserved-books").text(summary.reserved_books || 0);
|
||||
$("#borrowed-books").text(summary.borrowed_books || 0);
|
||||
}
|
||||
|
||||
function renderCharts(data) {
|
||||
// Подготовка данных для графиков
|
||||
const startDate = new Date(data.start_date);
|
||||
const endDate = new Date(data.end_date);
|
||||
const dates = [];
|
||||
const loansData = [];
|
||||
const returnsData = [];
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split("T")[0];
|
||||
dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }));
|
||||
loansData.push(data.daily_loans[dateStr] || 0);
|
||||
returnsData.push(data.daily_returns[dateStr] || 0);
|
||||
}
|
||||
|
||||
// График выдач
|
||||
const loansCtx = document.getElementById("loans-chart");
|
||||
if (loansChart) {
|
||||
loansChart.destroy();
|
||||
}
|
||||
loansChart = new Chart(loansCtx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [
|
||||
{
|
||||
label: "Выдачи",
|
||||
data: loansData,
|
||||
borderColor: "rgb(75, 85, 99)",
|
||||
backgroundColor: "rgba(75, 85, 99, 0.05)",
|
||||
borderWidth: 1.5,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
pointBackgroundColor: "rgb(75, 85, 99)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1.5,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 10,
|
||||
titleFont: { size: 12, weight: "500" },
|
||||
bodyFont: { size: 11 },
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderWidth: 1,
|
||||
titleSpacing: 4,
|
||||
bodySpacing: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.03)",
|
||||
drawBorder: false,
|
||||
lineWidth: 1,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// График возвратов
|
||||
const returnsCtx = document.getElementById("returns-chart");
|
||||
if (returnsChart) {
|
||||
returnsChart.destroy();
|
||||
}
|
||||
returnsChart = new Chart(returnsCtx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [
|
||||
{
|
||||
label: "Возвраты",
|
||||
data: returnsData,
|
||||
borderColor: "rgb(107, 114, 128)",
|
||||
backgroundColor: "rgba(107, 114, 128, 0.05)",
|
||||
borderWidth: 1.5,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 4,
|
||||
pointBackgroundColor: "rgb(107, 114, 128)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1.5,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 10,
|
||||
titleFont: { size: 12, weight: "500" },
|
||||
bodyFont: { size: 11 },
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderWidth: 1,
|
||||
titleSpacing: 4,
|
||||
bodySpacing: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.03)",
|
||||
drawBorder: false,
|
||||
lineWidth: 1,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
font: { size: 10 },
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopBooks(topBooks) {
|
||||
const $container = $("#top-books-container");
|
||||
$container.empty();
|
||||
|
||||
if (!topBooks || topBooks.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет данных</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
topBooks.forEach((book, index) => {
|
||||
const $item = $(`
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100 hover:bg-gray-100 transition-colors">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-7 h-7 bg-gray-600 text-white rounded-full flex items-center justify-center font-medium text-xs flex-shrink-0">
|
||||
${index + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="/book/${book.book_id}" class="text-sm font-medium text-gray-900 hover:text-gray-600 transition-colors block truncate">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span class="px-2.5 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
|
||||
${book.loan_count} ${book.loan_count === 1 ? "выдача" : book.loan_count < 5 ? "выдачи" : "выдач"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$container.append($item);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+413
-105
@@ -32,6 +32,194 @@ $(document).ready(() => {
|
||||
},
|
||||
};
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = parseInt(pathParts[pathParts.length - 1]);
|
||||
let currentBook = null;
|
||||
let cachedUsers = null;
|
||||
let selectedLoanUserId = null;
|
||||
let activeLoan = null;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
return;
|
||||
}
|
||||
loadBookData();
|
||||
setupEventHandlers();
|
||||
}
|
||||
|
||||
function setupEventHandlers() {
|
||||
$(document).on("click", (e) => {
|
||||
const $menu = $("#status-menu");
|
||||
const $toggleBtn = $("#status-toggle-btn");
|
||||
if (
|
||||
$menu.length &&
|
||||
!$menu.hasClass("hidden") &&
|
||||
!$toggleBtn.is(e.target) &&
|
||||
$toggleBtn.has(e.target).length === 0 &&
|
||||
!$menu.has(e.target).length
|
||||
) {
|
||||
$menu.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#cancel-loan-btn").on("click", closeLoanModal);
|
||||
$("#user-search-input").on("input", handleUserSearch);
|
||||
$("#confirm-loan-btn").on("click", submitLoan);
|
||||
$("#refresh-loans-btn").on("click", loadLoans);
|
||||
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 14);
|
||||
$("#loan-due-date").val(future.toISOString().split("T")[0]);
|
||||
}
|
||||
|
||||
function loadBookData() {
|
||||
Api.get(`/api/books/${bookId}`)
|
||||
.then((book) => {
|
||||
currentBook = book;
|
||||
document.title = `LiB - ${book.title}`;
|
||||
renderBook(book);
|
||||
if (window.canManage()) {
|
||||
$("#edit-book-btn")
|
||||
.attr("href", `/book/${book.id}/edit`)
|
||||
.removeClass("hidden");
|
||||
$("#loans-section").removeClass("hidden");
|
||||
loadLoans();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Книга не найдена", "error");
|
||||
$("#book-loader").html(
|
||||
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLoans() {
|
||||
if (!window.canManage()) return;
|
||||
|
||||
try {
|
||||
const data = await Api.get(
|
||||
`/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`
|
||||
);
|
||||
activeLoan = data.loans.length > 0 ? data.loans[0] : null;
|
||||
renderLoans(data.loans);
|
||||
} catch (error) {
|
||||
console.error("Failed to load loans", error);
|
||||
$("#loans-container").html(
|
||||
'<div class="text-center text-red-500 py-4">Ошибка загрузки выдач</div>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoans(loans) {
|
||||
const $container = $("#loans-container");
|
||||
$container.empty();
|
||||
|
||||
if (!loans || loans.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
loans.forEach((loan) => {
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
|
||||
"ru-RU"
|
||||
);
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
const isOverdue =
|
||||
!loan.returned_at && new Date(loan.due_date) < new Date();
|
||||
|
||||
const $loanCard = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 ${
|
||||
isOverdue ? "bg-red-50 border-red-300" : "bg-gray-50"
|
||||
}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="font-medium text-gray-900">ID выдачи: ${loan.id}</span>
|
||||
${
|
||||
isOverdue
|
||||
? '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">Просрочена</span>'
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-1">
|
||||
<span class="font-medium">Дата выдачи:</span> ${borrowedDate}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 mb-1">
|
||||
<span class="font-medium">Срок возврата:</span> ${dueDate}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
<span class="font-medium">Пользователь ID:</span> ${loan.user_id}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
${
|
||||
!loan.returned_at && currentBook.status === "reserved"
|
||||
? `<button class="confirm-loan-btn px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors" data-loan-id="${loan.id}">
|
||||
Подтвердить
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!loan.returned_at
|
||||
? `<button class="return-loan-btn px-3 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors" data-loan-id="${loan.id}">
|
||||
Вернуть
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$loanCard.find(".confirm-loan-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
confirmLoan(loanId);
|
||||
});
|
||||
|
||||
$loanCard.find(".return-loan-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
returnLoan(loanId);
|
||||
});
|
||||
|
||||
$container.append($loanCard);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmLoan(loanId) {
|
||||
try {
|
||||
await Api.post(`/api/loans/${loanId}/confirm`);
|
||||
Utils.showToast("Бронь подтверждена", "success");
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка подтверждения брони", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function returnLoan(loanId) {
|
||||
if (!confirm("Вы уверены, что хотите вернуть эту книгу?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Api.post(`/api/loans/${loanId}/return`);
|
||||
Utils.showToast("Книга возвращена", "success");
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка возврата книги", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
@@ -43,34 +231,6 @@ $(document).ready(() => {
|
||||
);
|
||||
}
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = pathParts[pathParts.length - 1];
|
||||
let currentBook = null;
|
||||
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Api.get(`/api/books/${bookId}`)
|
||||
.then((book) => {
|
||||
currentBook = book;
|
||||
document.title = `LiB - ${book.title}`;
|
||||
renderBook(book);
|
||||
if (window.canManage()) {
|
||||
$("#edit-book-btn")
|
||||
.attr("href", `/book/${book.id}/edit`)
|
||||
.removeClass("hidden");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Книга не найдена", "error");
|
||||
$("#book-loader").html(
|
||||
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
|
||||
);
|
||||
});
|
||||
|
||||
function renderBook(book) {
|
||||
$("#book-title").text(book.title);
|
||||
$("#book-id").text(`ID: ${book.id}`);
|
||||
@@ -81,8 +241,10 @@ $(document).ready(() => {
|
||||
|
||||
renderStatusWidget(book);
|
||||
|
||||
if (!window.canManage && book.status === "active") {
|
||||
if (!window.canManage() && book.status === "active") {
|
||||
renderReserveButton();
|
||||
} else {
|
||||
$("#book-actions-container").empty();
|
||||
}
|
||||
|
||||
if (book.genres && book.genres.length > 0) {
|
||||
@@ -91,10 +253,10 @@ $(document).ready(() => {
|
||||
$genres.empty();
|
||||
book.genres.forEach((g) => {
|
||||
$genres.append(`
|
||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||
${Utils.escapeHtml(g.name)}
|
||||
</a>
|
||||
`);
|
||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||
${Utils.escapeHtml(g.name)}
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,13 +266,13 @@ $(document).ready(() => {
|
||||
$authors.empty();
|
||||
book.authors.forEach((a) => {
|
||||
$authors.append(`
|
||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||
</a>
|
||||
`);
|
||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,86 +287,96 @@ $(document).ready(() => {
|
||||
|
||||
if (window.canManage()) {
|
||||
const $dropdownHTML = $(`
|
||||
<div class="relative inline-block text-left w-full md:w-auto">
|
||||
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
|
||||
${config.icon}
|
||||
<span class="ml-2">${config.label}</span>
|
||||
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative inline-block text-left w-full md:w-auto">
|
||||
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
|
||||
${config.icon}
|
||||
<span class="ml-2">${config.label}</span>
|
||||
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
|
||||
<div class="py-1" role="menu">
|
||||
${Object.entries(STATUS_CONFIG)
|
||||
.map(
|
||||
([key, conf]) => `
|
||||
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${book.status === key ? "bg-gray-50 font-medium" : "text-gray-700"}"
|
||||
data-status="${key}">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
|
||||
${conf.icon}
|
||||
</span>
|
||||
<span>${conf.label}</span>
|
||||
${book.status === key ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
|
||||
<div class="py-1" role="menu">
|
||||
${Object.entries(STATUS_CONFIG)
|
||||
.map(([key, conf]) => {
|
||||
const isCurrent = book.status === key;
|
||||
return `
|
||||
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${isCurrent ? "bg-gray-50 font-medium" : "text-gray-700"}"
|
||||
data-status="${key}">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
|
||||
${conf.icon}
|
||||
</span>
|
||||
<span>${conf.label}</span>
|
||||
${isCurrent ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($dropdownHTML);
|
||||
|
||||
const $toggleBtn = $("#status-toggle-btn");
|
||||
const $menu = $("#status-menu");
|
||||
|
||||
$toggleBtn.on("click", (e) => {
|
||||
$("#status-toggle-btn").on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
$menu.toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(document).on("click", (e) => {
|
||||
if (
|
||||
!$toggleBtn.is(e.target) &&
|
||||
$toggleBtn.has(e.target).length === 0 &&
|
||||
!$menu.has(e.target).length
|
||||
) {
|
||||
$menu.addClass("hidden");
|
||||
}
|
||||
$("#status-menu").toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(".status-option").on("click", function () {
|
||||
const newStatus = $(this).data("status");
|
||||
if (newStatus !== currentBook.status) {
|
||||
$("#status-menu").addClass("hidden");
|
||||
|
||||
if (newStatus === currentBook.status) return;
|
||||
|
||||
if (newStatus === "borrowed") {
|
||||
openLoanModal();
|
||||
} else {
|
||||
updateBookStatus(newStatus);
|
||||
}
|
||||
$menu.addClass("hidden");
|
||||
});
|
||||
} else {
|
||||
$container.append(`
|
||||
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
|
||||
${config.icon}
|
||||
${config.label}
|
||||
</span>
|
||||
`);
|
||||
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
|
||||
${config.icon}
|
||||
${config.label}
|
||||
</span>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReserveButton() {
|
||||
const $container = $("#book-actions-container");
|
||||
$container.html(`
|
||||
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Зарезервировать
|
||||
</button>
|
||||
`);
|
||||
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Зарезервировать
|
||||
</button>
|
||||
`);
|
||||
|
||||
$("#reserve-btn").on("click", function () {
|
||||
Utils.showToast("Функция бронирования в разработке", "info");
|
||||
const user = window.getUser();
|
||||
if (!user) {
|
||||
Utils.showToast("Необходима авторизация", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Api.post("/api/loans/", {
|
||||
book_id: currentBook.id,
|
||||
user_id: user.id,
|
||||
due_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
.then((loan) => {
|
||||
Utils.showToast("Книга забронирована", "success");
|
||||
loadBookData();
|
||||
})
|
||||
.catch((err) => {
|
||||
Utils.showToast(err.message || "Ошибка бронирования", "error");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,14 +385,12 @@ $(document).ready(() => {
|
||||
const originalContent = $toggleBtn.html();
|
||||
|
||||
$toggleBtn.prop("disabled", true).addClass("opacity-75").html(`
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
Обновление...
|
||||
`);
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
Обновление...
|
||||
`);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: currentBook.title,
|
||||
description: currentBook.description,
|
||||
status: newStatus,
|
||||
};
|
||||
|
||||
@@ -229,17 +399,155 @@ $(document).ready(() => {
|
||||
payload,
|
||||
);
|
||||
currentBook = updatedBook;
|
||||
|
||||
Utils.showToast("Статус успешно изменен", "success");
|
||||
|
||||
renderStatusWidget(updatedBook);
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка при смене статуса", "error");
|
||||
Utils.showToast(error.message || "Ошибка при смене статуса", "error");
|
||||
$toggleBtn
|
||||
.prop("disabled", false)
|
||||
.removeClass("opacity-75")
|
||||
.html(originalContent);
|
||||
}
|
||||
}
|
||||
|
||||
function openLoanModal() {
|
||||
$("#loan-modal").removeClass("hidden");
|
||||
$("#user-search-input").val("")[0].focus();
|
||||
$("#users-list-container").html(
|
||||
'<div class="p-4 text-center text-gray-500 text-sm">Загрузка списка пользователей...</div>',
|
||||
);
|
||||
$("#confirm-loan-btn").prop("disabled", true);
|
||||
selectedLoanUserId = null;
|
||||
|
||||
fetchUsers();
|
||||
}
|
||||
|
||||
function closeLoanModal() {
|
||||
$("#loan-modal").addClass("hidden");
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
if (cachedUsers) {
|
||||
renderUsersList(cachedUsers);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Api.get("/api/auth/users?skip=0&limit=500");
|
||||
cachedUsers = data.users;
|
||||
renderUsersList(cachedUsers);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users", error);
|
||||
$("#users-list-container").html(
|
||||
'<div class="p-4 text-center text-red-500 text-sm">Ошибка загрузки пользователей</div>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsersList(users) {
|
||||
const $container = $("#users-list-container");
|
||||
$container.empty();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
$container.html(
|
||||
'<div class="p-4 text-center text-gray-500 text-sm">Пользователи не найдены</div>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach((user) => {
|
||||
const roleBadges = user.roles
|
||||
.map((r) => {
|
||||
const color =
|
||||
r === "admin"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: r === "librarian"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800";
|
||||
return `<span class="text-xs px-2 py-0.5 rounded-full ${color} mr-1">${r}</span>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const $item = $(`
|
||||
<div class="user-item p-3 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-between group" data-id="${user.id}">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">${Utils.escapeHtml(user.full_name || user.username)}</div>
|
||||
<div class="text-xs text-gray-500">@${Utils.escapeHtml(user.username)} • ${Utils.escapeHtml(user.email)}</div>
|
||||
</div>
|
||||
<div>${roleBadges}</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$item.on("click", function () {
|
||||
$(".user-item").removeClass("bg-blue-100 border-l-4 border-blue-500");
|
||||
$(this).addClass("bg-blue-100 border-l-4 border-blue-500");
|
||||
selectedLoanUserId = user.id;
|
||||
$("#confirm-loan-btn")
|
||||
.prop("disabled", false)
|
||||
.text(`Выдать для ${user.username}`);
|
||||
});
|
||||
|
||||
$container.append($item);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUserSearch() {
|
||||
const query = $(this).val().toLowerCase();
|
||||
if (!cachedUsers) return;
|
||||
|
||||
if (!query) {
|
||||
renderUsersList(cachedUsers);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = cachedUsers.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(query) ||
|
||||
(u.full_name && u.full_name.toLowerCase().includes(query)) ||
|
||||
u.email.toLowerCase().includes(query),
|
||||
);
|
||||
renderUsersList(filtered);
|
||||
}
|
||||
|
||||
async function submitLoan() {
|
||||
if (!selectedLoanUserId) return;
|
||||
const dueDate = $("#loan-due-date").val();
|
||||
|
||||
if (!dueDate) {
|
||||
Utils.showToast("Выберите дату возврата", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $("#confirm-loan-btn");
|
||||
const originalText = $btn.text();
|
||||
$btn.prop("disabled", true).text("Обработка...");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
book_id: currentBook.id,
|
||||
user_id: selectedLoanUserId,
|
||||
due_date: new Date(dueDate).toISOString(),
|
||||
};
|
||||
|
||||
// Используем прямой эндпоинт выдачи для администраторов
|
||||
if (window.isAdmin()) {
|
||||
await Api.post("/api/loans/issue", payload);
|
||||
} else {
|
||||
// Для библиотекарей создаем бронь, которую потом нужно подтвердить
|
||||
await Api.post("/api/loans/", payload);
|
||||
}
|
||||
|
||||
Utils.showToast("Книга успешно выдана", "success");
|
||||
closeLoanModal();
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка выдачи", "error");
|
||||
} finally {
|
||||
$btn.prop("disabled", false).text(originalText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
$(document).ready(() => {
|
||||
let allLoans = [];
|
||||
let booksCache = new Map();
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
const user = window.getUser();
|
||||
if (!user) {
|
||||
Utils.showToast("Необходима авторизация", "error");
|
||||
window.location.href = "/auth";
|
||||
return;
|
||||
}
|
||||
loadLoans();
|
||||
}
|
||||
|
||||
async function loadLoans() {
|
||||
try {
|
||||
const data = await Api.get("/api/loans/?page=1&size=100");
|
||||
allLoans = data.loans;
|
||||
|
||||
// Загружаем информацию о книгах
|
||||
const bookIds = [...new Set(allLoans.map(loan => loan.book_id))];
|
||||
await loadBooks(bookIds);
|
||||
|
||||
renderLoans();
|
||||
} catch (error) {
|
||||
console.error("Failed to load loans", error);
|
||||
Utils.showToast("Ошибка загрузки выдач", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBooks(bookIds) {
|
||||
const promises = bookIds.map(async (bookId) => {
|
||||
if (!booksCache.has(bookId)) {
|
||||
try {
|
||||
const book = await Api.get(`/api/books/${bookId}`);
|
||||
booksCache.set(bookId, book);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load book ${bookId}`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
function renderLoans() {
|
||||
const reservations = allLoans.filter(
|
||||
loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved"
|
||||
);
|
||||
const activeLoans = allLoans.filter(
|
||||
loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed"
|
||||
);
|
||||
const returned = allLoans.filter(loan => loan.returned_at !== null);
|
||||
|
||||
renderReservations(reservations);
|
||||
renderActiveLoans(activeLoans);
|
||||
renderReturned(returned);
|
||||
}
|
||||
|
||||
function getBookStatus(bookId) {
|
||||
const book = booksCache.get(bookId);
|
||||
return book ? book.status : null;
|
||||
}
|
||||
|
||||
function renderReservations(reservations) {
|
||||
const $container = $("#reservations-container");
|
||||
$("#reservations-count").text(reservations.length);
|
||||
$container.empty();
|
||||
|
||||
if (reservations.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reservations.forEach((loan) => {
|
||||
const book = booksCache.get(loan.book_id);
|
||||
if (!book) return;
|
||||
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
|
||||
const $card = $(`
|
||||
<div class="border border-blue-200 rounded-lg p-4 bg-blue-50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
||||
</p>
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
|
||||
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||
Забронирована
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="cancel-reservation-btn ml-4 px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors"
|
||||
data-loan-id="${loan.id}"
|
||||
data-book-id="${book.id}"
|
||||
>
|
||||
Отменить бронь
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$card.find(".cancel-reservation-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
const bookId = $(this).data("book-id");
|
||||
cancelReservation(loanId, bookId);
|
||||
});
|
||||
|
||||
$container.append($card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActiveLoans(activeLoans) {
|
||||
const $container = $("#active-loans-container");
|
||||
$("#active-loans-count").text(activeLoans.length);
|
||||
$container.empty();
|
||||
|
||||
if (activeLoans.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeLoans.forEach((loan) => {
|
||||
const book = booksCache.get(loan.book_id);
|
||||
if (!book) return;
|
||||
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
const isOverdue = new Date(loan.due_date) < new Date();
|
||||
|
||||
const $card = $(`
|
||||
<div class="border ${isOverdue ? "border-red-300 bg-red-50" : "border-yellow-200 bg-yellow-50"} rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-yellow-600 transition-colors">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
||||
</p>
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
||||
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
|
||||
Выдана
|
||||
</span>
|
||||
${isOverdue ? '<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">Просрочена</span>' : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderReturned(returned) {
|
||||
const $container = $("#returned-container");
|
||||
$("#returned-count").text(returned.length);
|
||||
$container.empty();
|
||||
|
||||
if (returned.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
returned.forEach((loan) => {
|
||||
const book = booksCache.get(loan.book_id);
|
||||
if (!book) return;
|
||||
|
||||
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
|
||||
const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU");
|
||||
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
|
||||
|
||||
const $card = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors">
|
||||
${Utils.escapeHtml(book.title)}
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
|
||||
</p>
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
|
||||
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
|
||||
<p><span class="font-medium">Дата возврата:</span> ${returnedDate}</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-800 rounded-full text-xs font-medium">
|
||||
Возвращена
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($card);
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelReservation(loanId, bookId) {
|
||||
if (!confirm("Вы уверены, что хотите отменить бронирование?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Api.delete(`/api/loans/${loanId}`);
|
||||
Utils.showToast("Бронирование отменено", "success");
|
||||
|
||||
// Удаляем из кэша и перезагружаем
|
||||
allLoans = allLoans.filter(loan => loan.id !== loanId);
|
||||
const book = booksCache.get(bookId);
|
||||
if (book) {
|
||||
book.status = "active";
|
||||
booksCache.set(bookId, book);
|
||||
}
|
||||
|
||||
renderLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка отмены бронирования", "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user