fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 40s

This commit is contained in:
2026-07-01 15:04:56 +07:00
parent 0b02cc82b5
commit 7a9c7a93ff
4 changed files with 463 additions and 405 deletions

View File

@@ -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"/>

View File

@@ -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>

View File

@@ -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 ng Dụng</h1> <h1 className="page-title">ng Dụng & Hướng Dẫn</h1>
<p className="page-desc">Quản link tải các ng cụ học tập hỗ trợ sinh viên.</p> <p className="page-desc">Quản các công cụ, link tải ng dụng 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 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> 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 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)' }}> 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 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 &rarr;
</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">&times;</button> <button className="modal-close-btn" onClick={() => setShowAppModal(false)} aria-label="Đóng">&times;</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)' }}> tả ngắn</span> <span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}> 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">&times;</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>

View File

@@ -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 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">&times;</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>
); );
}; };