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

This commit is contained in:
2026-07-01 15:08:31 +07:00
parent 7a9c7a93ff
commit 7e84ddeaf9
8 changed files with 443 additions and 20 deletions

View File

@@ -358,7 +358,7 @@
const listDiv = document.getElementById('guides-list');
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');
const res = await response.json();

View File

@@ -287,6 +287,14 @@ export interface AppDownloadItem {
platform: string;
}
export interface AppGuideItem {
id: number;
createdAt: string;
updatedAt: string;
title: string;
url: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
@@ -434,6 +442,33 @@ export const api = {
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: {
page: number;
pageSize: number;

View File

@@ -1,5 +1,5 @@
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';
// Icons
@@ -54,7 +54,7 @@ export const ApplicationsTab: React.FC = () => {
const [appsLoading, setAppsLoading] = useState(true);
// Guides state
const [guides, setGuides] = useState<GuideLinkItem[]>([]);
const [guides, setGuides] = useState<AppGuideItem[]>([]);
const [guidesLoading, setGuidesLoading] = useState(true);
// App Modal states
@@ -68,7 +68,7 @@ export const ApplicationsTab: React.FC = () => {
// Guide Modal states
const [showGuideModal, setShowGuideModal] = useState(false);
const [editingGuide, setEditingGuide] = useState<GuideLinkItem | null>(null);
const [editingGuide, setEditingGuide] = useState<AppGuideItem | null>(null);
const [guideTitle, setGuideTitle] = useState('');
const [guideUrl, setGuideUrl] = useState('');
const [guideSaving, setGuideSaving] = useState(false);
@@ -88,10 +88,10 @@ export const ApplicationsTab: React.FC = () => {
const fetchGuides = async () => {
try {
setGuidesLoading(true);
const res = await api.listGuideLinks();
const res = await api.listAppGuides();
setGuides(res.data || []);
} 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 {
setGuidesLoading(false);
}
@@ -117,7 +117,6 @@ export const ApplicationsTab: React.FC = () => {
setAppName(app.name);
setAppDescription(app.description);
setAppDownloadUrl(app.downloadUrl);
// Standardize representation to 'MacOS'
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
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.');
return;
}
// Force write first letters uppercase
let finalPlatform = appPlatform;
if (finalPlatform.toLowerCase() === 'macos') {
finalPlatform = 'MacOS';
@@ -168,7 +166,7 @@ export const ApplicationsTab: React.FC = () => {
setShowGuideModal(true);
};
const handleOpenEditGuide = (guide: GuideLinkItem) => {
const handleOpenEditGuide = (guide: AppGuideItem) => {
setEditingGuide(guide);
setGuideTitle(guide.title);
setGuideUrl(guide.url);
@@ -183,9 +181,9 @@ export const ApplicationsTab: React.FC = () => {
try {
setGuideSaving(true);
if (editingGuide) {
await api.updateGuideLink(editingGuide.id, guideTitle.trim(), guideUrl.trim());
await api.updateAppGuide(editingGuide.id, guideTitle.trim(), guideUrl.trim());
} else {
await api.createGuideLink(guideTitle.trim(), guideUrl.trim());
await api.createAppGuide(guideTitle.trim(), guideUrl.trim());
}
setShowGuideModal(false);
await fetchGuides();
@@ -197,9 +195,9 @@ export const ApplicationsTab: React.FC = () => {
};
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 {
await api.deleteGuideLink(id);
await api.deleteAppGuide(id);
await fetchGuides();
} catch (err: any) {
alert(err.message || 'Xóa hướng dẫn thất bại');
@@ -229,7 +227,6 @@ export const ApplicationsTab: React.FC = () => {
)}
</header>
{/* Sub-tab Switch Header */}
<div style={{ padding: '0 2rem' }}>
<div style={{
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" />
<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>
<p style={{ fontSize: '0.95rem' }}>Chưa tài liệu hướng dẫn sinh viê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
Thêm hướng dẫn sinh viên đu tiên
</button>
)}
</div>
@@ -403,7 +400,7 @@ export const ApplicationsTab: React.FC = () => {
<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: '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>
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
</tr>
@@ -590,7 +587,7 @@ export const ApplicationsTab: React.FC = () => {
<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>
<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">&times;</button>
</div>
<div className="modal-body" style={{ padding: '1.5rem' }}>
@@ -601,7 +598,7 @@ export const ApplicationsTab: React.FC = () => {
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"
placeholder="VD: Hướng dẫn cài đặt và chạy ứng dụng cho sinh viên"
style={{
width: '100%',
padding: '0.6rem 0.85rem',

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api';
import { api, type GuideLinkItem } from '../api';
import type { StatsResponse } from '../api';
import { useAuth } from '../auth/AuthContext';
interface DashboardTabProps {
onNavigate: (tab: string) => void;
@@ -37,10 +38,36 @@ const IconAlert = () => (
);
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 [loading, setLoading] = useState(true);
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(() => {
const fetchStats = async () => {
try {
@@ -54,8 +81,55 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
}
};
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 (
<div className="tab-page">
<div className="tab-page-toolbar">
@@ -138,9 +212,179 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
</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 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>
{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>
);
};

View File

@@ -68,6 +68,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.SystemSetting{},
&models.GuideLink{},
&models.AppDownload{},
&models.AppGuide{},
); err != nil {
return err
}

View 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})
}
}

View File

@@ -212,4 +212,15 @@ type AppDownload struct {
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" }

View File

@@ -109,6 +109,7 @@ func main() {
api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(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
staff := api.Group("", middleware.RequireStaff())
@@ -215,6 +216,11 @@ func main() {
staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(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() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()