feat: refactor doctor listing and detail pages with dynamic routing, improved booking links, and optimized scheduling display.

This commit is contained in:
2026-08-03 21:32:26 +07:00
parent 417f6ecb24
commit 89c18f20fd
77 changed files with 2860 additions and 236 deletions
@@ -63,7 +63,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/**", "/api/categories", "/api/tags", "/page/**", "/pages/**", "/news/article/**", "/post/**", "/posts", "/posts/**", "/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/**", "/chuyen-khoa", "/chuyen-khoa/**",
"/about-us", "/specialty", "/doctor", "/bac-si", "/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/**", "/dat-lich", "/dat-lich/**", "/api/public/doctor-schedule/**")
.permitAll()
.requestMatchers(HttpMethod.HEAD, "/uploads/**", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**")
@@ -308,15 +308,15 @@ public class PageController {
return "Added " + count + " specialties to CHUYÊN KHOA menu.";
}
@GetMapping("/doctor")
@GetMapping({"/doctor", "/bac-si", "/bac-si/"})
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctor(Model model) {
try {
java.util.List<com.sisvietnamvn.web.domain.Doctor> doctors = doctorService.findAll();
// Show all doctors regardless of active status (so inactive still show up)
// Show only active doctors with valid doctorCode
doctors = doctors.stream()
.filter(d -> Boolean.TRUE.equals(d.getActive()))
.filter(d -> Boolean.TRUE.equals(d.getActive()) && d.getDoctorCode() != null && !d.getDoctorCode().trim().isEmpty())
.collect(java.util.stream.Collectors.toList());
// Sort: 00001 and 00002 at the top
@@ -351,6 +351,9 @@ public class PageController {
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctorDetail(@PathVariable String doctorCode, Model model) {
java.util.Optional<com.sisvietnamvn.web.domain.Doctor> doctorOpt = doctorService.findByDoctorCode(doctorCode);
if (doctorOpt.isEmpty() && doctorCode.matches("\\d+")) {
doctorOpt = doctorService.findById(Long.parseLong(doctorCode));
}
if (doctorOpt.isEmpty()) {
return "redirect:/bac-si";
@@ -60,6 +60,9 @@ public class Doctor extends AbstractAuditingEntity<Long> {
@Column(name = "active")
private Boolean active = true;
@jakarta.persistence.Transient
private Boolean hasSchedule = true;
@Size(max = 255)
@Column(name = "position", length = 255)
private String position;
@@ -240,6 +243,14 @@ public class Doctor extends AbstractAuditingEntity<Long> {
// --- equals, hashCode, toString ---
public Boolean getHasSchedule() {
return hasSchedule;
}
public void setHasSchedule(Boolean hasSchedule) {
this.hasSchedule = hasSchedule;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -12,7 +12,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@@ -28,6 +27,13 @@ import jakarta.annotation.PostConstruct;
@Service
public class DoctorApiSyncService {
private static final Logger LOG = LoggerFactory.getLogger(DoctorApiSyncService.class);
private static final String BASE_URL = "https://dhs.sisvietnam.vn";
private static final String CLIENT_ID = "DigitalHealthSolutions_HospitalIntegration";
private static final String CLIENT_SECRET = "XPWFParRQldtZopqdDFLPYblIzauxuWT";
private static final String SCOPE = "HospitalIntegration";
private static final String TENANT_ID = "3a0f747c-57b6-753b-ba21-3987e8e93b9a";
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper;
private final MediaRepository mediaRepository;
@@ -39,7 +45,8 @@ public class DoctorApiSyncService {
private List<Map<String, Object>> cachedDoctors = new ArrayList<>();
public DoctorApiSyncService(ObjectMapper objectMapper, MediaRepository mediaRepository,
DoctorRepository doctorRepository, SettingService settingService, com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
DoctorRepository doctorRepository, SettingService settingService,
com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
this.objectMapper = objectMapper;
this.mediaRepository = mediaRepository;
this.doctorRepository = doctorRepository;
@@ -67,28 +74,29 @@ public class DoctorApiSyncService {
private List<Map<String, Object>> fetchRealtimeSchedule(String doctorCode) {
List<Map<String, Object>> daysList = new ArrayList<>();
try {
// Get token
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
// 1. Get token (OAuth 2.0 Client Credentials - No tenant header on /connect/token)
String tokenUrl = BASE_URL + "/connect/token";
HttpHeaders tokenHeaders = new HttpHeaders();
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
tokenHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
org.springframework.util.MultiValueMap<String, String> tokenBody = new org.springframework.util.LinkedMultiValueMap<>();
MultiValueMap<String, String> tokenBody = new LinkedMultiValueMap<>();
tokenBody.add("grant_type", "client_credentials");
tokenBody.add("client_id", "DigitalHealthSolutions_LabConn");
tokenBody.add("client_secret", "AcEnBCaphERGOGYMaStATITEnaPETYpR");
tokenBody.add("scope", "LabConn");
HttpEntity<org.springframework.util.MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
tokenBody.add("client_id", CLIENT_ID);
tokenBody.add("client_secret", CLIENT_SECRET);
tokenBody.add("scope", SCOPE);
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenNode.get("access_token").asText();
// 2. Fetch doctor schedule days
HttpHeaders apiHeaders = new HttpHeaders();
apiHeaders.setBearerAuth(accessToken);
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("__tenant", TENANT_ID);
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
String step3Url = "https://dhs.sisvietnam.vn/api/app/his/doctors/" + doctorCode + "/work-schedule-days";
String step3Url = BASE_URL + "/api/app/his/doctors/" + doctorCode + "/work-schedule-days";
ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET, apiRequest, String.class);
JsonNode step3Root = objectMapper.readTree(step3Response.getBody());
JsonNode daysArrayNode = null;
@@ -123,20 +131,18 @@ public class DoctorApiSyncService {
public synchronized void syncDoctorsFromApi() {
try {
LOG.info("Starting API sync for doctors...");
LOG.info("Starting API sync for doctors with HIS Work Schedule API v3.0...");
// 1. Get Token
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
// 1. Get Token (No tenant header or Cookie in /connect/token request)
String tokenUrl = BASE_URL + "/connect/token";
HttpHeaders tokenHeaders = new HttpHeaders();
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
tokenHeaders.add("Cookie",
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
MultiValueMap<String, String> tokenBody = new LinkedMultiValueMap<>();
tokenBody.add("grant_type", "client_credentials");
tokenBody.add("client_id", "DigitalHealthSolutions_LabConn");
tokenBody.add("client_secret", "AcEnBCaphERGOGYMaStATITEnaPETYpR");
tokenBody.add("scope", "LabConn");
tokenBody.add("client_id", CLIENT_ID);
tokenBody.add("client_secret", CLIENT_SECRET);
tokenBody.add("scope", SCOPE);
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
@@ -144,13 +150,11 @@ public class DoctorApiSyncService {
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenNode.get("access_token").asText();
// 2. Fetch doctors
String apiUrl = "https://dhs.sisvietnam.vn/api/app/his/doctors/work-schedules";
// 2. Fetch doctors with work schedules
String apiUrl = BASE_URL + "/api/app/his/doctors/work-schedules";
HttpHeaders apiHeaders = new HttpHeaders();
apiHeaders.setBearerAuth(accessToken);
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("Cookie",
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("__tenant", TENANT_ID);
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest,
@@ -180,7 +184,10 @@ public class DoctorApiSyncService {
? node.get("doctorCode").asText()
: "";
doc.put("doctorCode", doctorCode);
doc.put("bookingUrl", "#");
// Deep-link booking URL based on HIS API doc spec
String bookingUrl = "https://dhs.sisvietnam.vn/book-appointment?doctorCode=" + doctorCode;
doc.put("bookingUrl", bookingUrl);
String avatarUrl = "/images/default-avatar.png";
if (!doctorCode.isEmpty()) {
@@ -199,7 +206,6 @@ public class DoctorApiSyncService {
doc.put("specialty", spec);
List<Map<String, Object>> daysList = new ArrayList<>();
doc.put("days", daysList);
newDoctorsList.add(doc);
}
@@ -283,14 +289,24 @@ public class DoctorApiSyncService {
// Handle inactive doctors (exempt 00001 and 00002 from auto-deactivation)
for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) {
if (dbDoc.getDoctorCode() != null && !dbDoc.getDoctorCode().isEmpty()
&& !activeCodes.contains(dbDoc.getDoctorCode())) {
if ("00001".equals(dbDoc.getDoctorCode()) || "00002".equals(dbDoc.getDoctorCode())) {
continue;
String code = dbDoc.getDoctorCode();
boolean hasSched = code != null && activeCodes.contains(code);
if (dbDoc.getHasSchedule() == null || !dbDoc.getHasSchedule().equals(hasSched)) {
dbDoc.setHasSchedule(hasSched);
if (!doctorsToSave.contains(dbDoc)) {
doctorsToSave.add(dbDoc);
}
}
if ("00001".equals(code) || "00002".equals(code)) {
continue;
}
if (code == null || code.trim().isEmpty() || !activeCodes.contains(code)) {
if (dbDoc.getActive() == null || dbDoc.getActive()) {
dbDoc.setActive(false);
doctorsToSave.add(dbDoc);
if (!doctorsToSave.contains(dbDoc)) {
doctorsToSave.add(dbDoc);
}
}
}
}
@@ -158,7 +158,7 @@
<div th:utext="${doctorIntroHtml}"></div>
<div style="display: flex; flex-direction: row; justify-content: start; align-items: center; gap: var(--s2);" class="mb-6 lg:mt-6">
<button type="button" class="btn space-x-2 text-center label-3 w-[286px] !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<button type="button" th:if="${scheduleDays != null and !scheduleDays.isEmpty()}" class="btn space-x-2 text-center label-3 w-[286px] !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() and !doctor.bookingUrl.equals('#') ? doctor.bookingUrl : (doctor.doctorCode != null and !doctor.doctorCode.isEmpty() ? 'https://dhs.sisvietnam.vn/book-appointment?doctorCode=' + doctor.doctorCode : 'https://dhs.sisvietnam.vn/book-appointment')}" onclick="window.location.href=this.getAttribute('data-url')">
<a href="tel:18001115" style="color: white; text-decoration: none; width: 100%; display: flex; flex-direction: row; gap: 10px; justify-content: center; align-items: center;">Đặt lịch khám
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_83_297)">
@@ -278,7 +278,7 @@
</div>
</div>
<div th:utext="${doctorIntroHtml}"></div>
<button type="button" class="btn space-x-2 text-center label-3 w-full !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<button type="button" th:if="${scheduleDays != null and !scheduleDays.isEmpty()}" class="btn space-x-2 text-center label-3 w-full !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() and !doctor.bookingUrl.equals('#') ? doctor.bookingUrl : (doctor.doctorCode != null and !doctor.doctorCode.isEmpty() ? 'https://dhs.sisvietnam.vn/book-appointment?doctorCode=' + doctor.doctorCode : 'https://dhs.sisvietnam.vn/book-appointment')}" onclick="window.location.href=this.getAttribute('data-url')">
<div>Đặt lịch khám</div>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_83_297)">
@@ -299,13 +299,13 @@
<div class="grid grid-cols-12 xl:gap-x-6 md:gap-x-4">
<div class="lg:col-span-6 col-span-full bg-[#fbfbfb] xl:py-6 md:py-3 py-3 xl:pl-6 md:pl-3 max-lg:pr-3 max-md:pr-0 rounded-lg">
<div>
<div class="mt-4">
<div class="mt-4" th:if="${scheduleDays != null and !scheduleDays.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2">
<div class="title-3 text-white">Lịch khám</div>
</div>
<div class="xl:m-3 m-2">
<div th:if="${scheduleDays != null and !scheduleDays.isEmpty()}">
<div>
<div class="flex gap-1 mb-1">
<span class="w-1 h-1 bg-primary-600 rounded-full md:mt-[11px] mt-2.5 flex-shrink-0"></span>
<div class="text-primary-600">
@@ -320,9 +320,6 @@
</li>
</ul>
</div>
<div th:if="${scheduleDays == null or scheduleDays.isEmpty()}">
<p class="text-gray-500 italic">Chưa có lịch khám</p>
</div>
</div>
</div>
</div>
@@ -1,210 +1,309 @@
<!DOCTYPE html>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='doctor-page')}">
<head>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='doctor-page')}">
<head>
<title>Đội ngũ bác sĩ | S.I.S</title>
</head>
<body>
<div layout:fragment="content">
<style>
:root {
--spacing: 0.25rem;
}
/* Fix umass.css overriding layered Tailwind classes */
h1.mb-4 { margin-bottom: calc(var(--spacing) * 4) !important; }
@media (min-width: 768px) {
h1.md\:mb-6 { margin-bottom: calc(var(--spacing) * 6) !important; }
}
@media (min-width: 1280px) {
h1.xl\:mb-8 { margin-bottom: calc(var(--spacing) * 8) !important; }
}
button {
padding-block: calc(var(--spacing) * 3);
padding-inline: calc(var(--spacing)*4);
}
</style>
<link rel="stylesheet" th:href="@{/css/umc_doctor.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_2.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_3.css}" />
<main class=""><!--$--><!--/$--><!--$--><!--/$--><section id="doctor-content" class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6]"><div class="container"><div class="flex flex-col md:flex-row md:items-center md:justify-between xl:mb-8 md:mb-6 mb-4 gap-4"><div class="flex gap-4 w-full xl:flex-row flex-col"><div class="relative xl:w-2/4 w-full flex-shrink-0"><input id="search-input" onkeyup="filterDoctors()" placeholder="Tìm kiếm bác sĩ..." class="w-full shadow rounded-lg border border-gray-200 px-4 py-3 label-3 bg-white pr-10 focus:outline-none focus:border-primary-600 lg:hover:border-primary-600 lg:duration-150 placeholder:text-gray-500" type="text" value=""><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 w-5 h-5"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg></div><div class="flex gap-4 md:flex-row flex-col md:w-full"><div class="relative w-full "><button type="button" class="w-full group rounded-lg cursor-pointer border label-3 bg-white flex justify-between items-center border-gray-200 lg:px-5 px-4 py-3 shadow lg:hover:border-primary-600 lg:duration-150" id="btn-specialty" aria-haspopup="listbox" aria-expanded="false" onclick="toggleDropdown('list-specialty')" style="height: 100%; background-color: var(--color-white);"><span id="text-specialty" class="text-gray-900 lg:group-hover:text-primary-600 lg:duration-150">--Theo chuyên khoa--</span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down ml-2 duration-150 md:size-5 size-4 rotate-0 text-gray-900 lg:group-hover:text-primary-600 duration-150"><path d="m6 9 6 6 6-6"></path></svg></button><ul id="list-specialty" class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" style="display: none;">
<li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectSpecialty('')">--Tất cả chuyên khoa--</li>
<li th:each="spec : ${specialties}" th:text="${spec.name}" th:data-specialty="${spec.name}" onclick="selectSpecialty(this.getAttribute('data-specialty'))" class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100"></li>
</ul></div><div class="relative w-full "><button type="button" class="w-full group rounded-lg cursor-pointer border label-3 bg-white flex justify-between items-center border-gray-200 lg:px-5 px-4 py-3 shadow lg:hover:border-primary-600 lg:duration-150" id="btn-gender" aria-haspopup="listbox" aria-expanded="false" onclick="toggleDropdown('list-gender')" style="height: 100%; background-color: var(--color-white);"><span id="text-gender" class="text-gray-900 lg:group-hover:text-primary-600 lg:duration-150">--Theo giới tính--</span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down ml-2 duration-150 md:size-5 size-4 rotate-0 text-gray-900 lg:group-hover:text-primary-600 duration-150"><path d="m6 9 6 6 6-6"></path></svg></button><ul id="list-gender" class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" style="display: none;"><li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('')">--Tất cả giới tính--</li><li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('Nam')">Nam</li><li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('Nữ')">Nữ</li></ul></div></div></div></div><h1 class="display-7 text-primary-500 xl:mb-8 md:mb-6 mb-4">Danh sách bác sĩ</h1><div id="doctors-grid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 xl:gap-4 md:gap-3 gap-2">
<!-- Thymeleaf Loop over doctors -->
<div th:each="doc : ${doctors}" class="bg-white rounded-xl overview-wrapper-shadow flex flex-col justify-between h-full doctor-card" th:data-specialty="${doc.specialty != null ? doc.specialty.name : ''}" data-gender="">
<div>
<div class="flex gap-4 items-center xl:px-6 px-4 xl:pt-6 pt-4 mb-3 relative">
<a class="group flex-shrink-0 relative aspect-square overflow-hidden rounded border border-primary-400 from-primary-75 to-primary-100 xl:size-[150px] size-[100px]" th:href="@{/bac-si/{code}(code=${doc.doctorCode})}">
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white drop-shadow-md">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</div>
<img loading="lazy" decoding="async" th:src="${doc.avatarUrl != null and !doc.avatarUrl.isEmpty() ? doc.avatarUrl : '/images/default-avatar.png'}" class="lg:hover:scale-105 scale-100 lg:duration-150" style="position: absolute; height: 100%; width: 100%; inset: 0px; object-fit: contain;">
</a>
<div class="flex-1">
<div class="text-primary-300 label-3 max-md:!text-[14px] !leading-[150%]" th:text="${doc.title}"></div>
<a class="text-[16px] leading-[150%] font-display font-bold text-[#1E293B] lg:hover:text-primary-600 lg:duration-150 mb-1 line-clamp-2" th:href="@{/bac-si/{code}(code=${doc.doctorCode})}" th:text="${doc.name}" style="text-decoration: none;"></a>
<div class="text-primary-300 label-3 !leading-[150%]" th:text="${doc.specialty != null ? doc.specialty.name : ''}"></div>
</div>
<div class="absolute xl:top-4 top-4 xl:right-4 right-4 size-[40px] rounded-lg bg-white card-icon-shadow p-1" th:if="${doc.specialty != null and doc.specialty.iconUrl != null and !doc.specialty.iconUrl.isEmpty()}">
<img loading="lazy" width="40" height="38" th:src="${doc.specialty.iconUrl}" style="object-fit: cover;">
</div>
</div>
</div>
<div class="xl:px-6 px-4 xl:pb-6 pb-4 xl:mt-3 mt-2 flex items-center justify-between gap-x-2">
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center !px-0 group items-center justify-center gap-1.5" style="text-decoration: none;" th:href="@{/bac-si/{code}(code=${doc.doctorCode})}">
<div class="text-white">Đặt lịch khám</div>
<svg class="text-white" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>
</a>
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center !px-0 group items-center justify-center gap-1.5" style="text-decoration: none;" href="tel:18001115">
<div class="text-white">Liên hệ</div>
<svg class="text-white" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</a>
</div>
</div>
</div>
<div id="pagination-controls" class="flex justify-center items-center space-x-2 mt-8"></div>
</section></main>
<script th:inline="javascript">
/*<![CDATA[*/
let currentSpecialty = '';
let currentGender = '';
function toggleDropdown(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function selectSpecialty(spec) {
currentSpecialty = spec;
document.getElementById('text-specialty').innerText = spec || '--Theo chuyên khoa--';
document.getElementById('list-specialty').style.display = 'none';
filterDoctors();
}
function selectGender(gender) {
currentGender = gender;
document.getElementById('text-gender').innerText = gender || '--Theo giới tính--';
document.getElementById('list-gender').style.display = 'none';
filterDoctors();
}
let currentPage = 1;
const itemsPerPage = 12;
let filteredCards = [];
function filterDoctors() {
const searchText = document.getElementById('search-input').value.toLowerCase();
const cards = document.querySelectorAll('#doctors-grid > div.doctor-card');
filteredCards = [];
cards.forEach(card => {
const textContent = card.innerText || '';
const textLower = textContent.toLowerCase();
let matchSearch = searchText === '' || textLower.includes(searchText);
let matchSpecialty = currentSpecialty === '' || textContent.includes(currentSpecialty);
if (matchSearch && matchSpecialty) {
filteredCards.push(card);
} else {
card.style.display = 'none';
}
});
currentPage = 1;
renderPagination();
}
function renderPagination() {
const totalPages = Math.ceil(filteredCards.length / itemsPerPage);
const paginationContainer = document.getElementById('pagination-controls');
paginationContainer.innerHTML = '';
if (totalPages <= 1) {
filteredCards.forEach(card => card.style.display = '');
return;
</head>
<body>
<div layout:fragment="content">
<style>
:root {
--spacing: 0.25rem;
}
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
filteredCards.forEach((card, index) => {
if (index >= startIndex && index < endIndex) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
/* Fix umass.css overriding layered Tailwind classes */
h1.mb-4 {
margin-bottom: calc(var(--spacing) * 4) !important;
}
const createButton = (content, onClick, disabled, isActive) => {
@media (min-width: 768px) {
h1.md\:mb-6 {
margin-bottom: calc(var(--spacing) * 6) !important;
}
}
@media (min-width: 1280px) {
h1.xl\:mb-8 {
margin-bottom: calc(var(--spacing) * 8) !important;
}
}
button {
padding-block: calc(var(--spacing) * 3);
padding-inline: calc(var(--spacing)*4);
}
.doctor-filter-bar {
display: flex !important;
flex-direction: row !important;
gap: 1rem !important;
width: 100% !important;
}
.doctor-filter-search {
flex: 2 !important;
}
.doctor-filter-select {
flex: 1 !important;
}
@media (max-width: 767px) {
.doctor-filter-bar {
flex-direction: column !important;
}
.doctor-filter-search, .doctor-filter-select {
width: 100% !important;
}
}
</style>
<link rel="stylesheet" th:href="@{/css/umc_doctor.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_2.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_3.css}" />
<main class="">
<section id="doctor-content" class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6]">
<div class="container">
<div class="mb-6">
<div class="doctor-filter-bar">
<div class="relative doctor-filter-search">
<input id="search-input" onkeyup="filterDoctors()" placeholder="Tìm kiếm bác sĩ..." class="w-full shadow rounded-lg border border-gray-200 px-4 py-3 label-3 bg-white pr-10 focus:outline-none focus:border-primary-600 lg:hover:border-primary-600 lg:duration-150 placeholder:text-gray-500" type="text" value="">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 w-5 h-5">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
</div>
<div class="relative doctor-filter-select">
<button type="button" class="w-full group rounded-lg cursor-pointer border label-3 bg-white flex justify-between items-center border-gray-200 lg:px-5 px-4 py-3 shadow lg:hover:border-primary-600 lg:duration-150" id="btn-specialty" aria-haspopup="listbox" aria-expanded="false" onclick="toggleDropdown('list-specialty')" style="height: 100%; background-color: var(--color-white);">
<span id="text-specialty" class="text-gray-900 lg:group-hover:text-primary-600 lg:duration-150">--Theo chuyên khoa--</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down ml-2 duration-150 md:size-5 size-4 rotate-0 text-gray-900 lg:group-hover:text-primary-600 duration-150">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
<ul id="list-specialty" class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" style="display: none;">
<li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectSpecialty('')">--Tất cả chuyên khoa--</li>
<li th:each="spec : ${specialties}" th:text="${spec.name}" th:data-specialty="${spec.name}" onclick="selectSpecialty(this.getAttribute('data-specialty'))" class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100"></li>
</ul>
</div>
<div class="relative doctor-filter-select">
<button type="button" class="w-full group rounded-lg cursor-pointer border label-3 bg-white flex justify-between items-center border-gray-200 lg:px-5 px-4 py-3 shadow lg:hover:border-primary-600 lg:duration-150" id="btn-gender" aria-haspopup="listbox" aria-expanded="false" onclick="toggleDropdown('list-gender')" style="height: 100%; background-color: var(--color-white);">
<span id="text-gender" class="text-gray-900 lg:group-hover:text-primary-600 lg:duration-150">--Theo giới tính--</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down ml-2 duration-150 md:size-5 size-4 rotate-0 text-gray-900 lg:group-hover:text-primary-600 duration-150">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
<ul id="list-gender" class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" style="display: none;">
<li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('')">--Tất cả giới tính--</li>
<li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('Nam')">Nam</li>
<li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('Nữ')">Nữ</li>
</ul>
</div>
</div>
</div>
<h1 class="display-7 text-primary-500 xl:mb-8 md:mb-6 mb-4">Danh sách bác sĩ</h1>
<div id="doctors-grid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 xl:gap-4 md:gap-3 gap-2">
<!-- Thymeleaf Loop over doctors -->
<div th:each="doc : ${doctors}" class="bg-white rounded-xl overview-wrapper-shadow flex flex-col justify-between h-full doctor-card" th:data-specialty="${doc.specialty != null ? doc.specialty.name : ''}" data-gender="">
<div>
<div class="flex gap-4 items-center xl:px-6 px-4 xl:pt-6 pt-4 mb-3 relative">
<a class="group flex-shrink-0 relative aspect-square overflow-hidden rounded border border-primary-400 from-primary-75 to-primary-100 xl:size-[150px] size-[100px]" th:href="@{/bac-si/{code}(code=${doc.doctorCode != null and !doc.doctorCode.isEmpty() ? doc.doctorCode : doc.id})}">
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white drop-shadow-md">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</div>
<img loading="lazy" decoding="async" th:src="${doc.avatarUrl != null and !doc.avatarUrl.isEmpty() ? doc.avatarUrl : '/images/default-avatar.png'}" class="lg:hover:scale-105 scale-100 lg:duration-150" style="position: absolute; height: 100%; width: 100%; inset: 0px; object-fit: contain;">
</a>
<div class="flex-1">
<div class="text-primary-300 label-3 max-md:!text-[14px] !leading-[150%]" th:text="${doc.title}"></div>
<a class="text-[16px] leading-[150%] font-display font-bold text-[#1E293B] lg:hover:text-primary-600 lg:duration-150 mb-1 line-clamp-2" th:href="@{/bac-si/{code}(code=${doc.doctorCode != null and !doc.doctorCode.isEmpty() ? doc.doctorCode : doc.id})}" th:text="${doc.name}" style="text-decoration: none;"></a>
<div class="text-primary-300 label-3 !leading-[150%]" th:text="${doc.specialty != null ? doc.specialty.name : ''}"></div>
</div>
<div class="absolute xl:top-4 top-4 xl:right-4 right-4 size-[40px] rounded-lg bg-white card-icon-shadow p-1" th:if="${doc.specialty != null and doc.specialty.iconUrl != null and !doc.specialty.iconUrl.isEmpty()}">
<img loading="lazy" width="40" height="38" th:src="${doc.specialty.iconUrl}" style="object-fit: cover;">
</div>
</div>
</div>
<div class="xl:px-6 px-4 xl:pb-6 pb-4 xl:mt-3 mt-2 flex items-center justify-between gap-x-2">
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center !px-0 group items-center justify-center gap-1.5" style="text-decoration: none;" th:if="${doc.hasSchedule == null or doc.hasSchedule == true}" th:href="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() and !doc.bookingUrl.equals('#') ? doc.bookingUrl : (doc.doctorCode != null and !doc.doctorCode.isEmpty() ? 'https://dhs.sisvietnam.vn/book-appointment?doctorCode=' + doc.doctorCode : 'https://dhs.sisvietnam.vn/book-appointment')}">
<div class="text-white">Đặt lịch khám</div>
<svg class="text-white" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
<path d="M8 14h.01" />
<path d="M12 14h.01" />
<path d="M16 14h.01" />
<path d="M8 18h.01" />
<path d="M12 18h.01" />
<path d="M16 18h.01" />
</svg>
</a>
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center !px-0 group items-center justify-center gap-1.5" style="text-decoration: none;" href="tel:18001115">
<div class="text-white">Liên hệ</div>
<svg class="text-white" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
</a>
</div>
</div>
</div>
<div id="pagination-controls" class="flex justify-center items-center space-x-2 mt-8"></div>
</section>
</main>
<script th:inline="javascript">
/*
<![CDATA[*/
let currentSpecialty = '';
let currentGender = '';
function toggleDropdown(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function selectSpecialty(spec) {
currentSpecialty = spec;
document.getElementById('text-specialty').innerText = spec || '--Theo chuyên khoa--';
document.getElementById('list-specialty').style.display = 'none';
filterDoctors();
}
function selectGender(gender) {
currentGender = gender;
document.getElementById('text-gender').innerText = gender || '--Theo giới tính--';
document.getElementById('list-gender').style.display = 'none';
filterDoctors();
}
let currentPage = 1;
const itemsPerPage = 12;
let filteredCards = [];
function filterDoctors() {
const searchText = document.getElementById('search-input').value.toLowerCase();
const cards = document.querySelectorAll('#doctors-grid > div.doctor-card');
filteredCards = [];
cards.forEach(card => {
const textContent = card.innerText || '';
const textLower = textContent.toLowerCase();
let matchSearch = searchText === '' || textLower.includes(searchText);
let matchSpecialty = currentSpecialty === '' || textContent.includes(currentSpecialty);
if (matchSearch && matchSpecialty) {
filteredCards.push(card);
} else {
card.style.display = 'none';
}
});
currentPage = 1;
renderPagination();
}
function renderPagination() {
const totalPages = Math.ceil(filteredCards.length / itemsPerPage);
const paginationContainer = document.getElementById('pagination-controls');
paginationContainer.innerHTML = '';
if (totalPages <= 1) {
filteredCards.forEach(card => card.style.display = '');
return;
}
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
filteredCards.forEach((card, index) => {
if (index >= startIndex && index < endIndex) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
const createButton = (content, onClick, disabled, isActive) => {
const btn = document.createElement('button');
if (isActive) {
btn.className = 'lg:size-10 md:size-9 size-8 flex items-center justify-center rounded-full border body-1 cursor-pointer bg-primary-600 text-white border-primary-600 is-active';
btn.className = 'lg:size-10 md:size-9 size-8 flex items-center justify-center rounded-full border body-1 cursor-pointer bg-primary-600 text-white border-primary-600 is-active';
} else {
btn.className = 'lg:size-10 md:size-9 size-8 flex items-center justify-center rounded-full border border-gray-300 bg-white text-black disabled:opacity-50 disabled:!bg-white disabled:!text-black cursor-pointer lg:hover:bg-primary-600 lg:hover:text-white lg:duration-150 transition-colors';
btn.className = 'lg:size-10 md:size-9 size-8 flex items-center justify-center rounded-full border border-gray-300 bg-white text-black disabled:opacity-50 disabled:!bg-white disabled:!text-black cursor-pointer lg:hover:bg-primary-600 lg:hover:text-white lg:duration-150 transition-colors';
}
btn.innerHTML = content;
if (disabled) btn.disabled = true;
if (!disabled && !isActive) {
btn.onclick = () => {
onClick();
renderPagination();
window.scrollTo(0, document.getElementById('doctor-content').offsetTop - 50);
};
btn.onclick = () => {
onClick();
renderPagination();
window.scrollTo(0, document.getElementById('doctor-content').offsetTop - 50);
};
}
return btn;
};
const iconFirst = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left size-4"><path d="m11 17-5-5 5-5"></path><path d="m18 17-5-5 5-5"></path></svg>';
const iconPrev = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90"><path d="m18 15-6-6-6 6"></path></svg>';
const iconNext = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90"><path d="m6 9 6 6 6-6"></path></svg>';
const iconLast = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-right size-4"><path d="m6 17 5-5-5-5"></path><path d="m13 17 5-5-5-5"></path></svg>';
paginationContainer.appendChild(createButton(iconFirst, () => currentPage = 1, currentPage === 1, false));
paginationContainer.appendChild(createButton(iconPrev, () => currentPage--, currentPage === 1, false));
let startPage = Math.max(1, currentPage - 1);
let endPage = Math.min(totalPages, currentPage + 1);
if (currentPage === 1 && totalPages > 2) endPage = 3;
if (currentPage === totalPages && totalPages > 2) startPage = totalPages - 2;
for (let i = startPage; i <= endPage; i++) {
};
const iconFirst = '<svg
xmlns = "http://www.w3.org/2000/svg"
width = "24"
height = "24"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
stroke - width = "2"
stroke - linecap = "round"
stroke - linejoin = "round"
class = "lucide lucide-chevrons-left size-4" > < path d = "m11 17-5-5 5-5" > < /path><path d="m18 17-5-5 5-5"></path > < /svg>';
const iconPrev = '<svg
xmlns = "http://www.w3.org/2000/svg"
width = "24"
height = "24"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
stroke - width = "2"
stroke - linecap = "round"
stroke - linejoin = "round"
class = "lucide lucide-chevron-up size-4 -rotate-90" > < path d = "m18 15-6-6-6 6" > < /path></svg > ';
const iconNext = '<svg
xmlns = "http://www.w3.org/2000/svg"
width = "24"
height = "24"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
stroke - width = "2"
stroke - linecap = "round"
stroke - linejoin = "round"
class = "lucide lucide-chevron-down size-4 -rotate-90" > < path d = "m6 9 6 6 6-6" > < /path></svg > ';
const iconLast = '<svg
xmlns = "http://www.w3.org/2000/svg"
width = "24"
height = "24"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
stroke - width = "2"
stroke - linecap = "round"
stroke - linejoin = "round"
class = "lucide lucide-chevrons-right size-4" > < path d = "m6 17 5-5-5-5" > < /path><path d="m13 17 5-5-5-5"></path > < /svg>';
paginationContainer.appendChild(createButton(iconFirst, () => currentPage = 1, currentPage === 1, false));
paginationContainer.appendChild(createButton(iconPrev, () => currentPage--, currentPage === 1, false));
let startPage = Math.max(1, currentPage - 1);
let endPage = Math.min(totalPages, currentPage + 1);
if (currentPage === 1 && totalPages > 2) endPage = 3;
if (currentPage === totalPages && totalPages > 2) startPage = totalPages - 2;
for (let i = startPage; i <= endPage; i++) {
paginationContainer.appendChild(createButton(i, () => currentPage = i, false, i === currentPage));
}
paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false));
paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false));
}
paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false));
paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false));
}
// Initialize pagination on load
document.addEventListener("DOMContentLoaded", function() {
filterDoctors();
});
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) {
const specBtn = document.getElementById('btn-specialty');
const specList = document.getElementById('list-specialty');
if (specBtn && !specBtn.contains(event.target) && !specList.contains(event.target)) {
// Initialize pagination on load
document.addEventListener("DOMContentLoaded", function() {
filterDoctors();
});
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) {
const specBtn = document.getElementById('btn-specialty');
const specList = document.getElementById('list-specialty');
if (specBtn && !specBtn.contains(event.target) && !specList.contains(event.target)) {
specList.style.display = 'none';
}
const genderBtn = document.getElementById('btn-gender');
const genderList = document.getElementById('list-gender');
if (genderBtn && !genderBtn.contains(event.target) && !genderList.contains(event.target)) {
}
const genderBtn = document.getElementById('btn-gender');
const genderList = document.getElementById('list-gender');
if (genderBtn && !genderBtn.contains(event.target) && !genderList.contains(event.target)) {
genderList.style.display = 'none';
}
});
/*]]>*/
</script>
</div>
</body>
</html>
}
});
/*]]>*/
</script>
</div>
</body>
</html>