feat: enhance menu management with three-level hierarchy support, additional metadata fields, and utility scripts for data extraction

This commit is contained in:
2026-07-22 10:30:42 +07:00
parent f37be85873
commit abdf9467e6
21 changed files with 859 additions and 2348 deletions
+13
View File
@@ -29,3 +29,16 @@ Use the following exact structure as a template:
</body>
</html>
```
### Dynamic Theme Component Inclusion
When writing or modifying Thymeleaf templates for the public frontend, NEVER hardcode paths for common layout components (such as header or footer) to a specific theme (e.g., `themes/umass/header`) or to the default fallback (e.g., `fragments/footer`) unless explicitly building an admin/manage page.
Instead, ALWAYS use the `activeTheme` variable to dynamically load the active theme's component.
**Correct Usage:**
`<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>`
`<footer th:replace="~{themes/__${activeTheme}__/footer :: footer}"></footer>`
**Incorrect Usage:**
`<div th:replace="~{themes/umass/header}"></div>`
`<footer th:replace="~{fragments/footer :: footer}"></footer>`
@@ -0,0 +1,168 @@
{
"info": {
"name": "SIS Lịch Đặt Khám API (Fixed)",
"description": "Collection tự động tạo từ tài liệu hướng dẫn API lịch đặt khám của bệnh viện S.I.S Cần Thơ.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "1. Get Token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json();",
"pm.globals.set(\"access_token\", jsonData.access_token);"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
},
{
"key": "Cookie",
"value": ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "grant_type",
"value": "client_credentials",
"type": "text"
},
{
"key": "client_id",
"value": "DigitalHealthSolutions_LabConn",
"type": "text"
},
{
"key": "client_secret",
"value": "AcEnBCaphERGOGYMaStATITEnaPETYpR",
"type": "text"
},
{
"key": "scope",
"value": "LabConn",
"type": "text"
}
]
},
"url": {
"raw": "{{base_url}}/connect/token",
"host": [
"{{base_url}}"
],
"path": [
"connect",
"token"
]
},
"description": "Lấy Access Token để chứng thực cho các API khác."
}
},
{
"name": "2. Danh sách bác sĩ có lịch làm việc",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "__tenant",
"value": "3a0f747c-57b6-753b-ba21-3987e8e93b9a"
},
{
"key": "Cookie",
"value": ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a"
}
],
"url": {
"raw": "{{base_url}}/api/app/his/doctors/work-schedules",
"host": [
"{{base_url}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"work-schedules"
]
},
"description": "Lấy danh sách các bác sĩ đang có lịch làm việc."
}
},
{
"name": "3. Lịch làm việc chi tiết bác sĩ",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "__tenant",
"value": "3a0f747c-57b6-753b-ba21-3987e8e93b9a"
},
{
"key": "Cookie",
"value": ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a"
}
],
"url": {
"raw": "{{base_url}}/api/app/his/doctors/:doctorCode/work-schedule-days",
"host": [
"{{base_url}}"
],
"path": [
"api",
"app",
"his",
"doctors",
":doctorCode",
"work-schedule-days"
],
"variable": [
{
"key": "doctorCode",
"value": "00492",
"description": "Mã bác sĩ (ví dụ 00492)"
}
]
},
"description": "Lấy chi tiết các ngày làm việc của một bác sĩ thông qua mã bác sĩ (DoctorCode)."
}
}
],
"variable": [
{
"key": "base_url",
"value": "https://dhs.sisvietnam.vn",
"type": "string"
}
]
}
+259
View File
@@ -0,0 +1,259 @@
{
"info": {
"_postman_id": "4c495f9e-cbb0-4bcc-9721-2c5cf9120247",
"name": "Web",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "6738901"
},
"item": [
{
"name": "get token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json();\r",
"pm.globals.set(\"access_token\", jsonData.access_token);"
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
},
{
"listen": "prerequest",
"script": {
"exec": [
""
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
},
{
"key": "Cookie",
"value": ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"type": "text",
"key": "grant_type",
"value": "client_credentials"
},
{
"type": "text",
"key": "client_id",
"value": "DigitalHealthSolutions_LabConn"
},
{
"type": "text",
"key": "client_secret",
"value": "AcEnBCaphERGOGYMaStATITEnaPETYpR"
},
{
"type": "text",
"key": "scope",
"value": "LabConn"
}
]
},
"url": {
"raw": "{{url}}/connect/token",
"host": [
"{{url}}"
],
"path": [
"connect",
"token"
]
}
},
"response": []
},
{
"name": "lich lam viec chi tiet bs",
"event": [
{
"listen": "test",
"script": {
"exec": [
""
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjdGNjFGNTMzMTAyRUI4OTlFQTc2Mjk3MDQ4MUVFQTQ2QTkxODdBMTYiLCJ4NXQiOiJmMkgxTXhBdXVKbnFkaWx3U0I3cVJxa1llaFkiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2Rocy5zaXN2aWV0bmFtLnZuLyIsImV4cCI6MTc4NDY5MTk1MSwiaWF0IjoxNzg0NjA1NTUxLCJhdWQiOiJEaWdpdGFsSGVhbHRoU29sdXRpb25zIiwic2NvcGUiOiJMYWJDb25uIiwianRpIjoiNWJkNTlhY2ItZjM5Yy00NGZhLTgyNmMtYTM4NjFkNDNmZGIxIiwib2lfcHJzdCI6IkRpZ2l0YWxIZWFsdGhTb2x1dGlvbnNfTGFiQ29ubiIsImNsaWVudF9pZCI6IkRpZ2l0YWxIZWFsdGhTb2x1dGlvbnNfTGFiQ29ubiIsIm9pX3Rrbl9pZCI6IjNhMjI5NGY0LTg0NGItMjJkZC05ZDNjLTQ4MjU2MmMyNjQxMCJ9.XwlnWLRrvuzr43CZjtOy6x9uIQqQZscjptTojX_ZlKA7uAuin6hn6ftgXyE8salvDmrETuS-1iWBiIkPaG2HAxjnf1rDtjw0MCfZBcqJC8Q1FOcskQhZn7GUUirVtEvv26t7LTHyCfjApwNtHnHmsfeIhYPbMb8A4PUikMABqd6qwgk8MLSA25endvloNmPUdLmov7xrPJIlePtYJorf0O-bi87WTMANDvMg6xRIY2CTPuDlzRTnsA5N4nDKcUzYHc75gwkC7U_5FLMhPulHB4ep8eEzUlU9TpageslzidSytBKwxGL_xM5Ts3UrPWKWt8dm5qCmw0B2Lnl0AvNttw",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "__tenant",
"value": "3a0f747c-57b6-753b-ba21-3987e8e93b9a"
},
{
"key": "Cookie",
"value": ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a"
},
{
"key": "Authorization",
"value": "Brear",
"type": "text",
"disabled": true
}
],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{url}}/api/app/his/doctors/:doctorCode/work-schedule-days",
"host": [
"{{url}}"
],
"path": [
"api",
"app",
"his",
"doctors",
":doctorCode",
"work-schedule-days"
],
"variable": [
{
"key": "doctorCode",
"value": "00492"
}
]
}
},
"response": []
},
{
"name": "get ds ich làm bs",
"event": [
{
"listen": "test",
"script": {
"exec": [
""
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [
{
"key": "__tenant",
"value": "3a0f747c-57b6-753b-ba21-3987e8e93b9a"
},
{
"key": "Cookie",
"value": ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a",
"disabled": true
},
{
"key": "Authorization",
"value": "Bearer {{access_token}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{url}}/api/app/his/doctors/work-schedules",
"host": [
"{{url}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"work-schedules"
]
}
},
"response": []
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"requests": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"requests": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "url",
"value": ""
}
]
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
[]
+15
View File
@@ -0,0 +1,15 @@
import sys
from pypdf import PdfReader
pdf_path = sys.argv[1]
output_path = sys.argv[2]
reader = PdfReader(pdf_path)
text = ""
for i, page in enumerate(reader.pages):
text += f"\n--- Page {i + 1} ---\n"
text += page.extract_text()
with open(output_path, "w", encoding="utf-8") as f:
f.write(text)
print(f"Extracted {len(reader.pages)} pages to {output_path}")
+68
View File
@@ -0,0 +1,68 @@
--- Page 1 ---
Api l ị ch đ ặ t khám - web
1. Get Token
Link:
Ph ươ ng th ứ c: POST
../connect/token
Header
Body x-www-form-urlencoded
Response
https://dhs.sisvietnam.vn
__tenant 3a0f747c-57b6-753b-ba21-
3987e8e93b9a
Content-Type application/json
Key Values
grant_type x client_credentials
client_id x DigitalHealthSolutions_LabConn
client_secret x AcEnBCaphERGOGYMaStATITEnaP
ETYpR
scope x LabConn
Thu ộ c tính B ắ t bu ộ c value
1{2 "access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdGNjFGNTMzMTAyRUI4OTlFQTc2Mjk3MDQ4MUVFQTQ2QTkxODdBMTYiLCJ4NXQiOiJmMkgxTXhBdXVKbnFkaWx3U0I3cVJxa1llaFkiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2Rocy5zaXN2aWV0bmFtLnZuLyIsImV4cCI6MTc4NDY5MjA1OCwiaWF0IjoxNzg0NjA1NjU4LCJhdWQiOiJEaWdpdGFsSGVhbHRoU29sdXRpb25zIiwic2NvcGUiOiJMYWJDb25uIiwianRpIjoiOWVlZDNhNzItZTcxZC00MWM4LWJhYTgtMzVlMTY5YmEwMzE5Iiwib2lfcHJzdCI6IkRpZ2l0YWxIZWFsdGhTb2x1dGlvbnNfTGFiQ29ubiIsImNsaWVudF9pZCI6IkRpZ2l0YWxIZWFsdGhTb2x1dGlvbnNfTGFiQ29ubiIsIm9pX3Rrbl9pZCI6IjNhMjI5NGY2LTI2NjMtNTQ0OC1iMjU4LWI5Yjk0YzYxYWQ2ZSJ9.R7GQQxWU7MqKnQMhaZPtIMAxwjkTklmPgoFF8aisAZTPq4z7TnMAL36tlKvLeazIKfE0KIVtdfi9AqXeIwXt6co4_-5i9MJ03QJvJVdqul9nGiXaRtuSUagPogclkZ9wZeZU4IHxSFS7WCKuuX-Rv4EjNP2H-Igr5ss5cqfw7mIgQLknIlQ7jQolxqeO9IJgDBH90lxnpaC0S8ZTg3S_6cv2kPG8s8FdqgodOJnFG-d7YFlS4WR9O_z0uRnBS9cIhCRaxoo1GqtzvLVPjeyJecy48DnrNsqYEwsNQx4hWTiHC_9rFlfKCIuWQnv7wKWkkNt-ZsUABkk5PZrtHeZVeg",
--- Page 2 ---
2. Danh sách bác s ĩ có l ị ch làm vi ệ c
Ph ươ ng th ứ c: GET
…/api/app/his/doctors/work-schedules
Header
Response
3 "token_type":"Bearer",4 "expires_in":863985}
__tenant 3a0f747c-57b6-753b-ba21-3987e8e93b9a
Authorization Bearer + Token
Key Vaules
1{2 "data":{3 "fromDate":"2026-07-15",4 "toDate":"2026-07-29",5 "doctors":[6 {7 "doctorId":"feaf54ac-e8c2-42a5-bb3b-65e9ba7fc2a7",8 "doctorCode":"00001",9 "fullName":"TS. BS. TR Ầ N CHÍ C Ư Ờ NG",10 "birthYear":null,11 "gender":null,12 "practicingCertificate":"001257/BYT-CCHN",13 "title":"TS. BS"14 }15 ]16 },17 "success":true,18 "statusCode":200,19 "timestamp":"2026-07-15T10:14:59.0784+07:00"20}
fromDate date Làm vi ệ c t ừ ngày
toDate date Làm vi ệ c đ ế n ngày
doctorId GUID Id bác s ĩ
doctorCode string Mã bác s ĩ
fullName string Tên bác s ĩ
birthYear string Gi ớ i tính
N ộ i dung Ki ể u d ữ li ệ u Mô t ả
--- Page 3 ---
3. L ị ch làm vi ệ c chi ti ế t bác s ĩ
Ph ươ ng th ứ c: GET
…/api/app/his/doctors/{DoctorCode}/work-schedule-days
Header
Response
gender string N ă m sinh
practicingCertificat
e
string Ch ứ ng ch ỉ hành ngh ề
title string Ch ứ c danh
__tenant 3a0f747c-57b6-753b-ba21-3987e8e93b9a
Authorization Bearer + Token
Key Vaules
1{2 "data":{3 "doctorId":"feaf54ac-e8c2-42a5-bb3b-65e9ba7fc2a7",4 "doctorCode":"00001",5 "fromDate":"2026-07-15",6 "toDate":"2026-07-29",7 "days":[8 {9 "date":"2026-07-15",10 "dayOfWeek":411 },12 {13 "date":"2026-07-20",14 "dayOfWeek":215 },16 {17 "date":"2026-07-27",18 "dayOfWeek":219 },20 {21 "date":"2026-07-29",22 "dayOfWeek":423 }24 ]25 },26 "success":true,27 "statusCode":200,28 "timestamp":"2026-07-15T08:59:26.2920087+07:00"29}
N ộ i dung Ki ể u d ữ li ệ uMô t ả
--- Page 4 ---
doctorId GUID ID bác s ĩ
doctorCode string Mã bác s ĩ
fromDate string Làm vi ệ c t ừ ngày
toDate string Làm vi ệ c đ ế n ngày
date string Ngày làm vi ệ c
dayOfWeek int Th ứ :
1: th ứ 2
2: th ứ 3
3: th ứ 4
4: th ứ 5
5: th ứ 6
File diff suppressed because it is too large Load Diff
@@ -1,53 +1,66 @@
<div layout:fragment="content-top">
<!-- Content Top (Banner 3 lớp kết hợp Title bài Post) -->
<div class="content-top">
<div class="lc--layout-container lc--full">
<div class="l--layout l--full">
<div class="lr--layout-region lr--main">
<div class="cc--component-container cc--homepage-hero">
<div class="c--component c--homepage-hero"
style="position: relative; overflow: hidden; height: 100vh; max-height: 38.125rem;">
<!-- ====== ẢNH NỀN (Dùng ảnh bài viết hoặc ảnh mặc định) ====== -->
<img class="static-fallback-image" th:src="${post.featuredImage != null and !post.featuredImage.isEmpty() ? post.featuredImage : 'https://res.cloudinary.com/ipufdhda/image/upload/v1784534008/BANNER-WEB-scaled_yeox4u.jpg'}" th:alt="${post.title}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1;" />
<!-- ====== TEXT OVERLAY (Nội dung chữ nằm trên ảnh, Hiển thị Tiêu đề bài viết) ====== -->
<!-- Thêm một lớp nền mờ (rgba đen) để chữ luôn hiển thị rõ ràng trên mọi hình ảnh -->
<div class="text-container centered"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 4; pointer-events: none; display: flex; align-items: center; justify-content: center; background-color: rgba(0, 0, 0, 0.4);">
<div class="slide-text-container-inner" style="pointer-events: auto; text-align: center; padding: 20px;">
<div class="f--field f--eyebrow" th:if="${post.category != null}" th:text="${post.category.name}" style="color: white; text-shadow: 0 2px 5px rgba(0,0,0,0.7); margin-bottom: 10px; font-weight: bold; text-transform: uppercase;">
University News
</div>
<div class="f--field f--page-title">
<h1 class="hero-post-title" th:text="${post.title}">Post Title</h1>
</div>
</div>
</div>
<style>
.hero-post-title {
color: white;
text-shadow: 0 2px 5px rgba(0,0,0,0.7);
line-height: 1.2;
margin: 0 auto;
max-width: 1000px;
font-size: 3.5rem;
}
@media (max-width: 1024px) {
.hero-post-title { font-size: 2.8rem; max-width: 800px; }
}
@media (max-width: 768px) {
.hero-post-title { font-size: 2.2rem; max-width: 600px; }
}
@media (max-width: 480px) {
.hero-post-title { font-size: 1.8rem; }
}
</style>
</div>
</div>
</div>
</div>
<div class="submenu-wrapper" style="left: -671.344px;width: 1920px;">
<button class="button-back">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 10" width="5" height="10">
<polygon fill-rule="evenodd" clip-rule="evenodd" fill="#881c1c" points="0,5 5,0 5,10 "></polygon>
</svg> Back </button>
<span class="navigation-title">VỀ BỆNH VIỆN</span>
<!-- Mobile Submenu Layout (Simple List) -->
<ul class="submenu mobile-only">
<li class="menu-item">
<a href="#">GIỚI THIỆU TỔNG QUAN</a>
</li>
<li class="menu-item">
<a href="#">LỊCH SỬ BỆNH VIỆN</a>
</li>
<li class="menu-item">
<a href="#">TẦM NHÌN - SỨ MỆNH - GIÁ TRỊ CỐT LÕI</a>
</li>
<li class="menu-item">
<a href="#">BAN LÃNH ĐẠO</a>
</li>
<li class="menu-item">
<a href="#">CÁC ĐƠN VỊ</a>
</li>
</ul>
<!-- Mega Menu Content Layout -->
<div class="mega-menu-content desktop-only">
<!-- Column 1: Info (Title and Text) -->
<div class="mega-col-info">
<h3>VỀ BỆNH VIỆN</h3>
<p style="text-transform: math-auto;">Bệnh viện Đột quỵ Quốc tế tự hào là đơn vị tiên phong trong việc cung cấp dịch vụ khám chữa bệnh chất lượng cao, mang lại sự tin cậy và an tâm cho bệnh nhân.</p>
</div>
<!-- Column 2: The actual Submenu Links -->
<div class="mega-col-links">
<ul class="submenu">
<li class="menu-item">
<a href="#">GIỚI THIỆU TỔNG QUAN</a>
</li>
<li class="menu-item">
<a href="#">LỊCH SỬ BỆNH VIỆN</a>
</li>
<li class="menu-item">
<a href="#">TẦM NHÌN - SỨ MỆNH - GIÁ TRỊ CỐT LÕI</a>
</li>
<li class="menu-item">
<a href="#">BAN LÃNH ĐẠO</a>
</li>
<li class="menu-item">
<a href="#">CÁC ĐƠN VỊ</a>
</li>
</ul>
</div>
<!-- Column 3: Featured Image -->
<div class="mega-col-image">
<!-- Try to use a hospital-like image if we can, or fallback to logo -->
<img src="/theme-assets/umass/images/logo.png" alt="Featured Image" style="object-fit: contain; background: white; padding: 20px;">
</div>
</div>
</div>
</div>
<!-- End Mega Menu Layout -->
</div>
Binary file not shown.
@@ -51,11 +51,13 @@ public class ManageMenuController {
}
@PostMapping("/{id}/items/add")
public String addMenuItem(@PathVariable Long id, @RequestParam String label, @RequestParam String url, @RequestParam(required = false) Long parentId, RedirectAttributes redirectAttributes) {
public String addMenuItem(@PathVariable Long id, @RequestParam String label, @RequestParam String url, @RequestParam(required = false) Long parentId, @RequestParam(required = false) String title, @RequestParam(required = false) String imageUrl, RedirectAttributes redirectAttributes) {
Menu menu = menuService.findById(id).orElseThrow(() -> new IllegalArgumentException("Invalid menu Id"));
MenuItem item = new MenuItem();
item.setLabel(label);
item.setUrl(url);
item.setTitle(title);
item.setImageUrl(imageUrl);
item.setMenu(menu);
item.setDisplayOrder(menu.getItems().size());
@@ -92,10 +94,12 @@ public class ManageMenuController {
}
@PostMapping("/{id}/items/{itemId}/update")
public String updateMenuItem(@PathVariable Long id, @PathVariable Long itemId, @RequestParam String label, @RequestParam String url, @RequestParam(required = false) Long parentId, @RequestParam(required = false) Integer displayOrder, RedirectAttributes redirectAttributes) {
public String updateMenuItem(@PathVariable Long id, @PathVariable Long itemId, @RequestParam String label, @RequestParam String url, @RequestParam(required = false) Long parentId, @RequestParam(required = false) Integer displayOrder, @RequestParam(required = false) String title, @RequestParam(required = false) String imageUrl, RedirectAttributes redirectAttributes) {
MenuItem item = menuItemRepository.findById(itemId).orElseThrow(() -> new IllegalArgumentException("Invalid item Id"));
item.setLabel(label);
item.setUrl(url);
item.setTitle(title);
item.setImageUrl(imageUrl);
if (displayOrder != null) {
item.setDisplayOrder(displayOrder);
}
@@ -43,6 +43,14 @@ public class MenuItem extends AbstractAuditingEntity<Long> {
@OrderBy("displayOrder ASC")
private java.util.Set<MenuItem> children = new java.util.LinkedHashSet<>();
@Size(max = 255)
@Column(name = "title", length = 255)
private String title;
@Size(max = 500)
@Column(name = "image_url", length = 500)
private String imageUrl;
@Column(name = "display_order")
private Integer displayOrder = 0;
@@ -103,6 +111,22 @@ public class MenuItem extends AbstractAuditingEntity<Long> {
this.displayOrder = displayOrder;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -11,7 +11,7 @@
</delete>
<insert tableName="sis_page">
<column name="id" valueSequenceNext="sequenceGenerator"/>
<column name="id" valueSequenceNext="sequence_generator"/>
<column name="title" value="Contact Us"/>
<column name="slug" value="contact-us"/>
<column name="content" value='{"time": 1721470000000,"blocks":[{"type":"header","data":{"text":"General Information","level":2}},{"type":"paragraph","data":{"text":"For General Campus Inquiries, please call the main switchboard at 413-545-0111. Operators are available from 8:00 a.m. to 5:00 p.m., Monday through Friday."}},{"type":"header","data":{"text":"Directories","level":2}},{"type":"paragraph","data":{"text":"Find contact information for academic departments, campus services, and other resources."}}],"version":"2.29.1"}'/>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260722000000-1" author="antigravity">
<addColumn tableName="sis_menu_item">
<column name="title" type="varchar(255)">
<constraints nullable="true"/>
</column>
<column name="image_url" type="varchar(500)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -42,4 +42,5 @@
<include file="config/liquibase/changelog/20260718100000_add_doctor_and_specialty.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718110000_seed_sample_doctors.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260720181500_seed_contact_us_page.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260722000000_add_fields_to_menu_item.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{fragments/layout}">
@@ -966,7 +966,7 @@
</div>
</main>
<footer th:replace="~{fragments/footer :: footer}"></footer>
<footer th:replace="~{themes/__${activeTheme}__/footer :: footer}"></footer>
@@ -1,5 +1,21 @@
<header id="l--main-header">
<h1 class="visually-hidden">The University of Massachusetts Amherst</h1>
<style>
body { overflow-x: hidden; }
/* Fix mega menu position and width via pure CSS */
.m--main-menu > li { position: static !important; }
#l--main-header .navigation-container,
#l--main-header #mobile-flyout-container,
#l--main-header .primary-navigation { position: static !important; }
.submenu-wrapper {
position: absolute !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
</style>
<h1 class="visually-hidden">Bệnh Viên Đa Khoa Quốc Tế S.I.S Cần Thơ</h1>
<div class="region region-header r--region r--header">
@@ -29,7 +29,7 @@
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
<!-- Header Fragment -->
<div th:replace="~{themes/umass/header}"></div>
<div th:replace="~{themes/__${activeTheme}__/header :: header}"></div>
<!-- Main Content Block -->
<main id="main-content" role="main">
@@ -39,7 +39,7 @@
</main>
<!-- Footer Fragment -->
<div th:replace="~{themes/umass/footer :: footer}"></div>
<div th:replace="~{themes/__${activeTheme}__/footer :: footer}"></div>
</div>
<!-- Core Scripts -->
@@ -107,7 +107,7 @@
style="display:none;visibility:hidden"></iframe></noscript>
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
<header th:replace="~{themes/umass/header :: header}"></header>
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
<main id="main-content">
<div class="l--content">
@@ -2737,7 +2737,7 @@
<footer th:replace="~{fragments/footer :: footer}"></footer>
<footer th:replace="~{themes/__${activeTheme}__/footer :: footer}"></footer>
</div>
@@ -61,11 +61,27 @@
<label for="url">URL</label>
<input type="text" class="form-control" id="url" name="url" placeholder="e.g. /" required>
</div>
<div class="form-group">
<label for="title">Title (Tùy chọn - Dùng cho Mega Menu)</label>
<input type="text" class="form-control" id="title" name="title" placeholder="VD: BỆNH VIỆN...">
</div>
<div class="form-group">
<label for="imageUrl">Image URL (Tùy chọn - Dùng cho Mega Menu)</label>
<input type="text" class="form-control" id="imageUrl" name="imageUrl" placeholder="VD: /images/banner.jpg">
</div>
<div class="form-group">
<label for="parentId">Parent Item (Optional)</label>
<select class="form-control" id="parentId" name="parentId">
<option value="">-- None (Top Level) --</option>
<option th:each="parentItem : ${menu.items}" th:if="${parentItem.parent == null}" th:value="${parentItem.id}" th:text="${parentItem.label}"></option>
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
<option th:value="${topOpt.id}" th:text="${topOpt.label}"></option>
<th:block th:each="childOpt : ${menu.items}" th:if="${childOpt.parent != null and childOpt.parent.id == topOpt.id}">
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}"></option>
<th:block th:each="grandChildOpt : ${menu.items}" th:if="${grandChildOpt.parent != null and grandChildOpt.parent.id == childOpt.id}">
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
</th:block>
</th:block>
</th:block>
</select>
</div>
<button type="submit" class="btn btn-primary btn-block">Add to Menu</button>
@@ -116,6 +132,24 @@
</form>
</div>
</li>
<!-- Grandchildren Items -->
<th:block th:each="grandchild : ${menu.items}" th:if="${grandchild.parent != null and grandchild.parent.id == child.id}">
<li class="list-group-item d-flex justify-content-between align-items-center bg-light" style="margin-left: 4rem; border-left: 4px solid #36b9cc;">
<div>
<strong th:text="${grandchild.label}">Link Text</strong>
<span class="badge badge-info ml-2">Sub-child</span>
<br>
<small class="text-muted" th:text="${grandchild.url}">URL</small>
</div>
<div>
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${grandchild.displayOrder}">0</span>
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${grandchild.id}">Edit</button>
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${grandchild.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</li>
</th:block>
</th:block>
</th:block>
</ul>
@@ -148,12 +182,26 @@
<label>URL</label>
<input type="text" class="form-control" name="url" th:value="${item.url}" required>
</div>
<div class="form-group">
<label>Title (Tùy chọn)</label>
<input type="text" class="form-control" name="title" th:value="${item.title}">
</div>
<div class="form-group">
<label>Image URL (Tùy chọn)</label>
<input type="text" class="form-control" name="imageUrl" th:value="${item.imageUrl}">
</div>
<div class="form-group">
<label>Parent Item (Optional)</label>
<select class="form-control" name="parentId">
<option value="">-- None (Top Level) --</option>
<th:block th:each="parentOpt : ${menu.items}" th:if="${parentOpt.parent == null and parentOpt.id != item.id}">
<option th:value="${parentOpt.id}" th:text="${parentOpt.label}" th:selected="${item.parent != null and item.parent.id == parentOpt.id}"></option>
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null and topOpt.id != item.id}">
<option th:value="${topOpt.id}" th:text="${topOpt.label}" th:selected="${item.parent != null and item.parent.id == topOpt.id}"></option>
<th:block th:each="childOpt : ${menu.items}" th:if="${childOpt.parent != null and childOpt.parent.id == topOpt.id and childOpt.id != item.id}">
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}" th:selected="${item.parent != null and item.parent.id == childOpt.id}"></option>
<th:block th:each="grandChildOpt : ${menu.items}" th:if="${grandChildOpt.parent != null and grandChildOpt.parent.id == childOpt.id and grandChildOpt.id != item.id}">
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}" th:selected="${item.parent != null and item.parent.id == grandChildOpt.id}"></option>
</th:block>
</th:block>
</th:block>
</select>
</div>
@@ -72,8 +72,8 @@
const rect = menu.getBoundingClientRect();
// Shift wrapper exactly to the left edge of the window
wrapper.style.left = '-' + rect.left + 'px';
// Set width to exact window width
wrapper.style.width = window.innerWidth + 'px';
// Set width to exact window width (excluding scrollbar to prevent horizontal scroll)
wrapper.style.width = document.documentElement.clientWidth + 'px';
}
});
}
@@ -87,10 +87,45 @@
megamenus.forEach(menu => {
menu.addEventListener('mouseenter', adjustMegaMenus);
});
// Mega Menu Level 2 Tab Logic
// Initialize first items
document.querySelectorAll('.mc--main-menu .megamenu.menu-item').forEach(menu => {
const firstL2 = menu.querySelector('.l2-hover-target');
if (firstL2) {
firstL2.classList.add('active');
const targetId = firstL2.getAttribute('data-target');
const targetContent = document.getElementById(targetId);
if (targetContent) targetContent.style.display = 'block';
}
});
document.querySelectorAll('.l2-hover-target').forEach(target => {
target.addEventListener('mouseenter', function() {
const wrapper = this.closest('.mega-menu-content');
if (!wrapper) return;
// Remove active from all L2 in this menu
wrapper.querySelectorAll('.l2-hover-target').forEach(el => el.classList.remove('active'));
// Hide all L3 content in this menu
wrapper.querySelectorAll('.l3-content').forEach(el => el.style.display = 'none');
// Activate current
this.classList.add('active');
const contentId = this.getAttribute('data-target');
const content = document.getElementById(contentId);
if (content) {
content.style.display = 'block';
}
});
});
});
</script>
<style>
body { overflow-x: hidden; }
@media (min-width: 1024px) {
/* Arrow Rotation */
@@ -195,6 +230,80 @@
object-fit: cover;
}
/* Middle Column for Tabbed Level 2 */
.mega-col-level2 {
flex: 1.2;
border-left: 1px solid #d1d5db;
padding-left: 30px;
max-height: 245px;
overflow-y: auto;
}
.mega-col-level2::-webkit-scrollbar { width: 6px; }
.mega-col-level2::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 10px; }
.mega-col-level2::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 10px; }
.submenu-l2 {
list-style: none;
padding: 0;
margin: 0;
}
.submenu-l2 .menu-item-l2 {
padding: 0;
margin: 0;
}
.submenu-l2 .menu-item-l2 a {
display: block;
padding: 12px 15px;
color: #111827 !important;
text-decoration: none;
font-weight: 600;
font-size: 14px;
border-radius: 6px;
margin-bottom: 4px;
transition: all 0.2s;
}
.submenu-l2 .menu-item-l2.active a {
background-color: #881c1c !important;
color: #ffffff !important;
}
.submenu-l2 .menu-item-l2:not(.active) a:hover {
background-color: #f3f4f6 !important;
color: #111827 !important;
}
/* Right Column for Level 3 Links */
.mega-col-level3-container {
flex: 1;
border-left: 1px solid #d1d5db;
padding-left: 30px;
max-height: 245px;
overflow-y: auto;
}
.mega-col-level3-container::-webkit-scrollbar { width: 6px; }
.mega-col-level3-container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 10px; }
.mega-col-level3-container::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 10px; }
.submenu-l3 {
list-style: none;
padding: 0;
margin: 0;
}
.submenu-l3 .menu-item-l3 a {
display: block;
padding: 12px 15px;
color: #111827 !important;
text-decoration: none;
font-weight: 600;
font-size: 14px;
border-radius: 6px;
margin-bottom: 4px;
transition: all 0.2s;
}
.submenu-l3 .menu-item-l3 a:hover {
background-color: var(--color-brand) !important;
color: var(--color-white) !important;
}
/* Hide the mobile back button and title on desktop */
.mc--main-menu .megamenu.menu-item .submenu-wrapper .button-back,
.mc--main-menu .megamenu.menu-item .submenu-wrapper .navigation-title {
@@ -203,18 +312,19 @@
}
</style>
<header id="l--main-header">
<h1 class="visually-hidden">The University of Massachusetts Amherst</h1>
<div class="region region-header r--region r--header">
<h1 class="visually-hidden">Bệnh viện Đa Khoa Quốc Tế S.I.S Cần Thơ</h1>
<div class="region region-header r--region r--header" style="background: linear-gradient(0deg, transparent, #000) !important;">
<section data-component-id="umass_base:site-header" aria-label="Site Header">
<div data-component-id="umass_base:header-branding">
<a href="/">
<img th:src="@{/theme-assets/umass/images/logo_white.png}" alt="Sisvietnamvn" style="height: auto; max-width: 250px;" />
</a>
</div>
<div class="header-right" style="display: flex; flex-direction: column; align-items: start; flex-grow: 1;">
<div class="header-hospital-name" style="color: var(--color-brand); font-size: 1.4rem; font-weight: bold; text-transform: uppercase; margin-bottom: 5px; -webkit-text-stroke: 1px white;">
Bệnh viện đa khoa quốc tế S.I.S Cần thơ
<div class="header-left" style="display: flex; flex-direction: column; align-items: center; width: max-content;">
<div data-component-id="umass_base:header-branding">
<a href="/">
<img th:src="@{/theme-assets/umass/images/logo_white.png}" alt="Sisvietnamvn" style="height: auto; max-width: 250px;" />
</a>
</div>
<div class="header-hospital-name" style="color: var(--color-brand);font-size: 1.4rem;font-weight: 900;text-transform: uppercase;margin-bottom: 5px;text-shadow: -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 1px 0 white; white-space: nowrap; text-align: center;">
Bệnh viện đa khoa quốc tế <br>S.I.S Cần thơ
</div>
</div>
<div class="navigation-container" style="width: 100%; display: flex; justify-content: flex-end;">
<div id="mobile-flyout-container">
<div class="primary-navigation">
@@ -244,7 +354,7 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 10" width="5" height="10">
<polygon fill-rule="evenodd" clip-rule="evenodd" fill="#881c1c" points="0,5 5,0 5,10 "></polygon>
</svg> Back </button>
<span class="navigation-title" th:text="${item.label}">Menu Item</span>
<span class="navigation-title" th:text="${not #strings.isEmpty(item.title) ? item.title : item.label}">Menu Item</span>
<!-- Mobile Submenu Layout (Simple List) -->
<ul class="submenu mobile-only">
@@ -257,25 +367,39 @@
<div class="mega-menu-content desktop-only">
<!-- Column 1: Info (Title and Text) -->
<div class="mega-col-info">
<h3 th:text="${item.label}">Về Bệnh viện</h3>
<p th:if="${item.label == 'Về Bệnh viện' or item.label == 'VỀ BỆNH VIỆN'}" style="text-transform: math-auto;">Bệnh viện Đột quỵ Quốc tế tự hào là đơn vị tiên phong trong việc cung cấp dịch vụ khám chữa bệnh chất lượng cao, mang lại sự tin cậy và an tâm cho bệnh nhân.</p>
<h3 th:text="${not #strings.isEmpty(item.title) ? item.title : item.label}">BỆNH VIỆN ĐA KHOA QUỐC TẾ S.I.S CẦN THƠ</h3>
<p th:if="${item.label == 'Về Bệnh viện' or item.label == 'VỀ BỆNH VIỆN'}" style="text-transform: math-auto;">Bệnh viện Đa Khoa Quốc Tế S.I.S Cần Thơ tự hào là đơn vị tiên phong trong việc cung cấp dịch vụ khám chữa bệnh chất lượng cao, mang lại sự tin cậy và an tâm cho bệnh nhân.</p>
<p th:if="${item.label == 'Chuyên khoa' or item.label == 'CHUYÊN KHOA'}" style="text-transform: math-auto;">Khám phá các chuyên khoa hàng đầu với đội ngũ bác sĩ chuyên môn cao, trang thiết bị y tế hiện đại đạt tiêu chuẩn quốc tế.</p>
<p th:if="${item.label != 'Về Bệnh viện' and item.label != 'VỀ BỆNH VIỆN' and item.label != 'Chuyên khoa' and item.label != 'CHUYÊN KHOA'}" style="text-transform: math-auto;">Khám phá thêm thông tin chi tiết về các dịch vụ, chuyên gia và hoạt động của chúng tôi để bảo vệ sức khỏe của bạn.</p>
</div>
<!-- Column 2: The actual Submenu Links -->
<div class="mega-col-links">
<ul class="submenu">
<li class="menu-item" th:each="child : ${item.children}">
<!-- Column 2: Level 2 Links -->
<div class="mega-col-level2" th:if="${not #lists.isEmpty(item.children)}">
<ul class="submenu-l2">
<li class="menu-item-l2 l2-hover-target" th:each="child : ${item.children}" th:attr="data-target='l3-' + ${item.id} + '-' + ${child.id}">
<a th:href="${child.url}" th:text="${child.label}">Child Item</a>
</li>
</ul>
</div>
<!-- Column 3: Featured Image -->
<div class="mega-col-image">
<!-- Try to use a hospital-like image if we can, or fallback to logo -->
<img th:src="@{/theme-assets/umass/images/logo.png}" alt="Featured Image" style="object-fit: contain; background: white; padding: 20px;"/>
<!-- Column 3: Level 3 Links OR Fallback Image -->
<div class="mega-col-level3-container" th:if="${not #lists.isEmpty(item.children)}">
<!-- Render each Level 3 target content -->
<th:block th:each="child : ${item.children}">
<ul th:id="'l3-' + ${item.id} + '-' + ${child.id}" class="submenu-l3 l3-content" style="display: none;">
<th:block th:if="${not #lists.isEmpty(child.children)}">
<li class="menu-item-l3" th:each="grandchild : ${child.children}">
<a th:href="${grandchild.url}" th:text="${grandchild.label}">Grandchild Item</a>
</li>
</th:block>
<th:block th:if="${#lists.isEmpty(child.children)}">
<div class="mega-col-image" style="padding-left: 0; border: none; flex: 1;">
<img th:if="${not #strings.isEmpty(item.imageUrl)}" th:src="${item.imageUrl}" style="object-fit: cover; border-radius: 8px; width: 100%;"/>
<img th:if="${#strings.isEmpty(item.imageUrl)}" th:src="@{/theme-assets/umass/images/logo.png}" style="object-fit: contain; background: white; padding: 20px; width: 100%;"/>
</div>
</th:block>
</ul>
</th:block>
</div>
</div>
<!-- End Mega Menu Layout -->
@@ -303,7 +427,6 @@
<span class="hamburger-inner"></span>
</span>
</button>
</div>
</section>
<div data-component-id="umass_base:header-search">
<div id="search-flyout-container">