add docs
All checks were successful
Deploy on Master Change / deploy (push) Successful in 44s

This commit is contained in:
2026-07-01 14:58:46 +07:00
parent dd766206f7
commit 0b02cc82b5
9 changed files with 992 additions and 2 deletions

View File

@@ -0,0 +1,354 @@
import React, { useEffect, useState } from 'react';
import { api, type AppDownloadItem } from '../api';
import { useAuth } from '../auth/AuthContext';
const IconDownload = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
const PlatformIcon = ({ platform }: { platform: string }) => {
const p = platform.toLowerCase();
if (p.includes('win')) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#0078d7' }}>
<path d="M0 3.449L9.75 2.1v9.45H0V3.449zM0 12.45h9.75v9.45L0 20.551v-8.1zM10.8 1.95L24 0v11.55H10.8V1.95zM10.8 12.45H24v11.55l-13.2-1.95v-9.6z" />
</svg>
);
}
if (p.includes('mac') || p.includes('apple') || p.includes('ios')) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#555555' }}>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 4.17c.66-.81 1.11-1.93.99-3.05-1 .04-2.22.67-2.94 1.51-.62.73-1.16 1.87-1.01 2.97 1.12.09 2.27-.58 2.96-1.43z" />
</svg>
);
}
if (p.includes('linux')) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#FCC624' }}>
<path d="M12 2c-3.3 0-6 2.7-6 6 0 2.2.8 4.2 2.2 5.6C6.5 15.6 5 18.5 5 22h14c0-3.5-1.5-6.4-3.2-8.4 1.4-1.4 2.2-3.4 2.2-5.6 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4 0 .9-.3 1.7-.8 2.3-.3.4-.7.7-1.2.9-.6.2-1.3.3-2 .3s-1.4-.1-2-.3c-.5-.2-.9-.5-1.2-.9-.5-.6-.8-1.4-.8-2.3 0-2.2 1.8-4 4-4z" />
</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(--accent)' }}>
<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>
);
};
export const ApplicationsTab: React.FC = () => {
const { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
const [apps, setApps] = useState<AppDownloadItem[]>([]);
const [loading, setLoading] = useState(true);
// Modal states
const [showModal, setShowModal] = useState(false);
const [editingApp, setEditingApp] = useState<AppDownloadItem | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [downloadUrl, setDownloadUrl] = useState('');
const [platform, setPlatform] = useState('Windows');
const [saving, setSaving] = useState(false);
const fetchApps = async () => {
try {
setLoading(true);
const res = await api.listAppDownloads();
setApps(res.data || []);
} catch (err) {
console.error('Không thể tải danh sách ứng dụng:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchApps();
}, []);
const handleOpenAdd = () => {
setEditingApp(null);
setName('');
setDescription('');
setDownloadUrl('');
setPlatform('Windows');
setShowModal(true);
};
const handleOpenEdit = (app: AppDownloadItem) => {
setEditingApp(app);
setName(app.name);
setDescription(app.description);
setDownloadUrl(app.downloadUrl);
setPlatform(app.platform);
setShowModal(true);
};
const handleSave = async () => {
if (!name.trim() || !downloadUrl.trim()) {
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
return;
}
try {
setSaving(true);
if (editingApp) {
await api.updateAppDownload(editingApp.id, name.trim(), description.trim(), downloadUrl.trim(), platform);
} else {
await api.createAppDownload(name.trim(), description.trim(), downloadUrl.trim(), platform);
}
setShowModal(false);
await fetchApps();
} catch (err: any) {
alert(err.message || 'Lưu ứng dụng thất bại');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number, appName: 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?`)) {
try {
await api.deleteAppDownload(id);
await fetchApps();
} catch (err: any) {
alert(err.message || 'Xóa ứng dụng thất bại');
}
}
};
return (
<div className="tab-page">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">Quản ng Dụng</h1>
<p className="page-desc">Quản link tải các công cụ học tập hỗ trợ sinh viên.</p>
</div>
{isSuperAdmin && (
<button className="btn btn-primary" onClick={handleOpenAdd}>
+ Thêm ng dụng
</button>
)}
</header>
<div className="tab-page-body tab-page-scroll">
<div className="content-card">
{loading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 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>
)}
{!loading && apps.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem 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>Chưa ng dụng nào đưc cấu hình tải.</p>
{isSuperAdmin && (
<button className="btn btn-secondary" onClick={handleOpenAdd} style={{ marginTop: '0.75rem' }}>
Thêm ng dụng đu tiên
</button>
)}
</div>
)}
{!loading && apps.length > 0 && (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Nền tảng</th>
<th>ng dụng</th>
<th> tả</th>
<th>Đưng dẫn tải</th>
{isSuperAdmin && <th style={{ textAlign: 'right' }}>Thao tác</th>}
</tr>
</thead>
<tbody>
{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>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<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>
</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)' }}>Tên ng dụng</span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="VD: Simple Care Client v1.2"
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)' }}>Nền tảng</span>
<select
value={platform}
onChange={e => setPlatform(e.target.value)}
style={{
width: '100%',
padding: '0.55rem 0.75rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
backgroundColor: '#fff',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
>
<option value="Windows">Windows</option>
<option value="macOS">macOS</option>
<option value="Linux">Linux</option>
<option value="Khác">Khác</option>
</select>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}> tả ngắn</span>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="VD: Dành cho máy Windows 64-bit"
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 tải</span>
<input
type="url"
value={downloadUrl}
onChange={e => setDownloadUrl(e.target.value)}
placeholder="https://..."
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={handleSave}
disabled={saving}
>
{saving ? 'Đang lưu...' : 'Lưu lại'}
</button>
</div>
</div>
</div>
)}
</div>
);
};