This commit is contained in:
@@ -299,7 +299,7 @@
|
|||||||
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`;
|
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = window.location.origin;
|
const host = 'https://sv.rikkeiraia.org';
|
||||||
|
|
||||||
async function loadApps() {
|
async function loadApps() {
|
||||||
const loader = document.getElementById('apps-loading');
|
const loader = document.getElementById('apps-loading');
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
<div class="card-desc">${app.description || 'Hỗ trợ quá trình kiểm tra và học tập.'}</div>
|
<div class="card-desc">${app.description || 'Hỗ trợ quá trình kiểm tra và học tập.'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span class="platform-badge">${app.platform}</span>
|
<span class="platform-badge">${app.platform === 'macOS' ? 'MacOS' : app.platform}</span>
|
||||||
<a href="${app.downloadUrl}" target="_blank" rel="noopener noreferrer" class="btn">
|
<a href="${app.downloadUrl}" target="_blank" rel="noopener noreferrer" class="btn">
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function App() {
|
|||||||
onClick={() => setActiveTab('dashboard')}
|
onClick={() => setActiveTab('dashboard')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconDashboard /></span>
|
<span className="nav-icon"><IconDashboard /></span>
|
||||||
Tổng quan
|
Tổng quan & Tài Liệu
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api, type AppDownloadItem } from '../api';
|
import { api, type AppDownloadItem, type GuideLinkItem } from '../api';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
// Icons
|
||||||
const IconDownload = () => (
|
const IconDownload = () => (
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
<polyline points="7 10 12 15 17 10" />
|
<polyline points="7 10 12 15 17 10" />
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
@@ -46,75 +47,110 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
const { staff } = useAuth();
|
const { staff } = useAuth();
|
||||||
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||||
|
|
||||||
const [apps, setApps] = useState<AppDownloadItem[]>([]);
|
const [activeSubTab, setActiveSubTab] = useState<'apps' | 'guides'>('apps');
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Modal states
|
// Apps state
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [apps, setApps] = useState<AppDownloadItem[]>([]);
|
||||||
|
const [appsLoading, setAppsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Guides state
|
||||||
|
const [guides, setGuides] = useState<GuideLinkItem[]>([]);
|
||||||
|
const [guidesLoading, setGuidesLoading] = useState(true);
|
||||||
|
|
||||||
|
// App Modal states
|
||||||
|
const [showAppModal, setShowAppModal] = useState(false);
|
||||||
const [editingApp, setEditingApp] = useState<AppDownloadItem | null>(null);
|
const [editingApp, setEditingApp] = useState<AppDownloadItem | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [appName, setAppName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [appDescription, setAppDescription] = useState('');
|
||||||
const [downloadUrl, setDownloadUrl] = useState('');
|
const [appDownloadUrl, setAppDownloadUrl] = useState('');
|
||||||
const [platform, setPlatform] = useState('Windows');
|
const [appPlatform, setAppPlatform] = useState('Windows');
|
||||||
const [saving, setSaving] = useState(false);
|
const [appSaving, setAppSaving] = useState(false);
|
||||||
|
|
||||||
|
// Guide Modal states
|
||||||
|
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||||
|
const [editingGuide, setEditingGuide] = useState<GuideLinkItem | null>(null);
|
||||||
|
const [guideTitle, setGuideTitle] = useState('');
|
||||||
|
const [guideUrl, setGuideUrl] = useState('');
|
||||||
|
const [guideSaving, setGuideSaving] = useState(false);
|
||||||
|
|
||||||
const fetchApps = async () => {
|
const fetchApps = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setAppsLoading(true);
|
||||||
const res = await api.listAppDownloads();
|
const res = await api.listAppDownloads();
|
||||||
setApps(res.data || []);
|
setApps(res.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Không thể tải danh sách ứng dụng:', err);
|
console.error('Không thể tải danh sách ứng dụng:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setAppsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGuides = async () => {
|
||||||
|
try {
|
||||||
|
setGuidesLoading(true);
|
||||||
|
const res = await api.listGuideLinks();
|
||||||
|
setGuides(res.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Không thể tải danh sách hướng dẫn:', err);
|
||||||
|
} finally {
|
||||||
|
setGuidesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchApps();
|
fetchApps();
|
||||||
|
fetchGuides();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenAdd = () => {
|
// App Modal Handlers
|
||||||
|
const handleOpenAddApp = () => {
|
||||||
setEditingApp(null);
|
setEditingApp(null);
|
||||||
setName('');
|
setAppName('');
|
||||||
setDescription('');
|
setAppDescription('');
|
||||||
setDownloadUrl('');
|
setAppDownloadUrl('');
|
||||||
setPlatform('Windows');
|
setAppPlatform('Windows');
|
||||||
setShowModal(true);
|
setShowAppModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEdit = (app: AppDownloadItem) => {
|
const handleOpenEditApp = (app: AppDownloadItem) => {
|
||||||
setEditingApp(app);
|
setEditingApp(app);
|
||||||
setName(app.name);
|
setAppName(app.name);
|
||||||
setDescription(app.description);
|
setAppDescription(app.description);
|
||||||
setDownloadUrl(app.downloadUrl);
|
setAppDownloadUrl(app.downloadUrl);
|
||||||
setPlatform(app.platform);
|
// Standardize representation to 'MacOS'
|
||||||
setShowModal(true);
|
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
|
||||||
|
setShowAppModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSaveApp = async () => {
|
||||||
if (!name.trim() || !downloadUrl.trim()) {
|
if (!appName.trim() || !appDownloadUrl.trim()) {
|
||||||
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
|
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Force write first letters uppercase
|
||||||
|
let finalPlatform = appPlatform;
|
||||||
|
if (finalPlatform.toLowerCase() === 'macos') {
|
||||||
|
finalPlatform = 'MacOS';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setAppSaving(true);
|
||||||
if (editingApp) {
|
if (editingApp) {
|
||||||
await api.updateAppDownload(editingApp.id, name.trim(), description.trim(), downloadUrl.trim(), platform);
|
await api.updateAppDownload(editingApp.id, appName.trim(), appDescription.trim(), appDownloadUrl.trim(), finalPlatform);
|
||||||
} else {
|
} else {
|
||||||
await api.createAppDownload(name.trim(), description.trim(), downloadUrl.trim(), platform);
|
await api.createAppDownload(appName.trim(), appDescription.trim(), appDownloadUrl.trim(), finalPlatform);
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowAppModal(false);
|
||||||
await fetchApps();
|
await fetchApps();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Lưu ứng dụng thất bại');
|
alert(err.message || 'Lưu ứng dụng thất bại');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setAppSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number, appName: string) => {
|
const handleDeleteApp = async (id: number, name: string) => {
|
||||||
if (window.confirm(`Bạn có chắc chắn muốn xóa liên kết tải cho ứng dụng "${appName}" không?`)) {
|
if (window.confirm(`Bạn có chắc chắn muốn xóa liên kết tải cho ứng dụng "${name}" không?`)) {
|
||||||
try {
|
try {
|
||||||
await api.deleteAppDownload(id);
|
await api.deleteAppDownload(id);
|
||||||
await fetchApps();
|
await fetchApps();
|
||||||
@@ -124,139 +160,338 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guide Modal Handlers
|
||||||
|
const handleOpenAddGuide = () => {
|
||||||
|
setEditingGuide(null);
|
||||||
|
setGuideTitle('');
|
||||||
|
setGuideUrl('');
|
||||||
|
setShowGuideModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditGuide = (guide: GuideLinkItem) => {
|
||||||
|
setEditingGuide(guide);
|
||||||
|
setGuideTitle(guide.title);
|
||||||
|
setGuideUrl(guide.url);
|
||||||
|
setShowGuideModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveGuide = async () => {
|
||||||
|
if (!guideTitle.trim() || !guideUrl.trim()) {
|
||||||
|
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn hướng dẫn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setGuideSaving(true);
|
||||||
|
if (editingGuide) {
|
||||||
|
await api.updateGuideLink(editingGuide.id, guideTitle.trim(), guideUrl.trim());
|
||||||
|
} else {
|
||||||
|
await api.createGuideLink(guideTitle.trim(), guideUrl.trim());
|
||||||
|
}
|
||||||
|
setShowGuideModal(false);
|
||||||
|
await fetchGuides();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Lưu hướng dẫn thất bại');
|
||||||
|
} finally {
|
||||||
|
setGuideSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteGuide = async (id: number, title: string) => {
|
||||||
|
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu hướng dẫn "${title}"?`)) {
|
||||||
|
try {
|
||||||
|
await api.deleteGuideLink(id);
|
||||||
|
await fetchGuides();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Xóa hướng dẫn thất bại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-page">
|
<div className="tab-page">
|
||||||
<header className="page-header page-header--row">
|
<header className="page-header page-header--row">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Quản Lý Ứng Dụng</h1>
|
<h1 className="page-title">Ứng Dụng & Hướng Dẫn</h1>
|
||||||
<p className="page-desc">Quản lý link tải các công cụ học tập hỗ trợ sinh viên.</p>
|
<p className="page-desc">Quản lý các công cụ, link tải ứng dụng và các tài liệu hướng dẫn cho sinh viên.</p>
|
||||||
</div>
|
</div>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<button className="btn btn-primary" onClick={handleOpenAdd}>
|
<div>
|
||||||
+ Thêm ứng dụng
|
{activeSubTab === 'apps' ? (
|
||||||
</button>
|
<button className="btn btn-primary" onClick={handleOpenAddApp}>
|
||||||
|
+ Thêm ứng dụng
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenAddGuide}>
|
||||||
|
+ Thêm tài liệu hướng dẫn
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="tab-page-body tab-page-scroll">
|
{/* Sub-tab Switch Header */}
|
||||||
<div className="content-card">
|
<div style={{ padding: '0 2rem' }}>
|
||||||
{loading && (
|
<div style={{
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
display: 'flex',
|
||||||
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
gap: '12px',
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách ứng dụng...</p>
|
borderBottom: '1px solid var(--border-color)',
|
||||||
</div>
|
paddingBottom: '12px',
|
||||||
)}
|
marginBottom: '1.5rem'
|
||||||
|
}}>
|
||||||
{!loading && apps.length === 0 && (
|
<button
|
||||||
<div style={{ textAlign: 'center', padding: '3rem 1.5rem', color: 'var(--text-muted)' }}>
|
type="button"
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: '0.75rem' }}>
|
className={`btn ${activeSubTab === 'apps' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
onClick={() => setActiveSubTab('apps')}
|
||||||
<path d="M21 12H3M12 3v18" />
|
style={{
|
||||||
</svg>
|
padding: '0.6rem 1.5rem',
|
||||||
<p>Chưa có ứng dụng nào được cấu hình tải.</p>
|
borderRadius: 'var(--radius-sm)',
|
||||||
{isSuperAdmin && (
|
fontWeight: 600,
|
||||||
<button className="btn btn-secondary" onClick={handleOpenAdd} style={{ marginTop: '0.75rem' }}>
|
fontSize: '0.9rem',
|
||||||
Thêm ứng dụng đầu tiên
|
transition: 'var(--transition)'
|
||||||
</button>
|
}}
|
||||||
)}
|
>
|
||||||
</div>
|
Ứng dụng tải về
|
||||||
)}
|
</button>
|
||||||
|
<button
|
||||||
{!loading && apps.length > 0 && (
|
type="button"
|
||||||
<div className="table-responsive">
|
className={`btn ${activeSubTab === 'guides' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
<table className="table">
|
onClick={() => setActiveSubTab('guides')}
|
||||||
<thead>
|
style={{
|
||||||
<tr>
|
padding: '0.6rem 1.5rem',
|
||||||
<th>Nền tảng</th>
|
borderRadius: 'var(--radius-sm)',
|
||||||
<th>Ứng dụng</th>
|
fontWeight: 600,
|
||||||
<th>Mô tả</th>
|
fontSize: '0.9rem',
|
||||||
<th>Đường dẫn tải</th>
|
transition: 'var(--transition)'
|
||||||
{isSuperAdmin && <th style={{ textAlign: 'right' }}>Thao tác</th>}
|
}}
|
||||||
</tr>
|
>
|
||||||
</thead>
|
Tài liệu & Video Hướng dẫn
|
||||||
<tbody>
|
</button>
|
||||||
{apps.map((app) => (
|
|
||||||
<tr key={app.id}>
|
|
||||||
<td style={{ width: '120px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<PlatformIcon platform={app.platform} />
|
|
||||||
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>{app.platform}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ width: '220px', fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
||||||
{app.name}
|
|
||||||
</td>
|
|
||||||
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
|
|
||||||
{app.description || '—'}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a
|
|
||||||
href={app.downloadUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 600
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
|
||||||
>
|
|
||||||
<IconDownload />
|
|
||||||
Tải ứng dụng
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<td style={{ textAlign: 'right', width: '130px' }}>
|
|
||||||
<div style={{ display: 'inline-flex', gap: '6px' }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => handleOpenEdit(app)}
|
|
||||||
>
|
|
||||||
Sửa
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => handleDelete(app.id, app.name)}
|
|
||||||
style={{ color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
|
||||||
>
|
|
||||||
Xóa
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModal && (
|
<div className="tab-page-body tab-page-scroll" style={{ padding: '0 2rem 2rem 2rem' }}>
|
||||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
{activeSubTab === 'apps' && (
|
||||||
|
<div className="content-card" style={{ padding: '1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)' }}>
|
||||||
|
{appsLoading && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '4rem 1.5rem' }}>
|
||||||
|
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách ứng dụng...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!appsLoading && apps.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1.5rem', color: 'var(--text-muted)' }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M21 12H3M12 3v18" />
|
||||||
|
</svg>
|
||||||
|
<p style={{ fontSize: '0.95rem' }}>Chưa có ứng dụng nào được cấu hình tải.</p>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button className="btn btn-secondary" onClick={handleOpenAddApp} style={{ marginTop: '1rem' }}>
|
||||||
|
Thêm ứng dụng đầu tiên
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!appsLoading && apps.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)' }}>Nền tảng</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '25%', fontWeight: 700, color: 'var(--text-secondary)' }}>Ứng dụng</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '35%', fontWeight: 700, color: 'var(--text-secondary)' }}>Mô tả</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)' }}>Đường dẫn tải</th>
|
||||||
|
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<tr key={app.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<PlatformIcon platform={app.platform} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)' }}>
|
||||||
|
{app.platform === 'macOS' ? 'MacOS' : app.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}>
|
||||||
|
{app.name}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', color: 'var(--text-secondary)', fontSize: '0.9rem', lineHeight: '1.5' }}>
|
||||||
|
{app.description || '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<a
|
||||||
|
href={app.downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||||
|
>
|
||||||
|
<IconDownload />
|
||||||
|
Tải ứng dụng
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'inline-flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenEditApp(app)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleDeleteApp(app.id, app.name)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSubTab === 'guides' && (
|
||||||
|
<div className="content-card" style={{ padding: '1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)' }}>
|
||||||
|
{guidesLoading && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '4rem 1.5rem' }}>
|
||||||
|
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách tài liệu hướng dẫn...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!guidesLoading && guides.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1.5rem', color: 'var(--text-muted)' }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
<p style={{ fontSize: '0.95rem' }}>Chưa có tài liệu hướng dẫn nào.</p>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button className="btn btn-secondary" onClick={handleOpenAddGuide} style={{ marginTop: '1rem' }}>
|
||||||
|
Thêm tài liệu hướng dẫn đầu tiên
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!guidesLoading && guides.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)' }}>Loại tài liệu</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '50%', fontWeight: 700, color: 'var(--text-secondary)' }}>Tiêu đề hướng dẫn</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '25%', fontWeight: 700, color: 'var(--text-secondary)' }}>Đường dẫn liên kết</th>
|
||||||
|
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{guides.map((guide) => {
|
||||||
|
const isVideo = guide.url.toLowerCase().includes('youtube.com') || guide.url.toLowerCase().includes('youtu.be') || guide.url.toLowerCase().includes('drive.google.com/file');
|
||||||
|
return (
|
||||||
|
<tr key={guide.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<span className="platform-badge" style={{ backgroundColor: isVideo ? '#ffebee' : '#e3f2fd', color: isVideo ? '#d32f2f' : '#1976d2', padding: '0.3rem 0.6rem', fontSize: '0.75rem', borderRadius: '4px', fontWeight: 700 }}>
|
||||||
|
{isVideo ? 'VIDEO' : 'TÀI LIỆU'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}>
|
||||||
|
{guide.title}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<a
|
||||||
|
href={guide.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||||
|
>
|
||||||
|
Xem liên kết →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'inline-flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenEditGuide(guide)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleDeleteGuide(guide.id, guide.title)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App Modal */}
|
||||||
|
{showAppModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAppModal(false)}>
|
||||||
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
|
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2 className="modal-title">{editingApp ? 'Cập nhật ứng dụng' : 'Thêm ứng dụng tải'}</h2>
|
<h2 className="modal-title">{editingApp ? 'Cập nhật ứng dụng' : 'Thêm ứng dụng tải'}</h2>
|
||||||
<button className="modal-close-btn" onClick={() => setShowModal(false)} aria-label="Đóng">×</button>
|
<button className="modal-close-btn" onClick={() => setShowAppModal(false)} aria-label="Đóng">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tên ứng dụng</span>
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tên ứng dụng</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={appName}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setAppName(e.target.value)}
|
||||||
placeholder="VD: Simple Care Client v1.2"
|
placeholder="VD: Simple Care Client v1.2"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '0.55rem 0.75rem',
|
padding: '0.6rem 0.85rem',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
@@ -271,11 +506,11 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Nền tảng</span>
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Nền tảng</span>
|
||||||
<select
|
<select
|
||||||
value={platform}
|
value={appPlatform}
|
||||||
onChange={e => setPlatform(e.target.value)}
|
onChange={e => setAppPlatform(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '0.55rem 0.75rem',
|
padding: '0.6rem 0.85rem',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
@@ -287,7 +522,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
>
|
>
|
||||||
<option value="Windows">Windows</option>
|
<option value="Windows">Windows</option>
|
||||||
<option value="macOS">macOS</option>
|
<option value="MacOS">MacOS</option>
|
||||||
<option value="Linux">Linux</option>
|
<option value="Linux">Linux</option>
|
||||||
<option value="Khác">Khác</option>
|
<option value="Khác">Khác</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -297,12 +532,12 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Mô tả ngắn</span>
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Mô tả ngắn</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={description}
|
value={appDescription}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setAppDescription(e.target.value)}
|
||||||
placeholder="VD: Dành cho máy Windows 64-bit"
|
placeholder="VD: Dành cho máy Windows 64-bit"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '0.55rem 0.75rem',
|
padding: '0.6rem 0.85rem',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
@@ -318,12 +553,12 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn tải</span>
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn tải</span>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={downloadUrl}
|
value={appDownloadUrl}
|
||||||
onChange={e => setDownloadUrl(e.target.value)}
|
onChange={e => setAppDownloadUrl(e.target.value)}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '0.55rem 0.75rem',
|
padding: '0.6rem 0.85rem',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
@@ -337,13 +572,80 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
||||||
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Hủy</button>
|
<button className="btn btn-secondary" onClick={() => setShowAppModal(false)}>Hủy</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleSave}
|
onClick={handleSaveApp}
|
||||||
disabled={saving}
|
disabled={appSaving}
|
||||||
>
|
>
|
||||||
{saving ? 'Đang lưu...' : 'Lưu lại'}
|
{appSaving ? 'Đang lưu...' : 'Lưu lại'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Guide Modal */}
|
||||||
|
{showGuideModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowGuideModal(false)}>
|
||||||
|
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">{editingGuide ? 'Cập nhật hướng dẫn' : 'Thêm tài liệu hướng dẫn'}</h2>
|
||||||
|
<button className="modal-close-btn" onClick={() => setShowGuideModal(false)} aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đề hướng dẫn</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={guideTitle}
|
||||||
|
onChange={e => setGuideTitle(e.target.value)}
|
||||||
|
placeholder="VD: Hướng dẫn cài đặt và đồng bộ dữ liệu"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn hướng dẫn (Google Docs / Video Youtube)</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={guideUrl}
|
||||||
|
onChange={e => setGuideUrl(e.target.value)}
|
||||||
|
placeholder="https://docs.google.com/document/d/... hoặc https://youtube.com/..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowGuideModal(false)}>Hủy</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSaveGuide}
|
||||||
|
disabled={guideSaving}
|
||||||
|
>
|
||||||
|
{guideSaving ? 'Đang lưu...' : 'Lưu lại'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api, type GuideLinkItem } from '../api';
|
import { api } from '../api';
|
||||||
import type { StatsResponse } from '../api';
|
import type { StatsResponse } from '../api';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
|
||||||
|
|
||||||
interface DashboardTabProps {
|
interface DashboardTabProps {
|
||||||
onNavigate: (tab: string) => void;
|
onNavigate: (tab: string) => void;
|
||||||
@@ -38,36 +37,10 @@ const IconAlert = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
||||||
const { staff } = useAuth();
|
|
||||||
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Guide links state
|
|
||||||
const [links, setLinks] = useState<GuideLinkItem[]>([]);
|
|
||||||
const [linksLoading, setLinksLoading] = useState(true);
|
|
||||||
|
|
||||||
// Add/Edit modal state
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [editingLink, setEditingLink] = useState<GuideLinkItem | null>(null);
|
|
||||||
const [modalTitle, setModalTitle] = useState('');
|
|
||||||
const [modalUrl, setModalUrl] = useState('');
|
|
||||||
const [modalSaving, setModalSaving] = useState(false);
|
|
||||||
|
|
||||||
const fetchLinks = async () => {
|
|
||||||
try {
|
|
||||||
setLinksLoading(true);
|
|
||||||
const res = await api.listGuideLinks();
|
|
||||||
setLinks(res.data || []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Không thể tải tài liệu hướng dẫn:', err);
|
|
||||||
} finally {
|
|
||||||
setLinksLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -81,55 +54,8 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchLinks();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenAddModal = () => {
|
|
||||||
setEditingLink(null);
|
|
||||||
setModalTitle('');
|
|
||||||
setModalUrl('');
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenEditModal = (link: GuideLinkItem) => {
|
|
||||||
setEditingLink(link);
|
|
||||||
setModalTitle(link.title);
|
|
||||||
setModalUrl(link.url);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveLink = async () => {
|
|
||||||
if (!modalTitle.trim() || !modalUrl.trim()) {
|
|
||||||
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setModalSaving(true);
|
|
||||||
if (editingLink) {
|
|
||||||
await api.updateGuideLink(editingLink.id, modalTitle.trim(), modalUrl.trim());
|
|
||||||
} else {
|
|
||||||
await api.createGuideLink(modalTitle.trim(), modalUrl.trim());
|
|
||||||
}
|
|
||||||
setShowModal(false);
|
|
||||||
await fetchLinks();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Lưu tài liệu thất bại');
|
|
||||||
} finally {
|
|
||||||
setModalSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLink = async (id: number, title: string) => {
|
|
||||||
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu "${title}"?`)) {
|
|
||||||
try {
|
|
||||||
await api.deleteGuideLink(id);
|
|
||||||
await fetchLinks();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Xóa tài liệu thất bại');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-page">
|
<div className="tab-page">
|
||||||
<div className="tab-page-toolbar">
|
<div className="tab-page-toolbar">
|
||||||
@@ -212,179 +138,9 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content-card" style={{ marginTop: '1.5rem' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.75rem' }}>
|
|
||||||
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--accent)' }}>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
</svg>
|
|
||||||
Tài liệu hướng dẫn
|
|
||||||
</h2>
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={handleOpenAddModal}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
|
||||||
>
|
|
||||||
+ Thêm tài liệu
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{linksLoading && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
|
|
||||||
<div className="sync-spinner" style={{ width: '24px', height: '24px' }}></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!linksLoading && links.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', fontSize: '0.9rem', margin: '0.5rem 0' }}>Chưa có tài liệu hướng dẫn nào được cấu hình.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!linksLoading && links.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
||||||
{links.map((link) => (
|
|
||||||
<div
|
|
||||||
key={link.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
backgroundColor: 'var(--bg-app)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
transition: 'var(--transition)'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
|
|
||||||
e.currentTarget.style.backgroundColor = '#fff';
|
|
||||||
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
|
||||||
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '0.92rem',
|
|
||||||
flex: 1
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#4285F4' }}>
|
|
||||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
</svg>
|
|
||||||
{link.title}
|
|
||||||
</a>
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<div style={{ display: 'flex', gap: '6px' }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => handleOpenEditModal(link)}
|
|
||||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
|
||||||
>
|
|
||||||
Sửa
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => handleDeleteLink(link.id, link.title)}
|
|
||||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
|
||||||
>
|
|
||||||
Xóa
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModal && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
|
||||||
<div className="modal-container" style={{ maxWidth: '450px' }} onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2 className="modal-title">{editingLink ? 'Cập nhật tài liệu' : 'Thêm tài liệu hướng dẫn'}</h2>
|
|
||||||
<button className="modal-close-btn" onClick={() => setShowModal(false)} aria-label="Đóng">×</button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
||||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
||||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đề tài liệu</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={modalTitle}
|
|
||||||
onChange={e => setModalTitle(e.target.value)}
|
|
||||||
placeholder="VD: Hướng dẫn coi thi cuối kỳ"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.55rem 0.75rem',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
outline: 'none',
|
|
||||||
transition: 'var(--transition)'
|
|
||||||
}}
|
|
||||||
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
|
||||||
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
||||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn Google Docs</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={modalUrl}
|
|
||||||
onChange={e => setModalUrl(e.target.value)}
|
|
||||||
placeholder="https://docs.google.com/document/d/..."
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.55rem 0.75rem',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
outline: 'none',
|
|
||||||
transition: 'var(--transition)'
|
|
||||||
}}
|
|
||||||
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
|
||||||
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Hủy</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleSaveLink}
|
|
||||||
disabled={modalSaving}
|
|
||||||
>
|
|
||||||
{modalSaving ? 'Đang lưu...' : 'Lưu lại'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user