From 7e84ddeaf9588a5a633db7abaa76305c4772c166 Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Wed, 1 Jul 2026 15:08:31 +0700 Subject: [PATCH] fix ui --- landing_page/index.html | 2 +- management/src/api.ts | 35 +++ management/src/components/ApplicationsTab.tsx | 33 ++- management/src/components/DashboardTab.tsx | 246 +++++++++++++++++- server/internal/db/db.go | 1 + .../internal/handlers/handlers_app_guides.go | 129 +++++++++ server/internal/models/models.go | 11 + server/main.go | 6 + 8 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 server/internal/handlers/handlers_app_guides.go diff --git a/landing_page/index.html b/landing_page/index.html index 2722ffb..2c5620e 100644 --- a/landing_page/index.html +++ b/landing_page/index.html @@ -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(); diff --git a/management/src/api.ts b/management/src/api.ts index 6a8c714..fcd7bc3 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -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 { 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; diff --git a/management/src/components/ApplicationsTab.tsx b/management/src/components/ApplicationsTab.tsx index 0986717..b13478b 100644 --- a/management/src/components/ApplicationsTab.tsx +++ b/management/src/components/ApplicationsTab.tsx @@ -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([]); + const [guides, setGuides] = useState([]); 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(null); + const [editingGuide, setEditingGuide] = useState(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 = () => { )} - {/* Sub-tab Switch Header */}
{ -

Chưa có tài liệu hướng dẫn nào.

+

Chưa có tài liệu hướng dẫn sinh viên nào.

{isSuperAdmin && ( )}
@@ -403,7 +400,7 @@ export const ApplicationsTab: React.FC = () => { Loại tài liệu - Tiêu đề hướng dẫn + Tiêu đề hướng dẫn sinh viên Đường dẫn liên kết {isSuperAdmin && Thao tác} @@ -590,7 +587,7 @@ export const ApplicationsTab: React.FC = () => {
setShowGuideModal(false)}>
e.stopPropagation()}>
-

{editingGuide ? 'Cập nhật hướng dẫn' : 'Thêm tài liệu hướng dẫn'}

+

{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'}

@@ -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', diff --git a/management/src/components/DashboardTab.tsx b/management/src/components/DashboardTab.tsx index 7b6710e..00197bf 100644 --- a/management/src/components/DashboardTab.tsx +++ b/management/src/components/DashboardTab.tsx @@ -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 = ({ onNavigate }) => { + const { staff } = useAuth(); + const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com'; + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Guide links (teacher guides) state + const [links, setLinks] = useState([]); + const [linksLoading, setLinksLoading] = useState(true); + + // Modal states + const [showModal, setShowModal] = useState(false); + const [editingLink, setEditingLink] = useState(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 = ({ 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 (
@@ -138,9 +212,179 @@ export const DashboardTab: React.FC = ({ onNavigate }) => {
+ +
+
+

+ + + + + + + Tài liệu nội bộ (Giáo viên) +

+ {isSuperAdmin && ( + + )} +
+ + {linksLoading && ( +
+
+
+ )} + + {!linksLoading && links.length === 0 && ( +

Chưa có tài liệu hướng dẫn nào được cấu hình.

+ )} + + {!linksLoading && links.length > 0 && ( +
+ {links.map((link) => ( +
{ + 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'; + }} + > + e.currentTarget.style.color = 'var(--accent)'} + onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-primary)'} + > + + + + + {link.title} + + {isSuperAdmin && ( +
+ + +
+ )} +
+ ))} +
+ )} +
)}
+ + {showModal && ( +
setShowModal(false)}> +
e.stopPropagation()}> +
+

{editingLink ? 'Cập nhật tài liệu' : 'Thêm tài liệu hướng dẫn'}

+ +
+
+
+ + +
+
+
+ + +
+
+
+ )}
); }; diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 4db04e4..df418a0 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -68,6 +68,7 @@ func AutoMigrate(db *gorm.DB) error { &models.SystemSetting{}, &models.GuideLink{}, &models.AppDownload{}, + &models.AppGuide{}, ); err != nil { return err } diff --git a/server/internal/handlers/handlers_app_guides.go b/server/internal/handlers/handlers_app_guides.go new file mode 100644 index 0000000..1ed6642 --- /dev/null +++ b/server/internal/handlers/handlers_app_guides.go @@ -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}) + } +} diff --git a/server/internal/models/models.go b/server/internal/models/models.go index 4e17b00..9aaeb81 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -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" } + diff --git a/server/main.go b/server/main.go index eca9d0a..01ac6a2 100644 --- a/server/main.go +++ b/server/main.go @@ -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()