feat: implement doctor schedule module, add detail fields to doctor profile, and integrate new appointment API client
@@ -0,0 +1 @@
|
|||||||
|
:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:var(--swiper-navigation-top-offset,50%);width:calc(var(--swiper-navigation-size) / 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(0px - (var(--swiper-navigation-size) / 2));z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next.swiper-button-hidden,.swiper-button-prev.swiper-button-hidden{opacity:0;cursor:auto;pointer-events:none}.swiper-navigation-disabled .swiper-button-next,.swiper-navigation-disabled .swiper-button-prev{display:none!important}.swiper-button-next svg,.swiper-button-prev svg{width:100%;height:100%;object-fit:contain;transform-origin:center}.swiper-rtl .swiper-button-next svg,.swiper-rtl .swiper-button-prev svg{transform:rotate(180deg)}.swiper-button-prev,.swiper-rtl .swiper-button-next{left:var(--swiper-navigation-sides-offset,10px);right:auto}.swiper-button-lock{display:none}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;font-variant:normal;line-height:1}.swiper-button-prev:after,.swiper-rtl .swiper-button-next:after{content:"prev"}.swiper-button-next,.swiper-rtl .swiper-button-prev{right:var(--swiper-navigation-sides-offset,10px);left:auto}.swiper-button-next:after,.swiper-rtl .swiper-button-prev:after{content:"next"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.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}
|
||||||
|
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);})();
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 581 B |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[7358],{24473:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,14933,23)),Promise.resolve().then(n.t.bind(n,86695,23)),Promise.resolve().then(n.t.bind(n,54775,23)),Promise.resolve().then(n.t.bind(n,22908,23)),Promise.resolve().then(n.t.bind(n,23624,23)),Promise.resolve().then(n.t.bind(n,59440,23)),Promise.resolve().then(n.t.bind(n,62920,23)),Promise.resolve().then(n.t.bind(n,19710,23))}},e=>{var s=s=>e(e.s=s);e.O(0,[1032,4619],()=>(s(36596),s(24473))),_N_E=e.O()}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!function(r,i){"use strict";var e,o=r.location,s=r.document,t=s.querySelector('[src*="'+i+'"]'),l=t&&t.getAttribute("data-domain"),p=r.localStorage.plausible_ignore;function c(e){console.warn("Ignoring Event: "+e)}function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return c("localhost");if(!(r.phantom||r._phantom||r.__nightmare||r.navigator.webdriver||r.Cypress)){if("true"==p)return c("localStorage flag");var a={};a.n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props));var n=new XMLHttpRequest;n.open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()}}}function n(){e!==o.pathname&&(e=o.pathname,a("pageview"))}try{var u,h=r.history;h.pushState&&(u=h.pushState,h.pushState=function(){u.apply(this,arguments),n()},r.addEventListener("popstate",n));var g=r.plausible&&r.plausible.q||[];r.plausible=a;for(var f=0;f<g.length;f++)a.apply(this,g[f]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){e||"visible"!==s.visibilityState||n()}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"https://analytics.jamstackvietnam.com");
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 959 B |
|
After Width: | Height: | Size: 585 B |
|
After Width: | Height: | Size: 796 B |
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!")
|
||||||
@@ -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 & Thành tựu</div>')
|
||||||
|
html = process_accordion(html, "Hiệp hội chuyên môn & 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!")
|
||||||
@@ -22,12 +22,14 @@ if (!project.hasProperty("runList")) {
|
|||||||
liquibase {
|
liquibase {
|
||||||
activities {
|
activities {
|
||||||
main {
|
main {
|
||||||
driver "org.h2.Driver"
|
driver "oracle.jdbc.OracleDriver"
|
||||||
url "jdbc:h2:file:./build/h2db/db/sisvietnamvn"
|
url "jdbc:oracle:thin:@localhost:1521/sisvietnam"
|
||||||
username "sisvietnamvn"
|
username "sisvietnam"
|
||||||
changelogFile "src/main/resources/config/liquibase/master.xml"
|
password "sisvietnam"
|
||||||
|
changelogFile "config/liquibase/master.xml"
|
||||||
logLevel "debug"
|
logLevel "debug"
|
||||||
classpath "src/main/resources/"
|
classpath "src/main/resources/"
|
||||||
|
searchPath "src/main/resources/"
|
||||||
}
|
}
|
||||||
diffLog {
|
diffLog {
|
||||||
driver "org.h2.Driver"
|
driver "org.h2.Driver"
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class SecurityConfiguration {
|
|||||||
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
|
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
|
||||||
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
|
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
|
||||||
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
|
"/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()
|
.permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.sisvietnamvn.web.controller;
|
package com.sisvietnamvn.web.controller;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import java.util.Collections;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import java.util.List;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import java.util.Map;
|
||||||
import com.sisvietnamvn.web.domain.Page;
|
import java.util.Optional;
|
||||||
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 org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -15,12 +12,17 @@ import org.springframework.stereotype.Controller;
|
|||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Collections;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import java.util.List;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import java.util.Map;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.util.Optional;
|
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.
|
* 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.DoctorService doctorService;
|
||||||
private final com.sisvietnamvn.web.service.SpecialtyService specialtyService;
|
private final com.sisvietnamvn.web.service.SpecialtyService specialtyService;
|
||||||
private final com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService;
|
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,
|
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.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.pageService = pageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.hookManager = hookManager;
|
this.hookManager = hookManager;
|
||||||
@@ -48,6 +54,8 @@ public class PageController {
|
|||||||
this.doctorService = doctorService;
|
this.doctorService = doctorService;
|
||||||
this.specialtyService = specialtyService;
|
this.specialtyService = specialtyService;
|
||||||
this.doctorApiSyncService = doctorApiSyncService;
|
this.doctorApiSyncService = doctorApiSyncService;
|
||||||
|
this.settingService = settingService;
|
||||||
|
this.doctorScheduleRepository = doctorScheduleRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/page/{slug}")
|
@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); }
|
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
|
||||||
|
|
||||||
@GetMapping("/doctor")
|
@GetMapping("/doctor")
|
||||||
|
@org.springframework.transaction.annotation.Transactional(readOnly = true)
|
||||||
public String getDoctor(Model model) {
|
public String getDoctor(Model model) {
|
||||||
try {
|
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) {
|
} 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("doctors", doctorService.findAll());
|
||||||
}
|
}
|
||||||
model.addAttribute("specialties", specialtyService.findAll());
|
model.addAttribute("specialties", specialtyService.findAll());
|
||||||
return "doctor";
|
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")
|
@GetMapping("/service")
|
||||||
public String getService(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SERVICE), model); }
|
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";
|
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;
|
package com.sisvietnamvn.web.controller.manage;
|
||||||
|
|
||||||
import com.sisvietnamvn.web.domain.Doctor;
|
import com.sisvietnamvn.web.domain.Doctor;
|
||||||
|
import com.sisvietnamvn.web.domain.Media;
|
||||||
import com.sisvietnamvn.web.service.DoctorService;
|
import com.sisvietnamvn.web.service.DoctorService;
|
||||||
import com.sisvietnamvn.web.service.SpecialtyService;
|
import com.sisvietnamvn.web.service.SpecialtyService;
|
||||||
|
import com.sisvietnamvn.web.service.DoctorApiSyncService;
|
||||||
|
import com.sisvietnamvn.web.service.MediaService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -11,6 +14,7 @@ import org.springframework.stereotype.Controller;
|
|||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.validation.BindingResult;
|
import org.springframework.validation.BindingResult;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||||
|
|
||||||
@@ -25,10 +29,14 @@ public class ManageDoctorController {
|
|||||||
|
|
||||||
private final DoctorService doctorService;
|
private final DoctorService doctorService;
|
||||||
private final SpecialtyService specialtyService;
|
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.doctorService = doctorService;
|
||||||
this.specialtyService = specialtyService;
|
this.specialtyService = specialtyService;
|
||||||
|
this.doctorApiSyncService = doctorApiSyncService;
|
||||||
|
this.mediaService = mediaService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -37,11 +45,53 @@ public class ManageDoctorController {
|
|||||||
populateModel(model, new Doctor(), true);
|
populateModel(model, new Doctor(), true);
|
||||||
return "manage/doctors/index";
|
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
|
@PostMapping
|
||||||
public String createDoctor(@Valid @ModelAttribute("doctor") Doctor doctor,
|
public String createDoctor(@Valid @ModelAttribute("doctor") Doctor doctor,
|
||||||
BindingResult bindingResult,
|
BindingResult bindingResult,
|
||||||
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
|
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
|
||||||
|
@RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
|
||||||
Model model,
|
Model model,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
LOG.debug("Request to create Doctor : {}", doctor);
|
LOG.debug("Request to create Doctor : {}", doctor);
|
||||||
@@ -52,6 +102,18 @@ public class ManageDoctorController {
|
|||||||
if (specialtyId != null) {
|
if (specialtyId != null) {
|
||||||
specialtyService.findById(specialtyId).ifPresent(doctor::setSpecialty);
|
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);
|
doctorService.save(doctor);
|
||||||
redirectAttributes.addFlashAttribute("successMessage", "Doctor created successfully!");
|
redirectAttributes.addFlashAttribute("successMessage", "Doctor created successfully!");
|
||||||
return "redirect:/manage/doctors";
|
return "redirect:/manage/doctors";
|
||||||
@@ -74,6 +136,7 @@ public class ManageDoctorController {
|
|||||||
@Valid @ModelAttribute("doctor") Doctor doctor,
|
@Valid @ModelAttribute("doctor") Doctor doctor,
|
||||||
BindingResult bindingResult,
|
BindingResult bindingResult,
|
||||||
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
|
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
|
||||||
|
@RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
|
||||||
Model model,
|
Model model,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
LOG.debug("Request to update Doctor : {}", id);
|
LOG.debug("Request to update Doctor : {}", id);
|
||||||
@@ -87,6 +150,18 @@ public class ManageDoctorController {
|
|||||||
} else {
|
} else {
|
||||||
doctor.setSpecialty(null);
|
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);
|
doctorService.save(doctor);
|
||||||
redirectAttributes.addFlashAttribute("successMessage", "Doctor updated successfully!");
|
redirectAttributes.addFlashAttribute("successMessage", "Doctor updated successfully!");
|
||||||
return "redirect:/manage/doctors";
|
return "redirect:/manage/doctors";
|
||||||
@@ -104,6 +179,18 @@ public class ManageDoctorController {
|
|||||||
return "redirect:/manage/doctors";
|
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) {
|
private void populateModel(Model model, Doctor doctor, boolean isNew) {
|
||||||
model.addAttribute("doctor", doctor);
|
model.addAttribute("doctor", doctor);
|
||||||
model.addAttribute("isNew", isNew);
|
model.addAttribute("isNew", isNew);
|
||||||
|
|||||||
@@ -29,20 +29,27 @@ public class ManageSpecialtyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@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");
|
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";
|
return "manage/specialties/index";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public String createSpecialty(@Valid @ModelAttribute("specialty") Specialty specialty,
|
public String createSpecialty(@Valid @ModelAttribute("specialty") Specialty specialty,
|
||||||
BindingResult bindingResult,
|
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,
|
Model model,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
LOG.debug("Request to create Specialty : {}", specialty);
|
LOG.debug("Request to create Specialty : {}", specialty);
|
||||||
if (bindingResult.hasErrors()) {
|
if (bindingResult.hasErrors()) {
|
||||||
populateModel(model, specialty, true);
|
populateModel(model, specialty, true, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||||
return "manage/specialties/index";
|
return "manage/specialties/index";
|
||||||
}
|
}
|
||||||
specialtyService.save(specialty);
|
specialtyService.save(specialty);
|
||||||
@@ -51,14 +58,18 @@ public class ManageSpecialtyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/edit")
|
@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);
|
LOG.debug("Request to show edit form for Specialty : {}", id);
|
||||||
Optional<Specialty> specialtyOptional = specialtyService.findById(id);
|
Optional<Specialty> specialtyOptional = specialtyService.findById(id);
|
||||||
if (specialtyOptional.isEmpty()) {
|
if (specialtyOptional.isEmpty()) {
|
||||||
redirectAttributes.addFlashAttribute("errorMessage", "Specialty not found.");
|
redirectAttributes.addFlashAttribute("errorMessage", "Specialty not found.");
|
||||||
return "redirect:/manage/specialties";
|
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";
|
return "manage/specialties/index";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +77,14 @@ public class ManageSpecialtyController {
|
|||||||
public String updateSpecialty(@PathVariable Long id,
|
public String updateSpecialty(@PathVariable Long id,
|
||||||
@Valid @ModelAttribute("specialty") Specialty specialty,
|
@Valid @ModelAttribute("specialty") Specialty specialty,
|
||||||
BindingResult bindingResult,
|
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,
|
Model model,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
LOG.debug("Request to update Specialty : {}", id);
|
LOG.debug("Request to update Specialty : {}", id);
|
||||||
if (bindingResult.hasErrors()) {
|
if (bindingResult.hasErrors()) {
|
||||||
populateModel(model, specialty, false);
|
populateModel(model, specialty, false, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||||
return "manage/specialties/index";
|
return "manage/specialties/index";
|
||||||
}
|
}
|
||||||
specialty.setId(id);
|
specialty.setId(id);
|
||||||
@@ -91,9 +105,11 @@ public class ManageSpecialtyController {
|
|||||||
return "redirect:/manage/specialties";
|
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("specialty", specialty);
|
||||||
model.addAttribute("isNew", isNew);
|
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)
|
@Column(name = "booking_url", length = 500)
|
||||||
private String bookingUrl;
|
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 ---
|
// --- Getters and Setters ---
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -92,6 +113,48 @@ public class Doctor extends AbstractAuditingEntity<Long> {
|
|||||||
this.bookingUrl = bookingUrl;
|
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 ---
|
// --- equals, hashCode, toString ---
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -114,6 +177,7 @@ public class Doctor extends AbstractAuditingEntity<Long> {
|
|||||||
", title='" + getTitle() + "'" +
|
", title='" + getTitle() + "'" +
|
||||||
", avatarUrl='" + getAvatarUrl() + "'" +
|
", avatarUrl='" + getAvatarUrl() + "'" +
|
||||||
", bookingUrl='" + getBookingUrl() + "'" +
|
", 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> {
|
public interface DoctorRepository extends JpaRepository<Doctor, Long> {
|
||||||
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"specialty"})
|
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"specialty"})
|
||||||
List<Doctor> findAllByOrderByCreatedDateDesc();
|
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.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface SpecialtyRepository extends JpaRepository<Specialty, Long> {
|
public interface SpecialtyRepository extends JpaRepository<Specialty, Long> {
|
||||||
|
Page<Specialty> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,185 @@
|
|||||||
package com.sisvietnamvn.web.service;
|
package com.sisvietnamvn.web.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import java.util.ArrayList;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import java.util.ArrayList;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.util.HashMap;
|
import com.sisvietnamvn.web.repository.DoctorRepository;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import com.sisvietnamvn.web.repository.MediaRepository;
|
import com.sisvietnamvn.web.repository.MediaRepository;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DoctorApiSyncService {
|
public class DoctorApiSyncService {
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(DoctorApiSyncService.class);
|
private static final Logger LOG = LoggerFactory.getLogger(DoctorApiSyncService.class);
|
||||||
private final RestTemplate restTemplate = new RestTemplate();
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final MediaRepository mediaRepository;
|
private final MediaRepository mediaRepository;
|
||||||
|
private final DoctorRepository doctorRepository;
|
||||||
|
private final SettingService settingService;
|
||||||
|
private final com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository;
|
||||||
|
|
||||||
// In-memory cache
|
// In-memory cache
|
||||||
private List<Map<String, Object>> cachedDoctors = new ArrayList<>();
|
private List<Map<String, Object>> cachedDoctors = new ArrayList<>();
|
||||||
|
|
||||||
public DoctorApiSyncService(ObjectMapper objectMapper, MediaRepository mediaRepository) {
|
public DoctorApiSyncService(ObjectMapper objectMapper, MediaRepository mediaRepository,
|
||||||
|
DoctorRepository doctorRepository, SettingService settingService, com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.mediaRepository = mediaRepository;
|
this.mediaRepository = mediaRepository;
|
||||||
|
this.doctorRepository = doctorRepository;
|
||||||
|
this.settingService = settingService;
|
||||||
|
this.doctorScheduleRepository = doctorScheduleRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> getCachedDoctors() {
|
public List<Map<String, Object>> getCachedDoctors() {
|
||||||
return cachedDoctors;
|
return cachedDoctors;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run immediately after startup and every 30 seconds
|
public void updateManualScheduleInCache(String doctorCode, List<Map<String, Object>> newDays) {
|
||||||
@PostConstruct
|
for (Map<String, Object> doc : cachedDoctors) {
|
||||||
@Scheduled(fixedRate = 30000)
|
if (doctorCode.equals(doc.get("doctorCode"))) {
|
||||||
public void syncDoctorsFromApi() {
|
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 {
|
try {
|
||||||
LOG.info("Starting API sync for doctors...");
|
// Get token
|
||||||
|
|
||||||
// 1. Get Token
|
|
||||||
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
|
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
|
||||||
HttpHeaders tokenHeaders = new HttpHeaders();
|
HttpHeaders tokenHeaders = new HttpHeaders();
|
||||||
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||||
tokenHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
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<>();
|
MultiValueMap<String, String> tokenBody = new LinkedMultiValueMap<>();
|
||||||
tokenBody.add("grant_type", "client_credentials");
|
tokenBody.add("grant_type", "client_credentials");
|
||||||
@@ -60,7 +189,7 @@ public class DoctorApiSyncService {
|
|||||||
|
|
||||||
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
|
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
|
||||||
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
|
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
|
||||||
|
|
||||||
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
|
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
|
||||||
String accessToken = tokenNode.get("access_token").asText();
|
String accessToken = tokenNode.get("access_token").asText();
|
||||||
|
|
||||||
@@ -69,33 +198,51 @@ public class DoctorApiSyncService {
|
|||||||
HttpHeaders apiHeaders = new HttpHeaders();
|
HttpHeaders apiHeaders = new HttpHeaders();
|
||||||
apiHeaders.setBearerAuth(accessToken);
|
apiHeaders.setBearerAuth(accessToken);
|
||||||
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
apiHeaders.add("__tenant", "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);
|
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 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<>();
|
List<Map<String, Object>> newDoctorsList = new ArrayList<>();
|
||||||
if (doctorsNode.isArray()) {
|
if (doctorsNode.isArray()) {
|
||||||
for (JsonNode node : doctorsNode) {
|
for (JsonNode node : doctorsNode) {
|
||||||
Map<String, Object> doc = new HashMap<>();
|
Map<String, Object> doc = new HashMap<>();
|
||||||
doc.put("title", node.get("title") != null && !node.get("title").isNull() ? node.get("title").asText("") : "BS");
|
if (newDoctorsList.isEmpty()) {
|
||||||
String fullName = node.get("fullName") != null && !node.get("fullName").isNull() ? node.get("fullName").asText("") : "";
|
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();
|
String title = doc.get("title").toString();
|
||||||
if (fullName.startsWith(title)) {
|
if (fullName.startsWith(title)) {
|
||||||
fullName = fullName.substring(title.length()).trim();
|
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);
|
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("doctorCode", doctorCode);
|
||||||
doc.put("bookingUrl", "#");
|
doc.put("bookingUrl", "#");
|
||||||
|
|
||||||
String avatarUrl = "/images/default-avatar.png";
|
String avatarUrl = "/images/default-avatar.png";
|
||||||
if (!doctorCode.isEmpty()) {
|
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()) {
|
if (mediaPage.hasContent()) {
|
||||||
avatarUrl = mediaPage.getContent().get(0).getFileUrl();
|
avatarUrl = mediaPage.getContent().get(0).getFileUrl();
|
||||||
LOG.info("Matched avatar for doctor {}: {}", doctorCode, avatarUrl);
|
LOG.info("Matched avatar for doctor {}: {}", doctorCode, avatarUrl);
|
||||||
@@ -104,30 +251,158 @@ public class DoctorApiSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
doc.put("avatarUrl", avatarUrl);
|
doc.put("avatarUrl", avatarUrl);
|
||||||
|
|
||||||
Map<String, String> spec = new HashMap<>();
|
Map<String, String> spec = new HashMap<>();
|
||||||
spec.put("name", "Đa khoa");
|
spec.put("name", "Đa khoa");
|
||||||
spec.put("iconUrl", "");
|
spec.put("iconUrl", "");
|
||||||
doc.put("specialty", spec);
|
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);
|
newDoctorsList.add(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically swap the reference
|
// Atomically swap the reference
|
||||||
if (!newDoctorsList.isEmpty()) {
|
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;
|
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) {
|
boolean needsUpdate = isNew;
|
||||||
LOG.error("Failed to sync doctors from API", e);
|
|
||||||
|
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);
|
return doctorRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<Doctor> findByDoctorCode(String doctorCode) {
|
||||||
|
return doctorRepository.findByDoctorCode(doctorCode);
|
||||||
|
}
|
||||||
|
|
||||||
public Doctor save(Doctor doctor) {
|
public Doctor save(Doctor doctor) {
|
||||||
return doctorRepository.save(doctor);
|
return doctorRepository.save(doctor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.sisvietnamvn.web.repository.SpecialtyRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -22,6 +24,13 @@ public class SpecialtyService {
|
|||||||
return specialtyRepository.findAll();
|
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) {
|
public Optional<Specialty> findById(Long id) {
|
||||||
return specialtyRepository.findById(id);
|
return specialtyRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,3 @@ jhipster:
|
|||||||
# More documentation is available at:
|
# More documentation is available at:
|
||||||
# https://www.jhipster.tech/common-application-properties/
|
# 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/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/20260718100000_add_doctor_and_specialty.xml" relativeToChangelogFile="false"/>
|
||||||
<include file="config/liquibase/changelog/20260718110000_seed_sample_doctors.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/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/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>
|
</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 & 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>
|
||||||