1741 lines
80 KiB
TypeScript
1741 lines
80 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, Image as ImageIcon, Map, FileText, CheckCircle, Star, Settings } from 'lucide-react';
|
|
import { useConfirm } from '../hooks/useConfirm';
|
|
import { useNotification } from '../hooks/useNotification';
|
|
|
|
interface UserManagementModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
|
const confirm = useConfirm();
|
|
const notify = useNotification();
|
|
const [activeTab, setActiveTab] = useState<'users' | 'tours' | 'photos' | 'notes' | 'recommendations' | 'trash' | 'filters' | 'reports'>('users');
|
|
const [users, setUsers] = useState<any[]>([]);
|
|
const [photos, setPhotos] = useState<any[]>([]);
|
|
const [reports, setReports] = useState<any[]>([]);
|
|
const [reportsLoading, setReportsLoading] = useState(false);
|
|
const [moderationSetting, setModerationSetting] = useState({ blockNsfw: false, blurFaces: false });
|
|
const [wordFilters, setWordFilters] = useState<any[]>([]);
|
|
const [newWord, setNewWord] = useState('');
|
|
const [newReplacement, setNewReplacement] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [photosLoading, setPhotosLoading] = useState(false);
|
|
const [trashLoading, setTrashLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
// Active Tours
|
|
const [tours, setTours] = useState<any[]>([]);
|
|
const [toursLoading, setToursLoading] = useState(false);
|
|
const [selectedTourIds, setSelectedTourIds] = useState<string[]>([]);
|
|
|
|
// Active Notes
|
|
const [notes, setNotes] = useState<any[]>([]);
|
|
const [notesLoading, setNotesLoading] = useState(false);
|
|
const [selectedNoteIds, setSelectedNoteIds] = useState<string[]>([]);
|
|
|
|
// Active Photos multiselect
|
|
const [selectedPhotoIds, setSelectedPhotoIds] = useState<string[]>([]);
|
|
|
|
// Recommended Locations
|
|
const [adminRecommendations, setAdminRecommendations] = useState<any[]>([]);
|
|
const [recommendationsLoading, setRecommendationsLoading] = useState(false);
|
|
const [selectedRecIds, setSelectedRecIds] = useState<string[]>([]);
|
|
|
|
// Trash Bin State
|
|
const [trashData, setTrashData] = useState<{
|
|
retentionDays: number;
|
|
tours: any[];
|
|
photos: any[];
|
|
notes: any[];
|
|
}>({ retentionDays: 30, tours: [], photos: [], notes: [] });
|
|
const [trashSubTab, setTrashSubTab] = useState<'tours' | 'photos' | 'notes'>('tours');
|
|
const [selectedTrashIds, setSelectedTrashIds] = useState<string[]>([]);
|
|
const [retentionDaysInput, setRetentionDaysInput] = useState<number>(30);
|
|
|
|
const handleToggleBlacklist = async (id: string, currentStatus: boolean) => {
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/reports/${id}/blacklist`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({ isBlacklisted: !currentStatus })
|
|
});
|
|
if (!response.ok) throw new Error('Thao tác thất bại');
|
|
fetchReports();
|
|
} catch (err: any) {
|
|
alert(err.message);
|
|
}
|
|
};
|
|
|
|
const handleDeleteReport = async (id: string) => {
|
|
if (!await confirm({
|
|
title: 'Xóa báo cáo',
|
|
message: 'Bạn có chắc muốn xóa báo cáo này?'
|
|
})) return;
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/reports/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (!response.ok) throw new Error('Xóa báo cáo thất bại');
|
|
fetchReports();
|
|
} catch (err: any) {
|
|
alert(err.message);
|
|
}
|
|
};
|
|
|
|
const handleUpdateModeration = async (field: 'blockNsfw' | 'blurFaces', value: boolean) => {
|
|
const updated = { ...moderationSetting, [field]: value };
|
|
setModerationSetting(updated);
|
|
try {
|
|
await fetch('/api/v1/admin/moderation', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify(updated)
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleToggleBlock = async (id: string) => {
|
|
try {
|
|
await fetch(`/api/v1/users/block/${id}`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
fetchUsers();
|
|
} catch (err) {
|
|
alert('Lỗi khi thay đổi trạng thái block');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!await confirm({
|
|
title: 'Xóa người dùng',
|
|
message: 'Bạn có chắc chắn muốn xóa người dùng này?'
|
|
})) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/users/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.message);
|
|
}
|
|
fetchUsers();
|
|
} catch (err: any) {
|
|
alert(err.message);
|
|
}
|
|
};
|
|
|
|
const handleAddWordFilter = async () => {
|
|
if (!newWord.trim()) return;
|
|
try {
|
|
const res = await fetch('/api/v1/admin/moderation/word-filters', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({ word: newWord, replacement: newReplacement })
|
|
});
|
|
if (res.ok) {
|
|
setNewWord('');
|
|
setNewReplacement('');
|
|
fetchWordFilters();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleDeleteWordFilter = async (id: string) => {
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/moderation/word-filters/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
fetchWordFilters();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const fetchReports = async () => {
|
|
setReportsLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/reports`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (!response.ok) throw new Error('Không thể tải danh sách báo cáo');
|
|
const data = await response.json();
|
|
setReports(data);
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setReportsLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchUsers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/users`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
|
|
const data = await response.json();
|
|
setUsers(data);
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchPhotos = async () => {
|
|
setPhotosLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/public-photos`);
|
|
if (!response.ok) throw new Error('Không thể tải danh sách ảnh công cộng');
|
|
const data = await response.json();
|
|
setPhotos(data);
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setPhotosLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchTours = async () => {
|
|
setToursLoading(true);
|
|
try {
|
|
const res = await fetch('/api/v1/admin/tours', {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setTours(data);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setToursLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchNotes = async () => {
|
|
setNotesLoading(true);
|
|
try {
|
|
const res = await fetch('/api/v1/admin/notes', {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setNotes(data);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setNotesLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchRecommendations = async () => {
|
|
setRecommendationsLoading(true);
|
|
try {
|
|
const res = await fetch('/api/v1/admin/recommendations', {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setAdminRecommendations(data);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setRecommendationsLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchTrashData = async () => {
|
|
setTrashLoading(true);
|
|
try {
|
|
const res = await fetch('/api/v1/admin/trash', {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setTrashData(data);
|
|
setRetentionDaysInput(data.retentionDays || 30);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setTrashLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteTour = async (id: string) => {
|
|
if (!confirm('Bạn có chắc chắn muốn chuyển tour này vào thùng rác? Tất cả ghi chú và hình ảnh thuộc tour này cũng sẽ bị ẩn đi.')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/tours/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
fetchTours();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleDeleteNote = async (id: string) => {
|
|
if (!confirm('Bạn có chắc chắn muốn chuyển ghi chú này vào thùng rác?')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/notes/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
fetchNotes();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleDeletePhoto = async (id: string) => {
|
|
if (!confirm('Bạn có chắc chắn muốn xóa bức ảnh công khai này?')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/photos/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.message);
|
|
}
|
|
fetchPhotos();
|
|
} catch (err: any) {
|
|
alert(err.message);
|
|
}
|
|
};
|
|
|
|
const handleBulkDeleteTours = async () => {
|
|
if (selectedTourIds.length === 0) return;
|
|
if (!confirm(`Bạn có chắc muốn chuyển ${selectedTourIds.length} Tour đã chọn vào thùng rác?`)) return;
|
|
setToursLoading(true);
|
|
try {
|
|
for (const id of selectedTourIds) {
|
|
await fetch(`/api/v1/admin/tours/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
}
|
|
setSelectedTourIds([]);
|
|
fetchTours();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setToursLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkDeleteNotes = async () => {
|
|
if (selectedNoteIds.length === 0) return;
|
|
if (!confirm(`Bạn có chắc muốn chuyển ${selectedNoteIds.length} Ghi chú đã chọn vào thùng rác?`)) return;
|
|
setNotesLoading(true);
|
|
try {
|
|
for (const id of selectedNoteIds) {
|
|
await fetch(`/api/v1/admin/notes/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
}
|
|
setSelectedNoteIds([]);
|
|
fetchNotes();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setNotesLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkDeletePhotos = async () => {
|
|
if (selectedPhotoIds.length === 0) return;
|
|
if (!confirm(`Bạn có chắc muốn chuyển ${selectedPhotoIds.length} Ảnh đã chọn vào thùng rác?`)) return;
|
|
setPhotosLoading(true);
|
|
try {
|
|
for (const id of selectedPhotoIds) {
|
|
await fetch(`/api/v1/photos/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
}
|
|
setSelectedPhotoIds([]);
|
|
fetchPhotos();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setPhotosLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleApproveRecommendation = async (id: string, isApproved: boolean) => {
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/recommendations/${id}/approve`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({ isApproved })
|
|
});
|
|
if (res.ok) {
|
|
fetchRecommendations();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleDeleteRecommendation = async (id: string) => {
|
|
if (!confirm('Bạn có chắc chắn muốn xóa địa điểm đề xuất này?')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/recommendations/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
fetchRecommendations();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleBulkApproveRecs = async (isApproved: boolean) => {
|
|
if (selectedRecIds.length === 0) return;
|
|
const actionText = isApproved ? 'phê duyệt' : 'bỏ phê duyệt';
|
|
if (!confirm(`Bạn có chắc muốn ${actionText} ${selectedRecIds.length} địa điểm đã chọn?`)) return;
|
|
setRecommendationsLoading(true);
|
|
try {
|
|
for (const id of selectedRecIds) {
|
|
await fetch(`/api/v1/admin/recommendations/${id}/approve`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({ isApproved })
|
|
});
|
|
}
|
|
setSelectedRecIds([]);
|
|
fetchRecommendations();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setRecommendationsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkDeleteRecs = async () => {
|
|
if (selectedRecIds.length === 0) return;
|
|
if (!confirm(`Bạn có chắc muốn xóa vĩnh viễn ${selectedRecIds.length} địa điểm đề xuất đã chọn?`)) return;
|
|
setRecommendationsLoading(true);
|
|
try {
|
|
for (const id of selectedRecIds) {
|
|
await fetch(`/api/v1/admin/recommendations/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
}
|
|
setSelectedRecIds([]);
|
|
fetchRecommendations();
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setRecommendationsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRestoreTrash = async () => {
|
|
if (selectedTrashIds.length === 0) return;
|
|
if (!await confirm({
|
|
title: 'Khôi phục các mục',
|
|
message: `Bạn có chắc muốn khôi phục ${selectedTrashIds.length} mục đã chọn?`
|
|
})) return;
|
|
|
|
setTrashLoading(true);
|
|
const idsToRestore = [...selectedTrashIds];
|
|
// Convert plural to singular: 'photos' -> 'photo', 'tours' -> 'tour', 'notes' -> 'note'
|
|
const itemType = trashSubTab === 'photos' ? 'photo' : trashSubTab === 'tours' ? 'tour' : 'note';
|
|
|
|
try {
|
|
console.log(`[Trash] Starting restore for ${idsToRestore.length} ${itemType} items`);
|
|
const res = await fetch('/api/v1/admin/trash/restore', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
type: itemType,
|
|
ids: idsToRestore
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
console.log('[Trash] Restore successful');
|
|
setSelectedTrashIds([]);
|
|
notify({ title: '✓ Thành công', message: `Đã khôi phục ${idsToRestore.length} mục.`, type: 'success' });
|
|
setTimeout(() => {
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 500);
|
|
} else {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
const errorMsg = errorData?.message || `Lỗi HTTP ${res.status}`;
|
|
console.error('[Trash] Restore failed:', errorMsg);
|
|
notify({ title: '✗ Khôi phục thất bại', message: errorMsg, type: 'error' });
|
|
setTrashLoading(false);
|
|
}
|
|
} catch (e: any) {
|
|
console.error('[Trash] Restore error:', e);
|
|
notify({ title: '✗ Lỗi', message: e?.message || 'Lỗi khi khôi phục các mục', type: 'error' });
|
|
setTrashLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeletePermanentTrash = async () => {
|
|
if (selectedTrashIds.length === 0) return;
|
|
if (!await confirm({
|
|
title: '⚠️ XÓA VĨNH VIỄN',
|
|
message: `Cảnh báo: Bạn có chắc muốn XÓA VĨNH VIỄN ${selectedTrashIds.length} mục đã chọn? Thao tác này KHÔNG THỂ hoàn tác!`
|
|
})) return;
|
|
|
|
setTrashLoading(true);
|
|
const idsToDelete = [...selectedTrashIds];
|
|
// Convert plural to singular: 'photos' -> 'photo', 'tours' -> 'tour', 'notes' -> 'note'
|
|
const itemType = trashSubTab === 'photos' ? 'photo' : trashSubTab === 'tours' ? 'tour' : 'note';
|
|
setSelectedTrashIds([]);
|
|
|
|
try {
|
|
console.log(`[Trash] Starting permanent delete for ${idsToDelete.length} ${itemType} items:`, idsToDelete);
|
|
|
|
const res = await fetch('/api/v1/admin/trash/delete-permanent', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
type: itemType,
|
|
ids: idsToDelete
|
|
})
|
|
});
|
|
|
|
console.log('[Trash] Delete response status:', res.status);
|
|
|
|
if (res.ok) {
|
|
const responseData = await res.json();
|
|
console.log('[Trash] Delete successful, response:', responseData);
|
|
setError('');
|
|
|
|
// Build success message
|
|
const { deleted = 0, failed = 0, errors = [] } = responseData;
|
|
if (failed === 0) {
|
|
notify({ title: '✓ Xóa thành công', message: `Đã xóa vĩnh viễn ${deleted} mục.`, type: 'success' });
|
|
} else {
|
|
let errorMsg = `Xóa thành công ${deleted} mục, thất bại ${failed} mục.`;
|
|
if (errors && errors.length > 0) {
|
|
errorMsg += ` Lỗi: ${errors[0]}`;
|
|
}
|
|
notify({ title: '⚠️ Xóa một phần', message: errorMsg, type: 'error' });
|
|
}
|
|
|
|
// Refresh trash data after delete
|
|
setTimeout(() => {
|
|
console.log('[Trash] Refreshing trash data after delete');
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 800);
|
|
} else {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
const errorMsg = errorData?.message || `Lỗi HTTP ${res.status}`;
|
|
console.error('[Trash] Delete failed with status', res.status, ':', errorMsg);
|
|
setError(errorMsg);
|
|
notify({ title: '✗ Xóa thất bại', message: errorMsg, type: 'error' });
|
|
|
|
// Refresh to see current state
|
|
setTimeout(() => {
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 500);
|
|
}
|
|
} catch (e: any) {
|
|
console.error('[Trash] Delete error:', e);
|
|
const errorMsg = e?.message || 'Lỗi mạng khi xóa các mục';
|
|
setError(errorMsg);
|
|
notify({ title: '✗ Lỗi', message: errorMsg, type: 'error' });
|
|
|
|
// Refresh on error
|
|
setTimeout(() => {
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
const handleEmptyAllTrash = async () => {
|
|
if (!await confirm({
|
|
title: '⚠️ LÀMTRỐNG THÙNG RÁC',
|
|
message: `Cảnh báo: Thao tác này sẽ XÓA VĨNH VIỄN TẤT CẢ mục trong thùng rác! KHÔNG THỂ hoàn tác!`
|
|
})) return;
|
|
|
|
setTrashLoading(true);
|
|
|
|
try {
|
|
console.log('[Trash] Starting to empty ALL trash');
|
|
|
|
const res = await fetch('/api/v1/admin/trash/empty-all', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
console.log('[Trash] Empty all response status:', res.status);
|
|
|
|
if (res.ok) {
|
|
const responseData = await res.json();
|
|
console.log('[Trash] Empty all successful, response:', responseData);
|
|
setError('');
|
|
setSelectedTrashIds([]);
|
|
|
|
const { totalDeleted = 0 } = responseData;
|
|
notify({
|
|
title: '✓ Thùng rác đã được làm trống',
|
|
message: `Đã xóa vĩnh viễn ${totalDeleted} mục.`,
|
|
type: 'success'
|
|
});
|
|
|
|
// Refresh trash data
|
|
setTimeout(() => {
|
|
console.log('[Trash] Refreshing trash data after empty all');
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 800);
|
|
} else {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
const errorMsg = errorData?.message || `Lỗi HTTP ${res.status}`;
|
|
console.error('[Trash] Empty all failed with status', res.status, ':', errorMsg);
|
|
setError(errorMsg);
|
|
notify({ title: '✗ Làm trống thất bại', message: errorMsg, type: 'error' });
|
|
|
|
setTimeout(() => {
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 500);
|
|
}
|
|
} catch (e: any) {
|
|
console.error('[Trash] Empty all error:', e);
|
|
const errorMsg = e?.message || 'Lỗi mạng khi làm trống thùng rác';
|
|
setError(errorMsg);
|
|
notify({ title: '✗ Lỗi', message: errorMsg, type: 'error' });
|
|
|
|
setTimeout(() => {
|
|
fetchTrashData();
|
|
setTrashLoading(false);
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
const handleSaveRetentionDays = async () => {
|
|
try {
|
|
const res = await fetch('/api/v1/admin/trash/retention-days', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({ days: retentionDaysInput })
|
|
});
|
|
if (res.ok) {
|
|
notify({ title: '✓ Cập nhật thành công', message: 'Số ngày lưu trữ đã được cập nhật.', type: 'success' });
|
|
fetchTrashData();
|
|
} else {
|
|
const data = await res.json();
|
|
notify({ title: '✗ Lỗi', message: data.message || 'Lỗi khi cập nhật số ngày lưu trữ.', type: 'error' });
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const fetchModerationSettings = async () => {
|
|
try {
|
|
const res = await fetch('/api/v1/moderation/settings');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setModerationSetting({ blockNsfw: data.blockNsfw, blurFaces: data.blurFaces });
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const fetchWordFilters = async () => {
|
|
try {
|
|
const res = await fetch('/api/v1/admin/moderation/word-filters', {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setWordFilters(data);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (activeTab === 'users') {
|
|
fetchUsers();
|
|
} else if (activeTab === 'tours') {
|
|
fetchTours();
|
|
} else if (activeTab === 'photos') {
|
|
fetchPhotos();
|
|
} else if (activeTab === 'notes') {
|
|
fetchNotes();
|
|
} else if (activeTab === 'recommendations') {
|
|
fetchRecommendations();
|
|
} else if (activeTab === 'trash') {
|
|
fetchTrashData();
|
|
} else if (activeTab === 'filters') {
|
|
fetchModerationSettings();
|
|
fetchWordFilters();
|
|
} else if (activeTab === 'reports') {
|
|
fetchReports();
|
|
}
|
|
}
|
|
}, [isOpen, activeTab]);
|
|
|
|
useEffect(() => {
|
|
setSelectedTrashIds([]);
|
|
}, [trashSubTab]);
|
|
|
|
const getCountdownDays = (deletedAtStr: string, retentionDays: number) => {
|
|
const deletedAt = new Date(deletedAtStr);
|
|
const expiryDate = new Date(deletedAt.getTime() + retentionDays * 24 * 60 * 60 * 1000);
|
|
const now = new Date();
|
|
const diffTime = expiryDate.getTime() - now.getTime();
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
return diffDays > 0 ? diffDays : 0;
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const currentTrashList = trashSubTab === 'tours' ? trashData.tours : trashSubTab === 'photos' ? trashData.photos : trashData.notes;
|
|
const isAllTrashSelected = currentTrashList?.length > 0 && selectedTrashIds.length === currentTrashList?.length;
|
|
const toggleSelectAllTrash = () => {
|
|
if (isAllTrashSelected) {
|
|
setSelectedTrashIds([]);
|
|
} else {
|
|
setSelectedTrashIds(currentTrashList.map((t: any) => t.id));
|
|
}
|
|
};
|
|
|
|
const isAllToursSelected = tours.length > 0 && selectedTourIds.length === tours.length;
|
|
const toggleSelectAllTours = () => {
|
|
if (isAllToursSelected) {
|
|
setSelectedTourIds([]);
|
|
} else {
|
|
setSelectedTourIds(tours.map(t => t.id));
|
|
}
|
|
};
|
|
|
|
const isAllNotesSelected = notes.length > 0 && selectedNoteIds.length === notes.length;
|
|
const toggleSelectAllNotes = () => {
|
|
if (isAllNotesSelected) {
|
|
setSelectedNoteIds([]);
|
|
} else {
|
|
setSelectedNoteIds(notes.map(n => n.id));
|
|
}
|
|
};
|
|
|
|
const isAllPhotosSelected = photos.length > 0 && selectedPhotoIds.length === photos.length;
|
|
const toggleSelectAllPhotos = () => {
|
|
if (isAllPhotosSelected) {
|
|
setSelectedPhotoIds([]);
|
|
} else {
|
|
setSelectedPhotoIds(photos.map(p => p.id));
|
|
}
|
|
};
|
|
|
|
const isAllRecsSelected = adminRecommendations.length > 0 && selectedRecIds.length === adminRecommendations.length;
|
|
const toggleSelectAllRecs = () => {
|
|
if (isAllRecsSelected) {
|
|
setSelectedRecIds([]);
|
|
} else {
|
|
setSelectedRecIds(adminRecommendations.map(r => r.id));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
|
<div className="relative w-full max-w-5xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col h-[85vh]">
|
|
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
|
<Shield className="w-6 h-6 text-blue-600" /> Hệ thống quản trị
|
|
</h2>
|
|
<p className="text-sm text-gray-500">Quản lý thành viên, kiểm duyệt tour, ghi chú, địa điểm đề xuất và dọn dẹp hệ thống.</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors cursor-pointer">
|
|
<X className="w-6 h-6 text-gray-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Selection */}
|
|
<div className="flex border-b border-gray-100 bg-gray-50/20 px-6 overflow-x-auto no-scrollbar whitespace-nowrap">
|
|
<button
|
|
onClick={() => setActiveTab('users')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'users' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<User className="w-4 h-4" />
|
|
Thành viên
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('tours')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'tours' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<Map className="w-4 h-4" />
|
|
Tour hoạt động
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('photos')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'photos' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<ImageIcon className="w-4 h-4" />
|
|
Ảnh công cộng
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('notes')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'notes' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<FileText className="w-4 h-4" />
|
|
Ghi chú
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('recommendations')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'recommendations' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<Star className="w-4 h-4" />
|
|
Địa điểm đề xuất
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('trash')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'trash' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Thùng rác
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('filters')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'filters' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
Bộ lọc
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('reports')}
|
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 shrink-0 ${
|
|
activeTab === 'reports' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
|
}`}
|
|
>
|
|
<ShieldAlert className="w-4 h-4" />
|
|
Blacklist
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content Body */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{error && (
|
|
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
|
|
)}
|
|
|
|
{activeTab === 'users' ? (
|
|
loading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : (
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
|
|
<th className="pb-4 font-bold px-2">Người dùng</th>
|
|
<th className="pb-4 font-bold">Vai trò</th>
|
|
<th className="pb-4 font-bold">Trạng thái</th>
|
|
<th className="pb-4 font-bold text-right">Thao tác</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{users.map(u => (
|
|
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
|
|
<td className="py-4 px-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
|
{u.name?.charAt(0) || <User className="w-5 h-5" />}
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
|
|
<div className="text-xs text-gray-400">{u.email}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-4">
|
|
{u.isAdmin ? (
|
|
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
|
|
) : (
|
|
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
|
|
)}
|
|
</td>
|
|
<td className="py-4">
|
|
{u.isBlocked ? (
|
|
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
|
|
) : (
|
|
<span className="text-green-500 text-xs font-bold">Đang hoạt động</span>
|
|
)}
|
|
</td>
|
|
<td className="py-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => handleToggleBlock(u.id)}
|
|
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'} cursor-pointer`}
|
|
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
|
|
>
|
|
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(u.id)}
|
|
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all cursor-pointer"
|
|
title="Xóa người dùng"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)
|
|
) : activeTab === 'tours' ? (
|
|
toursLoading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : (
|
|
<div className="space-y-4 text-gray-800">
|
|
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
|
|
<span className="text-sm font-bold text-gray-700">
|
|
Chọn <span className="text-blue-600">{selectedTourIds.length}</span> trên {tours.length} Tour
|
|
</span>
|
|
{selectedTourIds.length > 0 && (
|
|
<button
|
|
onClick={handleBulkDeleteTours}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Chuyển vào thùng rác
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-left border-collapse bg-white">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
|
|
<th className="py-3 px-4 font-bold w-12 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={isAllToursSelected}
|
|
onChange={toggleSelectAllTours}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</th>
|
|
<th className="py-3 px-4 font-bold">Tên Tour</th>
|
|
<th className="py-3 px-4 font-bold">Người tạo</th>
|
|
<th className="py-3 px-4 font-bold">Thành viên</th>
|
|
<th className="py-3 px-4 font-bold">Ngày tạo</th>
|
|
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{tours.map(t => (
|
|
<tr key={t.id} className="group hover:bg-gray-50/30 transition-colors">
|
|
<td className="py-3.5 px-4 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedTourIds.includes(t.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedTourIds([...selectedTourIds, t.id]);
|
|
} else {
|
|
setSelectedTourIds(selectedTourIds.filter(id => id !== t.id));
|
|
}
|
|
}}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<div className="font-bold text-gray-900 text-sm truncate max-w-[200px]" title={t.title}>{t.title}</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs font-medium text-gray-700">
|
|
<div>{t.creator?.name || 'Không rõ'}</div>
|
|
<div className="text-[10px] text-gray-400">{t.creator?.email}</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs font-semibold text-gray-600">
|
|
{t._count?.participants || 0} người
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs text-gray-400">
|
|
{new Date(t.createdAt).toLocaleDateString('vi-VN')}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-right">
|
|
<button
|
|
onClick={() => handleDeleteTour(t.id)}
|
|
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all cursor-pointer"
|
|
title="Chuyển vào thùng rác"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : activeTab === 'photos' ? (
|
|
photosLoading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : photos.length === 0 ? (
|
|
<div className="text-center py-20 text-gray-400 italic">Chưa có ảnh công cộng nào được tải lên.</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="select-all-photos"
|
|
checked={isAllPhotosSelected}
|
|
onChange={toggleSelectAllPhotos}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
<label htmlFor="select-all-photos" className="text-xs font-bold text-gray-700 cursor-pointer">
|
|
Chọn tất cả ({selectedPhotoIds.length}/{photos.length})
|
|
</label>
|
|
</div>
|
|
{selectedPhotoIds.length > 0 && (
|
|
<button
|
|
onClick={handleBulkDeletePhotos}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Chuyển vào thùng rác
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
|
|
{photos.map(p => (
|
|
<div key={p.id} className={`relative group bg-gray-50 border rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col justify-between ${
|
|
selectedPhotoIds.includes(p.id) ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-gray-100'
|
|
}`}>
|
|
<div className="aspect-square bg-slate-900 overflow-hidden flex items-center justify-center relative">
|
|
<img src={p.imageUrl} alt="Public content" className="w-full h-full object-cover transition-transform group-hover:scale-105" />
|
|
<div className="absolute top-2 left-2 z-10">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedPhotoIds.includes(p.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedPhotoIds([...selectedPhotoIds, p.id]);
|
|
} else {
|
|
setSelectedPhotoIds(selectedPhotoIds.filter(id => id !== p.id));
|
|
}
|
|
}}
|
|
className="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 cursor-pointer shadow"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => handleDeletePhoto(p.id)}
|
|
className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-500 text-white rounded-xl shadow-lg transition-all active:scale-95 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10 cursor-pointer"
|
|
title="Xóa ảnh công cộng"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="p-3 bg-white">
|
|
<div className="font-bold text-xs text-gray-800 truncate" title={p.uploader?.name || 'Ẩn danh'}>
|
|
Đăng bởi: {p.uploader?.name || 'Ẩn danh'}
|
|
</div>
|
|
<div className="text-[10px] text-gray-400 mt-1">
|
|
{new Date(p.capturedAt).toLocaleDateString('vi-VN', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
) : activeTab === 'notes' ? (
|
|
notesLoading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : notes.length === 0 ? (
|
|
<div className="text-center py-20 text-gray-400 italic">Chưa có ghi chú nào hoạt động.</div>
|
|
) : (
|
|
<div className="space-y-4 text-gray-800">
|
|
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
|
|
<span className="text-sm font-bold text-gray-700">
|
|
Chọn <span className="text-blue-600">{selectedNoteIds.length}</span> trên {notes.length} ghi chú
|
|
</span>
|
|
{selectedNoteIds.length > 0 && (
|
|
<button
|
|
onClick={handleBulkDeleteNotes}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Chuyển vào thùng rác
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-left border-collapse bg-white">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
|
|
<th className="py-3 px-4 font-bold w-12 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={isAllNotesSelected}
|
|
onChange={toggleSelectAllNotes}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</th>
|
|
<th className="py-3 px-4 font-bold">Tiêu đề</th>
|
|
<th className="py-3 px-4 font-bold">Thuộc Tour</th>
|
|
<th className="py-3 px-4 font-bold">Người tạo</th>
|
|
<th className="py-3 px-4 font-bold">Nội dung</th>
|
|
<th className="py-3 px-4 font-bold">Ngày tạo</th>
|
|
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{notes.map(n => (
|
|
<tr key={n.id} className="group hover:bg-gray-50/30 transition-colors">
|
|
<td className="py-3.5 px-4 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedNoteIds.includes(n.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedNoteIds([...selectedNoteIds, n.id]);
|
|
} else {
|
|
setSelectedNoteIds(selectedNoteIds.filter(id => id !== n.id));
|
|
}
|
|
}}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<div className="font-bold text-gray-900 text-sm truncate max-w-[150px]" title={n.title}>{n.title}</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs font-semibold text-blue-600">
|
|
{n.tour?.title || 'Không rõ'}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs">
|
|
<div className="font-semibold text-gray-800">{n.user?.name || 'Ẩn danh'}</div>
|
|
<div className="text-[10px] text-gray-400">{n.user?.email}</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs text-gray-500 truncate max-w-[200px]" title={n.content?.replace(/<[^>]*>/g, '')}>
|
|
{n.content?.replace(/<[^>]*>/g, '')}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs text-gray-400">
|
|
{new Date(n.createdAt).toLocaleDateString('vi-VN')}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-right">
|
|
<button
|
|
onClick={() => handleDeleteNote(n.id)}
|
|
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all cursor-pointer"
|
|
title="Chuyển vào thùng rác"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : activeTab === 'recommendations' ? (
|
|
recommendationsLoading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : adminRecommendations.length === 0 ? (
|
|
<div className="text-center py-20 text-gray-400 italic">Chưa có đề xuất địa điểm nào.</div>
|
|
) : (
|
|
<div className="space-y-4 text-gray-800">
|
|
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
|
|
<span className="text-sm font-bold text-gray-700">
|
|
Chọn <span className="text-blue-600">{selectedRecIds.length}</span> trên {adminRecommendations.length} địa điểm
|
|
</span>
|
|
{selectedRecIds.length > 0 && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleBulkApproveRecs(true)}
|
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
Phê duyệt
|
|
</button>
|
|
<button
|
|
onClick={() => handleBulkApproveRecs(false)}
|
|
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
Huỷ phê duyệt
|
|
</button>
|
|
<button
|
|
onClick={handleBulkDeleteRecs}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Xóa vĩnh viễn
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-left border-collapse bg-white">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
|
|
<th className="py-3 px-4 font-bold w-12 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={isAllRecsSelected}
|
|
onChange={toggleSelectAllRecs}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</th>
|
|
<th className="py-3 px-4 font-bold">Địa điểm</th>
|
|
<th className="py-3 px-4 font-bold">Loại</th>
|
|
<th className="py-3 px-4 font-bold">Đánh giá</th>
|
|
<th className="py-3 px-4 font-bold">Liên hệ & Địa chỉ</th>
|
|
<th className="py-3 px-4 font-bold">Mô tả</th>
|
|
<th className="py-3 px-4 font-bold">Trạng thái</th>
|
|
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{adminRecommendations.map(r => (
|
|
<tr key={r.id} className="group hover:bg-gray-50/30 transition-colors">
|
|
<td className="py-3.5 px-4 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRecIds.includes(r.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedRecIds([...selectedRecIds, r.id]);
|
|
} else {
|
|
setSelectedRecIds(selectedRecIds.filter(id => id !== r.id));
|
|
}
|
|
}}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<div className="font-bold text-gray-900 text-sm truncate max-w-[150px]" title={r.name}>{r.name}</div>
|
|
{r.latitude && r.longitude && (
|
|
<span className="text-[10px] text-gray-400 font-mono">GPS: {r.latitude}, {r.longitude}</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded text-[10px] font-black uppercase">
|
|
{r.type === 'RESTAURANT' ? 'Nhà hàng' : r.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-amber-500 text-xs font-bold">
|
|
{Array.from({ length: r.stars || 5 }).map((_, i) => (
|
|
<span key={i}>★</span>
|
|
))}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs">
|
|
{r.phone && <div className="font-semibold">SĐT: {r.phone}</div>}
|
|
{r.email && <div className="text-gray-500">{r.email}</div>}
|
|
{r.address && <div className="text-gray-400 truncate max-w-[150px]" title={r.address}>{r.address}</div>}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs text-gray-500 truncate max-w-[200px]" title={r.description}>
|
|
{r.description}
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
{r.isApproved ? (
|
|
<span className="px-2 py-1 bg-green-50 text-green-600 text-[10px] font-bold rounded-md border border-green-100 flex items-center gap-1 w-max">
|
|
<CheckCircle className="w-3 h-3" /> Đã duyệt
|
|
</span>
|
|
) : (
|
|
<span className="px-2 py-1 bg-amber-50 text-amber-600 text-[10px] font-bold rounded-md border border-amber-100 flex items-center gap-1 w-max">
|
|
Chờ duyệt
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-right">
|
|
<div className="flex justify-end gap-1.5">
|
|
<button
|
|
onClick={() => handleApproveRecommendation(r.id, !r.isApproved)}
|
|
className={`px-2.5 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
|
r.isApproved ? 'bg-amber-50 text-amber-600 hover:bg-amber-100' : 'bg-green-600 text-white hover:bg-green-700 shadow-sm'
|
|
}`}
|
|
>
|
|
{r.isApproved ? 'Gỡ' : 'Duyệt'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteRecommendation(r.id)}
|
|
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all cursor-pointer"
|
|
title="Xóa"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : activeTab === 'trash' ? (
|
|
trashLoading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : (
|
|
<div className="space-y-4 text-gray-800">
|
|
{/* Retention days setting block */}
|
|
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-5 rounded-2xl border border-gray-100 text-left">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-bold text-gray-800">Cấu hình thời gian giữ rác</span>
|
|
<span className="text-xs text-gray-400 mt-0.5">Số ngày lưu trữ các mục trong thùng rác trước khi tự động xóa vĩnh viễn trên máy chủ.</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={retentionDaysInput}
|
|
onChange={(e) => setRetentionDaysInput(parseInt(e.target.value) || 30)}
|
|
className="w-20 px-3 py-1.5 border border-gray-200 rounded-xl text-center text-sm font-bold bg-white"
|
|
/>
|
|
<span className="text-xs font-bold text-gray-500">ngày</span>
|
|
<button
|
|
onClick={handleSaveRetentionDays}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95 cursor-pointer flex items-center gap-1"
|
|
>
|
|
<Settings className="w-3.5 h-3.5" /> Lưu cấu hình
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Empty All Trash button */}
|
|
{(trashData.tours?.length > 0 || trashData.photos?.length > 0 || trashData.notes?.length > 0) && (
|
|
<div className="bg-red-50 p-4 rounded-2xl border border-red-100">
|
|
<button
|
|
onClick={handleEmptyAllTrash}
|
|
disabled={trashLoading}
|
|
className="w-full px-4 py-3 bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white rounded-xl font-bold text-sm uppercase tracking-wide flex items-center justify-center gap-2 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
{trashLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
|
Làm trống thùng rác ({(trashData.tours?.length || 0) + (trashData.photos?.length || 0) + (trashData.notes?.length || 0)} mục)
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sub-tab selection */}
|
|
<div className="flex gap-2 border-b border-gray-100 pb-2">
|
|
<button
|
|
onClick={() => {
|
|
setTrashSubTab('tours');
|
|
setSelectedTrashIds([]);
|
|
}}
|
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
|
trashSubTab === 'tours' ? 'bg-blue-50 text-blue-600 font-extrabold' : 'text-gray-400 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
Tours rác ({trashData.tours?.length || 0})
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setTrashSubTab('photos');
|
|
setSelectedTrashIds([]);
|
|
}}
|
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
|
trashSubTab === 'photos' ? 'bg-blue-50 text-blue-600 font-extrabold' : 'text-gray-400 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
Ảnh rác ({trashData.photos?.length || 0})
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setTrashSubTab('notes');
|
|
setSelectedTrashIds([]);
|
|
}}
|
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
|
trashSubTab === 'notes' ? 'bg-blue-50 text-blue-600 font-extrabold' : 'text-gray-400 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
Ghi chú rác ({trashData.notes?.length || 0})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Bulk Actions inside Trash */}
|
|
<div className="flex justify-between items-center bg-gray-50/50 p-4 rounded-2xl border border-gray-100">
|
|
<span className="text-sm font-bold text-gray-700">
|
|
Chọn <span className="text-blue-600">{selectedTrashIds.length}</span> mục trong danh mục rác hiện tại
|
|
</span>
|
|
{selectedTrashIds.length > 0 && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleRestoreTrash}
|
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
Khôi phục
|
|
</button>
|
|
<button
|
|
onClick={handleDeletePermanentTrash}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-1.5 shadow-md transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
Xóa vĩnh viễn
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table details */}
|
|
{(!currentTrashList || currentTrashList.length === 0) ? (
|
|
<div className="text-center py-16 text-gray-400 italic">Thư mục rác trống!</div>
|
|
) : (
|
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-left border-collapse bg-white">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
|
|
<th className="py-3 px-4 font-bold w-12 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={isAllTrashSelected}
|
|
onChange={toggleSelectAllTrash}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</th>
|
|
<th className="py-3 px-4 font-bold">Đối tượng</th>
|
|
<th className="py-3 px-4 font-bold">Thông tin chi tiết</th>
|
|
<th className="py-3 px-4 font-bold">Ngày xóa</th>
|
|
<th className="py-3 px-4 font-bold text-red-500">Tự động xóa</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{currentTrashList.map((item: any) => {
|
|
const countdown = getCountdownDays(item.deletedAt, trashData.retentionDays || 30);
|
|
return (
|
|
<tr key={item.id} className="group hover:bg-gray-50/30 transition-colors">
|
|
<td className="py-3.5 px-4 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedTrashIds.includes(item.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedTrashIds([...selectedTrashIds, item.id]);
|
|
} else {
|
|
setSelectedTrashIds(selectedTrashIds.filter(id => id !== item.id));
|
|
}
|
|
}}
|
|
className="rounded text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
/>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-sm">
|
|
{trashSubTab === 'photos' ? (
|
|
<div className="w-12 h-12 rounded-lg bg-gray-900 overflow-hidden shadow-sm border border-gray-100 flex items-center justify-center">
|
|
<img src={item.imageUrl} alt="Trash content" className="w-full h-full object-cover" />
|
|
</div>
|
|
) : trashSubTab === 'tours' ? (
|
|
<div className="font-bold text-gray-900">{item.title}</div>
|
|
) : (
|
|
<div className="font-bold text-gray-900">{item.title}</div>
|
|
)}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs">
|
|
{trashSubTab === 'tours' ? (
|
|
<span className="text-gray-500">Tạo bởi: {item.creator?.name || 'Không rõ'}</span>
|
|
) : trashSubTab === 'photos' ? (
|
|
<span className="text-gray-500 font-mono truncate block max-w-[200px]" title={item.imageUrl}>
|
|
{item.imageUrl} (Tải lên bởi: {item.uploader?.name || 'Ẩn danh'})
|
|
</span>
|
|
) : (
|
|
<div className="space-y-0.5 text-gray-500">
|
|
<span>Ghi chú của: {item.user?.name || 'Ẩn danh'}</span>
|
|
<span className="block text-[10px] text-blue-500">Thuộc Tour: {item.tour?.title}</span>
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs text-gray-400">
|
|
{new Date(item.deletedAt).toLocaleDateString('vi-VN')} {new Date(item.deletedAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })}
|
|
</td>
|
|
<td className="py-3.5 px-4 text-xs font-bold text-red-500">
|
|
{countdown > 0 ? `Còn ${countdown} ngày` : 'Sắp bị xóa'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
) : null}
|
|
|
|
{activeTab === 'reports' && (
|
|
reportsLoading ? (
|
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
|
) : reports.length === 0 ? (
|
|
<div className="text-center py-20 text-gray-400 italic">Chưa có báo cáo sai phạm nào.</div>
|
|
) : (
|
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-left border-collapse bg-white">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
|
|
<th className="py-3 px-4 font-bold">Đối tượng</th>
|
|
<th className="py-3 px-4 font-bold">Loại</th>
|
|
<th className="py-3 px-4 font-bold">Liên hệ & Địa chỉ</th>
|
|
<th className="py-3 px-4 font-bold">Lý do báo cáo</th>
|
|
<th className="py-3 px-4 font-bold">Trạng thái</th>
|
|
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{reports.map((item) => (
|
|
<tr key={item.id} className="group hover:bg-gray-50/50 transition-colors">
|
|
<td className="py-3 px-4">
|
|
<div className="font-bold text-gray-900">{item.name}</div>
|
|
{item.latitude && item.longitude && (
|
|
<span className="text-[10px] text-gray-400 font-mono block mt-0.5">
|
|
GPS: {item.latitude}, {item.longitude}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
|
item.type === 'USER' ? 'bg-indigo-50 text-indigo-600' :
|
|
item.type === 'RESTAURANT' ? 'bg-amber-50 text-amber-600' :
|
|
item.type === 'HOTEL' ? 'bg-teal-50 text-teal-600' :
|
|
'bg-purple-50 text-purple-600'
|
|
}`}>
|
|
{item.type}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-xs">
|
|
{item.phone && <div className="text-gray-700 font-semibold">SĐT: {item.phone}</div>}
|
|
{item.email && <div className="text-gray-500">{item.email}</div>}
|
|
{item.address && <div className="text-gray-400 truncate max-w-[150px]" title={item.address}>{item.address}</div>}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs text-red-600 max-w-[200px] whitespace-pre-wrap font-medium">
|
|
{item.reason}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs font-bold">
|
|
{item.isBlacklisted ? (
|
|
<span className="px-2.5 py-1 bg-red-50 text-red-600 rounded-lg border border-red-100 flex items-center gap-1 w-max">
|
|
<ShieldAlert className="w-3.5 h-3.5" /> Blacklisted
|
|
</span>
|
|
) : (
|
|
<span className="px-2.5 py-1 bg-gray-50 text-gray-500 rounded-lg border border-gray-100 w-max block">
|
|
Chờ duyệt
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => handleToggleBlacklist(item.id, item.isBlacklisted)}
|
|
className={`px-3 py-1.5 rounded-xl font-bold text-xs transition-all cursor-pointer ${
|
|
item.isBlacklisted
|
|
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
|
|
: 'bg-red-600 text-white hover:bg-red-700 shadow-md shadow-red-100'
|
|
}`}
|
|
>
|
|
{item.isBlacklisted ? 'Gỡ' : 'Duyệt'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteReport(item.id)}
|
|
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all cursor-pointer"
|
|
title="Xóa báo cáo"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{activeTab === 'filters' && (
|
|
<div className="space-y-6 text-gray-800">
|
|
<div className="bg-gray-50/50 p-6 rounded-2xl border border-gray-100/80 space-y-4 text-left">
|
|
<h3 className="text-sm font-black uppercase text-blue-600 tracking-wider flex items-center gap-1.5">
|
|
⚙️ Cấu hình kiểm duyệt hình ảnh
|
|
</h3>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-white rounded-xl border border-gray-100 shadow-sm">
|
|
<div className="flex flex-col text-left">
|
|
<span className="text-sm font-bold text-gray-800">Lọc hình ảnh khiêu dâm (NSFW)</span>
|
|
<span className="text-xs text-gray-400 mt-0.5">Tự động phát hiện và chặn tải lên các hình ảnh có nội dung người lớn nhạy cảm.</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleUpdateModeration('blockNsfw', !moderationSetting.blockNsfw)}
|
|
className={`w-14 h-8 rounded-full transition-all relative p-1 cursor-pointer shrink-0 ${
|
|
moderationSetting.blockNsfw ? 'bg-blue-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<div className={`w-6 h-6 bg-white rounded-full shadow-md transition-all absolute top-1 ${
|
|
moderationSetting.blockNsfw ? 'right-1' : 'left-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-white rounded-xl border border-gray-100 shadow-sm">
|
|
<div className="flex flex-col text-left">
|
|
<span className="text-sm font-bold text-gray-800">Tự động làm mờ khuôn mặt</span>
|
|
<span className="text-xs text-gray-400 mt-0.5">Tự động nhận diện khuôn mặt người trong ảnh để làm mờ bảo mật trước khi lưu trữ.</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleUpdateModeration('blurFaces', !moderationSetting.blurFaces)}
|
|
className={`w-14 h-8 rounded-full transition-all relative p-1 cursor-pointer shrink-0 ${
|
|
moderationSetting.blurFaces ? 'bg-blue-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<div className={`w-6 h-6 bg-white rounded-full shadow-md transition-all absolute top-1 ${
|
|
moderationSetting.blurFaces ? 'right-1' : 'left-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50/50 p-6 rounded-2xl border border-gray-100/80 space-y-4 text-left">
|
|
<h3 className="text-sm font-black uppercase text-blue-600 tracking-wider flex items-center gap-1.5">
|
|
📝 Cấu hình bộ lọc văn bản
|
|
</h3>
|
|
|
|
{/* Add Word Filter Form */}
|
|
<div className="flex flex-col sm:flex-row gap-3 bg-white p-4 rounded-xl border border-gray-100 shadow-sm">
|
|
<div className="flex-1">
|
|
<label className="block text-[10px] uppercase font-black text-gray-400 mb-1">Từ cấm</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Nhập từ cấm..."
|
|
value={newWord}
|
|
onChange={(e) => setNewWord(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 bg-gray-50/20"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="block text-[10px] uppercase font-black text-gray-400 mb-1">Từ thay thế</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Nhập từ thay thế..."
|
|
value={newReplacement}
|
|
onChange={(e) => setNewReplacement(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 bg-gray-50/20"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<button
|
|
onClick={handleAddWordFilter}
|
|
className="w-full sm:w-auto px-5 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-xl text-xs font-bold transition-all shadow-md active:scale-95 cursor-pointer h-[38px] flex items-center justify-center shrink-0"
|
|
>
|
|
Thêm bộ lọc
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Word Filter List */}
|
|
{wordFilters.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400 italic text-xs">Chưa cấu hình bộ lọc từ khóa nào.</div>
|
|
) : (
|
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm bg-white">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="text-gray-400 text-xs uppercase border-b border-gray-100 bg-gray-50/30">
|
|
<th className="py-2.5 px-4 font-bold">Từ cấm</th>
|
|
<th className="py-2.5 px-4 font-bold">Từ thay thế</th>
|
|
<th className="py-2.5 px-4 font-bold text-right">Thao tác</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{wordFilters.map((wf) => (
|
|
<tr key={wf.id} className="hover:bg-gray-50/30">
|
|
<td className="py-2.5 px-4 text-xs font-bold text-red-500">{wf.word}</td>
|
|
<td className="py-2.5 px-4 text-xs font-semibold text-green-600">{wf.replacement}</td>
|
|
<td className="py-2.5 px-4 text-right">
|
|
<button
|
|
onClick={() => handleDeleteWordFilter(wf.id)}
|
|
className="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |