feat: implement specialty content module with expanded Page entity fields, database migrations, and data import utilities

This commit is contained in:
2026-07-28 19:20:56 +07:00
parent 01a3b83672
commit 186f70ec32
429 changed files with 52283 additions and 35 deletions
@@ -0,0 +1,16 @@
package com.sisvietnamvn.web.config;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import com.sisvietnamvn.web.repository.PageRepository;
@Component
public class DebugRunner implements CommandLineRunner {
@Autowired
private PageRepository pageRepository;
@Override
public void run(String... args) {
long count = pageRepository.findAll().stream().filter(p -> "SPECIALTY_DETAIL".equals(p.getLayout().name())).count();
System.out.println("TOTAL_SPECIALTIES_IN_DB: " + count);
}
}
@@ -0,0 +1,73 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Menu;
import com.sisvietnamvn.web.domain.MenuItem;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageLayout;
import com.sisvietnamvn.web.repository.MenuItemRepository;
import com.sisvietnamvn.web.repository.MenuRepository;
import com.sisvietnamvn.web.service.PageService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class MenuSeeder implements CommandLineRunner {
private final MenuRepository menuRepository;
private final MenuItemRepository menuItemRepository;
private final PageService pageService;
public MenuSeeder(MenuRepository menuRepository, MenuItemRepository menuItemRepository, PageService pageService) {
this.menuRepository = menuRepository;
this.menuItemRepository = menuItemRepository;
this.pageService = pageService;
}
@Override
@org.springframework.transaction.annotation.Transactional
public void run(String... args) {
Menu primaryMenu = menuRepository.findAll().stream()
.filter(m -> m.getName() != null && m.getName().trim().equals("primaryMenu"))
.findFirst().orElse(null);
if (primaryMenu == null) return;
MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> i.getLabel() != null && "CHUYÊN KHOA".equalsIgnoreCase(i.getLabel().trim()))
.findFirst().orElse(null);
if (chuyenKhoa == null) return;
List<Page> specialties = pageService.findAll().stream()
.filter(p -> PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.collect(Collectors.toList());
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
.max().orElse(0);
for (Page spec : specialties) {
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.anyMatch(i -> (i.getLabel() != null && i.getLabel().equalsIgnoreCase(spec.getTitle())) ||
(i.getTitle() != null && i.getTitle().equalsIgnoreCase(spec.getTitle())));
if (!exists) {
maxOrder++;
MenuItem newItem = new MenuItem();
newItem.setMenu(primaryMenu);
newItem.setParent(chuyenKhoa);
newItem.setLabel(spec.getTitle()); // Set label
newItem.setTitle(spec.getTitle()); // Set title as well
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
newItem.setDisplayOrder(maxOrder);
menuItemRepository.save(newItem);
System.out.println("ADDED MENU ITEM: " + spec.getTitle());
}
}
}
}
@@ -55,7 +55,7 @@ public class SecurityConfiguration {
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**",
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**", "/chuyen-khoa", "/chuyen-khoa/**",
"/Đào tạo tại UMC_files/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
@@ -0,0 +1,19 @@
package com.sisvietnamvn.web.controller;
import com.sisvietnamvn.web.repository.PageRepository;
import com.sisvietnamvn.web.domain.Page;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
@RestController
public class DebugController {
@Autowired
private PageRepository pageRepository;
@GetMapping("/api/debug-page")
public String debugPage(@RequestParam String slug) {
Page page = pageRepository.findBySlug(slug).orElse(null);
if (page == null) return "PAGE NOT FOUND";
String content = page.getContent();
return "CONTENT_LENGTH: " + (content == null ? "NULL" : content.length()) + "\nCONTENT_PREVIEW: " + (content == null ? "NULL" : content);
}
}
@@ -132,6 +132,44 @@ public class PageController {
@GetMapping("/specialty")
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
@GetMapping("/chuyen-khoa")
public String getSpecialtyList(@org.springframework.web.bind.annotation.RequestParam(required = false) String category, Model model) {
List<Page> allSpecialties = pageService.findAll().stream()
.filter(p -> com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.filter(p -> com.sisvietnamvn.web.domain.PageStatus.PUBLISHED.equals(p.getStatus()) || com.sisvietnamvn.web.domain.PageStatus.DRAFT.equals(p.getStatus()))
.sorted((a, b) -> {
String ta = a.getTitle() != null ? a.getTitle() : "";
String tb = b.getTitle() != null ? b.getTitle() : "";
return ta.compareToIgnoreCase(tb);
})
.collect(java.util.stream.Collectors.toList());
java.util.function.Predicate<Page> catFilter = p -> true;
if ("khoa-lam-sang".equals(category)) {
catFilter = p -> "KHOA_LAM_SANG".equals(p.getSpecialtyCategory());
} else if ("khoa-can-lam-sang".equals(category)) {
catFilter = p -> "KHOA_CAN_LAM_SANG".equals(p.getSpecialtyCategory());
} else if ("khoa-ho-tro-lam-sang".equals(category)) {
catFilter = p -> "KHOA_HO_TRO_LAM_SANG".equals(p.getSpecialtyCategory());
}
List<Page> filtered = allSpecialties.stream().filter(catFilter).collect(java.util.stream.Collectors.toList());
model.addAttribute("specialties", filtered);
model.addAttribute("currentCategory", category != null ? category : "all");
model.addAttribute("countAll", allSpecialties.size());
model.addAttribute("countLamSang", allSpecialties.stream().filter(p -> "KHOA_LAM_SANG".equals(p.getSpecialtyCategory())).count());
model.addAttribute("countCanLamSang", allSpecialties.stream().filter(p -> "KHOA_CAN_LAM_SANG".equals(p.getSpecialtyCategory())).count());
model.addAttribute("countHoTro", allSpecialties.stream().filter(p -> "KHOA_HO_TRO_LAM_SANG".equals(p.getSpecialtyCategory())).count());
return "pages/specialty-list";
}
@GetMapping("/chuyen-khoa/{slug}")
public String getSpecialtyDetail(@PathVariable String slug, Model model) {
model.addAttribute("activeTheme", "umass"); // Consistent with other routes
return renderPage(pageService.findBySlug(slug), model);
}
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.TagRepository tagRepository;
@org.springframework.beans.factory.annotation.Autowired
@@ -174,6 +212,54 @@ public class PageController {
return "Seeded " + items.size() + " posts!";
}
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.MenuItemRepository menuItemRepository;
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.MenuRepository menuRepository;
@GetMapping("/run-add-specialties")
@org.springframework.web.bind.annotation.ResponseBody
@org.springframework.transaction.annotation.Transactional
public String addSpecialtiesToMenu() {
com.sisvietnamvn.web.domain.Menu primaryMenu = menuRepository.findByName("primaryMenu").orElse(null);
if (primaryMenu == null) return "Primary menu not found";
com.sisvietnamvn.web.domain.MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> "CHUYÊN KHOA".equalsIgnoreCase(i.getTitle()))
.findFirst().orElse(null);
if (chuyenKhoa == null) return "CHUYÊN KHOA menu item not found";
List<com.sisvietnamvn.web.domain.Page> specialties = pageService.findAll().stream()
.filter(p -> com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.collect(java.util.stream.Collectors.toList());
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
.max().orElse(0);
int count = 0;
for (com.sisvietnamvn.web.domain.Page spec : specialties) {
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.anyMatch(i -> i.getTitle().equalsIgnoreCase(spec.getTitle()));
if (!exists) {
maxOrder++;
com.sisvietnamvn.web.domain.MenuItem newItem = new com.sisvietnamvn.web.domain.MenuItem();
newItem.setMenu(primaryMenu);
newItem.setParent(chuyenKhoa);
newItem.setTitle(spec.getTitle());
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
newItem.setDisplayOrder(maxOrder);
menuItemRepository.save(newItem);
count++;
}
}
return "Added " + count + " specialties to CHUYÊN KHOA menu.";
}
@GetMapping("/doctor")
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctor(Model model) {
@@ -273,24 +359,42 @@ public class PageController {
}
// Parse Editor.js content JSON to extract blocks
List<Map<String, Object>> blocks = Collections.emptyList();
List<Map<String, Object>> blocks = new java.util.ArrayList<>();
if (page.getContent() != null && !page.getContent().trim().isEmpty()) {
try {
Map<String, Object> editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) block.get("data");
if (data != null && data.containsKey("id")) {
String snippetId = (String) data.get("id");
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
String contentTrimmed = page.getContent().trim();
if (contentTrimmed.startsWith("{")) {
try {
Map<String, Object> editorData = objectMapper.readValue(contentTrimmed, new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) block.get("data");
if (data != null && data.containsKey("id")) {
String snippetId = (String) data.get("id");
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
}
}
}
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
// Fallback to raw HTML on error
Map<String, Object> rawBlock = new java.util.HashMap<>();
rawBlock.put("type", "raw");
Map<String, Object> data = new java.util.HashMap<>();
data.put("html", contentTrimmed);
rawBlock.put("data", data);
blocks.add(rawBlock);
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
} else {
// Fallback to raw HTML
Map<String, Object> rawBlock = new java.util.HashMap<>();
rawBlock.put("type", "raw");
Map<String, Object> data = new java.util.HashMap<>();
data.put("html", contentTrimmed);
rawBlock.put("data", data);
blocks.add(rawBlock);
}
}
@@ -301,6 +405,10 @@ public class PageController {
return "pages/contact-us";
}
if (com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(page.getLayout())) {
return "pages/specialty-detail";
}
return "page";
}
@@ -58,6 +58,21 @@ public class Page extends AbstractAuditingEntity<Long> {
@Column(name = "layout", length = 20, nullable = false)
private PageLayout layout = PageLayout.STANDARD;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "contact_address", length = 500)
private String contactAddress;
@Column(name = "contact_phone", length = 100)
private String contactPhone;
@Column(name = "hero_image", length = 1000)
private String heroImage;
@Column(name = "specialty_category", length = 50)
private String specialtyCategory;
// --- Getters and Setters ---
@Override
@@ -133,6 +148,46 @@ public class Page extends AbstractAuditingEntity<Long> {
this.layout = layout;
}
public String getContactEmail() {
return contactEmail;
}
public void setContactEmail(String contactEmail) {
this.contactEmail = contactEmail;
}
public String getContactAddress() {
return contactAddress;
}
public void setContactAddress(String contactAddress) {
this.contactAddress = contactAddress;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getHeroImage() {
return heroImage;
}
public void setHeroImage(String heroImage) {
this.heroImage = heroImage;
}
public String getSpecialtyCategory() {
return specialtyCategory;
}
public void setSpecialtyCategory(String specialtyCategory) {
this.specialtyCategory = specialtyCategory;
}
// --- equals, hashCode, toString ---
@Override
@@ -6,5 +6,6 @@ package com.sisvietnamvn.web.domain;
public enum PageLayout {
STANDARD,
SIDEBAR,
FULL_WIDTH
FULL_WIDTH,
SPECIALTY_DETAIL
}
@@ -261,7 +261,7 @@ public class SwiperSliderPlugin {
return "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
}
if ("course-card".equals(slug)) {
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 18px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">{{badge}}</span></div></div></div><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></article></div>";
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px; text-align: left;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 20px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5; overflow: hidden;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">({{badge}})</span></div></div></div><div class=\"btn\"><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></div></article></div>";
}
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
}
@@ -9,4 +9,5 @@ import org.springframework.stereotype.Repository;
*/
@Repository
public interface MenuItemRepository extends JpaRepository<MenuItem, Long> {
java.util.List<MenuItem> findByMenu_Id(Long menuId);
}
@@ -21,4 +21,7 @@ public interface MenuRepository extends JpaRepository<Menu, Long> {
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"items", "items.children"})
Optional<Menu> findByLocation(String location);
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"items", "items.children"})
Optional<Menu> findByName(String name);
}
@@ -0,0 +1,16 @@
<?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="20260728140500-1" author="system">
<addColumn tableName="sis_page">
<column name="contact_email" type="varchar(255)"/>
<column name="contact_address" type="varchar(500)"/>
<column name="contact_phone" type="varchar(100)"/>
<column name="hero_image" type="varchar(1000)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?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="20260728160000-1" author="system">
<addColumn tableName="sis_page">
<column name="specialty_category" type="varchar(50)"/>
</addColumn>
</changeSet>
<changeSet id="20260728160000-2" author="system">
<!-- Khoa lâm sàng -->
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-cap-cuu'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-than-kinh-dot-quy'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'tim-mach'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-ngoai-tong-hop'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-kham-benh'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'phau-thuat-gay-me-hoi-suc'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'don-vi-can-thiep-mach-dsa'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'don-vi-cap-cuu-ngoai-vien'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'chuyen-khoa-tai-mui-hong'</where></update>
<!-- Khoa cận lâm sàng -->
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-chan-doan-hinh-anh'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-xet-nghiem'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang'</where></update>
<!-- Khoa hỗ trợ lâm sàng -->
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'khoa-duoc'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'khoa-dinh-duong-tiet-che'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'don-vi-kiem-soat-nhiem-khuan'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'don-vi-kham-suc-khoe-ngoai-vien'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'phong-quan-ly-van-hanh'</where></update>
</changeSet>
</databaseChangeLog>
@@ -50,4 +50,6 @@
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260724150000_alter_setting_value.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260724151500_alter_setting_value_to_clob.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260728140500_add_contact_fields_to_page.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260728160000_add_specialty_category.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>
@@ -1213,12 +1213,19 @@ figure.table table tr:hover {
}
.education {
.gap-3 {
gap: 0.75rem;
}
.flex {
display: flex !important;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.rounded-full {
justify-content: center;
background-color: var(--color-white);
@@ -1227,10 +1234,27 @@ figure.table table tr:hover {
height: 32px;
align-items: center;
}
.btn-navigation {
border-radius: 24px;
cursor: pointer;
background-color: var(--color-white);
color: var(--color-black);
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15);
position: relative;
display: flex;
justify-content: center;
align-items: center;
svg {
color: var(--color-old-brick);
}
svg:hover{
background-color: unset;
}
}
.training-sidebar {
.gap-3 {
gap: 0.75rem;
}
.text-white {
margin-bottom: 4px;
@@ -1245,4 +1269,21 @@ figure.table table tr:hover {
}
}
}
.swiper-slide {
border: solid 1px #efeff0;
border-radius: 15px;
padding: 1rem;
.btn {
padding: 0.75rem;
border-radius: 15px;
}
.flex.items-center {
justify-content: center;
}
a {
text-decoration-line: none;
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:var(--swiper-navigation-top-offset,50%);width:calc(var(--swiper-navigation-size) / 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(0px - (var(--swiper-navigation-size) / 2));z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next.swiper-button-hidden,.swiper-button-prev.swiper-button-hidden{opacity:0;cursor:auto;pointer-events:none}.swiper-navigation-disabled .swiper-button-next,.swiper-navigation-disabled .swiper-button-prev{display:none!important}.swiper-button-next svg,.swiper-button-prev svg{width:100%;height:100%;object-fit:contain;transform-origin:center}.swiper-rtl .swiper-button-next svg,.swiper-rtl .swiper-button-prev svg{transform:rotate(180deg)}.swiper-button-prev,.swiper-rtl .swiper-button-next{left:var(--swiper-navigation-sides-offset,10px);right:auto}.swiper-button-lock{display:none}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;font-variant:normal;line-height:1}.swiper-button-prev:after,.swiper-rtl .swiper-button-next:after{content:"prev"}.swiper-button-next,.swiper-rtl .swiper-button-prev{right:var(--swiper-navigation-sides-offset,10px);left:auto}.swiper-button-next:after,.swiper-rtl .swiper-button-prev:after{content:"next"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
/* PLEASE DO NOT COPY AND PASTE THIS CODE. */(function(){var w=window,C='___grecaptcha_cfg',cfg=w[C]=w[C]||{},N='grecaptcha';var gr=w[N]=w[N]||{};gr.ready=gr.ready||function(f){(cfg['fns']=cfg['fns']||[]).push(f);};w['__recaptcha_api']='https://www.google.com/recaptcha/api2/';(cfg['render']=cfg['render']||[]).push('onload');(cfg['anchor-ms']=cfg['anchor-ms']||[]).push(20000);(cfg['execute-ms']=cfg['execute-ms']||[]).push(30000);w['__google_recaptcha_client']=true;var d=document,po=d.createElement('script');po.type='text/javascript';po.async=true; po.charset='utf-8';po.src='https://www.gstatic.com/recaptcha/releases/A7KpaEASfhDcK0nXxgQEyyYv/recaptcha__en_gb.js';po.crossOrigin='anonymous';po.integrity='sha384-5OOK2erh/YOEG9kGHGnFP3+VW3JJ6xFMntvj2Hukmf0CErQumRfz6RUGAs6A/57r';var e=d.querySelector('script[nonce]'),n=e&&(e['nonce']||e.getAttribute('nonce'));if(n){po.setAttribute('nonce',n);}var s=d.getElementsByTagName('script')[0];s.parentNode.insertBefore(po, s);})();
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[7358],{24473:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,14933,23)),Promise.resolve().then(n.t.bind(n,86695,23)),Promise.resolve().then(n.t.bind(n,54775,23)),Promise.resolve().then(n.t.bind(n,22908,23)),Promise.resolve().then(n.t.bind(n,23624,23)),Promise.resolve().then(n.t.bind(n,59440,23)),Promise.resolve().then(n.t.bind(n,62920,23)),Promise.resolve().then(n.t.bind(n,19710,23))}},e=>{var s=s=>e(e.s=s);e.O(0,[1032,4619],()=>(s(36596),s(24473))),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(r,i){"use strict";var e,o=r.location,s=r.document,t=s.querySelector('[src*="'+i+'"]'),l=t&&t.getAttribute("data-domain"),p=r.localStorage.plausible_ignore;function c(e){console.warn("Ignoring Event: "+e)}function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return c("localhost");if(!(r.phantom||r._phantom||r.__nightmare||r.navigator.webdriver||r.Cypress)){if("true"==p)return c("localStorage flag");var a={};a.n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props));var n=new XMLHttpRequest;n.open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()}}}function n(){e!==o.pathname&&(e=o.pathname,a("pageview"))}try{var u,h=r.history;h.pushState&&(u=h.pushState,h.pushState=function(){u.apply(this,arguments),n()},r.addEventListener("popstate",n));var g=r.plausible&&r.plausible.q||[];r.plausible=a;for(var f=0;f<g.length;f++)a.apply(this,g[f]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){e||"visible"!==s.visibilityState||n()}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"https://analytics.jamstackvietnam.com");
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -186,22 +186,20 @@
cursor: auto;
pointer-events: none;
}
[class*='btn-navigation-']:hover:not([disabled]):not(.swiper-button-disabled):not(.swiper-button-lock) {
background-color: var(--color-primary-600);
color: white;
}
</style>
<!-- Related Courses (Chương trình khác) -->
<section class="py-6 xl:py-12 md:py-8 bg-white">
<div class="container mx-auto px-4">
<div class="container mx-auto px-4" style="text-align: center">
<h2 class="display-7 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chương trình khác</h2>
<div class="relative md:flex md:items-center">
<div class="relative flex items-center gap-3">
<button
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-prev md:!flex hidden"
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 z-10 flex items-center justify-center btn-navigation-chuong-trinh-khac-prev"
style="width: 42px; height: 42px; min-width: 42px; flex-shrink: 0"
>
<svg
class="lucide lucide-chevron-up size-4 -rotate-90"
class="lucide lucide-chevron-up size-4"
style="transform: rotate(-90deg)"
fill="none"
height="24"
stroke="currentColor"
@@ -215,19 +213,19 @@
<path d="m18 15-6-6-6 6"></path>
</svg>
</button>
<div
class="swiper swiper-chuong-trinh-khac [&>.swiper-pagination]:!static [&>.swiper-pagination]:mt-1 lg:[&>.swiper-pagination]:!hidden w-full"
>
<div class="swiper swiper-chuong-trinh-khac w-full" style="overflow: hidden">
<div class="swiper-wrapper">
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'chuong-trinh-khac')}"></th:block>
</div>
<div class="swiper-pagination"></div>
<div class="swiper-pagination hidden"></div>
</div>
<button
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-next md:!flex hidden"
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 z-10 flex items-center justify-center btn-navigation-chuong-trinh-khac-next"
style="width: 42px; height: 42px; min-width: 42px; flex-shrink: 0"
>
<svg
class="lucide lucide-chevron-down size-4 -rotate-90"
class="lucide lucide-chevron-down size-4"
style="transform: rotate(-90deg)"
fill="none"
height="24"
stroke="currentColor"
@@ -180,6 +180,42 @@
title.</small>
</div>
<!-- Specialty/Contact Info -->
<div class="card bg-light mb-4">
<div class="card-body">
<h6 class="font-weight-bold text-primary mb-3">Thông tin liên hệ & Banner (Dành riêng cho trang Chuyên khoa)</h6>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="contactEmail" class="font-weight-bold">Email</label>
<input type="text" class="form-control" id="contactEmail" th:field="*{contactEmail}" placeholder="vd: capcuu@umc.edu.vn">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="contactPhone" class="font-weight-bold">Điện thoại / SĐT</label>
<input type="text" class="form-control" id="contactPhone" th:field="*{contactPhone}" placeholder="vd: 028 3952 5115">
</div>
</div>
</div>
<div class="form-group">
<label for="contactAddress" class="font-weight-bold">Địa chỉ</label>
<input type="text" class="form-control" id="contactAddress" th:field="*{contactAddress}" placeholder="vd: Tầng trệt - Khu A">
</div>
<div class="form-group">
<label for="heroImage" class="font-weight-bold">Ảnh Banner (Hero Image URL)</label>
<div class="input-group">
<input type="text" class="form-control" id="heroImage" th:field="*{heroImage}" placeholder="URL ảnh">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('mediaManagerBtn').click()">
<i class="fas fa-image"></i> Chọn ảnh
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Block Editor Content -->
<div class="form-group">
<div class="d-flex justify-content-between align-items-center mb-2">
@@ -249,6 +285,7 @@
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/raw@2.4.3/dist/bundle.js"></script>
<!--
============================================================
File diff suppressed because one or more lines are too long
@@ -0,0 +1,302 @@
<!doctype html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='specialty-list-page')}"
>
<head>
<title>Chuyên khoa - Bệnh viện S.I.S Cần Thơ</title>
<meta name="description" content="Danh sách các chuyên khoa tại Bệnh viện Đa khoa Quốc tế S.I.S Cần Thơ" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/b6b2bf2d3af810a6.css}" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/3cd83cfe34ca397f.css}" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/45d4f6442d75f756.css}" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/c37340727ca4fe15.css}" />
<style>
.specialty-card {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 2px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
}
.specialty-card:hover {
background: var(--color-old-brick);
color: white;
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 84, 166, 0.25);
}
.specialty-card:hover .specialty-icon {
background: rgba(255, 255, 255, 0.2);
}
.specialty-card:hover .specialty-icon svg {
color: white;
}
.specialty-card:hover .specialty-name {
color: white;
}
.specialty-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: #f0f7ff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.specialty-icon svg {
width: 32px;
height: 32px;
color: var(--color-old-brick);
transition: color 0.3s ease;
}
.specialty-name {
font-weight: 600;
font-size: 0.9rem;
line-height: 1.4;
color: var(--color-old-brick);
transition: color 0.3s ease;
}
.filter-btn {
padding: 0.5rem 1.25rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid #e5e7eb;
background: white;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.filter-btn:hover {
border-color: var(--color-primary-600, #0054a6);
color: var(--color-primary-600, #0054a6);
}
.filter-btn.active {
background: var(--color-primary-600, #0054a6);
color: white;
border-color: var(--color-primary-600, #0054a6);
}
.filter-btn .count {
background: rgba(0, 0, 0, 0.1);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
}
.filter-btn.active .count {
background: rgba(255, 255, 255, 0.25);
}
.section-label {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-primary-600, #0054a6);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-primary-600, #0054a6);
display: inline-block;
}
@media (max-width: 768px) {
.specialty-card {
padding: 1rem;
}
.specialty-icon {
width: 56px;
height: 56px;
}
.specialty-icon svg {
width: 24px;
height: 24px;
}
.specialty-name {
font-size: 0.8rem;
}
}
</style>
</head>
<body>
<div layout:fragment="content">
<section class="xl:py-12 md:py-8 py-6" style="background: #f6f6f6">
<div class="container mx-auto px-4">
<!-- Page Title -->
<h1 class="display-4 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chuyên khoa</h1>
<!-- Filter Dropdown -->
<div
id="specialties_sel"
class="flex justify-center xl:mb-10 md:mb-7 mb-5 relative w-full md:max-w-[320px] mx-auto xl:mb-10 md:mb-7 mb-5 title-4"
>
<select
onchange="window.location.href = this.value"
class="w-full group bg-white rounded-lg border lg:hover:border-primary-600 cursor-pointer xl:px-6 px-4 py-3 flex items-center justify-between lg:duration-150 shadow border-gray-200"
>
<option th:value="@{/chuyen-khoa}" th:selected="${currentCategory == 'all'}">Tất cả ([[${countAll}]])</option>
<option th:value="@{/chuyen-khoa(category='khoa-lam-sang')}" th:selected="${currentCategory == 'khoa-lam-sang'}">
Khoa lâm sàng ([[${countLamSang}]])
</option>
<option th:value="@{/chuyen-khoa(category='khoa-can-lam-sang')}" th:selected="${currentCategory == 'khoa-can-lam-sang'}">
Khoa cận lâm sàng ([[${countCanLamSang}]])
</option>
<option
th:value="@{/chuyen-khoa(category='khoa-ho-tro-lam-sang')}"
th:selected="${currentCategory == 'khoa-ho-tro-lam-sang'}"
>
Khoa hỗ trợ lâm sàng ([[${countHoTro}]])
</option>
</select>
</div>
<!-- Specialty Grid -->
<div class="grid lg:grid-cols-4 md:grid-cols-3 grid-cols-2 xl:gap-8 md:gap-6 gap-4">
<a th:each="spec : ${specialties}" th:href="@{/chuyen-khoa/{slug}(slug=${spec.slug})}" class="specialty-card">
<div class="specialty-icon">
<th:block th:switch="${spec.slug}">
<!-- chuyen-khoa-tai-mui-hong -->
<th:block th:case="'chuyen-khoa-tai-mui-hong'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Taimuihong-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- don-vi-kiem-soat-nhiem-khuan -->
<th:block th:case="'don-vi-kiem-soat-nhiem-khuan'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-29.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-cap-cuu -->
<th:block th:case="'khoa-cap-cuu'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-37.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-chan-doan-hinh-anh -->
<th:block th:case="'khoa-chan-doan-hinh-anh'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-3.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-dinh-duong-tiet-che -->
<th:block th:case="'khoa-dinh-duong-tiet-che'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-9.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-duoc -->
<th:block th:case="'khoa-duoc'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-11.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-kham-benh -->
<th:block th:case="'khoa-kham-benh'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-27.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-than-kinh-dot-quy -->
<th:block th:case="'khoa-than-kinh-dot-quy'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Thankinh-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang -->
<th:block th:case="'khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Phuchoichucnang-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-xet-nghiem -->
<th:block th:case="'khoa-xet-nghiem'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Xetnghiem-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- phau-thuat-gay-me-hoi-suc -->
<th:block th:case="'phau-thuat-gay-me-hoi-suc'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-13.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- tim-mach -->
<th:block th:case="'tim-mach'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Timmachcanthiep-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- Default -->
<th:block th:case="*">
<div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 mx-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-6 h-6"
>
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6" />
</svg>
</div>
</th:block>
</th:block>
</div>
<span class="specialty-name" th:text="${spec.title}">Tên chuyên khoa</span>
</a>
</div>
<!-- Empty state -->
<div th:if="${#lists.isEmpty(specialties)}" class="text-center py-12">
<p class="text-gray-500 text-lg">Không tìm thấy chuyên khoa nào trong nhóm này.</p>
</div>
</div>
</section>
</div>
</body>
</html>