feat: refactor doctor listing and detail pages with dynamic routing, improved booking links, and optimized scheduling display.
@@ -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"}
|
||||||
@@ -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);})();
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 581 B |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
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()}]);
|
||||||
@@ -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");
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 959 B |
|
After Width: | Height: | Size: 585 B |
|
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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,14 +289,24 @@ 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)) {
|
||||||
continue;
|
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()) {
|
if (dbDoc.getActive() == null || dbDoc.getActive()) {
|
||||||
dbDoc.setActive(false);
|
dbDoc.setActive(false);
|
||||||
doctorsToSave.add(dbDoc);
|
if (!doctorsToSave.contains(dbDoc)) {
|
||||||
|
doctorsToSave.add(dbDoc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,210 +1,309 @@
|
|||||||
<!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 */
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
/* Fix umass.css overriding layered Tailwind classes */
|
||||||
const endIndex = startIndex + itemsPerPage;
|
h1.mb-4 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 4) !important;
|
||||||
filteredCards.forEach((card, index) => {
|
}
|
||||||
if (index >= startIndex && index < endIndex) {
|
|
||||||
card.style.display = '';
|
|
||||||
} else {
|
|
||||||
card.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
const btn = document.createElement('button');
|
||||||
if (isActive) {
|
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 {
|
} 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;
|
btn.innerHTML = content;
|
||||||
if (disabled) btn.disabled = true;
|
if (disabled) btn.disabled = true;
|
||||||
if (!disabled && !isActive) {
|
if (!disabled && !isActive) {
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
onClick();
|
onClick();
|
||||||
renderPagination();
|
renderPagination();
|
||||||
window.scrollTo(0, document.getElementById('doctor-content').offsetTop - 50);
|
window.scrollTo(0, document.getElementById('doctor-content').offsetTop - 50);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
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"
|
||||||
paginationContainer.appendChild(createButton(iconFirst, () => currentPage = 1, currentPage === 1, false));
|
stroke = "currentColor"
|
||||||
paginationContainer.appendChild(createButton(iconPrev, () => currentPage--, currentPage === 1, false));
|
stroke - width = "2"
|
||||||
|
stroke - linecap = "round"
|
||||||
let startPage = Math.max(1, currentPage - 1);
|
stroke - linejoin = "round"
|
||||||
let endPage = Math.min(totalPages, currentPage + 1);
|
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
|
||||||
if (currentPage === 1 && totalPages > 2) endPage = 3;
|
xmlns = "http://www.w3.org/2000/svg"
|
||||||
if (currentPage === totalPages && totalPages > 2) startPage = totalPages - 2;
|
width = "24"
|
||||||
|
height = "24"
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
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(i, () => currentPage = i, false, i === currentPage));
|
||||||
|
}
|
||||||
|
paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false));
|
||||||
|
paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false));
|
||||||
}
|
}
|
||||||
|
// Initialize pagination on load
|
||||||
paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false));
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false));
|
filterDoctors();
|
||||||
}
|
});
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
// Initialize pagination on load
|
document.addEventListener('click', function(event) {
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
const specBtn = document.getElementById('btn-specialty');
|
||||||
filterDoctors();
|
const specList = document.getElementById('list-specialty');
|
||||||
});
|
if (specBtn && !specBtn.contains(event.target) && !specList.contains(event.target)) {
|
||||||
|
|
||||||
// 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';
|
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>
|
|
||||||