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
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 @@
: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
@@ -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);})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

@@ -0,0 +1,350 @@
{
"info": {
"_postman_id": "a5e8f1d2-3b4c-4e5f-9a8b-7c6d5e4f3a2b",
"name": "HIS Work Schedule API v3.0",
"description": "Postman Collection for SIS SOFT HIS Work Schedule API v3.0 integration, including OAuth2 Token, Work Schedules, Work Schedule Days, and Test Cases.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "https://dhs.sisvietnam.vn",
"type": "string"
},
{
"key": "clientId",
"value": "DigitalHealthSolutions_HospitalIntegration",
"type": "string"
},
{
"key": "clientSecret",
"value": "XPWFParRQldtZopqdDFLPYblIzauxuWT",
"type": "string"
},
{
"key": "scope",
"value": "HospitalIntegration",
"type": "string"
},
{
"key": "tenantId",
"value": "3a0f747c-57b6-753b-ba21-3987e8e93b9a",
"type": "string"
},
{
"key": "accessToken",
"value": "",
"type": "string"
},
{
"key": "doctorCode",
"value": "01472",
"type": "string"
}
],
"item": [
{
"name": "01 - Get Hospital Integration Token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json();",
"if (jsonData.access_token) {",
" pm.collectionVariables.set('accessToken', jsonData.access_token);",
" pm.environment.set('accessToken', jsonData.access_token);",
" console.log('Access token saved successfully.');",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded",
"type": "text"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "grant_type",
"value": "client_credentials",
"type": "text"
},
{
"key": "client_id",
"value": "{{clientId}}",
"type": "text"
},
{
"key": "client_secret",
"value": "{{clientSecret}}",
"type": "text"
},
{
"key": "scope",
"value": "{{scope}}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/connect/token",
"host": [
"{{baseUrl}}"
],
"path": [
"connect",
"token"
]
},
"description": "OAuth 2.0 Client Credentials token request. Saves access_token into collection/environment variable {{accessToken}}."
},
"response": []
},
{
"name": "02 - Get Doctors With Work Schedules",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json();",
"if (jsonData.data && jsonData.data.doctors && jsonData.data.doctors.length > 0) {",
" var code = jsonData.data.doctors[0].doctorCode;",
" pm.collectionVariables.set('doctorCode', code);",
" console.log('Saved doctorCode:', code);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}",
"type": "text"
},
{
"key": "__tenant",
"value": "{{tenantId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/api/app/his/doctors/work-schedules?fromDate=2026-07-28&toDate=2026-08-11",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"work-schedules"
],
"query": [
{
"key": "fromDate",
"value": "2026-07-28"
},
{
"key": "toDate",
"value": "2026-08-11"
}
]
},
"description": "Returns list of doctors with active work schedules in requested date range."
},
"response": []
},
{
"name": "03 - Get Doctor Work Schedule Days",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}",
"type": "text"
},
{
"key": "__tenant",
"value": "{{tenantId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/api/app/his/doctors/{{doctorCode}}/work-schedule-days?fromDate=2026-07-28&toDate=2026-08-11",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"{{doctorCode}}",
"work-schedule-days"
],
"query": [
{
"key": "fromDate",
"value": "2026-07-28"
},
{
"key": "toDate",
"value": "2026-08-11"
}
]
},
"description": "Returns active work schedule days for a specific doctorCode."
},
"response": []
},
{
"name": "04 - Invalid Date Format (HTTP 400)",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}",
"type": "text"
},
{
"key": "__tenant",
"value": "{{tenantId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/api/app/his/doctors/work-schedules?fromDate=28-07-2026",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"work-schedules"
],
"query": [
{
"key": "fromDate",
"value": "28-07-2026"
}
]
},
"description": "Test case for HTTP 400 WORK_SCHEDULE_INVALID_DATE_FORMAT."
},
"response": []
},
{
"name": "05 - Missing Token (HTTP 401)",
"request": {
"method": "GET",
"header": [
{
"key": "__tenant",
"value": "{{tenantId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/api/app/his/doctors/work-schedules",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"work-schedules"
]
},
"description": "Test case for HTTP 401 UNAUTHORIZED when Authorization header is omitted."
},
"response": []
},
{
"name": "06 - Invalid Client Token (HTTP 403)",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer INVALID_CLIENT_TOKEN",
"type": "text"
},
{
"key": "__tenant",
"value": "{{tenantId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/api/app/his/doctors/work-schedules",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"work-schedules"
]
},
"description": "Test case for HTTP 403 FORBIDDEN when using token from wrong client or scope."
},
"response": []
},
{
"name": "07 - Doctor Not Found (HTTP 404)",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}",
"type": "text"
},
{
"key": "__tenant",
"value": "{{tenantId}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/api/app/his/doctors/UNKNOWN_DOCTOR_CODE/work-schedule-days",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"app",
"his",
"doctors",
"UNKNOWN_DOCTOR_CODE",
"work-schedule-days"
]
},
"description": "Test case for HTTP 404 DOCTOR_NOT_FOUND when doctorCode does not exist."
},
"response": []
}
]
}
+33
View File
@@ -200,3 +200,36 @@ Mobile Styles (@media (max-width: 768px)): Lines 701 - 850.
Tablet & Flyout Nav (@media (max-width: 1024px)): Lines 851 - 1257. Tablet & Flyout Nav (@media (max-width: 1024px)): Lines 851 - 1257.
Desktop Styles (@media (min-width: 1024px)): Lines 1258 - 1420. Desktop Styles (@media (min-width: 1024px)): Lines 1258 - 1420.
Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481.
📄 Discovery Report: SIS-SOFT-HOSPITAL-WORK-SCHEDULE-API-INTEGRATION.docx
This document specifies the HIS Work Schedule API (v3.0) released by SIS SOFT for integrating doctor work schedules and appointment deep-links.
🔑 1. Authentication (/connect/token)
Base URL: https://dhs.sisvietnam.vn
Endpoint: POST /connect/token
Content-Type: application/x-www-form-urlencoded
Parameters:
grant_type: client_credentials
client_id: DigitalHealthSolutions_HospitalIntegration
client_secret: XPWFParRQldtZopqdDFLPYblIzauxuWT
scope: HospitalIntegration
⚠️ Note: Do NOT send **tenant in /connect/token. The **tenant header is only sent with API calls.
👨‍⚕️ 2. API 1: List Doctors with Work Schedules
Endpoint: GET /api/app/his/doctors/work-schedules
Headers:
Authorization: Bearer <accessToken>
**tenant: 3a0f747c-57b6-753b-ba21-3987e8e93b9a
Query Params:
fromDate (Optional): yyyy-MM-dd (default: today)
toDate (Optional): yyyy-MM-dd (default: max 14 days from today)
Response Data: Returns doctors[] with doctorId, doctorCode, fullName, title, and departments[].
📅 3. API 2: Get Doctor Work Schedule Days
Endpoint: GET /api/app/his/doctors/{doctorCode}/work-schedule-days
Headers: Same as API 1 (Authorization, **tenant).
Path Param: {doctorCode} (e.g. BS001).
Query Params: fromDate, toDate.
Response Data: Returns days[] containing date (yyyy-MM-dd) and dayOfWeek (1 = Sunday, 2 = Monday, ..., 7 = Saturday).
🔗 4. Booking Deep-Link
Format: https://<booking-host>/book-appointment?doctorCode=BS001 (or legacy mabs=BS001)
Flow: Directs external web visitors straight to the doctor's appointment step for selecting schedule dates and time slots.
@@ -63,7 +63,7 @@ public class SecurityConfiguration {
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he", .requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**", "/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", "/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/**") "/Đào tạo tại UMC_files/**", "/dat-lich", "/dat-lich/**", "/api/public/doctor-schedule/**")
.permitAll() .permitAll()
.requestMatchers(HttpMethod.HEAD, "/uploads/**", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**") .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."; return "Added " + count + " specialties to CHUYÊN KHOA menu.";
} }
@GetMapping("/doctor") @GetMapping({"/doctor", "/bac-si", "/bac-si/"})
@org.springframework.transaction.annotation.Transactional(readOnly = true) @org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctor(Model model) { public String getDoctor(Model model) {
try { try {
java.util.List<com.sisvietnamvn.web.domain.Doctor> doctors = doctorService.findAll(); 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() 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()); .collect(java.util.stream.Collectors.toList());
// Sort: 00001 and 00002 at the top // Sort: 00001 and 00002 at the top
@@ -351,6 +351,9 @@ public class PageController {
@org.springframework.transaction.annotation.Transactional(readOnly = true) @org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctorDetail(@PathVariable String doctorCode, Model model) { public String getDoctorDetail(@PathVariable String doctorCode, Model model) {
java.util.Optional<com.sisvietnamvn.web.domain.Doctor> doctorOpt = doctorService.findByDoctorCode(doctorCode); 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()) { if (doctorOpt.isEmpty()) {
return "redirect:/bac-si"; return "redirect:/bac-si";
@@ -60,6 +60,9 @@ public class Doctor extends AbstractAuditingEntity<Long> {
@Column(name = "active") @Column(name = "active")
private Boolean active = true; private Boolean active = true;
@jakarta.persistence.Transient
private Boolean hasSchedule = true;
@Size(max = 255) @Size(max = 255)
@Column(name = "position", length = 255) @Column(name = "position", length = 255)
private String position; private String position;
@@ -240,6 +243,14 @@ public class Doctor extends AbstractAuditingEntity<Long> {
// --- equals, hashCode, toString --- // --- equals, hashCode, toString ---
public Boolean getHasSchedule() {
return hasSchedule;
}
public void setHasSchedule(Boolean hasSchedule) {
this.hasSchedule = hasSchedule;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
@@ -12,7 +12,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
@@ -28,6 +27,13 @@ import jakarta.annotation.PostConstruct;
@Service @Service
public class DoctorApiSyncService { public class DoctorApiSyncService {
private static final Logger LOG = LoggerFactory.getLogger(DoctorApiSyncService.class); 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 RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final MediaRepository mediaRepository; private final MediaRepository mediaRepository;
@@ -39,7 +45,8 @@ public class DoctorApiSyncService {
private List<Map<String, Object>> cachedDoctors = new ArrayList<>(); private List<Map<String, Object>> cachedDoctors = new ArrayList<>();
public DoctorApiSyncService(ObjectMapper objectMapper, MediaRepository mediaRepository, 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.objectMapper = objectMapper;
this.mediaRepository = mediaRepository; this.mediaRepository = mediaRepository;
this.doctorRepository = doctorRepository; this.doctorRepository = doctorRepository;
@@ -67,28 +74,29 @@ public class DoctorApiSyncService {
private List<Map<String, Object>> fetchRealtimeSchedule(String doctorCode) { private List<Map<String, Object>> fetchRealtimeSchedule(String doctorCode) {
List<Map<String, Object>> daysList = new ArrayList<>(); List<Map<String, Object>> daysList = new ArrayList<>();
try { try {
// Get token // 1. Get token (OAuth 2.0 Client Credentials - No tenant header on /connect/token)
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token"; String tokenUrl = BASE_URL + "/connect/token";
HttpHeaders tokenHeaders = new HttpHeaders(); HttpHeaders tokenHeaders = new HttpHeaders();
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 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("grant_type", "client_credentials");
tokenBody.add("client_id", "DigitalHealthSolutions_LabConn"); tokenBody.add("client_id", CLIENT_ID);
tokenBody.add("client_secret", "AcEnBCaphERGOGYMaStATITEnaPETYpR"); tokenBody.add("client_secret", CLIENT_SECRET);
tokenBody.add("scope", "LabConn"); tokenBody.add("scope", SCOPE);
HttpEntity<org.springframework.util.MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class); ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody()); JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenNode.get("access_token").asText(); String accessToken = tokenNode.get("access_token").asText();
// 2. Fetch doctor schedule days
HttpHeaders apiHeaders = new HttpHeaders(); HttpHeaders apiHeaders = new HttpHeaders();
apiHeaders.setBearerAuth(accessToken); apiHeaders.setBearerAuth(accessToken);
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a"); apiHeaders.add("__tenant", TENANT_ID);
apiHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders); 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); ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET, apiRequest, String.class);
JsonNode step3Root = objectMapper.readTree(step3Response.getBody()); JsonNode step3Root = objectMapper.readTree(step3Response.getBody());
JsonNode daysArrayNode = null; JsonNode daysArrayNode = null;
@@ -123,20 +131,18 @@ public class DoctorApiSyncService {
public synchronized void syncDoctorsFromApi() { public synchronized void syncDoctorsFromApi() {
try { 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 // 1. Get Token (No tenant header or Cookie in /connect/token request)
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token"; String tokenUrl = BASE_URL + "/connect/token";
HttpHeaders tokenHeaders = new HttpHeaders(); HttpHeaders tokenHeaders = new HttpHeaders();
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 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<>(); MultiValueMap<String, String> tokenBody = new LinkedMultiValueMap<>();
tokenBody.add("grant_type", "client_credentials"); tokenBody.add("grant_type", "client_credentials");
tokenBody.add("client_id", "DigitalHealthSolutions_LabConn"); tokenBody.add("client_id", CLIENT_ID);
tokenBody.add("client_secret", "AcEnBCaphERGOGYMaStATITEnaPETYpR"); tokenBody.add("client_secret", CLIENT_SECRET);
tokenBody.add("scope", "LabConn"); tokenBody.add("scope", SCOPE);
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders); HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class); ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
@@ -144,13 +150,11 @@ public class DoctorApiSyncService {
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody()); JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenNode.get("access_token").asText(); String accessToken = tokenNode.get("access_token").asText();
// 2. Fetch doctors // 2. Fetch doctors with work schedules
String apiUrl = "https://dhs.sisvietnam.vn/api/app/his/doctors/work-schedules"; String apiUrl = BASE_URL + "/api/app/his/doctors/work-schedules";
HttpHeaders apiHeaders = new HttpHeaders(); HttpHeaders apiHeaders = new HttpHeaders();
apiHeaders.setBearerAuth(accessToken); apiHeaders.setBearerAuth(accessToken);
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a"); apiHeaders.add("__tenant", TENANT_ID);
apiHeaders.add("Cookie",
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders); HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest, ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest,
@@ -180,7 +184,10 @@ public class DoctorApiSyncService {
? node.get("doctorCode").asText() ? node.get("doctorCode").asText()
: ""; : "";
doc.put("doctorCode", doctorCode); 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"; String avatarUrl = "/images/default-avatar.png";
if (!doctorCode.isEmpty()) { if (!doctorCode.isEmpty()) {
@@ -199,7 +206,6 @@ public class DoctorApiSyncService {
doc.put("specialty", spec); doc.put("specialty", spec);
List<Map<String, Object>> daysList = new ArrayList<>(); List<Map<String, Object>> daysList = new ArrayList<>();
doc.put("days", daysList); doc.put("days", daysList);
newDoctorsList.add(doc); newDoctorsList.add(doc);
} }
@@ -283,17 +289,27 @@ public class DoctorApiSyncService {
// Handle inactive doctors (exempt 00001 and 00002 from auto-deactivation) // Handle inactive doctors (exempt 00001 and 00002 from auto-deactivation)
for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) { for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) {
if (dbDoc.getDoctorCode() != null && !dbDoc.getDoctorCode().isEmpty() String code = dbDoc.getDoctorCode();
&& !activeCodes.contains(dbDoc.getDoctorCode())) { boolean hasSched = code != null && activeCodes.contains(code);
if ("00001".equals(dbDoc.getDoctorCode()) || "00002".equals(dbDoc.getDoctorCode())) { 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; continue;
} }
if (code == null || code.trim().isEmpty() || !activeCodes.contains(code)) {
if (dbDoc.getActive() == null || dbDoc.getActive()) { if (dbDoc.getActive() == null || dbDoc.getActive()) {
dbDoc.setActive(false); dbDoc.setActive(false);
if (!doctorsToSave.contains(dbDoc)) {
doctorsToSave.add(dbDoc); doctorsToSave.add(dbDoc);
} }
} }
} }
}
LOG.info("DEBUG: newDoctorsList.size()={}, allDbDoctors.size()={}, doctorsToSave.size()={}", LOG.info("DEBUG: newDoctorsList.size()={}, allDbDoctors.size()={}, doctorsToSave.size()={}",
newDoctorsList.size(), allDbDoctors.size(), doctorsToSave.size()); newDoctorsList.size(), allDbDoctors.size(), doctorsToSave.size());
@@ -158,7 +158,7 @@
<div th:utext="${doctorIntroHtml}"></div> <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"> <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 <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"> <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)"> <g clip-path="url(#clip0_83_297)">
@@ -278,7 +278,7 @@
</div> </div>
</div> </div>
<div th:utext="${doctorIntroHtml}"></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> <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"> <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)"> <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="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 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>
<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-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2"> <div class="bg-primary-600 xl:p-3 p-2">
<div class="title-3 text-white">Lịch khám</div> <div class="title-3 text-white">Lịch khám</div>
</div> </div>
<div class="xl:m-3 m-2"> <div class="xl:m-3 m-2">
<div th:if="${scheduleDays != null and !scheduleDays.isEmpty()}"> <div>
<div class="flex gap-1 mb-1"> <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> <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"> <div class="text-primary-600">
@@ -320,9 +320,6 @@
</li> </li>
</ul> </ul>
</div> </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> </div>
</div> </div>
@@ -1,41 +1,106 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" <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')}">
layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='doctor-page')}"> <head>
<head>
<title>Đội ngũ bác sĩ | S.I.S</title> <title>Đội ngũ bác sĩ | S.I.S</title>
</head> </head>
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<style> <style>
:root { :root {
--spacing: 0.25rem; --spacing: 0.25rem;
} }
/* Fix umass.css overriding layered Tailwind classes */ /* Fix umass.css overriding layered Tailwind classes */
h1.mb-4 { margin-bottom: calc(var(--spacing) * 4) !important; } h1.mb-4 {
margin-bottom: calc(var(--spacing) * 4) !important;
}
@media (min-width: 768px) { @media (min-width: 768px) {
h1.md\:mb-6 { margin-bottom: calc(var(--spacing) * 6) !important; } h1.md\:mb-6 {
margin-bottom: calc(var(--spacing) * 6) !important;
} }
}
@media (min-width: 1280px) { @media (min-width: 1280px) {
h1.xl\:mb-8 { margin-bottom: calc(var(--spacing) * 8) !important; } h1.xl\:mb-8 {
margin-bottom: calc(var(--spacing) * 8) !important;
} }
}
button { button {
padding-block: calc(var(--spacing) * 3); padding-block: calc(var(--spacing) * 3);
padding-inline: calc(var(--spacing)*4); 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> </style>
<link rel="stylesheet" th:href="@{/css/umc_doctor.css}" /> <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_2.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_3.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;"> <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 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> <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"> </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 --> <!-- 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 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>
<div class="flex gap-4 items-center xl:px-6 px-4 xl:pt-6 pt-4 mb-3 relative"> <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})}"> <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"> <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"> <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> <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>
@@ -46,7 +111,7 @@
</a> </a>
<div class="flex-1"> <div class="flex-1">
<div class="text-primary-300 label-3 max-md:!text-[14px] !leading-[150%]" th:text="${doc.title}"></div> <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> <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 class="text-primary-300 label-3 !leading-[150%]" th:text="${doc.specialty != null ? doc.specialty.name : ''}"></div>
</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()}"> <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()}">
@@ -55,22 +120,36 @@
</div> </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"> <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})}"> <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> <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> <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>
<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"> <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> <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> <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> </a>
</div> </div>
</div> </div>
</div> </div>
<div id="pagination-controls" class="flex justify-center items-center space-x-2 mt-8"></div> <div id="pagination-controls" class="flex justify-center items-center space-x-2 mt-8"></div>
</section></main> </section>
</main>
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ /*
<![CDATA[*/
let currentSpecialty = ''; let currentSpecialty = '';
let currentGender = ''; let currentGender = '';
@@ -92,7 +171,6 @@
document.getElementById('list-gender').style.display = 'none'; document.getElementById('list-gender').style.display = 'none';
filterDoctors(); filterDoctors();
} }
let currentPage = 1; let currentPage = 1;
const itemsPerPage = 12; const itemsPerPage = 12;
let filteredCards = []; let filteredCards = [];
@@ -100,23 +178,18 @@
function filterDoctors() { function filterDoctors() {
const searchText = document.getElementById('search-input').value.toLowerCase(); const searchText = document.getElementById('search-input').value.toLowerCase();
const cards = document.querySelectorAll('#doctors-grid > div.doctor-card'); const cards = document.querySelectorAll('#doctors-grid > div.doctor-card');
filteredCards = []; filteredCards = [];
cards.forEach(card => { cards.forEach(card => {
const textContent = card.innerText || ''; const textContent = card.innerText || '';
const textLower = textContent.toLowerCase(); const textLower = textContent.toLowerCase();
let matchSearch = searchText === '' || textLower.includes(searchText); let matchSearch = searchText === '' || textLower.includes(searchText);
let matchSpecialty = currentSpecialty === '' || textContent.includes(currentSpecialty); let matchSpecialty = currentSpecialty === '' || textContent.includes(currentSpecialty);
if (matchSearch && matchSpecialty) { if (matchSearch && matchSpecialty) {
filteredCards.push(card); filteredCards.push(card);
} else { } else {
card.style.display = 'none'; card.style.display = 'none';
} }
}); });
currentPage = 1; currentPage = 1;
renderPagination(); renderPagination();
} }
@@ -125,15 +198,12 @@
const totalPages = Math.ceil(filteredCards.length / itemsPerPage); const totalPages = Math.ceil(filteredCards.length / itemsPerPage);
const paginationContainer = document.getElementById('pagination-controls'); const paginationContainer = document.getElementById('pagination-controls');
paginationContainer.innerHTML = ''; paginationContainer.innerHTML = '';
if (totalPages <= 1) { if (totalPages <= 1) {
filteredCards.forEach(card => card.style.display = ''); filteredCards.forEach(card => card.style.display = '');
return; return;
} }
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage; const endIndex = startIndex + itemsPerPage;
filteredCards.forEach((card, index) => { filteredCards.forEach((card, index) => {
if (index >= startIndex && index < endIndex) { if (index >= startIndex && index < endIndex) {
card.style.display = ''; card.style.display = '';
@@ -141,7 +211,6 @@
card.style.display = 'none'; card.style.display = 'none';
} }
}); });
const createButton = (content, onClick, disabled, isActive) => { const createButton = (content, onClick, disabled, isActive) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
if (isActive) { if (isActive) {
@@ -160,34 +229,66 @@
} }
return btn; return btn;
}; };
const iconFirst = '<svg
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>'; xmlns = "http://www.w3.org/2000/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>'; width = "24"
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>'; height = "24"
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>'; 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(iconFirst, () => currentPage = 1, currentPage === 1, false));
paginationContainer.appendChild(createButton(iconPrev, () => currentPage--, currentPage === 1, false)); paginationContainer.appendChild(createButton(iconPrev, () => currentPage--, currentPage === 1, false));
let startPage = Math.max(1, currentPage - 1); let startPage = Math.max(1, currentPage - 1);
let endPage = Math.min(totalPages, currentPage + 1); let endPage = Math.min(totalPages, currentPage + 1);
if (currentPage === 1 && totalPages > 2) endPage = 3; if (currentPage === 1 && totalPages > 2) endPage = 3;
if (currentPage === totalPages && totalPages > 2) startPage = totalPages - 2; if (currentPage === totalPages && totalPages > 2) startPage = totalPages - 2;
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
paginationContainer.appendChild(createButton(i, () => currentPage = i, false, i === currentPage)); paginationContainer.appendChild(createButton(i, () => currentPage = i, false, i === currentPage));
} }
paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false)); paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false));
paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false)); paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false));
} }
// Initialize pagination on load // Initialize pagination on load
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
filterDoctors(); filterDoctors();
}); });
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const specBtn = document.getElementById('btn-specialty'); const specBtn = document.getElementById('btn-specialty');
@@ -195,16 +296,14 @@
if (specBtn && !specBtn.contains(event.target) && !specList.contains(event.target)) { if (specBtn && !specBtn.contains(event.target) && !specList.contains(event.target)) {
specList.style.display = 'none'; specList.style.display = 'none';
} }
const genderBtn = document.getElementById('btn-gender'); const genderBtn = document.getElementById('btn-gender');
const genderList = document.getElementById('list-gender'); const genderList = document.getElementById('list-gender');
if (genderBtn && !genderBtn.contains(event.target) && !genderList.contains(event.target)) { if (genderBtn && !genderBtn.contains(event.target) && !genderList.contains(event.target)) {
genderList.style.display = 'none'; genderList.style.display = 'none';
} }
}); });
/*]]>*/ /*]]>*/
</script> </script>
</div>
</div> </body>
</body>
</html> </html>