feat: implement doctor schedule module, add detail fields to doctor profile, and integrate new appointment API client

This commit is contained in:
2026-07-23 19:34:02 +07:00
parent 107e191e93
commit 6d84979f03
111 changed files with 5138 additions and 288 deletions
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
@@ -0,0 +1 @@
.swiper-fade.swiper-free-mode .swiper-slide{transition-timing-function:ease-out}.swiper-fade .swiper-slide{pointer-events:none;transition-property:opacity}.swiper-fade .swiper-slide .swiper-slide{pointer-events:none}.swiper-fade .swiper-slide-active,.swiper-fade .swiper-slide-active .swiper-slide-active{pointer-events:auto}
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

@@ -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.js';po.crossOrigin='anonymous';po.integrity='sha384-DMJucfgjcmtc4a8x9gFfPgwoWXK+1qozk7K/wTFGVtduYs3wg2BI8Z5lrJXZV+iE';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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://bvdaihoc.com.vn/bac-si/bui-cao-my-ai-id-53
HostUrl=https://bvdaihoc.com.vn/_next/static/css/ef46db3751d8e999.css
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: 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");
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

+95
View File
@@ -0,0 +1,95 @@
package vn.sis.appointment;
import java.net.URI;
import java.util.Map;
import java.util.Objects;
public record ApiConfig(
String baseUrl,
String tenant,
String clientId,
String clientSecret,
String scope,
String cookieHeader) {
public static ApiConfig fromForm(Map<String, String> form) {
return new ApiConfig(
value(form, "baseUrl", "https://dhs.sisvietnam.vn"),
value(form, "tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a"),
value(form, "clientId", "DigitalHealthSolutions_LabConn"),
value(form, "clientSecret", ""),
value(form, "scope", "LabConn"),
value(form, "cookie", ""));
}
public ApiConfig {
baseUrl = trimTrailingSlash(Objects.requireNonNullElse(baseUrl, ""));
tenant = Objects.requireNonNullElse(tenant, "").trim();
clientId = Objects.requireNonNullElse(clientId, "").trim();
clientSecret = Objects.requireNonNullElse(clientSecret, "");
scope = Objects.requireNonNullElse(scope, "").trim();
cookieHeader = Objects.requireNonNullElse(cookieHeader, "").trim();
}
public void validate() {
URI uri;
try {
uri = URI.create(baseUrl);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Base URL khong hop le.", ex);
}
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http"))) {
throw new IllegalArgumentException("Base URL phai bat dau bang http:// hoac https://.");
}
if (tenant.isBlank()) {
throw new IllegalArgumentException("Tenant khong duoc de trong.");
}
if (clientId.isBlank()) {
throw new IllegalArgumentException("Client ID khong duoc de trong.");
}
if (clientSecret.isBlank()) {
throw new IllegalArgumentException("Client Secret khong duoc de trong.");
}
if (scope.isBlank()) {
throw new IllegalArgumentException("Scope khong duoc de trong.");
}
}
public String tokenUrl() {
return baseUrl + "/connect/token";
}
public String doctorsUrl() {
return baseUrl + "/api/app/his/doctors/work-schedules";
}
public String scheduleUrl(String doctorCode) {
return baseUrl + "/api/app/his/doctors/" + Urls.encodePathSegment(doctorCode) + "/work-schedule-days";
}
public String effectiveCookieHeader() {
if (!cookieHeader.isBlank()) {
return cookieHeader;
}
return ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=" + tenant;
}
public String cacheKeyMaterial() {
return String.join("|", baseUrl, tenant, clientId, clientSecret, scope);
}
private static String value(Map<String, String> form, String key, String defaultValue) {
String value = form.get(key);
return value == null ? defaultValue : value;
}
private static String trimTrailingSlash(String value) {
String result = value.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
}
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
package vn.sis.appointment;
public final class Application {
private Application() {
}
public static void main(String[] args) throws Exception {
int port = resolvePort();
AppointmentWebServer server = new AppointmentWebServer(port);
server.start();
System.out.printf("S.I.S Appointment Java Web dang chay tai http://localhost:%d%n", port);
System.out.println("Nhan Ctrl+C de dung ung dung.");
}
private static int resolvePort() {
String value = System.getenv().getOrDefault("PORT", "8080").trim();
try {
int port = Integer.parseInt(value);
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("PORT phai nam trong khoang 1-65535.");
}
return port;
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Bien moi truong PORT khong hop le: " + value, ex);
}
}
}
Binary file not shown.
+155
View File
@@ -0,0 +1,155 @@
package vn.sis.appointment;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.Executors;
final class AppointmentWebServer {
private final HttpServer server;
private final SisApiClient apiClient = new SisApiClient();
AppointmentWebServer(int port) throws IOException {
server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/api/health", exchange -> {
if (!requireMethod(exchange, "GET")) {
return;
}
sendJson(exchange, 200, Json.stringify(Map.of(
"ok", true,
"application", "SisAppointmentJavaWeb",
"javaVersion", System.getProperty("java.version"))));
});
server.createContext("/api/token", postHandler((form, config) -> apiClient.checkToken(config)));
server.createContext("/api/doctors", postHandler((form, config) -> apiClient.getDoctors(config)));
server.createContext("/api/schedule", postHandler((form, config) ->
apiClient.getSchedule(config, form.getOrDefault("doctorCode", ""))));
server.createContext("/api/clear-token", postHandler((form, config) -> {
apiClient.clearToken(config);
return new ProxyResponse(true, 200, "OK", "-", "-", 0,
"", "", "", null, "Da xoa access token trong bo nho may chu.");
}));
server.createContext("/", new StaticHandler());
}
void start() {
server.start();
}
private HttpHandler postHandler(ApiOperation operation) {
return exchange -> {
if (!requireMethod(exchange, "POST")) {
return;
}
try {
Map<String, String> form = FormData.read(exchange);
ApiConfig config = ApiConfig.fromForm(form);
ProxyResponse result = operation.execute(form, config);
sendJson(exchange, 200, result.toJson());
} catch (IllegalArgumentException ex) {
ProxyResponse error = new ProxyResponse(false, 400, "Bad Request", "-", "-", 0,
"", "", "", null, ex.getMessage());
sendJson(exchange, 400, error.toJson());
} catch (Exception ex) {
ProxyResponse error = new ProxyResponse(false, 500, "Internal Server Error", "-", "-", 0,
"", "", "", null,
ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage());
sendJson(exchange, 500, error.toJson());
}
};
}
private static boolean requireMethod(HttpExchange exchange, String expected) throws IOException {
if (exchange.getRequestMethod().equalsIgnoreCase(expected)) {
return true;
}
exchange.getResponseHeaders().set("Allow", expected);
sendJson(exchange, 405, Json.stringify(Map.of(
"ok", false,
"message", "Phuong thuc khong duoc ho tro.")));
return false;
}
private static void sendJson(HttpExchange exchange, int status, String json) throws IOException {
byte[] data = json.getBytes(StandardCharsets.UTF_8);
applySecurityHeaders(exchange.getResponseHeaders());
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(status, data.length);
exchange.getResponseBody().write(data);
exchange.close();
}
private static void applySecurityHeaders(Headers headers) {
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Frame-Options", "DENY");
headers.set("Referrer-Policy", "no-referrer");
headers.set("Content-Security-Policy",
"default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'");
}
@FunctionalInterface
private interface ApiOperation {
ProxyResponse execute(Map<String, String> form, ApiConfig config);
}
private static final class StaticHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) {
exchange.getResponseHeaders().set("Allow", "GET");
exchange.sendResponseHeaders(405, -1);
exchange.close();
return;
}
String path = exchange.getRequestURI().getPath();
if (path.equals("/")) {
path = "/index.html";
}
if (path.contains("..")) {
exchange.sendResponseHeaders(400, -1);
exchange.close();
return;
}
String resource = "web" + path;
try (InputStream input = AppointmentWebServer.class.getClassLoader().getResourceAsStream(resource)) {
if (input == null) {
exchange.sendResponseHeaders(404, -1);
exchange.close();
return;
}
byte[] data = input.readAllBytes();
Headers headers = exchange.getResponseHeaders();
applySecurityHeaders(headers);
headers.set("Content-Type", contentType(path));
headers.set("Cache-Control", path.endsWith("index.html") ? "no-cache" : "public, max-age=3600");
exchange.sendResponseHeaders(200, data.length);
exchange.getResponseBody().write(data);
exchange.close();
}
}
private static String contentType(String path) {
if (path.endsWith(".html")) return "text/html; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
return "application/octet-stream";
}
}
}
+10
View File
@@ -0,0 +1,10 @@
package vn.sis.appointment;
import java.time.Instant;
record CachedToken(String accessToken, String tokenType, Instant expiresAt) {
boolean isValid() {
return accessToken != null && !accessToken.isBlank()
&& Instant.now().isBefore(expiresAt.minusSeconds(15));
}
}
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
package vn.sis.appointment;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
final class FormData {
private static final int MAX_REQUEST_BYTES = 256 * 1024;
private FormData() {
}
static Map<String, String> read(HttpExchange exchange) throws IOException {
byte[] data = exchange.getRequestBody().readNBytes(MAX_REQUEST_BYTES + 1);
if (data.length > MAX_REQUEST_BYTES) {
throw new IllegalArgumentException("Du lieu gui len vuot qua gioi han 256 KB.");
}
String body = new String(data, StandardCharsets.UTF_8);
Map<String, String> result = new LinkedHashMap<>();
if (body.isBlank()) {
return result;
}
for (String pair : body.split("&")) {
int separator = pair.indexOf('=');
String rawKey = separator >= 0 ? pair.substring(0, separator) : pair;
String rawValue = separator >= 0 ? pair.substring(separator + 1) : "";
result.put(decode(rawKey), decode(rawValue));
}
return result;
}
private static String decode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
}
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
package vn.sis.appointment;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
final class Hashes {
private Hashes() {
}
static String sha256(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("JDK khong ho tro SHA-256.", ex);
}
}
}
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
package vn.sis.appointment;
import java.util.Collection;
import java.util.Map;
final class Json {
private Json() {
}
static String stringify(Object value) {
if (value == null) {
return "null";
}
if (value instanceof String text) {
return quote(text);
}
if (value instanceof Number || value instanceof Boolean) {
return value.toString();
}
if (value instanceof Map<?, ?> map) {
StringBuilder builder = new StringBuilder("{");
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) {
builder.append(',');
}
first = false;
builder.append(quote(String.valueOf(entry.getKey())))
.append(':')
.append(stringify(entry.getValue()));
}
return builder.append('}').toString();
}
if (value instanceof Collection<?> collection) {
StringBuilder builder = new StringBuilder("[");
boolean first = true;
for (Object item : collection) {
if (!first) {
builder.append(',');
}
first = false;
builder.append(stringify(item));
}
return builder.append(']').toString();
}
return quote(value.toString());
}
static String quote(String value) {
StringBuilder builder = new StringBuilder(value.length() + 16).append('"');
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
switch (ch) {
case '"' -> builder.append("\\\"");
case '\\' -> builder.append("\\\\");
case '\b' -> builder.append("\\b");
case '\f' -> builder.append("\\f");
case '\n' -> builder.append("\\n");
case '\r' -> builder.append("\\r");
case '\t' -> builder.append("\\t");
default -> {
if (ch < 0x20) {
builder.append(String.format("\\u%04x", (int) ch));
} else {
builder.append(ch);
}
}
}
}
return builder.append('"').toString();
}
}
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
package vn.sis.appointment;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
record ProxyResponse(
boolean ok,
int httpStatus,
String statusText,
String method,
String url,
int attempts,
String body,
String requestInfo,
String responseHeaders,
Instant tokenExpiresAt,
String message) {
String toJson() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("ok", ok);
values.put("httpStatus", httpStatus);
values.put("statusText", statusText);
values.put("method", method);
values.put("url", url);
values.put("attempts", attempts);
values.put("body", body == null ? "" : body);
values.put("requestInfo", requestInfo == null ? "" : requestInfo);
values.put("responseHeaders", responseHeaders == null ? "" : responseHeaders);
values.put("tokenExpiresAt", tokenExpiresAt == null ? null : tokenExpiresAt.toString());
values.put("message", message == null ? "" : message);
return Json.stringify(values);
}
}
Binary file not shown.
+347
View File
@@ -0,0 +1,347 @@
package vn.sis.appointment;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
final class SisApiClient {
private final HttpClient httpClient;
private final Map<String, CachedToken> tokenCache = new ConcurrentHashMap<>();
SisApiClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_1_1)
.build();
}
ProxyResponse checkToken(ApiConfig config) {
try {
config.validate();
TokenResult tokenResult = obtainToken(config, true);
if (!tokenResult.success()) {
return tokenResult.proxyResponse();
}
CachedToken token = tokenResult.token();
String safeBody = Json.stringify(Map.of(
"access_token", "[da an]",
"token_type", token.tokenType(),
"expires_at", token.expiresAt().toString()));
ProxyResponse source = tokenResult.proxyResponse();
return new ProxyResponse(
true,
source.httpStatus(),
source.statusText(),
source.method(),
source.url(),
1,
safeBody,
source.requestInfo(),
source.responseHeaders(),
token.expiresAt(),
"Lay access token thanh cong.");
} catch (IllegalArgumentException ex) {
return validationError(ex.getMessage());
} catch (Exception ex) {
return errorResponse(ex);
}
}
ProxyResponse getDoctors(ApiConfig config) {
return authorizedGet(config, config.doctorsUrl());
}
ProxyResponse getSchedule(ApiConfig config, String doctorCode) {
if (doctorCode == null || doctorCode.isBlank()) {
return validationError("Ma bac si khong duoc de trong.");
}
return authorizedGet(config, config.scheduleUrl(doctorCode.trim()));
}
void clearToken(ApiConfig config) {
tokenCache.remove(cacheKey(config));
}
private ProxyResponse authorizedGet(ApiConfig config, String url) {
try {
config.validate();
int attempts = 1;
TokenResult tokenResult = obtainToken(config, false);
if (!tokenResult.success()) {
return tokenResult.proxyResponse();
}
CachedToken token = tokenResult.token();
HttpCall call = sendGet(config, url, token.accessToken());
if (call.statusCode() == 401) {
attempts = 2;
clearToken(config);
tokenResult = obtainToken(config, true);
if (!tokenResult.success()) {
return tokenResult.proxyResponse();
}
token = tokenResult.token();
call = sendGet(config, url, token.accessToken());
}
boolean ok = call.statusCode() >= 200 && call.statusCode() <= 299;
return new ProxyResponse(
ok,
call.statusCode(),
call.statusText(),
"GET",
url,
attempts,
call.body(),
requestInfo(config, "GET", url, true),
call.responseHeaders(),
token.expiresAt(),
ok ? "Goi API thanh cong." : "API tra ve loi HTTP " + call.statusCode() + ".");
} catch (IllegalArgumentException ex) {
return validationError(ex.getMessage());
} catch (Exception ex) {
return errorResponse(ex);
}
}
private TokenResult obtainToken(ApiConfig config, boolean forceRefresh)
throws IOException, InterruptedException {
String key = cacheKey(config);
CachedToken cached = tokenCache.get(key);
if (!forceRefresh && cached != null && cached.isValid()) {
ProxyResponse cachedResponse = new ProxyResponse(
true,
200,
"OK",
"CACHE",
config.tokenUrl(),
0,
"",
"Su dung access token dang con han trong bo nho may chu.",
"",
cached.expiresAt(),
"Token con hieu luc.");
return new TokenResult(true, cached, cachedResponse);
}
String form = formEncode(Map.of(
"grant_type", "client_credentials",
"client_id", config.clientId(),
"client_secret", config.clientSecret(),
"scope", config.scope()));
HttpRequest request = HttpRequest.newBuilder(URI.create(config.tokenUrl()))
.timeout(Duration.ofSeconds(60))
.version(HttpClient.Version.HTTP_1_1)
.header("__tenant", config.tenant())
.header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", "SisAppointmentJavaWeb/1.0")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpCall call = HttpCall.from(response);
boolean ok = call.statusCode() >= 200 && call.statusCode() <= 299;
if (!ok) {
ProxyResponse proxyResponse = new ProxyResponse(
false,
call.statusCode(),
call.statusText(),
"POST",
config.tokenUrl(),
1,
call.body(),
requestInfo(config, "POST", config.tokenUrl(), false),
call.responseHeaders(),
null,
"Khong lay duoc access token.");
return new TokenResult(false, null, proxyResponse);
}
TokenJsonParser.ParsedToken parsed;
try {
parsed = TokenJsonParser.parse(call.body());
} catch (RuntimeException ex) {
ProxyResponse proxyResponse = new ProxyResponse(
false,
call.statusCode(),
call.statusText(),
"POST",
config.tokenUrl(),
1,
limit(call.body()),
requestInfo(config, "POST", config.tokenUrl(), false),
call.responseHeaders(),
null,
ex.getMessage());
return new TokenResult(false, null, proxyResponse);
}
long safeLifetime = Math.max(parsed.expiresIn() - 60, 60);
CachedToken token = new CachedToken(
parsed.accessToken(),
parsed.tokenType(),
Instant.now().plusSeconds(safeLifetime));
tokenCache.put(key, token);
ProxyResponse proxyResponse = new ProxyResponse(
true,
call.statusCode(),
call.statusText(),
"POST",
config.tokenUrl(),
1,
"",
requestInfo(config, "POST", config.tokenUrl(), false),
call.responseHeaders(),
token.expiresAt(),
"Lay access token thanh cong.");
return new TokenResult(true, token, proxyResponse);
}
private HttpCall sendGet(ApiConfig config, String url, String accessToken)
throws IOException, InterruptedException {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(60))
.version(HttpClient.Version.HTTP_1_1)
.header("__tenant", config.tenant())
.header("Authorization", "Bearer " + accessToken)
.header("Accept", "application/json")
.header("Cache-Control", "no-cache, no-store, max-age=0")
.header("Pragma", "no-cache")
.header("User-Agent", "SisAppointmentJavaWeb/1.0")
.GET();
String cookie = config.effectiveCookieHeader();
if (!cookie.isBlank()) {
builder.header("Cookie", cookie);
}
HttpResponse<String> response = httpClient.send(
builder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return HttpCall.from(response);
}
private static String requestInfo(ApiConfig config, String method, String url, boolean includeCookie) {
StringBuilder info = new StringBuilder()
.append(method).append(' ').append(url).append('\n')
.append("__tenant: ").append(config.tenant()).append('\n')
.append("Authorization: Bearer [da an]").append('\n');
if (method.equals("POST")) {
info.append("Content-Type: application/x-www-form-urlencoded\n");
} else {
info.append("Content-Type: --\n");
}
if (includeCookie) {
info.append("Cookie: ").append(maskCookie(config.effectiveCookieHeader()));
}
return info.toString();
}
private static String maskCookie(String cookie) {
if (cookie == null || cookie.isBlank()) {
return "--";
}
String[] parts = cookie.split(";");
for (int i = 0; i < parts.length; i++) {
String part = parts[i].trim();
int equals = part.indexOf('=');
if (equals > 0 && part.substring(0, equals).trim().equalsIgnoreCase("affinity")) {
parts[i] = " affinity=[da an]";
}
}
return String.join(";", parts).trim();
}
private static String formEncode(Map<String, String> values) {
StringBuilder result = new StringBuilder();
for (Map.Entry<String, String> entry : values.entrySet()) {
if (!result.isEmpty()) {
result.append('&');
}
result.append(java.net.URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
.append('=')
.append(java.net.URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
}
return result.toString();
}
private static String cacheKey(ApiConfig config) {
return Hashes.sha256(config.cacheKeyMaterial());
}
private static ProxyResponse validationError(String message) {
return new ProxyResponse(false, 400, "Bad Request", "-", "-", 0,
"", "", "", null, message);
}
private static ProxyResponse errorResponse(Exception ex) {
String message = ex.getMessage() == null || ex.getMessage().isBlank()
? ex.getClass().getSimpleName()
: ex.getMessage();
return new ProxyResponse(false, 500, "Internal Server Error", "-", "-", 0,
"", "", "", null, message);
}
private static String limit(String value) {
if (value == null || value.length() <= 4000) {
return value == null ? "" : value;
}
return value.substring(0, 4000) + "...";
}
private record TokenResult(boolean success, CachedToken token, ProxyResponse proxyResponse) {
}
private record HttpCall(int statusCode, String statusText, String body, String responseHeaders) {
static HttpCall from(HttpResponse<String> response) {
StringBuilder headers = new StringBuilder();
response.headers().map().forEach((name, values) ->
headers.append(name).append(": ").append(String.join(", ", values)).append('\n'));
return new HttpCall(
response.statusCode(),
statusText(response.statusCode()),
Optional.ofNullable(response.body()).orElse(""),
headers.toString().trim());
}
private static String statusText(int code) {
return switch (code) {
case 200 -> "OK";
case 201 -> "Created";
case 204 -> "No Content";
case 400 -> "Bad Request";
case 401 -> "Unauthorized";
case 403 -> "Forbidden";
case 404 -> "Not Found";
case 405 -> "Method Not Allowed";
case 408 -> "Request Timeout";
case 429 -> "Too Many Requests";
case 500 -> "Internal Server Error";
case 502 -> "Bad Gateway";
case 503 -> "Service Unavailable";
case 504 -> "Gateway Timeout";
default -> "HTTP " + code;
};
}
}
}
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
package vn.sis.appointment;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class TokenJsonParser {
private static final Pattern ACCESS_TOKEN = Pattern.compile(
"\\\"access_token\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"");
private static final Pattern TOKEN_TYPE = Pattern.compile(
"\\\"token_type\\\"\\s*:\\s*\\\"([^\\\"]*)\\\"");
private static final Pattern EXPIRES_IN = Pattern.compile(
"\\\"expires_in\\\"\\s*:\\s*(\\d+)");
private TokenJsonParser() {
}
static ParsedToken parse(String json) {
String accessToken = group(ACCESS_TOKEN, json);
if (accessToken == null || accessToken.isBlank()) {
throw new IllegalArgumentException("API token khong tra ve access_token hop le.");
}
String tokenType = group(TOKEN_TYPE, json);
String expiresText = group(EXPIRES_IN, json);
long expiresIn = expiresText == null ? 3600 : Long.parseLong(expiresText);
return new ParsedToken(accessToken, tokenType == null ? "Bearer" : tokenType, expiresIn);
}
private static String group(Pattern pattern, String text) {
Matcher matcher = pattern.matcher(text == null ? "" : text);
return matcher.find() ? matcher.group(1) : null;
}
record ParsedToken(String accessToken, String tokenType, long expiresIn) {
}
}
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
package vn.sis.appointment;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
final class Urls {
private Urls() {
}
static String encodePathSegment(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
}
Binary file not shown.
+101
View File
@@ -0,0 +1,101 @@
from bs4 import BeautifulSoup
import re
# Read template
with open("html_snippets/content_main_blocks/temp", "r", encoding="utf-8") as f:
html = f.read()
# Strip styles and scripts (we can just remove script tags by regex)
html = re.sub(r"<script.*?</script>", "", html, flags=re.DOTALL)
html = re.sub(r" style=\"[^\"]*\"", "", html)
soup = BeautifulSoup(html, 'html.parser')
# Replace doctor text
# Find "Đào Thị Thanh An"
for el in soup.find_all(string=re.compile("Đào Thị Thanh An")):
parent = el.parent
parent.clear()
span = soup.new_tag("span")
span['th:text'] = '${doctor.name}'
span.string = "Tên Bác sĩ"
parent.append(span)
# Find "TS BS."
for el in soup.find_all(string=re.compile("TS BS.")):
parent = el.parent
parent.clear()
span = soup.new_tag("span")
span['th:text'] = '${doctor.title}'
span.string = "Học hàm/Học vị"
parent.append(span)
# Find "Khoa Khám bệnh"
for el in soup.find_all(string=re.compile("Khoa Khám bệnh")):
parent = el.parent
parent.clear()
span = soup.new_tag("span")
span['th:text'] = '${specialtyName}'
span.string = "Chuyên khoa"
parent.append(span)
# Find Avatar img
img = soup.find('img', alt="Avatar Bác sĩ")
if img:
img['th:src'] = '${doctor.avatarUrl != null and !doctor.avatarUrl.isEmpty() ? doctor.avatarUrl : \'/images/default-avatar.png\'}'
img['style'] = "width: 100%; height: 100%; object-fit: contain;"
# Find Đặt lịch khám href
for a in soup.find_all('a', href="/dat-lich-kham"):
del a['href']
a['th:href'] = "${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}"
# Find accordions
accordions = soup.find_all('div', class_='jam-accordion')
for acc in accordions:
title_div = acc.find('div', class_='title-3')
if not title_div:
continue
title = title_div.get_text(strip=True)
content_div = acc.find('div', class_='jam-accordion-content')
if not content_div:
continue
# Clear original hardcoded list content
content_div.clear()
# Create replacement div
new_div = soup.new_tag("div")
if "Quá trình đào tạo" in title:
acc['th:if'] = "${doctor.education != null and !doctor.education.isEmpty()}"
new_div['class'] = "prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2"
new_div['th:utext'] = "${doctor.education}"
content_div.append(new_div)
elif "Quá trình công tác" in title:
acc['th:if'] = "${doctor.workExperience != null and !doctor.workExperience.isEmpty()}"
new_div['class'] = "prose doctor-info-prose transition-all duration-200 !text-[14px] space-y-2 xl:m-3 m-2"
new_div['th:utext'] = "${doctor.workExperience}"
content_div.append(new_div)
elif "Hiệp hội chuyên môn" in title:
title_div.string = "Hiệp hội chuyên môn & Thành tựu"
acc['th:if'] = "${doctor.achievements != null and !doctor.achievements.isEmpty()}"
new_div['class'] = "prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2"
new_div['th:utext'] = "${doctor.achievements}"
content_div.append(new_div)
# Get inner HTML of <main>
main_tag = soup.find('main')
main_content = "".join(str(item) for item in main_tag.contents)
# Read original target template
with open("src/main/resources/templates/doctor-detail.html", "r", encoding="utf-8") as tf:
template = tf.read()
# Replace content inside <main>
new_template = re.sub(r"(<main[^>]*>).*?(</main>)", r"\1" + main_content.replace("\\", "\\\\") + r"\2", template, flags=re.DOTALL)
with open("src/main/resources/templates/doctor-detail.html", "w", encoding="utf-8") as tf:
tf.write(new_template)
print("Fixed layout using BeautifulSoup!")
+85
View File
@@ -0,0 +1,85 @@
with open("html_snippets/content_main_blocks/temp", "r", encoding="utf-8") as f:
html = f.read()
# Replace simple strings first (very fast)
html = html.replace("Đào Thị Thanh An", "<span th:text=\"${doctor.name}\">Tên Bác sĩ</span>")
html = html.replace("TS BS.", "<span th:text=\"${doctor.title}\">Học hàm/Học vị</span>")
html = html.replace("Khoa Khám bệnh", "<span th:text=\"${specialtyName}\">Chuyên khoa</span>")
# Find Avatar img and replace
avatar_start = html.find('<img')
while avatar_start != -1:
avatar_end = html.find('>', avatar_start)
img_tag = html[avatar_start:avatar_end+1]
if 'A17-243' in img_tag:
new_img = '<img th:src="${doctor.avatarUrl != null and !doctor.avatarUrl.isEmpty() ? doctor.avatarUrl : \'/images/default-avatar.png\'}" style="width: 100%; height: 100%; object-fit: contain;" alt="Avatar Bác sĩ" />'
html = html[:avatar_start] + new_img + html[avatar_end+1:]
break
avatar_start = html.find('<img', avatar_start + 1)
# Add href to Đặt lịch khám
html = html.replace('href="/dat-lich-kham"', 'th:href="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : \'#\'}"')
def process_accordion(html_str, title_text, th_if, th_utext, extra_classes):
title_idx = html_str.find(f'<div class="title-3 text-white">{title_text}</div>')
if title_idx == -1: return html_str
start_idx = html_str.rfind('<div class="jam-accordion active">', 0, title_idx)
content_idx = html_str.find('<div class="jam-accordion-content">', title_idx)
content_start = content_idx + len('<div class="jam-accordion-content">')
# We find the matching closing div for jam-accordion active.
# But since we know it ends with </div></div></div> (content, shadow box, accordion)
end_idx = html_str.find('</div></div></div>', content_start)
if end_idx == -1: return html_str
prefix = html_str[:start_idx]
acc_open = f'<div class="jam-accordion active" th:if="{th_if}">'
middle = html_str[start_idx + len('<div class="jam-accordion active">') : content_start]
new_content = f'<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] {extra_classes}" th:utext="{th_utext}"></div>'
suffix = html_str[end_idx:]
return prefix + acc_open + middle + new_content + suffix
html = process_accordion(html, "Quá trình đào tạo", "${doctor.education != null and !doctor.education.isEmpty()}", "${doctor.education}", "xl:m-3 m-2")
html = process_accordion(html, "Quá trình công tác", "${doctor.workExperience != null and !doctor.workExperience.isEmpty()}", "${doctor.workExperience}", "space-y-2 xl:m-3 m-2")
html = html.replace('<div class="title-3 text-white">Hiệp hội chuyên môn</div>', '<div class="title-3 text-white">Hiệp hội chuyên môn &amp; Thành tựu</div>')
html = process_accordion(html, "Hiệp hội chuyên môn &amp; Thành tựu", "${doctor.achievements != null and !doctor.achievements.isEmpty()}", "${doctor.achievements}", "xl:m-3 m-2")
# Strip script and style carefully without ReGex to avoid hangs
# Strip styles
while ' style="' in html:
s_start = html.find(' style="')
s_end = html.find('"', s_start + 8)
html = html[:s_start] + html[s_end+1:]
# Strip scripts
while '<script' in html:
sc_start = html.find('<script')
sc_end = html.find('</script>', sc_start)
if sc_end != -1:
html = html[:sc_start] + html[sc_end+9:]
else:
break
# Read target template
with open("src/main/resources/templates/doctor-detail.html", "r", encoding="utf-8") as tf:
template = tf.read()
# Extract new main
start_main = html.find('<main')
end_main = html.find('</main>')
if end_main != -1: end_main += 7
new_main_html = html[start_main:end_main]
# Extract template parts
t_start = template.find('<main')
t_end = template.find('</main>')
if t_end != -1: t_end += 7
new_template = template[:t_start] + new_main_html + template[t_end:]
with open("src/main/resources/templates/doctor-detail.html", "w", encoding="utf-8") as tf:
tf.write(new_template)
print("Successfully replaced layout completely without Regex!")
+6 -4
View File
@@ -22,12 +22,14 @@ if (!project.hasProperty("runList")) {
liquibase {
activities {
main {
driver "org.h2.Driver"
url "jdbc:h2:file:./build/h2db/db/sisvietnamvn"
username "sisvietnamvn"
changelogFile "src/main/resources/config/liquibase/master.xml"
driver "oracle.jdbc.OracleDriver"
url "jdbc:oracle:thin:@localhost:1521/sisvietnam"
username "sisvietnam"
password "sisvietnam"
changelogFile "config/liquibase/master.xml"
logLevel "debug"
classpath "src/main/resources/"
searchPath "src/main/resources/"
}
diffLog {
driver "org.h2.Driver"
File diff suppressed because one or more lines are too long
@@ -57,7 +57,7 @@ public class SecurityConfiguration {
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
"/about-us", "/specialty", "/doctor", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**")
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
@@ -1,13 +1,10 @@
package com.sisvietnamvn.web.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.security.SecurityUtils;
import com.sisvietnamvn.web.service.PageService;
import com.sisvietnamvn.web.hook.HookManager;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -15,12 +12,17 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.security.SecurityUtils;
import com.sisvietnamvn.web.service.PageService;
/**
* Controller for rendering public-facing pages dynamically.
@@ -37,10 +39,14 @@ public class PageController {
private final com.sisvietnamvn.web.service.DoctorService doctorService;
private final com.sisvietnamvn.web.service.SpecialtyService specialtyService;
private final com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService;
private final com.sisvietnamvn.web.service.SettingService settingService;
private final com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository;
public PageController(PageService pageService, ObjectMapper objectMapper, HookManager hookManager, com.sisvietnamvn.web.service.HtmlSnippetService snippetService,
com.sisvietnamvn.web.service.DoctorService doctorService, com.sisvietnamvn.web.service.SpecialtyService specialtyService,
com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService) {
com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService,
com.sisvietnamvn.web.service.SettingService settingService,
com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
this.pageService = pageService;
this.objectMapper = objectMapper;
this.hookManager = hookManager;
@@ -48,6 +54,8 @@ public class PageController {
this.doctorService = doctorService;
this.specialtyService = specialtyService;
this.doctorApiSyncService = doctorApiSyncService;
this.settingService = settingService;
this.doctorScheduleRepository = doctorScheduleRepository;
}
@GetMapping("/page/{slug}")
@@ -76,17 +84,65 @@ public class PageController {
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
@GetMapping("/doctor")
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctor(Model model) {
try {
model.addAttribute("doctors", doctorApiSyncService.getCachedDoctors());
java.util.List<com.sisvietnamvn.web.domain.Doctor> doctors = doctorService.findAll();
// Filter active only
doctors = doctors.stream()
.filter(d -> Boolean.TRUE.equals(d.getActive()))
.collect(java.util.stream.Collectors.toList());
// Sort: 00001 and 00002 at the top
doctors.sort((d1, d2) -> {
String code1 = d1.getDoctorCode();
String code2 = d2.getDoctorCode();
if (code1 == null) code1 = "";
if (code2 == null) code2 = "";
int weight1 = code1.equals("00001") ? 0 : (code1.equals("00002") ? 1 : 2);
int weight2 = code2.equals("00001") ? 0 : (code2.equals("00002") ? 1 : 2);
if (weight1 != weight2) {
return Integer.compare(weight1, weight2);
}
String name1 = d1.getName() != null ? d1.getName() : "";
String name2 = d2.getName() != null ? d2.getName() : "";
return name1.compareToIgnoreCase(name2);
});
model.addAttribute("doctors", doctors);
} catch (Exception e) {
LOG.error("Failed to load doctor API data", e);
LOG.error("Failed to load doctor data", e);
model.addAttribute("doctors", doctorService.findAll());
}
model.addAttribute("specialties", specialtyService.findAll());
return "doctor";
}
@GetMapping("/bac-si/{doctorCode}")
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctorDetail(@PathVariable String doctorCode, Model model) {
java.util.Optional<com.sisvietnamvn.web.domain.Doctor> doctorOpt = doctorService.findByDoctorCode(doctorCode);
if (doctorOpt.isEmpty() || !Boolean.TRUE.equals(doctorOpt.get().getActive())) {
return "error/404";
}
com.sisvietnamvn.web.domain.Doctor doctor = doctorOpt.get();
String docSpecialty = doctor.getSpecialty() != null ? doctor.getSpecialty().getName() : "";
model.addAttribute("doctor", doctor);
model.addAttribute("specialtyName", docSpecialty);
List<Map<String, Object>> scheduleDays = doctorApiSyncService.getDoctorSchedule(doctorCode);
model.addAttribute("scheduleDays", scheduleDays);
return "doctor-detail";
}
@GetMapping("/service")
public String getService(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SERVICE), model); }
@@ -152,4 +208,80 @@ public class PageController {
return "page";
}
@GetMapping("/manage/doctor-scheduler")
public String getQuanLyLichKham(Model model) {
model.addAttribute("activeTheme", "umass");
model.addAttribute("cachedDoctors", doctorApiSyncService.getCachedDoctors());
model.addAttribute("syncMode", settingService.getValue("doctor.sync.mode", "SCHEDULED"));
model.addAttribute("syncInterval", settingService.getValue("doctor.sync.interval", "480"));
return "manage/doctor-scheduler";
}
@PostMapping("/manage/doctor-scheduler/force-sync")
public String forceSyncLichKham() {
LOG.info("Force sync API triggered from admin page.");
doctorApiSyncService.syncDoctorsFromApi();
return "redirect:/manage/doctor-scheduler";
}
@PostMapping("/manage/doctor-scheduler/mode")
public String updateSyncMode(@org.springframework.web.bind.annotation.RequestParam String mode,
@org.springframework.web.bind.annotation.RequestParam(required=false) String interval) {
settingService.setValue("doctor.sync.mode", mode);
if (interval != null && !interval.trim().isEmpty()) {
settingService.setValue("doctor.sync.interval", interval);
}
return "redirect:/manage/doctor-scheduler";
}
@PostMapping("/manage/doctor-scheduler/manual/{doctorCode}")
@org.springframework.transaction.annotation.Transactional
@org.springframework.web.bind.annotation.ResponseBody
public org.springframework.http.ResponseEntity<String> updateManualSchedule(
@PathVariable String doctorCode,
@org.springframework.web.bind.annotation.RequestBody List<Map<String, Object>> schedules) {
try {
doctorScheduleRepository.deleteByDoctor_DoctorCode(doctorCode);
com.sisvietnamvn.web.domain.Doctor doctor = doctorService.findByDoctorCode(doctorCode).orElse(null);
if (doctor != null) {
for (Map<String, Object> s : schedules) {
com.sisvietnamvn.web.domain.DoctorSchedule ds = new com.sisvietnamvn.web.domain.DoctorSchedule();
ds.setDoctor(doctor);
ds.setDate(String.valueOf(s.get("date")));
ds.setDayOfWeek(Integer.valueOf(String.valueOf(s.get("dayOfWeek"))));
doctorScheduleRepository.save(ds);
}
}
// Update in-memory cache instantly
doctorApiSyncService.updateManualScheduleInCache(doctorCode, schedules);
return org.springframework.http.ResponseEntity.ok("OK");
} catch (Exception e) {
LOG.error("Failed to update manual schedule for {}", doctorCode, e);
return org.springframework.http.ResponseEntity.internalServerError().build();
}
}
@GetMapping("/manage/doctor-scheduler/manual/{doctorCode}")
@org.springframework.web.bind.annotation.ResponseBody
public org.springframework.http.ResponseEntity<List<Map<String, Object>>> getManualSchedule(@PathVariable String doctorCode) {
List<com.sisvietnamvn.web.domain.DoctorSchedule> schedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
List<Map<String, Object>> result = new java.util.ArrayList<>();
if (!schedules.isEmpty()) {
for (com.sisvietnamvn.web.domain.DoctorSchedule s : schedules) {
Map<String, Object> map = new java.util.HashMap<>();
map.put("date", s.getDate());
map.put("dayOfWeek", s.getDayOfWeek());
result.add(map);
}
} else {
// No manual schedule, return API schedule so they can edit it
result = doctorApiSyncService.getDoctorSchedule(doctorCode);
}
return org.springframework.http.ResponseEntity.ok(result);
}
}
@@ -1,8 +1,11 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Doctor;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.service.DoctorService;
import com.sisvietnamvn.web.service.SpecialtyService;
import com.sisvietnamvn.web.service.DoctorApiSyncService;
import com.sisvietnamvn.web.service.MediaService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -11,6 +14,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
@@ -25,10 +29,14 @@ public class ManageDoctorController {
private final DoctorService doctorService;
private final SpecialtyService specialtyService;
private final DoctorApiSyncService doctorApiSyncService;
private final MediaService mediaService;
public ManageDoctorController(DoctorService doctorService, SpecialtyService specialtyService) {
public ManageDoctorController(DoctorService doctorService, SpecialtyService specialtyService, DoctorApiSyncService doctorApiSyncService, MediaService mediaService) {
this.doctorService = doctorService;
this.specialtyService = specialtyService;
this.doctorApiSyncService = doctorApiSyncService;
this.mediaService = mediaService;
}
@GetMapping
@@ -37,11 +45,53 @@ public class ManageDoctorController {
populateModel(model, new Doctor(), true);
return "manage/doctors/index";
}
@GetMapping("/import-csv")
@ResponseBody
public String importCsv() {
try {
String file = "/home/x79/sisvietnamvn_01/Media/HÌNH ẢNH BÁC SĨ NGỒI PHÒNG KHÁM/ALL_IMAGES/DANH SÁCH BÁC SĨ NGỒI PHÒNG KHÁM.csv";
java.util.List<String> lines = java.nio.file.Files.readAllLines(java.nio.file.Paths.get(file));
int updatedCount = 0;
for (int i = 1; i < lines.size(); i++) {
String line = lines.get(i);
String[] parts = line.split(",", -1);
if (parts.length >= 7) {
String doctorCode = parts[1].trim();
String specialtyName = parts[6].trim();
if (!doctorCode.isEmpty() && !specialtyName.isEmpty()) {
java.util.Optional<Doctor> docOpt = doctorService.findByDoctorCode(doctorCode);
if (docOpt.isPresent()) {
Doctor doctor = docOpt.get();
// Find or create specialty
java.util.List<com.sisvietnamvn.web.domain.Specialty> specs = specialtyService.findAll();
com.sisvietnamvn.web.domain.Specialty spec = specs.stream().filter(s -> s.getName().equalsIgnoreCase(specialtyName)).findFirst().orElse(null);
if (spec == null) {
spec = new com.sisvietnamvn.web.domain.Specialty();
spec.setName(specialtyName);
spec = specialtyService.save(spec);
}
doctor.setSpecialty(spec);
doctorService.save(doctor);
updatedCount++;
}
}
}
}
return "Import success! Updated " + updatedCount + " doctors.";
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
@PostMapping
public String createDoctor(@Valid @ModelAttribute("doctor") Doctor doctor,
BindingResult bindingResult,
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
@RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Doctor : {}", doctor);
@@ -52,6 +102,18 @@ public class ManageDoctorController {
if (specialtyId != null) {
specialtyService.findById(specialtyId).ifPresent(doctor::setSpecialty);
}
if (avatarFile != null && !avatarFile.isEmpty()) {
try {
Media media = mediaService.upload(avatarFile);
doctor.setAvatarUrl(media.getFileUrl());
} catch (Exception e) {
LOG.error("Failed to upload avatar", e);
redirectAttributes.addFlashAttribute("errorMessage", "Failed to upload avatar: " + e.getMessage());
return "redirect:/manage/doctors";
}
}
doctorService.save(doctor);
redirectAttributes.addFlashAttribute("successMessage", "Doctor created successfully!");
return "redirect:/manage/doctors";
@@ -74,6 +136,7 @@ public class ManageDoctorController {
@Valid @ModelAttribute("doctor") Doctor doctor,
BindingResult bindingResult,
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
@RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Doctor : {}", id);
@@ -87,6 +150,18 @@ public class ManageDoctorController {
} else {
doctor.setSpecialty(null);
}
if (avatarFile != null && !avatarFile.isEmpty()) {
try {
Media media = mediaService.upload(avatarFile);
doctor.setAvatarUrl(media.getFileUrl());
} catch (Exception e) {
LOG.error("Failed to upload avatar", e);
redirectAttributes.addFlashAttribute("errorMessage", "Failed to upload avatar: " + e.getMessage());
return "redirect:/manage/doctors";
}
}
doctorService.save(doctor);
redirectAttributes.addFlashAttribute("successMessage", "Doctor updated successfully!");
return "redirect:/manage/doctors";
@@ -104,6 +179,18 @@ public class ManageDoctorController {
return "redirect:/manage/doctors";
}
@PostMapping("/sync")
public String syncDoctorsFromApi(RedirectAttributes redirectAttributes) {
LOG.debug("Request to force sync doctors from API");
try {
doctorApiSyncService.syncDoctorsFromApi();
redirectAttributes.addFlashAttribute("successMessage", "API Sync completed successfully!");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "API Sync failed: " + e.getMessage());
}
return "redirect:/manage/doctors";
}
private void populateModel(Model model, Doctor doctor, boolean isNew) {
model.addAttribute("doctor", doctor);
model.addAttribute("isNew", isNew);
@@ -29,20 +29,27 @@ public class ManageSpecialtyController {
}
@GetMapping
public String listSpecialties(Model model) {
public String listSpecialties(
@RequestParam(value = "search", required = false) String search,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "10") int size,
Model model) {
LOG.debug("Request to list all specialties");
populateModel(model, new Specialty(), true);
populateModel(model, new Specialty(), true, search, org.springframework.data.domain.PageRequest.of(page, size));
return "manage/specialties/index";
}
@PostMapping
public String createSpecialty(@Valid @ModelAttribute("specialty") Specialty specialty,
BindingResult bindingResult,
@RequestParam(value = "search", required = false) String search,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "10") int size,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Specialty : {}", specialty);
if (bindingResult.hasErrors()) {
populateModel(model, specialty, true);
populateModel(model, specialty, true, search, org.springframework.data.domain.PageRequest.of(page, size));
return "manage/specialties/index";
}
specialtyService.save(specialty);
@@ -51,14 +58,18 @@ public class ManageSpecialtyController {
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
public String showEditForm(@PathVariable Long id,
@RequestParam(value = "search", required = false) String search,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "10") int size,
Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Specialty : {}", id);
Optional<Specialty> specialtyOptional = specialtyService.findById(id);
if (specialtyOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Specialty not found.");
return "redirect:/manage/specialties";
}
populateModel(model, specialtyOptional.get(), false);
populateModel(model, specialtyOptional.get(), false, search, org.springframework.data.domain.PageRequest.of(page, size));
return "manage/specialties/index";
}
@@ -66,11 +77,14 @@ public class ManageSpecialtyController {
public String updateSpecialty(@PathVariable Long id,
@Valid @ModelAttribute("specialty") Specialty specialty,
BindingResult bindingResult,
@RequestParam(value = "search", required = false) String search,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "10") int size,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Specialty : {}", id);
if (bindingResult.hasErrors()) {
populateModel(model, specialty, false);
populateModel(model, specialty, false, search, org.springframework.data.domain.PageRequest.of(page, size));
return "manage/specialties/index";
}
specialty.setId(id);
@@ -91,9 +105,11 @@ public class ManageSpecialtyController {
return "redirect:/manage/specialties";
}
private void populateModel(Model model, Specialty specialty, boolean isNew) {
private void populateModel(Model model, Specialty specialty, boolean isNew, String search, org.springframework.data.domain.Pageable pageable) {
model.addAttribute("specialty", specialty);
model.addAttribute("isNew", isNew);
model.addAttribute("allSpecialties", specialtyService.findAll());
org.springframework.data.domain.Page<Specialty> specialtyPage = specialtyService.findAll(search, pageable);
model.addAttribute("specialtyPage", specialtyPage);
model.addAttribute("search", search);
}
}
@@ -41,6 +41,27 @@ public class Doctor extends AbstractAuditingEntity<Long> {
@Column(name = "booking_url", length = 500)
private String bookingUrl;
@Size(max = 50)
@Column(name = "doctor_code", length = 50)
private String doctorCode;
@Lob
@Column(name = "work_experience")
private String workExperience;
@Lob
@Column(name = "education")
private String education;
@Lob
@Column(name = "achievements")
private String achievements;
@Column(name = "active")
private Boolean active = true;
// jhipster-needle-entity-add-field - JHipster will add fields here
// --- Getters and Setters ---
@Override
@@ -92,6 +113,48 @@ public class Doctor extends AbstractAuditingEntity<Long> {
this.bookingUrl = bookingUrl;
}
public String getDoctorCode() {
return doctorCode;
}
public void setDoctorCode(String doctorCode) {
this.doctorCode = doctorCode;
}
public String getWorkExperience() {
return workExperience;
}
public void setWorkExperience(String workExperience) {
this.workExperience = workExperience;
}
public String getEducation() {
return education;
}
public void setEducation(String education) {
this.education = education;
}
public String getAchievements() {
return achievements;
}
public void setAchievements(String achievements) {
this.achievements = achievements;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
// --- End Getters and Setters ---
// --- equals, hashCode, toString ---
@Override
@@ -114,6 +177,7 @@ public class Doctor extends AbstractAuditingEntity<Long> {
", title='" + getTitle() + "'" +
", avatarUrl='" + getAvatarUrl() + "'" +
", bookingUrl='" + getBookingUrl() + "'" +
", doctorCode='" + getDoctorCode() + "'" +
"}";
}
}
@@ -0,0 +1,65 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import java.io.Serial;
import java.io.Serializable;
/**
* A DoctorSchedule entity for manual scheduling.
*/
@Entity
@Table(name = "sis_doctor_schedule")
public class DoctorSchedule implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "doctor_id", nullable = false)
private Doctor doctor;
@Column(name = "schedule_date", length = 50, nullable = false)
private String date;
@Column(name = "day_of_week", nullable = false)
private Integer dayOfWeek;
// --- Getters and Setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Doctor getDoctor() {
return doctor;
}
public void setDoctor(Doctor doctor) {
this.doctor = doctor;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
}
@@ -10,4 +10,6 @@ import java.util.List;
public interface DoctorRepository extends JpaRepository<Doctor, Long> {
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"specialty"})
List<Doctor> findAllByOrderByCreatedDateDesc();
java.util.Optional<Doctor> findByDoctorCode(String doctorCode);
}
@@ -0,0 +1,18 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.DoctorSchedule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA repository for the DoctorSchedule entity.
*/
@Repository
public interface DoctorScheduleRepository extends JpaRepository<DoctorSchedule, Long> {
List<DoctorSchedule> findByDoctor_DoctorCode(String doctorCode);
void deleteByDoctor_DoctorCode(String doctorCode);
}
@@ -4,6 +4,10 @@ import com.sisvietnamvn.web.domain.Specialty;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@Repository
public interface SpecialtyRepository extends JpaRepository<Specialty, Long> {
Page<Specialty> findByNameContainingIgnoreCase(String name, Pageable pageable);
}
@@ -1,56 +1,185 @@
package com.sisvietnamvn.web.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.PageRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sisvietnamvn.web.repository.DoctorRepository;
import com.sisvietnamvn.web.repository.MediaRepository;
import jakarta.annotation.PostConstruct;
@Service
public class DoctorApiSyncService {
private static final Logger LOG = LoggerFactory.getLogger(DoctorApiSyncService.class);
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper;
private final MediaRepository mediaRepository;
private final DoctorRepository doctorRepository;
private final SettingService settingService;
private final com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository;
// In-memory cache
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) {
this.objectMapper = objectMapper;
this.mediaRepository = mediaRepository;
this.doctorRepository = doctorRepository;
this.settingService = settingService;
this.doctorScheduleRepository = doctorScheduleRepository;
}
public List<Map<String, Object>> getCachedDoctors() {
return cachedDoctors;
}
// Run immediately after startup and every 30 seconds
@PostConstruct
@Scheduled(fixedRate = 30000)
public void syncDoctorsFromApi() {
public void updateManualScheduleInCache(String doctorCode, List<Map<String, Object>> newDays) {
for (Map<String, Object> doc : cachedDoctors) {
if (doctorCode.equals(doc.get("doctorCode"))) {
doc.put("days", new ArrayList<>(newDays));
break;
}
}
}
public List<Map<String, Object>> getDoctorSchedule(String doctorCode) {
// ALWAYS check manual schedule first
List<com.sisvietnamvn.web.domain.DoctorSchedule> schedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
if (!schedules.isEmpty()) {
List<Map<String, Object>> result = new ArrayList<>();
for (com.sisvietnamvn.web.domain.DoctorSchedule s : schedules) {
Map<String, Object> map = new HashMap<>();
map.put("date", s.getDate());
map.put("dayOfWeek", s.getDayOfWeek());
result.add(map);
}
return result;
}
String mode = settingService.getValue("doctor.sync.mode", "SCHEDULED");
if ("MANUAL".equals(mode)) {
return new ArrayList<>(); // Already checked manual above, so return empty
}
if ("REALTIME".equals(mode)) {
return fetchRealtimeSchedule(doctorCode);
}
// SCHEDULED mode
for (Map<String, Object> doc : cachedDoctors) {
if (doctorCode.equals(doc.get("doctorCode"))) {
Object daysObj = doc.get("days");
if (daysObj instanceof List) {
return (List<Map<String, Object>>) daysObj;
}
}
}
return new ArrayList<>();
}
private List<Map<String, Object>> fetchRealtimeSchedule(String doctorCode) {
List<Map<String, Object>> daysList = new ArrayList<>();
try {
LOG.info("Starting API sync for doctors...");
// 1. Get Token
// Get token
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
HttpHeaders tokenHeaders = new HttpHeaders();
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
tokenHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
org.springframework.util.MultiValueMap<String, String> tokenBody = new org.springframework.util.LinkedMultiValueMap<>();
tokenBody.add("grant_type", "client_credentials");
tokenBody.add("client_id", "DigitalHealthSolutions_LabConn");
tokenBody.add("client_secret", "AcEnBCaphERGOGYMaStATITEnaPETYpR");
tokenBody.add("scope", "LabConn");
HttpEntity<org.springframework.util.MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenNode.get("access_token").asText();
HttpHeaders apiHeaders = new HttpHeaders();
apiHeaders.setBearerAuth(accessToken);
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
String step3Url = "https://dhs.sisvietnam.vn/api/app/his/doctors/" + doctorCode + "/work-schedule-days";
ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET, apiRequest, String.class);
JsonNode step3Root = objectMapper.readTree(step3Response.getBody());
JsonNode daysArrayNode = null;
if (step3Root.has("data") && step3Root.get("data").has("days")
&& step3Root.get("data").get("days").isArray()) {
daysArrayNode = step3Root.get("data").get("days");
}
if (daysArrayNode != null && daysArrayNode.isArray()) {
for (JsonNode dayNode : daysArrayNode) {
Map<String, Object> dayInfo = new HashMap<>();
if (dayNode.has("dayOfWeek")) {
dayInfo.put("dayOfWeek", dayNode.get("dayOfWeek").asInt());
}
if (dayNode.has("date")) {
dayInfo.put("date", dayNode.get("date").asText());
}
if (!dayInfo.isEmpty()) {
daysList.add(dayInfo);
}
}
}
} catch (Exception e) {
LOG.error("Failed to fetch realtime schedule for doctor: {}", doctorCode, e);
}
return daysList;
}
private long lastSyncTime = 0;
@PostConstruct
public void init() {
syncDoctorsFromApi();
}
@Scheduled(fixedRate = 60000)
public void checkAndSync() {
int intervalMinutes = 480; // default 8 hours
try {
intervalMinutes = Integer.parseInt(settingService.getValue("doctor.sync.interval", "480"));
} catch (NumberFormatException e) {
LOG.warn("Invalid doctor.sync.interval, using default 480");
}
if (System.currentTimeMillis() - lastSyncTime >= intervalMinutes * 60L * 1000L) {
syncDoctorsFromApi();
}
}
public synchronized void syncDoctorsFromApi() {
lastSyncTime = System.currentTimeMillis();
try {
LOG.info("Starting API sync for doctors...");
// 1. Get Token
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
HttpHeaders tokenHeaders = new HttpHeaders();
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
tokenHeaders.add("Cookie",
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
MultiValueMap<String, String> tokenBody = new LinkedMultiValueMap<>();
tokenBody.add("grant_type", "client_credentials");
@@ -60,7 +189,7 @@ public class DoctorApiSyncService {
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
String accessToken = tokenNode.get("access_token").asText();
@@ -69,33 +198,51 @@ public class DoctorApiSyncService {
HttpHeaders apiHeaders = new HttpHeaders();
apiHeaders.setBearerAuth(accessToken);
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
apiHeaders.add("Cookie",
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest, String.class);
ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest,
String.class);
JsonNode root = objectMapper.readTree(apiResponse.getBody());
JsonNode doctorsNode = root.at("/data/doctors");
try {
java.nio.file.Files.writeString(java.nio.file.Paths.get("/tmp/debug_api.json"), root.toPrettyString());
} catch (Exception e) {
}
JsonNode doctorsNode = root.at("/data/doctors");
List<Map<String, Object>> newDoctorsList = new ArrayList<>();
if (doctorsNode.isArray()) {
for (JsonNode node : doctorsNode) {
Map<String, Object> doc = new HashMap<>();
doc.put("title", node.get("title") != null && !node.get("title").isNull() ? node.get("title").asText("") : "BS");
String fullName = node.get("fullName") != null && !node.get("fullName").isNull() ? node.get("fullName").asText("") : "";
if (newDoctorsList.isEmpty()) {
LOG.info("DEBUG API DOCTOR NODE: {}", node.toString());
}
doc.put("title",
node.get("title") != null && !node.get("title").isNull() ? node.get("title").asText("")
: "BS");
String fullName = node.get("fullName") != null && !node.get("fullName").isNull()
? node.get("fullName").asText("")
: "";
String title = doc.get("title").toString();
if (fullName.startsWith(title)) {
fullName = fullName.substring(title.length()).trim();
if (fullName.startsWith(".")) fullName = fullName.substring(1).trim();
if (fullName.startsWith("."))
fullName = fullName.substring(1).trim();
}
doc.put("name", fullName);
String doctorCode = node.get("doctorCode") != null && !node.get("doctorCode").isNull() ? node.get("doctorCode").asText() : "";
String doctorCode = node.get("doctorCode") != null && !node.get("doctorCode").isNull()
? node.get("doctorCode").asText()
: "";
doc.put("doctorCode", doctorCode);
doc.put("bookingUrl", "#");
String avatarUrl = "/images/default-avatar.png";
if (!doctorCode.isEmpty()) {
org.springframework.data.domain.Page<com.sisvietnamvn.web.domain.Media> mediaPage = mediaRepository.findByOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(doctorCode, org.springframework.data.domain.PageRequest.of(0, 1));
org.springframework.data.domain.Page<com.sisvietnamvn.web.domain.Media> mediaPage = mediaRepository
.findByOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(doctorCode,
org.springframework.data.domain.PageRequest.of(0, 1));
if (mediaPage.hasContent()) {
avatarUrl = mediaPage.getContent().get(0).getFileUrl();
LOG.info("Matched avatar for doctor {}: {}", doctorCode, avatarUrl);
@@ -104,30 +251,158 @@ public class DoctorApiSyncService {
}
}
doc.put("avatarUrl", avatarUrl);
Map<String, String> spec = new HashMap<>();
spec.put("name", "Đa khoa");
spec.put("iconUrl", "");
doc.put("specialty", spec);
List<Map<String, Object>> daysList = new ArrayList<>();
// Check manual schedule first
List<com.sisvietnamvn.web.domain.DoctorSchedule> manualSchedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
if (!manualSchedules.isEmpty()) {
for (com.sisvietnamvn.web.domain.DoctorSchedule s : manualSchedules) {
Map<String, Object> map = new HashMap<>();
map.put("date", s.getDate());
map.put("dayOfWeek", s.getDayOfWeek());
daysList.add(map);
}
} else {
String mode = settingService.getValue("doctor.sync.mode", "SCHEDULED");
if ("SCHEDULED".equals(mode) && !doctorCode.isEmpty()) {
try {
String step3Url = "https://dhs.sisvietnam.vn/api/app/his/doctors/" + doctorCode
+ "/work-schedule-days";
ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET,
apiRequest, String.class);
String rawJson = step3Response.getBody();
JsonNode step3Root = objectMapper.readTree(rawJson);
JsonNode daysArrayNode = null;
if (step3Root.has("data") && step3Root.get("data").has("days")
&& step3Root.get("data").get("days").isArray()) {
daysArrayNode = step3Root.get("data").get("days");
}
if (daysArrayNode != null && daysArrayNode.isArray()) {
for (JsonNode dayNode : daysArrayNode) {
Map<String, Object> dayInfo = new HashMap<>();
if (dayNode.has("dayOfWeek")) {
dayInfo.put("dayOfWeek", dayNode.get("dayOfWeek").asInt());
}
if (dayNode.has("date")) {
dayInfo.put("date", dayNode.get("date").asText());
}
if (!dayInfo.isEmpty()) {
daysList.add(dayInfo);
}
}
}
} catch (Exception e) {
LOG.error("Failed to fetch work-schedule-days for doctor: {}", doctorCode, e);
}
}
}
doc.put("days", daysList);
newDoctorsList.add(doc);
}
}
// Atomically swap the reference
if (!newDoctorsList.isEmpty()) {
newDoctorsList.sort((d1, d2) -> {
String c1 = (String) d1.get("doctorCode");
String c2 = (String) d2.get("doctorCode");
int r1 = "00001".equals(c1) ? 0 : ("00002".equals(c1) ? 1 : 2);
int r2 = "00001".equals(c2) ? 0 : ("00002".equals(c2) ? 1 : 2);
return Integer.compare(r1, r2);
});
this.cachedDoctors = newDoctorsList;
LOG.info("Successfully synced {} doctors to cache.", newDoctorsList.size());
LOG.info("Successfully refreshed doctor cache. Total doctors: {}", cachedDoctors.size());
// Update active status in database
updateActiveStatusInDatabase(newDoctorsList);
}
} catch (Exception e) {
LOG.error("Failed to sync doctors from API: {}", e.getMessage(), e);
}
}
@org.springframework.transaction.annotation.Transactional
protected void updateActiveStatusInDatabase(List<Map<String, Object>> newDoctorsList) {
List<com.sisvietnamvn.web.domain.Doctor> allDbDoctors = doctorRepository.findAll();
Map<String, com.sisvietnamvn.web.domain.Doctor> dbDoctorMap = new HashMap<>();
for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) {
if (dbDoc.getDoctorCode() != null && !dbDoc.getDoctorCode().isEmpty()) {
dbDoctorMap.put(dbDoc.getDoctorCode(), dbDoc);
}
}
List<com.sisvietnamvn.web.domain.Doctor> doctorsToSave = new ArrayList<>();
List<String> activeCodes = new ArrayList<>();
for (Map<String, Object> apiDoc : newDoctorsList) {
String code = (String) apiDoc.get("doctorCode");
if (code == null || code.isEmpty())
continue;
activeCodes.add(code);
com.sisvietnamvn.web.domain.Doctor dbDoc = dbDoctorMap.get(code);
boolean isNew = false;
if (dbDoc == null) {
dbDoc = new com.sisvietnamvn.web.domain.Doctor();
dbDoc.setDoctorCode(code);
isNew = true;
}
} catch (Exception e) {
LOG.error("Failed to sync doctors from API", e);
boolean needsUpdate = isNew;
String name = (String) apiDoc.get("name");
if (name != null && !name.equals(dbDoc.getName())) {
dbDoc.setName(name);
needsUpdate = true;
}
String title = (String) apiDoc.get("title");
if (title != null && !title.equals(dbDoc.getTitle())) {
dbDoc.setTitle(title);
needsUpdate = true;
}
String avatarUrl = (String) apiDoc.get("avatarUrl");
if (avatarUrl != null && !avatarUrl.equals(dbDoc.getAvatarUrl())) {
dbDoc.setAvatarUrl(avatarUrl);
needsUpdate = true;
}
String bookingUrl = (String) apiDoc.get("bookingUrl");
if (bookingUrl != null && !bookingUrl.equals(dbDoc.getBookingUrl())) {
dbDoc.setBookingUrl(bookingUrl);
needsUpdate = true;
}
if (dbDoc.getActive() == null || !dbDoc.getActive()) {
dbDoc.setActive(true);
needsUpdate = true;
}
if (needsUpdate) {
doctorsToSave.add(dbDoc);
}
}
// Handle inactive doctors
for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) {
if (dbDoc.getDoctorCode() != null && !dbDoc.getDoctorCode().isEmpty()
&& !activeCodes.contains(dbDoc.getDoctorCode())) {
if (dbDoc.getActive() == null || dbDoc.getActive()) {
dbDoc.setActive(false);
doctorsToSave.add(dbDoc);
}
}
}
LOG.info("DEBUG: newDoctorsList.size()={}, allDbDoctors.size()={}, doctorsToSave.size()={}",
newDoctorsList.size(), allDbDoctors.size(), doctorsToSave.size());
if (!doctorsToSave.isEmpty()) {
doctorRepository.saveAll(doctorsToSave);
LOG.info("Upserted {} doctors in the database.", doctorsToSave.size());
}
}
}
@@ -26,6 +26,10 @@ public class DoctorService {
return doctorRepository.findById(id);
}
public Optional<Doctor> findByDoctorCode(String doctorCode) {
return doctorRepository.findByDoctorCode(doctorCode);
}
public Doctor save(Doctor doctor) {
return doctorRepository.save(doctor);
}
@@ -5,6 +5,8 @@ import com.sisvietnamvn.web.repository.SpecialtyRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
@@ -22,6 +24,13 @@ public class SpecialtyService {
return specialtyRepository.findAll();
}
public Page<Specialty> findAll(String search, Pageable pageable) {
if (search != null && !search.trim().isEmpty()) {
return specialtyRepository.findByNameContainingIgnoreCase(search, pageable);
}
return specialtyRepository.findAll(pageable);
}
public Optional<Specialty> findById(Long id) {
return specialtyRepository.findById(id);
}
@@ -108,5 +108,3 @@ jhipster:
# More documentation is available at:
# https://www.jhipster.tech/common-application-properties/
# ===================================================================
# application:
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260723100000-1" author="antigravity">
<addColumn tableName="sis_doctor">
<column name="doctor_code" type="varchar(50)"/>
<column name="work_experience" type="${clobType}"/>
<column name="education" type="${clobType}"/>
<column name="achievements" type="${clobType}"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260723110000-1" author="antigravity">
<addColumn tableName="sis_doctor">
<column name="active" type="boolean" defaultValueBoolean="true"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.5.xsd">
<changeSet id="20260723115000-1" author="antigravity">
<delete tableName="sis_doctor">
<where>doctor_code IS NULL</where>
</delete>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260723185500-1" author="antigravity">
<createTable tableName="sis_doctor_schedule">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="doctor_id" type="bigint">
<constraints nullable="false"/>
</column>
<column name="schedule_date" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="day_of_week" type="int">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseColumnNames="doctor_id"
baseTableName="sis_doctor_schedule"
constraintName="fk_doctor_schedule_doctor_id"
referencedColumnNames="id"
referencedTableName="sis_doctor"/>
</changeSet>
</databaseChangeLog>
@@ -41,6 +41,11 @@
<include file="config/liquibase/changelog/20260714113000_update_events_list.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718100000_add_doctor_and_specialty.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718110000_seed_sample_doctors.xml" relativeToChangelogFile="false"/>
<!-- Custom extensions -->
<include file="config/liquibase/changelog/20260720181500_seed_contact_us_page.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260722000000_add_fields_to_menu_item.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723100000_add_detail_fields_to_doctor.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723110000_add_active_to_doctor.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723115000_delete_sample_doctors.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>
@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='doctor-detail-page')}">
<head>
<th:block layout:fragment="head">
<title th:text="${doctor.title + ' ' + doctor.name + ' | UMC'}">Chi tiết Bác sĩ</title>
<style>
.doctor-detail-page {
background-color: #f6f6f6;
}
.section-doctor-shadow {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.doctor-avatar-container {
width: 250px;
height: 250px;
background: linear-gradient(to top left, #f0faff, #e0f2fe);
}
@media (max-width: 1024px) {
.doctor-avatar-container {
width: 200px;
height: 200px;
}
}
@media (max-width: 768px) {
.doctor-avatar-container {
width: 150px;
height: 150px;
}
}
</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}" />
</th:block>
</head>
<body>
<div layout:fragment="content">
<main class="">
<section class="xl:py-8 md:py-6 py-4 bg-gray-50">
<div class="container">
<div class="section-doctor-shadow rounded-lg">
<div class="grid grid-cols-12 xl:gap-x-6 md:gap-x-4">
<div class="lg:col-span-6 col-span-full bg-[#fbfbfb] xl:py-6 md:py-3 py-3 xl:pl-6 md:pl-3 max-lg:pr-3 max-md:pr-0 rounded-lg">
<div>
<div class="max-md:border-b max-md:border-gray-100 max-md:pb-3">
<div class="flex md:items-center xl:space-x-3 md:space-x-2 space-x-2 xl:pb-6 md:pb-4 max-md:mb-3 md:border-b md:border-gray-100">
<div class="xl:size-[170px] md:size-[160px] size-[120px] aspect-[1/1] relative rounded-lg border border-primary-400 overflow-hidden flex-shrink-0 relative bg-gradient-to-tl from-primary-75 to-primary-100">
<img th:src="${doctor.avatarUrl != null and !doctor.avatarUrl.isEmpty() ? doctor.avatarUrl : '/images/default-avatar.png'}" style="width: 100%; height: 100%; object-fit: contain;" alt="Avatar Bác sĩ" />
</div>
<div class="space-y-[5px]">
<p class="title-5 text-primary-500 mb-0 max-md:!text-[12.8px]">
<span th:text="${doctor.title}">Học hàm/Học vị</span>
</p>
<h1 class="heading-3 text-[#1E293B] mb-1 max-md:!text-[18px]">
<span th:text="${doctor.name}">Tên Bác sĩ</span>
</h1>
<p class="body-3 text-gray-900 max-md:!text-[12px]">
<span th:text="${specialtyName}">Chuyên khoa</span>
</p>
<div class="max-md:hidden">
<div class="flex items-center space-x-2 body-3 max-md:!text-[12px] text-gray-900 flex-wrap mb-[5px]">
<div class="flex items-center space-x-1">
<div>Đánh giá
<!-- -->:
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-0">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-0">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-0)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-1">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-1">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-1)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-2">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-2">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-2)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-3">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-3">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-3)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-4">
<stop offset="50%" stop-color="#facc15"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="100%" stop-color="#d1d5db"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-4">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-4)"></path>
</svg>
<span class="text-primary-600 font-semibold ml-2">4.5
<!-- -->/5
</span>
</div>
</div>
<div class="text-primary-600 lg:hover:underline lg:hover:text-primary-400 lg:duration-150 cursor-pointer underline-offset-2">(
<!-- -->1
<!-- -->
<!-- -->đánh giá
<!-- -->)
</div>
</div>
<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" style="background-color: var(--color-brand);" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<div>Đặt lịch khám</div>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_83_297)">
<path d="M0 16.26C0 17.3 0.366667 18.18 1.1 18.9C1.83333 19.62 2.72 19.9867 3.76 20H16.26C17.2867 20 18.1667 19.6333 18.9 18.9C19.6333 18.1667 20 17.2867 20 16.26V3.76C20 2.94667 19.7667 2.22 19.3 1.58C18.8333 0.94 18.2333 0.493333 17.5 0.24V1.88C17.5 2.4 17.32 2.84667 16.96 3.22C16.6 3.59333 16.1533 3.77333 15.62 3.76C15.0867 3.74667 14.6467 3.56667 14.3 3.22C13.9533 2.87333 13.7733 2.42667 13.76 1.88V0H6.26V1.88C6.26 2.4 6.07333 2.84667 5.7 3.22C5.32667 3.59333 4.88667 3.77333 4.38 3.76C3.87333 3.74667 3.42667 3.56667 3.04 3.22C2.65333 2.87333 2.47333 2.42667 2.5 1.88V0.24C1.76667 0.506667 1.16667 0.953333 0.7 1.58C0.233333 2.20667 0 2.93333 0 3.76L0 16.26ZM2.5 16.26V6.26H17.5V16.26C17.5 16.6067 17.38 16.9 17.14 17.14C16.9 17.38 16.6067 17.5 16.26 17.5H3.76C3.41333 17.5 3.11333 17.38 2.86 17.14C2.60667 16.9 2.48667 16.6067 2.5 16.26ZM3.76 1.88C3.76 2.05333 3.82 2.2 3.94 2.32C4.06 2.44 4.20667 2.5 4.38 2.5C4.55333 2.5 4.7 2.44 4.82 2.32C4.94 2.2 5 2.05333 5 1.88V0H3.76V1.88ZM5 15H7.5V12.5H5V15ZM5 11.26H7.5V8.76H5V11.26ZM8.76 15H11.26V12.5H8.76V15ZM8.76 11.26H11.26V8.76H8.76V11.26ZM12.5 15H15V12.5H12.5V15ZM12.5 11.26H15V8.76H12.5V11.26ZM15 1.88C15 2.05333 15.06 2.2 15.18 2.32C15.3 2.44 15.4467 2.5 15.62 2.5C15.7933 2.5 15.94 2.44 16.06 2.32C16.18 2.2 16.2467 2.05333 16.26 1.88V0H15V1.88Z" fill="white"></path>
</g>
<defs>
<clipPath id="clip0_83_297">
<rect width="20" height="20" fill="white"></rect>
</clipPath>
</defs>
</svg>
</button>
</div>
</div>
</div>
<div class="md:hidden">
<div class="flex items-center mb-2 space-x-2 body-3 max-md:!text-[12px] text-gray-900 flex-wrap">
<div class="flex items-center space-x-1">
<div>Đánh giá
<!-- -->:
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-0">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-0">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-0)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-1">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-1">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-1)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-2">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-2">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-2)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-3">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-3">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-3)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-4">
<stop offset="50%" stop-color="#facc15"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="100%" stop-color="#d1d5db"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-4">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-4)"></path>
</svg>
<span class="text-primary-600 font-semibold ml-2">4.5
<!-- -->/5
</span>
</div>
</div>
<div class="text-primary-600 lg:hover:underline lg:hover:text-primary-400 lg:duration-150 cursor-pointer underline-offset-2">(
<!-- -->1
<!-- -->
<!-- -->đánh giá
<!-- -->)
</div>
</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" style="background-color: var(--color-brand);" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<div>Đặt lịch khám</div>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_83_297)">
<path d="M0 16.26C0 17.3 0.366667 18.18 1.1 18.9C1.83333 19.62 2.72 19.9867 3.76 20H16.26C17.2867 20 18.1667 19.6333 18.9 18.9C19.6333 18.1667 20 17.2867 20 16.26V3.76C20 2.94667 19.7667 2.22 19.3 1.58C18.8333 0.94 18.2333 0.493333 17.5 0.24V1.88C17.5 2.4 17.32 2.84667 16.96 3.22C16.6 3.59333 16.1533 3.77333 15.62 3.76C15.0867 3.74667 14.6467 3.56667 14.3 3.22C13.9533 2.87333 13.7733 2.42667 13.76 1.88V0H6.26V1.88C6.26 2.4 6.07333 2.84667 5.7 3.22C5.32667 3.59333 4.88667 3.77333 4.38 3.76C3.87333 3.74667 3.42667 3.56667 3.04 3.22C2.65333 2.87333 2.47333 2.42667 2.5 1.88V0.24C1.76667 0.506667 1.16667 0.953333 0.7 1.58C0.233333 2.20667 0 2.93333 0 3.76L0 16.26ZM2.5 16.26V6.26H17.5V16.26C17.5 16.6067 17.38 16.9 17.14 17.14C16.9 17.38 16.6067 17.5 16.26 17.5H3.76C3.41333 17.5 3.11333 17.38 2.86 17.14C2.60667 16.9 2.48667 16.6067 2.5 16.26ZM3.76 1.88C3.76 2.05333 3.82 2.2 3.94 2.32C4.06 2.44 4.20667 2.5 4.38 2.5C4.55333 2.5 4.7 2.44 4.82 2.32C4.94 2.2 5 2.05333 5 1.88V0H3.76V1.88ZM5 15H7.5V12.5H5V15ZM5 11.26H7.5V8.76H5V11.26ZM8.76 15H11.26V12.5H8.76V15ZM8.76 11.26H11.26V8.76H8.76V11.26ZM12.5 15H15V12.5H12.5V15ZM12.5 11.26H15V8.76H12.5V11.26ZM15 1.88C15 2.05333 15.06 2.2 15.18 2.32C15.3 2.44 15.4467 2.5 15.62 2.5C15.7933 2.5 15.94 2.44 16.06 2.32C16.18 2.2 16.2467 2.05333 16.26 1.88V0H15V1.88Z" fill="white"></path>
</g>
<defs>
<clipPath id="clip0_83_297">
<rect width="20" height="20" fill="white"></rect>
</clipPath>
</defs>
</svg>
</button>
</div>
</div>
<div class="mt-4">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2">
<div class="title-3 text-white">Lịch khám</div>
</div>
<div class="xl:m-3 m-2">
<div th:if="${scheduleDays != null and !scheduleDays.isEmpty()}">
<div class="flex gap-1 mb-1">
<span class="w-1 h-1 bg-primary-600 rounded-full md:mt-[11px] mt-2.5 flex-shrink-0"></span>
<div class="text-primary-600">
<span class="uppercase max-md:!text-[16px] heading-5" th:text="${specialtyName != null and !specialtyName.isEmpty() ? specialtyName : 'Chung'}"></span>
</div>
</div>
<ul class="list-none space-y-1">
<li th:each="day : ${scheduleDays}" class="text-gray-900 flex items-center justify-between space-x-2 relative pl-3 before:content-[''] before:w-1 before:h-1 before:bg-primary-600 before:rounded-full before:absolute md:before:top-[12px] before:top-3 before:left-1">
<div>
<span class="body-3 max-md:!text-[14px]" th:text="'Sáng thứ ' + ${day.dayOfWeek} + (${day.date != null} ? ' (' + ${day.date} + ')' : '')"></span>
</div>
</li>
</ul>
</div>
<div th:if="${scheduleDays == null or scheduleDays.isEmpty()}">
<p class="text-gray-500 italic">Chưa có lịch khám</p>
</div>
</div>
</div>
</div>
<div class="mt-4 scroll-mt-[132px]" style="display: none;">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2">
<div class="flex md:items-center md:justify-between md:space-x-2 max-md:flex-col max-md:items-start max-md:space-y-2">
<div class="title-3 text-white">Cảm nhận của người bệnh</div>
<div class="flex items-center max-md:flex-1 gap-2 text-white body-2">
<div class="flex items-center gap-2">
<div class="flex gap-0.5">
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-0">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-0">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-0)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-1">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-1">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-1)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-2">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-2">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-2)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-3">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-3">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-3)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-4">
<stop offset="50%" stop-color="#facc15"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="100%" stop-color="#d1d5db"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-4">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-4)"></path>
</svg>
</div>
<span class="font-semibold">4.5
<!-- -->/5
</span>
</div>
<div>(
<!-- -->1
<!-- -->
<!-- -->đánh giá
<!-- -->)
</div>
</div>
</div>
</div>
<div class="xl:m-3 m-2">
<div class="mb-4 xl:p-4 md:p-3 p-2 bg-gray-50 rounded-lg">
<h4 class="title-1 text-primary-600 xl:mb-2 md:mb-1.5 mb-1">Viết đánh giá của bạn</h4>
<div class="space-y-3">
<div class="flex items-center space-x-2">
<span class="label-3 text-gray-900">Đánh giá
<!-- -->:
</span>
<div class="flex space-x-1">
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-1">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-1">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-1)" stroke="url(#interactive-star-stroke-1)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-2">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-2">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-2)" stroke="url(#interactive-star-stroke-2)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-3">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-3">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-3)" stroke="url(#interactive-star-stroke-3)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-4">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-4">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-4)" stroke="url(#interactive-star-stroke-4)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-5">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-5">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-5)" stroke="url(#interactive-star-stroke-5)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
</div>
<span class="body-3 text-gray-900">Chọn sao đánh giá</span>
</div>
<div>
<textarea placeholder="Chia sẻ trải nghiệm khám bệnh của bạn..." class="w-full xl:p-3 p-2 border body-3 border-gray-300 rounded-lg focus:outline-none focus:border-primary-600 resize-none" rows="3"></textarea>
</div>
<div class="flex justify-end">
<button class="btn btn-light label-2" disabled="">Gửi đánh giá</button>
</div>
</div>
</div>
<div class="space-y-2">
<h4 class="title-1 text-primary-600">Đánh giá từ người bệnh</h4>
<div class="text-center py-8">
<div class="mb-4">
<svg class="w-16 h-16 mx-auto text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"></path>
</svg>
</div>
<p class="body-2 text-gray-600">Chưa có đánh giá</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="xl:p-6 lg:p-4 max-lg:py-3 max-lg:px-4 max-md:px-3 bg-white lg:col-span-6 col-span-full rounded-lg overflow-hidden space-y-4">
<div class="space-y-4"></div>
<div class="space-y-4">
<div class="jam-accordion active" th:if="${doctor.education != null and !doctor.education.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="education-content">
<div class="title-3 text-white">Quá trình đào tạo</div>
<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 w-4 h-4 text-white duration-150 -rotate-180">
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<div class="jam-accordion-content">
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2" th:utext="${doctor.education}"></div>
</div>
</div>
</div>
</div>
<div class="jam-accordion active" th:if="${doctor.workExperience != null and !doctor.workExperience.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="experience-content">
<div class="title-3 text-white">Quá trình công tác</div>
<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 w-4 h-4 text-white duration-150 -rotate-180">
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<div class="jam-accordion-content">
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] space-y-2 xl:m-3 m-2" th:utext="${doctor.workExperience}"></div>
</div>
</div>
</div>
</div>
</div>
<div class="jam-accordion active" th:if="${doctor.achievements != null and !doctor.achievements.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="association-content">
<div class="title-3 text-white">Hiệp hội chuyên môn &amp; Thành tựu</div>
<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 w-4 h-4 text-white duration-150 -rotate-180">
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<div class="jam-accordion-content">
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2" th:utext="${doctor.achievements}"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>undefined </div>undefined </section>undefined
<!--$-->undefined
<!--/$-->undefined
<!--$-->undefined
<!--/$-->undefined </main>undefined</div>undefined
</body>undefined
</html>

Some files were not shown because too many files have changed in this diff Show More