This commit is contained in:
@@ -358,7 +358,7 @@
|
|||||||
const listDiv = document.getElementById('guides-list');
|
const listDiv = document.getElementById('guides-list');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${host}/api/public/guide-links`);
|
const response = await fetch(`${host}/api/public/app-guides`);
|
||||||
if (!response.ok) throw new Error('Không thể lấy danh sách hướng dẫn');
|
if (!response.ok) throw new Error('Không thể lấy danh sách hướng dẫn');
|
||||||
|
|
||||||
const res = await response.json();
|
const res = await response.json();
|
||||||
|
|||||||
@@ -287,6 +287,14 @@ export interface AppDownloadItem {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppGuideItem {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -434,6 +442,33 @@ export const api = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listAppGuides: async (): Promise<{ data: AppGuideItem[] }> => {
|
||||||
|
const res = await staffFetch('/app-guides');
|
||||||
|
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu hướng dẫn');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
createAppGuide: async (title: string, url: string): Promise<{ data: AppGuideItem }> => {
|
||||||
|
const res = await staffFetch('/app-guides', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title, url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Thêm tài liệu hướng dẫn thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
updateAppGuide: async (id: number, title: string, url: string): Promise<{ data: AppGuideItem }> => {
|
||||||
|
const res = await staffFetch(`/app-guides/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ title, url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Cập nhật tài liệu hướng dẫn thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
deleteAppGuide: async (id: number): Promise<{ ok: boolean }> => {
|
||||||
|
const res = await staffFetch(`/app-guides/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa tài liệu hướng dẫn thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
getClasses: async (params: {
|
getClasses: async (params: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api, type AppDownloadItem, type GuideLinkItem } from '../api';
|
import { api, type AppDownloadItem, type AppGuideItem } from '../api';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
@@ -54,7 +54,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
const [appsLoading, setAppsLoading] = useState(true);
|
const [appsLoading, setAppsLoading] = useState(true);
|
||||||
|
|
||||||
// Guides state
|
// Guides state
|
||||||
const [guides, setGuides] = useState<GuideLinkItem[]>([]);
|
const [guides, setGuides] = useState<AppGuideItem[]>([]);
|
||||||
const [guidesLoading, setGuidesLoading] = useState(true);
|
const [guidesLoading, setGuidesLoading] = useState(true);
|
||||||
|
|
||||||
// App Modal states
|
// App Modal states
|
||||||
@@ -68,7 +68,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
|
|
||||||
// Guide Modal states
|
// Guide Modal states
|
||||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||||
const [editingGuide, setEditingGuide] = useState<GuideLinkItem | null>(null);
|
const [editingGuide, setEditingGuide] = useState<AppGuideItem | null>(null);
|
||||||
const [guideTitle, setGuideTitle] = useState('');
|
const [guideTitle, setGuideTitle] = useState('');
|
||||||
const [guideUrl, setGuideUrl] = useState('');
|
const [guideUrl, setGuideUrl] = useState('');
|
||||||
const [guideSaving, setGuideSaving] = useState(false);
|
const [guideSaving, setGuideSaving] = useState(false);
|
||||||
@@ -88,10 +88,10 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
const fetchGuides = async () => {
|
const fetchGuides = async () => {
|
||||||
try {
|
try {
|
||||||
setGuidesLoading(true);
|
setGuidesLoading(true);
|
||||||
const res = await api.listGuideLinks();
|
const res = await api.listAppGuides();
|
||||||
setGuides(res.data || []);
|
setGuides(res.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Không thể tải danh sách hướng dẫn:', err);
|
console.error('Không thể tải danh sách hướng dẫn sinh viên:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setGuidesLoading(false);
|
setGuidesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,6 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
setAppName(app.name);
|
setAppName(app.name);
|
||||||
setAppDescription(app.description);
|
setAppDescription(app.description);
|
||||||
setAppDownloadUrl(app.downloadUrl);
|
setAppDownloadUrl(app.downloadUrl);
|
||||||
// Standardize representation to 'MacOS'
|
|
||||||
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
|
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
|
||||||
setShowAppModal(true);
|
setShowAppModal(true);
|
||||||
};
|
};
|
||||||
@@ -127,7 +126,6 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
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;
|
let finalPlatform = appPlatform;
|
||||||
if (finalPlatform.toLowerCase() === 'macos') {
|
if (finalPlatform.toLowerCase() === 'macos') {
|
||||||
finalPlatform = 'MacOS';
|
finalPlatform = 'MacOS';
|
||||||
@@ -168,7 +166,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
setShowGuideModal(true);
|
setShowGuideModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEditGuide = (guide: GuideLinkItem) => {
|
const handleOpenEditGuide = (guide: AppGuideItem) => {
|
||||||
setEditingGuide(guide);
|
setEditingGuide(guide);
|
||||||
setGuideTitle(guide.title);
|
setGuideTitle(guide.title);
|
||||||
setGuideUrl(guide.url);
|
setGuideUrl(guide.url);
|
||||||
@@ -183,9 +181,9 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setGuideSaving(true);
|
setGuideSaving(true);
|
||||||
if (editingGuide) {
|
if (editingGuide) {
|
||||||
await api.updateGuideLink(editingGuide.id, guideTitle.trim(), guideUrl.trim());
|
await api.updateAppGuide(editingGuide.id, guideTitle.trim(), guideUrl.trim());
|
||||||
} else {
|
} else {
|
||||||
await api.createGuideLink(guideTitle.trim(), guideUrl.trim());
|
await api.createAppGuide(guideTitle.trim(), guideUrl.trim());
|
||||||
}
|
}
|
||||||
setShowGuideModal(false);
|
setShowGuideModal(false);
|
||||||
await fetchGuides();
|
await fetchGuides();
|
||||||
@@ -197,9 +195,9 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteGuide = async (id: number, title: string) => {
|
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}"?`)) {
|
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu hướng dẫn sinh viên "${title}"?`)) {
|
||||||
try {
|
try {
|
||||||
await api.deleteGuideLink(id);
|
await api.deleteAppGuide(id);
|
||||||
await fetchGuides();
|
await fetchGuides();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Xóa hướng dẫn thất bại');
|
alert(err.message || 'Xóa hướng dẫn thất bại');
|
||||||
@@ -229,7 +227,6 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Sub-tab Switch Header */}
|
|
||||||
<div style={{ padding: '0 2rem' }}>
|
<div style={{ padding: '0 2rem' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -388,10 +385,10 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</svg>
|
</svg>
|
||||||
<p style={{ fontSize: '0.95rem' }}>Chưa có tài liệu hướng dẫn nào.</p>
|
<p style={{ fontSize: '0.95rem' }}>Chưa có tài liệu hướng dẫn sinh viên nào.</p>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<button className="btn btn-secondary" onClick={handleOpenAddGuide} style={{ marginTop: '1rem' }}>
|
<button className="btn btn-secondary" onClick={handleOpenAddGuide} style={{ marginTop: '1rem' }}>
|
||||||
Thêm tài liệu hướng dẫn đầu tiên
|
Thêm hướng dẫn sinh viên đầu tiên
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -403,7 +400,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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: '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: '50%', fontWeight: 700, color: 'var(--text-secondary)' }}>Tiêu đề hướng dẫn sinh viê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>
|
<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>}
|
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -590,7 +587,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
<div className="modal-overlay" onClick={() => setShowGuideModal(false)}>
|
<div className="modal-overlay" onClick={() => setShowGuideModal(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">{editingGuide ? 'Cập nhật hướng dẫn' : 'Thêm tài liệu hướng dẫn'}</h2>
|
<h2 className="modal-title">{editingGuide ? 'Cập nhật hướng dẫn sinh viên' : 'Thêm tài liệu hướng dẫn sinh viên'}</h2>
|
||||||
<button className="modal-close-btn" onClick={() => setShowGuideModal(false)} aria-label="Đóng">×</button>
|
<button className="modal-close-btn" onClick={() => setShowGuideModal(false)} aria-label="Đóng">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
@@ -601,7 +598,7 @@ export const ApplicationsTab: React.FC = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={guideTitle}
|
value={guideTitle}
|
||||||
onChange={e => setGuideTitle(e.target.value)}
|
onChange={e => setGuideTitle(e.target.value)}
|
||||||
placeholder="VD: Hướng dẫn cài đặt và đồng bộ dữ liệu"
|
placeholder="VD: Hướng dẫn cài đặt và chạy ứng dụng cho sinh viên"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '0.6rem 0.85rem',
|
padding: '0.6rem 0.85rem',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api } from '../api';
|
import { api, type GuideLinkItem } 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;
|
||||||
@@ -37,10 +38,36 @@ 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 (teacher guides) state
|
||||||
|
const [links, setLinks] = useState<GuideLinkItem[]>([]);
|
||||||
|
const [linksLoading, setLinksLoading] = useState(true);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
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 giáo viên:', err);
|
||||||
|
} finally {
|
||||||
|
setLinksLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,8 +81,55 @@ 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 giáo viên "${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">
|
||||||
@@ -138,9 +212,179 @@ 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 nội bộ (Giáo viê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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.SystemSetting{},
|
&models.SystemSetting{},
|
||||||
&models.GuideLink{},
|
&models.GuideLink{},
|
||||||
&models.AppDownload{},
|
&models.AppDownload{},
|
||||||
|
&models.AppGuide{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
129
server/internal/handlers/handlers_app_guides.go
Normal file
129
server/internal/handlers/handlers_app_guides.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"server/internal/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/public/app-guides
|
||||||
|
func ListAppGuidesPublicHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var guides []models.AppGuide
|
||||||
|
if err := db.Order("created_at desc").Find(&guides).Error; err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu hướng dẫn"})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": guides})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/app-guides
|
||||||
|
func ListAppGuidesHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var guides []models.AppGuide
|
||||||
|
if err := db.Order("created_at desc").Find(&guides).Error; err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu hướng dẫn"})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": guides})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/app-guides
|
||||||
|
func CreateAppGuideHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if !isSuperAdmin(c) {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
var req Request
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.URL == "" {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
|
||||||
|
}
|
||||||
|
|
||||||
|
guide := models.AppGuide{
|
||||||
|
Title: req.Title,
|
||||||
|
URL: req.URL,
|
||||||
|
}
|
||||||
|
if err := db.Create(&guide).Error; err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Lưu tài liệu hướng dẫn thất bại"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "data": guide})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/app-guides/:id
|
||||||
|
func UpdateAppGuideHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if !isSuperAdmin(c) {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
|
||||||
|
}
|
||||||
|
|
||||||
|
idParam := c.Params("id")
|
||||||
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var guide models.AppGuide
|
||||||
|
if err := db.First(&guide, uint(id)).Error; err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu hướng dẫn"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
var req Request
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.URL == "" {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
|
||||||
|
}
|
||||||
|
|
||||||
|
guide.Title = req.Title
|
||||||
|
guide.URL = req.URL
|
||||||
|
|
||||||
|
if err := db.Save(&guide).Error; err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật tài liệu hướng dẫn thất bại"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "data": guide})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/app-guides/:id
|
||||||
|
func DeleteAppGuideHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if !isSuperAdmin(c) {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
|
||||||
|
}
|
||||||
|
|
||||||
|
idParam := c.Params("id")
|
||||||
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var guide models.AppGuide
|
||||||
|
if err := db.First(&guide, uint(id)).Error; err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu hướng dẫn"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Delete(&guide).Error; err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Xóa tài liệu hướng dẫn thất bại"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,4 +212,15 @@ type AppDownload struct {
|
|||||||
|
|
||||||
func (AppDownload) TableName() string { return "app_downloads" }
|
func (AppDownload) TableName() string { return "app_downloads" }
|
||||||
|
|
||||||
|
// AppGuide đại diện cho tài liệu/video hướng dẫn ứng dụng dành cho sinh viên
|
||||||
|
type AppGuide struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Title string `gorm:"column:title;size:512;not null" json:"title"`
|
||||||
|
URL string `gorm:"column:url;size:1024;not null" json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AppGuide) TableName() string { return "app_guides" }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func main() {
|
|||||||
|
|
||||||
api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(gormDB))
|
api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(gormDB))
|
||||||
api.Get("/public/guide-links", handlers.ListGuideLinksPublicHandler(gormDB))
|
api.Get("/public/guide-links", handlers.ListGuideLinksPublicHandler(gormDB))
|
||||||
|
api.Get("/public/app-guides", handlers.ListAppGuidesPublicHandler(gormDB))
|
||||||
|
|
||||||
// Management — yêu cầu đăng nhập staff
|
// Management — yêu cầu đăng nhập staff
|
||||||
staff := api.Group("", middleware.RequireStaff())
|
staff := api.Group("", middleware.RequireStaff())
|
||||||
@@ -215,6 +216,11 @@ func main() {
|
|||||||
staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(gormDB))
|
staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(gormDB))
|
||||||
staff.Delete("/app-downloads/:id", handlers.DeleteAppDownloadHandler(gormDB))
|
staff.Delete("/app-downloads/:id", handlers.DeleteAppDownloadHandler(gormDB))
|
||||||
|
|
||||||
|
staff.Get("/app-guides", handlers.ListAppGuidesHandler(gormDB))
|
||||||
|
staff.Post("/app-guides", handlers.CreateAppGuideHandler(gormDB))
|
||||||
|
staff.Put("/app-guides/:id", handlers.UpdateAppGuideHandler(gormDB))
|
||||||
|
staff.Delete("/app-guides/:id", handlers.DeleteAppGuideHandler(gormDB))
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user