Commit 9ae40825 authored by BangNSK's avatar BangNSK

feat(frontend): connect to deploy api and implement users/logs pages

- Update API_BASE_URL to http://171.247.68.96:4011/api/v1
- Implement Users page: CRUD (list, create, update, delete) with pagination and search
- Implement Logs page: audit logs with userId/action filter, expandable details, pagination
- Support URL_LIST mode in Dashboard create job form (textarea for batch URLs)
- Fix unused mimeType param in JobDetail to pass TypeScript strict check
parent dd2ea5d4
...@@ -30,6 +30,7 @@ export const Dashboard: React.FC = () => { ...@@ -30,6 +30,7 @@ export const Dashboard: React.FC = () => {
const [mode, setMode] = useState<'SCRAPE' | 'CRAWL' | 'SITEMAP' | 'URL_LIST'>('SCRAPE'); const [mode, setMode] = useState<'SCRAPE' | 'CRAWL' | 'SITEMAP' | 'URL_LIST'>('SCRAPE');
const [maxPages, setMaxPages] = useState<number>(20); const [maxPages, setMaxPages] = useState<number>(20);
const [maxDepth, setMaxDepth] = useState<number>(1); const [maxDepth, setMaxDepth] = useState<number>(1);
const [urlsList, setUrlsList] = useState(''); // URL_LIST mode: danh sách URL mỗi dòng 1 URL
const [createError, setCreateError] = useState(''); const [createError, setCreateError] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
...@@ -39,7 +40,7 @@ export const Dashboard: React.FC = () => { ...@@ -39,7 +40,7 @@ export const Dashboard: React.FC = () => {
try { try {
const response = await api.get('/crawl-jobs'); const response = await api.get('/crawl-jobs');
if (response.data.success) { if (response.data.success) {
setJobs(response.data.data.items || []); setJobs(response.data.data.jobs || response.data.data.items || []);
} }
} catch (error) { } catch (error) {
console.error('Error fetching jobs', error); console.error('Error fetching jobs', error);
...@@ -81,11 +82,16 @@ export const Dashboard: React.FC = () => { ...@@ -81,11 +82,16 @@ export const Dashboard: React.FC = () => {
setCreating(true); setCreating(true);
try { try {
const isUrlListMode = mode === 'URL_LIST';
const parsedUrls = isUrlListMode
? urlsList.split('\n').map((u) => u.trim()).filter(Boolean)
: undefined;
const response = await api.post('/crawl-jobs', { const response = await api.post('/crawl-jobs', {
startUrl, ...(isUrlListMode ? { urls: parsedUrls } : { startUrl }),
mode, mode,
maxPages: Number(maxPages), maxPages: Number(maxPages),
maxDepth: Number(maxDepth), maxDepth: isUrlListMode ? undefined : Number(maxDepth),
}); });
if (response.data.success) { if (response.data.success) {
...@@ -95,6 +101,7 @@ export const Dashboard: React.FC = () => { ...@@ -95,6 +101,7 @@ export const Dashboard: React.FC = () => {
setMode('SCRAPE'); setMode('SCRAPE');
setMaxPages(20); setMaxPages(20);
setMaxDepth(1); setMaxDepth(1);
setUrlsList('');
fetchJobs(true); fetchJobs(true);
} }
} catch (error: any) { } catch (error: any) {
...@@ -270,19 +277,38 @@ export const Dashboard: React.FC = () => { ...@@ -270,19 +277,38 @@ export const Dashboard: React.FC = () => {
)} )}
<form onSubmit={handleCreateJob} className="space-y-4"> <form onSubmit={handleCreateJob} className="space-y-4">
<div> {mode === 'URL_LIST' ? (
<label className="block text-sm font-medium text-slate-700 mb-1"> <div>
Đường dẫn bắt đầu (Start URL) * <label className="block text-sm font-medium text-slate-700 mb-1">
</label> Danh sách URL (mỗi dòng 1 URL) *
<input </label>
type="url" <textarea
required required
placeholder="https://example.com" rows={5}
value={startUrl} placeholder={`https://example.com/page1\nhttps://example.com/page2\nhttps://example.com/page3`}
onChange={(e) => setStartUrl(e.target.value)} value={urlsList}
className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm" onChange={(e) => setUrlsList(e.target.value)}
/> className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm font-mono text-xs"
</div> />
<p className="text-xs text-slate-400 mt-1">
Tối đa 1000 URL. Mỗi dòng một URL hợp lệ.
</p>
</div>
) : (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Đường dẫn bắt đầu (Start URL) *
</label>
<input
type="url"
required
placeholder="https://example.com"
value={startUrl}
onChange={(e) => setStartUrl(e.target.value)}
className="block w-full rounded-lg border border-slate-300 bg-white py-2 px-3 text-slate-900 placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
......
...@@ -158,7 +158,7 @@ export const JobDetail: React.FC = () => { ...@@ -158,7 +158,7 @@ export const JobDetail: React.FC = () => {
} }
}; };
const handleDownload = async (exportId: string, fileName: string, mimeType: string) => { const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => {
try { try {
console.log(`Downloading export ${exportId}, requesting blob...`); console.log(`Downloading export ${exportId}, requesting blob...`);
const res = await api.get(`/exports/${exportId}/download`, { responseType: 'blob' }); const res = await api.get(`/exports/${exportId}/download`, { responseType: 'blob' });
......
import React from 'react'; import React, { useEffect, useState } from 'react';
import { api } from '../services/api';
import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react';
interface AuditLog {
id: string;
userId: string;
action: string;
ipAddress: string | null;
userAgent: string | null;
details: Record<string, unknown> | null;
createdAt: string;
}
interface Meta {
total: number;
page: number;
limit: number;
totalPages: number;
}
const ACTION_COLORS: Record<string, string> = {
CREATE_JOB: 'bg-emerald-100 text-emerald-700',
CANCEL_JOB: 'bg-amber-100 text-amber-700',
DELETE_JOB: 'bg-rose-100 text-rose-700',
EXPORT_DATA: 'bg-blue-100 text-blue-700',
LOGIN: 'bg-slate-100 text-slate-600',
LOGOUT: 'bg-slate-100 text-slate-500',
REGISTER: 'bg-violet-100 text-violet-700',
CREATE_USER: 'bg-emerald-100 text-emerald-700',
DELETE_USER: 'bg-rose-100 text-rose-700',
UPDATE_USER: 'bg-indigo-100 text-indigo-700',
};
export const Logs: React.FC = () => { export const Logs: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [userIdFilter, setUserIdFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [userIdInput, setUserIdInput] = useState('');
const [actionInput, setActionInput] = useState('');
const [expandedLogId, setExpandedLogId] = useState<string | null>(null);
const fetchLogs = async (page = 1, silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
try {
const res = await api.get('/audit-logs', {
params: {
page,
limit: 15,
userId: userIdFilter || undefined,
action: actionFilter || undefined,
},
});
if (res.data.success) {
setLogs(res.data.data.logs || []);
setMeta(res.data.data.meta);
}
} catch (e) {
console.error('Failed to fetch audit logs', e);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchLogs(1);
}, [userIdFilter, actionFilter]);
const handleFilterSubmit = (e: React.FormEvent) => {
e.preventDefault();
setUserIdFilter(userIdInput.trim());
setActionFilter(actionInput.trim().toUpperCase());
};
const clearFilters = () => {
setUserIdInput('');
setActionInput('');
setUserIdFilter('');
setActionFilter('');
};
const actionBadge = (action: string) => {
const cls = ACTION_COLORS[action] ?? 'bg-slate-100 text-slate-600';
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold ${cls}`}>
{action}
</span>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> {/* Header */}
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Nhật ký hệ thống</h1> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p className="text-sm text-slate-500">Xem lịch sử hoạt động hệ thống (Chỉ dành cho Admin).</p> <div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900 flex items-center gap-2">
<ScrollText className="h-6 w-6 text-indigo-500" />
Nhật ký hệ thống
</h1>
<p className="text-sm text-slate-500 mt-0.5">
Lịch sử hoạt động (Chỉ Admin). Tổng: <strong>{meta.total}</strong> bản ghi.
</p>
</div>
<button
onClick={() => fetchLogs(meta.page, true)}
className="self-start sm:self-auto p-2.5 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
title="Làm mới"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div> </div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 text-center"> {/* Filters */}
<p className="text-slate-500">Chức năng xem nhật ký hoạt động hệ thống đang được cập nhật (Kế hoạch Ngày 2)...</p> <form onSubmit={handleFilterSubmit} className="flex flex-wrap gap-2 items-end">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Lọc theo User ID..."
value={userIdInput}
onChange={(e) => setUserIdInput(e.target.value)}
className="pl-9 pr-3 py-2 rounded-lg border border-slate-300 text-sm w-64 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="relative">
<input
type="text"
placeholder="Lọc theo Action (CREATE_JOB...)"
value={actionInput}
onChange={(e) => setActionInput(e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-300 text-sm w-60 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors"
>
Lọc
</button>
{(userIdFilter || actionFilter) && (
<button
type="button"
onClick={clearFilters}
className="px-3 py-2 text-slate-500 hover:text-slate-900 text-sm"
>
Xóa lọc
</button>
)}
</form>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent" />
</div>
) : logs.length === 0 ? (
<div className="text-center py-16 text-slate-400">
<ScrollText className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Không có bản ghi nhật ký nào.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-sm text-left">
<thead className="bg-slate-50 text-slate-500 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4">Thời gian</th>
<th className="px-6 py-4">Hành động</th>
<th className="px-6 py-4">User ID</th>
<th className="px-6 py-4">IP Address</th>
<th className="px-6 py-4">Chi tiết</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700">
{logs.map((log) => (
<React.Fragment key={log.id}>
<tr className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-3 text-xs text-slate-500 whitespace-nowrap">
{new Date(log.createdAt).toLocaleString('vi-VN')}
</td>
<td className="px-6 py-3">{actionBadge(log.action)}</td>
<td className="px-6 py-3">
<span className="font-mono text-xs text-slate-500 truncate max-w-[120px] block" title={log.userId}>
{log.userId}
</span>
</td>
<td className="px-6 py-3 text-xs font-mono text-slate-500">
{log.ipAddress || '—'}
</td>
<td className="px-6 py-3">
{log.details ? (
<button
onClick={() => setExpandedLogId(expandedLogId === log.id ? null : log.id)}
className="inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-900 font-medium"
>
Xem
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${expandedLogId === log.id ? 'rotate-180' : ''}`}
/>
</button>
) : (
<span className="text-slate-300 text-xs"></span>
)}
</td>
</tr>
{expandedLogId === log.id && log.details && (
<tr>
<td colSpan={5} className="px-6 pb-3 pt-0">
<pre className="bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs font-mono text-slate-700 overflow-x-auto max-h-40">
{JSON.stringify(log.details, null, 2)}
</pre>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-slate-100 bg-slate-50 text-sm">
<span className="text-slate-500">Trang {meta.page} / {meta.totalPages}</span>
<div className="flex gap-2">
<button
onClick={() => fetchLogs(meta.page - 1)}
disabled={meta.page <= 1}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
← Trước
</button>
<button
onClick={() => fetchLogs(meta.page + 1)}
disabled={meta.page >= meta.totalPages}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
Tiếp →
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
......
import React from 'react'; import React, { useEffect, useState } from 'react';
import { api } from '../services/api';
import {
Users as UsersIcon,
Plus,
Search,
RefreshCw,
Pencil,
Trash2,
XCircle,
CheckCircle2,
ShieldCheck,
User,
} from 'lucide-react';
interface UserItem {
id: string;
email: string;
fullName: string | null;
role: 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
isActive: boolean;
createdAt: string;
}
interface Meta {
total: number;
page: number;
limit: number;
totalPages: number;
}
interface CreateUserForm {
email: string;
password: string;
fullName: string;
role: 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
}
interface UpdateUserForm {
fullName: string;
role: 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
isActive: boolean;
}
const ROLE_BADGES: Record<string, { label: string; cls: string }> = {
ADMIN: { label: 'Admin', cls: 'bg-violet-100 text-violet-700 border-violet-200' },
CRAWLER_USER: { label: 'Crawler', cls: 'bg-blue-100 text-blue-700 border-blue-200' },
VIEWER: { label: 'Viewer', cls: 'bg-slate-100 text-slate-600 border-slate-200' },
};
const emptyCreate: CreateUserForm = { email: '', password: '', fullName: '', role: 'CRAWLER_USER' };
export const Users: React.FC = () => { export const Users: React.FC = () => {
const [users, setUsers] = useState<UserItem[]>([]);
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 10, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
// Create modal
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState<CreateUserForm>(emptyCreate);
const [createError, setCreateError] = useState('');
const [creating, setCreating] = useState(false);
// Edit modal
const [editUser, setEditUser] = useState<UserItem | null>(null);
const [editForm, setEditForm] = useState<UpdateUserForm>({ fullName: '', role: 'CRAWLER_USER', isActive: true });
const [editError, setEditError] = useState('');
const [editing, setEditing] = useState(false);
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchUsers = async (page = 1, searchQuery = search, silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
try {
const res = await api.get('/users', {
params: { page, limit: 10, search: searchQuery || undefined },
});
if (res.data.success) {
setUsers(res.data.data.users || []);
setMeta(res.data.data.meta);
}
} catch (e) {
console.error('Failed to fetch users', e);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchUsers(1, search);
}, [search]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError('');
setCreating(true);
try {
await api.post('/users', {
email: createForm.email,
password: createForm.password,
fullName: createForm.fullName || undefined,
role: createForm.role,
});
setShowCreate(false);
setCreateForm(emptyCreate);
fetchUsers(1, search, true);
} catch (err: any) {
setCreateError(err.response?.data?.message || 'Tạo user thất bại.');
} finally {
setCreating(false);
}
};
const openEdit = (u: UserItem) => {
setEditUser(u);
setEditForm({ fullName: u.fullName || '', role: u.role, isActive: u.isActive });
setEditError('');
};
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editUser) return;
setEditError('');
setEditing(true);
try {
await api.put(`/users/${editUser.id}`, {
fullName: editForm.fullName || undefined,
role: editForm.role,
isActive: editForm.isActive,
});
setEditUser(null);
fetchUsers(meta.page, search, true);
} catch (err: any) {
setEditError(err.response?.data?.message || 'Cập nhật thất bại.');
} finally {
setEditing(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/users/${deleteTarget.id}`);
setDeleteTarget(null);
fetchUsers(meta.page, search, true);
} catch (err: any) {
alert(err.response?.data?.message || 'Xóa thất bại.');
} finally {
setDeleting(false);
}
};
const roleBadge = (role: string) => {
const cfg = ROLE_BADGES[role] ?? { label: role, cls: 'bg-slate-100 text-slate-600' };
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold border ${cfg.cls}`}>
{cfg.label}
</span>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> {/* Header */}
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Quản lý Users</h1> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p className="text-sm text-slate-500">Chỉ dành cho tài khoản Admin.</p> <div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900 flex items-center gap-2">
<UsersIcon className="h-6 w-6 text-violet-500" />
Quản lý Users
</h1>
<p className="text-sm text-slate-500 mt-0.5">Chỉ dành cho tài khoản Admin. Tổng: <strong>{meta.total}</strong> người dùng.</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fetchUsers(meta.page, search, true)}
className="p-2.5 bg-white border border-slate-200 rounded-lg text-slate-600 hover:text-slate-900 hover:bg-slate-50 transition-colors"
title="Làm mới"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => { setShowCreate(true); setCreateError(''); setCreateForm(emptyCreate); }}
className="flex items-center gap-1.5 px-4 py-2.5 bg-violet-600 text-white rounded-lg hover:bg-violet-700 font-medium text-sm transition-colors shadow-sm"
>
<Plus className="h-4 w-4" />
Tạo user mới
</button>
</div>
</div> </div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 text-center"> {/* Search */}
<p className="text-slate-500">Chức năng quản lý Users đang được cập nhật (Kế hoạch Ngày 2)...</p> <form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Tìm theo tên hoặc email..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="block w-full pl-9 pr-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-900 placeholder-slate-400 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors"
>
Tìm
</button>
{search && (
<button
type="button"
onClick={() => { setSearch(''); setSearchInput(''); }}
className="px-3 py-2 text-slate-500 hover:text-slate-900 text-sm"
>
Xóa lọc
</button>
)}
</form>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-violet-600 border-t-transparent" />
</div>
) : users.length === 0 ? (
<div className="text-center py-16 text-slate-400">
<User className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p>Không tìm thấy người dùng nào.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-sm text-left">
<thead className="bg-slate-50 text-slate-500 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4">Người dùng</th>
<th className="px-6 py-4">Vai trò</th>
<th className="px-6 py-4">Trạng thái</th>
<th className="px-6 py-4">Ngày tạo</th>
<th className="px-6 py-4 text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700">
{users.map((u) => (
<tr key={u.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4">
<div>
<p className="font-medium text-slate-900">{u.fullName || '—'}</p>
<p className="text-xs text-slate-400 font-mono">{u.email}</p>
</div>
</td>
<td className="px-6 py-4">{roleBadge(u.role)}</td>
<td className="px-6 py-4">
{u.isActive ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700">
<CheckCircle2 className="h-3.5 w-3.5" /> Kích hoạt
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-rose-600">
<XCircle className="h-3.5 w-3.5" /> Bị khóa
</span>
)}
</td>
<td className="px-6 py-4 text-slate-500">
{new Date(u.createdAt).toLocaleDateString('vi-VN')}
</td>
<td className="px-6 py-4 text-right space-x-3">
<button
onClick={() => openEdit(u)}
className="inline-flex items-center gap-1 text-sm font-semibold text-indigo-600 hover:text-indigo-900 transition-colors"
>
<Pencil className="h-3.5 w-3.5" /> Sửa
</button>
<button
onClick={() => setDeleteTarget(u)}
className="inline-flex items-center gap-1 text-sm font-semibold text-rose-600 hover:text-rose-900 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" /> Xóa
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-slate-100 bg-slate-50 text-sm">
<span className="text-slate-500">Trang {meta.page} / {meta.totalPages}</span>
<div className="flex gap-2">
<button
onClick={() => fetchUsers(meta.page - 1, search)}
disabled={meta.page <= 1}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
← Trước
</button>
<button
onClick={() => fetchUsers(meta.page + 1, search)}
disabled={meta.page >= meta.totalPages}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
Tiếp →
</button>
</div>
</div>
)}
</div> </div>
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
<div className="w-full max-w-md bg-white rounded-2xl p-6 shadow-2xl border border-slate-100 space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-violet-500" />
Tạo user mới
</h3>
<button onClick={() => setShowCreate(false)} className="text-slate-400 hover:text-slate-600">
<XCircle className="h-5 w-5" />
</button>
</div>
{createError && (
<div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-600 border border-rose-100">{createError}</div>
)}
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Email *</label>
<input
type="email"
required
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
placeholder="user@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu *</label>
<input
type="password"
required
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
placeholder="Tối thiểu 8 ký tự"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label>
<input
type="text"
value={createForm.fullName}
onChange={(e) => setCreateForm({ ...createForm, fullName: e.target.value })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
placeholder="Nguyễn Văn A"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Vai trò</label>
<select
value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as any })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="CRAWLER_USER">CRAWLER_USER</option>
<option value="VIEWER">VIEWER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 text-sm font-medium"
>
Hủy
</button>
<button
type="submit"
disabled={creating}
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 text-sm font-medium disabled:bg-violet-300"
>
{creating ? 'Đang tạo...' : 'Tạo user'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Modal */}
{editUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
<div className="w-full max-w-md bg-white rounded-2xl p-6 shadow-2xl border border-slate-100 space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="text-lg font-bold text-slate-900">Cập nhật user</h3>
<button onClick={() => setEditUser(null)} className="text-slate-400 hover:text-slate-600">
<XCircle className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-slate-500 font-mono">{editUser.email}</p>
{editError && (
<div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-600 border border-rose-100">{editError}</div>
)}
<form onSubmit={handleEdit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label>
<input
type="text"
value={editForm.fullName}
onChange={(e) => setEditForm({ ...editForm, fullName: e.target.value })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Vai trò</label>
<select
value={editForm.role}
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as any })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="CRAWLER_USER">CRAWLER_USER</option>
<option value="VIEWER">VIEWER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
id="is-active-toggle"
type="checkbox"
checked={editForm.isActive}
onChange={(e) => setEditForm({ ...editForm, isActive: e.target.checked })}
className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
<label htmlFor="is-active-toggle" className="text-sm font-medium text-slate-700">
Tài khoản đang kích hoạt
</label>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => setEditUser(null)}
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 text-sm font-medium"
>
Hủy
</button>
<button
type="submit"
disabled={editing}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-medium disabled:bg-indigo-300"
>
{editing ? 'Đang lưu...' : 'Lưu thay đổi'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirm Modal */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
<div className="w-full max-w-sm bg-white rounded-2xl p-6 shadow-2xl border border-slate-100 space-y-4">
<h3 className="text-lg font-bold text-slate-900">Xác nhận xóa</h3>
<p className="text-sm text-slate-600">
Bạn có chắc muốn xóa người dùng{' '}
<strong className="text-slate-900">{deleteTarget.fullName || deleteTarget.email}</strong>?
Hành động này không thể hoàn tác.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteTarget(null)}
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 text-sm font-medium"
>
Hủy
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 bg-rose-600 text-white rounded-lg hover:bg-rose-700 text-sm font-medium disabled:bg-rose-300"
>
{deleting ? 'Đang xóa...' : 'Xóa'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
import axios from 'axios'; import axios from 'axios';
const API_BASE_URL = 'http://localhost:3000/api/v1'; const API_BASE_URL = 'http://171.247.68.96:4011/api/v1';
export const api = axios.create({ export const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment