feat: implement doctor schedule module, add detail fields to doctor profile, and integrate new appointment API client
This commit is contained in:
+1
-1
@@ -57,7 +57,7 @@ public class SecurityConfiguration {
|
||||
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
|
||||
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
|
||||
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
|
||||
"/about-us", "/specialty", "/doctor", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**")
|
||||
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
|
||||
+147
-15
@@ -1,13 +1,10 @@
|
||||
package com.sisvietnamvn.web.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
import com.sisvietnamvn.web.security.SecurityUtils;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -15,12 +12,17 @@ import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
import com.sisvietnamvn.web.security.SecurityUtils;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
|
||||
/**
|
||||
* Controller for rendering public-facing pages dynamically.
|
||||
@@ -37,10 +39,14 @@ public class PageController {
|
||||
private final com.sisvietnamvn.web.service.DoctorService doctorService;
|
||||
private final com.sisvietnamvn.web.service.SpecialtyService specialtyService;
|
||||
private final com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService;
|
||||
private final com.sisvietnamvn.web.service.SettingService settingService;
|
||||
private final com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository;
|
||||
|
||||
public PageController(PageService pageService, ObjectMapper objectMapper, HookManager hookManager, com.sisvietnamvn.web.service.HtmlSnippetService snippetService,
|
||||
com.sisvietnamvn.web.service.DoctorService doctorService, com.sisvietnamvn.web.service.SpecialtyService specialtyService,
|
||||
com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService) {
|
||||
com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService,
|
||||
com.sisvietnamvn.web.service.SettingService settingService,
|
||||
com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
|
||||
this.pageService = pageService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.hookManager = hookManager;
|
||||
@@ -48,6 +54,8 @@ public class PageController {
|
||||
this.doctorService = doctorService;
|
||||
this.specialtyService = specialtyService;
|
||||
this.doctorApiSyncService = doctorApiSyncService;
|
||||
this.settingService = settingService;
|
||||
this.doctorScheduleRepository = doctorScheduleRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/page/{slug}")
|
||||
@@ -76,17 +84,65 @@ public class PageController {
|
||||
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
|
||||
|
||||
@GetMapping("/doctor")
|
||||
@org.springframework.transaction.annotation.Transactional(readOnly = true)
|
||||
public String getDoctor(Model model) {
|
||||
try {
|
||||
model.addAttribute("doctors", doctorApiSyncService.getCachedDoctors());
|
||||
java.util.List<com.sisvietnamvn.web.domain.Doctor> doctors = doctorService.findAll();
|
||||
|
||||
// Filter active only
|
||||
doctors = doctors.stream()
|
||||
.filter(d -> Boolean.TRUE.equals(d.getActive()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
// Sort: 00001 and 00002 at the top
|
||||
doctors.sort((d1, d2) -> {
|
||||
String code1 = d1.getDoctorCode();
|
||||
String code2 = d2.getDoctorCode();
|
||||
if (code1 == null) code1 = "";
|
||||
if (code2 == null) code2 = "";
|
||||
|
||||
int weight1 = code1.equals("00001") ? 0 : (code1.equals("00002") ? 1 : 2);
|
||||
int weight2 = code2.equals("00001") ? 0 : (code2.equals("00002") ? 1 : 2);
|
||||
|
||||
if (weight1 != weight2) {
|
||||
return Integer.compare(weight1, weight2);
|
||||
}
|
||||
|
||||
String name1 = d1.getName() != null ? d1.getName() : "";
|
||||
String name2 = d2.getName() != null ? d2.getName() : "";
|
||||
return name1.compareToIgnoreCase(name2);
|
||||
});
|
||||
|
||||
model.addAttribute("doctors", doctors);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to load doctor API data", e);
|
||||
LOG.error("Failed to load doctor data", e);
|
||||
model.addAttribute("doctors", doctorService.findAll());
|
||||
}
|
||||
model.addAttribute("specialties", specialtyService.findAll());
|
||||
return "doctor";
|
||||
}
|
||||
|
||||
@GetMapping("/bac-si/{doctorCode}")
|
||||
@org.springframework.transaction.annotation.Transactional(readOnly = true)
|
||||
public String getDoctorDetail(@PathVariable String doctorCode, Model model) {
|
||||
java.util.Optional<com.sisvietnamvn.web.domain.Doctor> doctorOpt = doctorService.findByDoctorCode(doctorCode);
|
||||
|
||||
if (doctorOpt.isEmpty() || !Boolean.TRUE.equals(doctorOpt.get().getActive())) {
|
||||
return "error/404";
|
||||
}
|
||||
|
||||
com.sisvietnamvn.web.domain.Doctor doctor = doctorOpt.get();
|
||||
String docSpecialty = doctor.getSpecialty() != null ? doctor.getSpecialty().getName() : "";
|
||||
|
||||
model.addAttribute("doctor", doctor);
|
||||
model.addAttribute("specialtyName", docSpecialty);
|
||||
|
||||
List<Map<String, Object>> scheduleDays = doctorApiSyncService.getDoctorSchedule(doctorCode);
|
||||
model.addAttribute("scheduleDays", scheduleDays);
|
||||
|
||||
return "doctor-detail";
|
||||
}
|
||||
|
||||
@GetMapping("/service")
|
||||
public String getService(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SERVICE), model); }
|
||||
|
||||
@@ -152,4 +208,80 @@ public class PageController {
|
||||
|
||||
return "page";
|
||||
}
|
||||
|
||||
@GetMapping("/manage/doctor-scheduler")
|
||||
public String getQuanLyLichKham(Model model) {
|
||||
model.addAttribute("activeTheme", "umass");
|
||||
model.addAttribute("cachedDoctors", doctorApiSyncService.getCachedDoctors());
|
||||
model.addAttribute("syncMode", settingService.getValue("doctor.sync.mode", "SCHEDULED"));
|
||||
model.addAttribute("syncInterval", settingService.getValue("doctor.sync.interval", "480"));
|
||||
return "manage/doctor-scheduler";
|
||||
}
|
||||
|
||||
@PostMapping("/manage/doctor-scheduler/force-sync")
|
||||
public String forceSyncLichKham() {
|
||||
LOG.info("Force sync API triggered from admin page.");
|
||||
doctorApiSyncService.syncDoctorsFromApi();
|
||||
return "redirect:/manage/doctor-scheduler";
|
||||
}
|
||||
|
||||
@PostMapping("/manage/doctor-scheduler/mode")
|
||||
public String updateSyncMode(@org.springframework.web.bind.annotation.RequestParam String mode,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required=false) String interval) {
|
||||
settingService.setValue("doctor.sync.mode", mode);
|
||||
if (interval != null && !interval.trim().isEmpty()) {
|
||||
settingService.setValue("doctor.sync.interval", interval);
|
||||
}
|
||||
return "redirect:/manage/doctor-scheduler";
|
||||
}
|
||||
|
||||
@PostMapping("/manage/doctor-scheduler/manual/{doctorCode}")
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
@org.springframework.web.bind.annotation.ResponseBody
|
||||
public org.springframework.http.ResponseEntity<String> updateManualSchedule(
|
||||
@PathVariable String doctorCode,
|
||||
@org.springframework.web.bind.annotation.RequestBody List<Map<String, Object>> schedules) {
|
||||
try {
|
||||
doctorScheduleRepository.deleteByDoctor_DoctorCode(doctorCode);
|
||||
com.sisvietnamvn.web.domain.Doctor doctor = doctorService.findByDoctorCode(doctorCode).orElse(null);
|
||||
if (doctor != null) {
|
||||
for (Map<String, Object> s : schedules) {
|
||||
com.sisvietnamvn.web.domain.DoctorSchedule ds = new com.sisvietnamvn.web.domain.DoctorSchedule();
|
||||
ds.setDoctor(doctor);
|
||||
ds.setDate(String.valueOf(s.get("date")));
|
||||
ds.setDayOfWeek(Integer.valueOf(String.valueOf(s.get("dayOfWeek"))));
|
||||
doctorScheduleRepository.save(ds);
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory cache instantly
|
||||
doctorApiSyncService.updateManualScheduleInCache(doctorCode, schedules);
|
||||
|
||||
return org.springframework.http.ResponseEntity.ok("OK");
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to update manual schedule for {}", doctorCode, e);
|
||||
return org.springframework.http.ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/manage/doctor-scheduler/manual/{doctorCode}")
|
||||
@org.springframework.web.bind.annotation.ResponseBody
|
||||
public org.springframework.http.ResponseEntity<List<Map<String, Object>>> getManualSchedule(@PathVariable String doctorCode) {
|
||||
List<com.sisvietnamvn.web.domain.DoctorSchedule> schedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
|
||||
List<Map<String, Object>> result = new java.util.ArrayList<>();
|
||||
|
||||
if (!schedules.isEmpty()) {
|
||||
for (com.sisvietnamvn.web.domain.DoctorSchedule s : schedules) {
|
||||
Map<String, Object> map = new java.util.HashMap<>();
|
||||
map.put("date", s.getDate());
|
||||
map.put("dayOfWeek", s.getDayOfWeek());
|
||||
result.add(map);
|
||||
}
|
||||
} else {
|
||||
// No manual schedule, return API schedule so they can edit it
|
||||
result = doctorApiSyncService.getDoctorSchedule(doctorCode);
|
||||
}
|
||||
|
||||
return org.springframework.http.ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
+88
-1
@@ -1,8 +1,11 @@
|
||||
package com.sisvietnamvn.web.controller.manage;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Doctor;
|
||||
import com.sisvietnamvn.web.domain.Media;
|
||||
import com.sisvietnamvn.web.service.DoctorService;
|
||||
import com.sisvietnamvn.web.service.SpecialtyService;
|
||||
import com.sisvietnamvn.web.service.DoctorApiSyncService;
|
||||
import com.sisvietnamvn.web.service.MediaService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -11,6 +14,7 @@ import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
|
||||
@@ -25,10 +29,14 @@ public class ManageDoctorController {
|
||||
|
||||
private final DoctorService doctorService;
|
||||
private final SpecialtyService specialtyService;
|
||||
private final DoctorApiSyncService doctorApiSyncService;
|
||||
private final MediaService mediaService;
|
||||
|
||||
public ManageDoctorController(DoctorService doctorService, SpecialtyService specialtyService) {
|
||||
public ManageDoctorController(DoctorService doctorService, SpecialtyService specialtyService, DoctorApiSyncService doctorApiSyncService, MediaService mediaService) {
|
||||
this.doctorService = doctorService;
|
||||
this.specialtyService = specialtyService;
|
||||
this.doctorApiSyncService = doctorApiSyncService;
|
||||
this.mediaService = mediaService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -37,11 +45,53 @@ public class ManageDoctorController {
|
||||
populateModel(model, new Doctor(), true);
|
||||
return "manage/doctors/index";
|
||||
}
|
||||
|
||||
@GetMapping("/import-csv")
|
||||
@ResponseBody
|
||||
public String importCsv() {
|
||||
try {
|
||||
String file = "/home/x79/sisvietnamvn_01/Media/HÌNH ẢNH BÁC SĨ NGỒI PHÒNG KHÁM/ALL_IMAGES/DANH SÁCH BÁC SĨ NGỒI PHÒNG KHÁM.csv";
|
||||
java.util.List<String> lines = java.nio.file.Files.readAllLines(java.nio.file.Paths.get(file));
|
||||
int updatedCount = 0;
|
||||
for (int i = 1; i < lines.size(); i++) {
|
||||
String line = lines.get(i);
|
||||
String[] parts = line.split(",", -1);
|
||||
if (parts.length >= 7) {
|
||||
String doctorCode = parts[1].trim();
|
||||
String specialtyName = parts[6].trim();
|
||||
|
||||
if (!doctorCode.isEmpty() && !specialtyName.isEmpty()) {
|
||||
java.util.Optional<Doctor> docOpt = doctorService.findByDoctorCode(doctorCode);
|
||||
if (docOpt.isPresent()) {
|
||||
Doctor doctor = docOpt.get();
|
||||
|
||||
// Find or create specialty
|
||||
java.util.List<com.sisvietnamvn.web.domain.Specialty> specs = specialtyService.findAll();
|
||||
com.sisvietnamvn.web.domain.Specialty spec = specs.stream().filter(s -> s.getName().equalsIgnoreCase(specialtyName)).findFirst().orElse(null);
|
||||
if (spec == null) {
|
||||
spec = new com.sisvietnamvn.web.domain.Specialty();
|
||||
spec.setName(specialtyName);
|
||||
spec = specialtyService.save(spec);
|
||||
}
|
||||
|
||||
doctor.setSpecialty(spec);
|
||||
doctorService.save(doctor);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Import success! Updated " + updatedCount + " doctors.";
|
||||
} catch (Exception e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String createDoctor(@Valid @ModelAttribute("doctor") Doctor doctor,
|
||||
BindingResult bindingResult,
|
||||
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
|
||||
@RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to create Doctor : {}", doctor);
|
||||
@@ -52,6 +102,18 @@ public class ManageDoctorController {
|
||||
if (specialtyId != null) {
|
||||
specialtyService.findById(specialtyId).ifPresent(doctor::setSpecialty);
|
||||
}
|
||||
|
||||
if (avatarFile != null && !avatarFile.isEmpty()) {
|
||||
try {
|
||||
Media media = mediaService.upload(avatarFile);
|
||||
doctor.setAvatarUrl(media.getFileUrl());
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to upload avatar", e);
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Failed to upload avatar: " + e.getMessage());
|
||||
return "redirect:/manage/doctors";
|
||||
}
|
||||
}
|
||||
|
||||
doctorService.save(doctor);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Doctor created successfully!");
|
||||
return "redirect:/manage/doctors";
|
||||
@@ -74,6 +136,7 @@ public class ManageDoctorController {
|
||||
@Valid @ModelAttribute("doctor") Doctor doctor,
|
||||
BindingResult bindingResult,
|
||||
@RequestParam(value = "specialtyId", required = false) Long specialtyId,
|
||||
@RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to update Doctor : {}", id);
|
||||
@@ -87,6 +150,18 @@ public class ManageDoctorController {
|
||||
} else {
|
||||
doctor.setSpecialty(null);
|
||||
}
|
||||
|
||||
if (avatarFile != null && !avatarFile.isEmpty()) {
|
||||
try {
|
||||
Media media = mediaService.upload(avatarFile);
|
||||
doctor.setAvatarUrl(media.getFileUrl());
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to upload avatar", e);
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Failed to upload avatar: " + e.getMessage());
|
||||
return "redirect:/manage/doctors";
|
||||
}
|
||||
}
|
||||
|
||||
doctorService.save(doctor);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Doctor updated successfully!");
|
||||
return "redirect:/manage/doctors";
|
||||
@@ -104,6 +179,18 @@ public class ManageDoctorController {
|
||||
return "redirect:/manage/doctors";
|
||||
}
|
||||
|
||||
@PostMapping("/sync")
|
||||
public String syncDoctorsFromApi(RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to force sync doctors from API");
|
||||
try {
|
||||
doctorApiSyncService.syncDoctorsFromApi();
|
||||
redirectAttributes.addFlashAttribute("successMessage", "API Sync completed successfully!");
|
||||
} catch (Exception e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "API Sync failed: " + e.getMessage());
|
||||
}
|
||||
return "redirect:/manage/doctors";
|
||||
}
|
||||
|
||||
private void populateModel(Model model, Doctor doctor, boolean isNew) {
|
||||
model.addAttribute("doctor", doctor);
|
||||
model.addAttribute("isNew", isNew);
|
||||
|
||||
+24
-8
@@ -29,20 +29,27 @@ public class ManageSpecialtyController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String listSpecialties(Model model) {
|
||||
public String listSpecialties(
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
Model model) {
|
||||
LOG.debug("Request to list all specialties");
|
||||
populateModel(model, new Specialty(), true);
|
||||
populateModel(model, new Specialty(), true, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String createSpecialty(@Valid @ModelAttribute("specialty") Specialty specialty,
|
||||
BindingResult bindingResult,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to create Specialty : {}", specialty);
|
||||
if (bindingResult.hasErrors()) {
|
||||
populateModel(model, specialty, true);
|
||||
populateModel(model, specialty, true, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
specialtyService.save(specialty);
|
||||
@@ -51,14 +58,18 @@ public class ManageSpecialtyController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/edit")
|
||||
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
|
||||
public String showEditForm(@PathVariable Long id,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
Model model, RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to show edit form for Specialty : {}", id);
|
||||
Optional<Specialty> specialtyOptional = specialtyService.findById(id);
|
||||
if (specialtyOptional.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Specialty not found.");
|
||||
return "redirect:/manage/specialties";
|
||||
}
|
||||
populateModel(model, specialtyOptional.get(), false);
|
||||
populateModel(model, specialtyOptional.get(), false, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
|
||||
@@ -66,11 +77,14 @@ public class ManageSpecialtyController {
|
||||
public String updateSpecialty(@PathVariable Long id,
|
||||
@Valid @ModelAttribute("specialty") Specialty specialty,
|
||||
BindingResult bindingResult,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to update Specialty : {}", id);
|
||||
if (bindingResult.hasErrors()) {
|
||||
populateModel(model, specialty, false);
|
||||
populateModel(model, specialty, false, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
specialty.setId(id);
|
||||
@@ -91,9 +105,11 @@ public class ManageSpecialtyController {
|
||||
return "redirect:/manage/specialties";
|
||||
}
|
||||
|
||||
private void populateModel(Model model, Specialty specialty, boolean isNew) {
|
||||
private void populateModel(Model model, Specialty specialty, boolean isNew, String search, org.springframework.data.domain.Pageable pageable) {
|
||||
model.addAttribute("specialty", specialty);
|
||||
model.addAttribute("isNew", isNew);
|
||||
model.addAttribute("allSpecialties", specialtyService.findAll());
|
||||
org.springframework.data.domain.Page<Specialty> specialtyPage = specialtyService.findAll(search, pageable);
|
||||
model.addAttribute("specialtyPage", specialtyPage);
|
||||
model.addAttribute("search", search);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,27 @@ public class Doctor extends AbstractAuditingEntity<Long> {
|
||||
@Column(name = "booking_url", length = 500)
|
||||
private String bookingUrl;
|
||||
|
||||
@Size(max = 50)
|
||||
@Column(name = "doctor_code", length = 50)
|
||||
private String doctorCode;
|
||||
|
||||
@Lob
|
||||
@Column(name = "work_experience")
|
||||
private String workExperience;
|
||||
|
||||
@Lob
|
||||
@Column(name = "education")
|
||||
private String education;
|
||||
|
||||
@Lob
|
||||
@Column(name = "achievements")
|
||||
private String achievements;
|
||||
|
||||
@Column(name = "active")
|
||||
private Boolean active = true;
|
||||
|
||||
// jhipster-needle-entity-add-field - JHipster will add fields here
|
||||
|
||||
// --- Getters and Setters ---
|
||||
|
||||
@Override
|
||||
@@ -92,6 +113,48 @@ public class Doctor extends AbstractAuditingEntity<Long> {
|
||||
this.bookingUrl = bookingUrl;
|
||||
}
|
||||
|
||||
public String getDoctorCode() {
|
||||
return doctorCode;
|
||||
}
|
||||
|
||||
public void setDoctorCode(String doctorCode) {
|
||||
this.doctorCode = doctorCode;
|
||||
}
|
||||
|
||||
public String getWorkExperience() {
|
||||
return workExperience;
|
||||
}
|
||||
|
||||
public void setWorkExperience(String workExperience) {
|
||||
this.workExperience = workExperience;
|
||||
}
|
||||
|
||||
public String getEducation() {
|
||||
return education;
|
||||
}
|
||||
|
||||
public void setEducation(String education) {
|
||||
this.education = education;
|
||||
}
|
||||
|
||||
public String getAchievements() {
|
||||
return achievements;
|
||||
}
|
||||
|
||||
public void setAchievements(String achievements) {
|
||||
this.achievements = achievements;
|
||||
}
|
||||
|
||||
public Boolean getActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(Boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
// --- End Getters and Setters ---
|
||||
|
||||
// --- equals, hashCode, toString ---
|
||||
|
||||
@Override
|
||||
@@ -114,6 +177,7 @@ public class Doctor extends AbstractAuditingEntity<Long> {
|
||||
", title='" + getTitle() + "'" +
|
||||
", avatarUrl='" + getAvatarUrl() + "'" +
|
||||
", bookingUrl='" + getBookingUrl() + "'" +
|
||||
", doctorCode='" + getDoctorCode() + "'" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.sisvietnamvn.web.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* A DoctorSchedule entity for manual scheduling.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sis_doctor_schedule")
|
||||
public class DoctorSchedule implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
|
||||
@SequenceGenerator(name = "sequenceGenerator")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "doctor_id", nullable = false)
|
||||
private Doctor doctor;
|
||||
|
||||
@Column(name = "schedule_date", length = 50, nullable = false)
|
||||
private String date;
|
||||
|
||||
@Column(name = "day_of_week", nullable = false)
|
||||
private Integer dayOfWeek;
|
||||
|
||||
// --- Getters and Setters ---
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Doctor getDoctor() {
|
||||
return doctor;
|
||||
}
|
||||
|
||||
public void setDoctor(Doctor doctor) {
|
||||
this.doctor = doctor;
|
||||
}
|
||||
|
||||
public String getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(String date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
public void setDayOfWeek(Integer dayOfWeek) {
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,6 @@ import java.util.List;
|
||||
public interface DoctorRepository extends JpaRepository<Doctor, Long> {
|
||||
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"specialty"})
|
||||
List<Doctor> findAllByOrderByCreatedDateDesc();
|
||||
|
||||
java.util.Optional<Doctor> findByDoctorCode(String doctorCode);
|
||||
}
|
||||
|
||||
+18
@@ -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
@@ -4,6 +4,10 @@ import com.sisvietnamvn.web.domain.Specialty;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
@Repository
|
||||
public interface SpecialtyRepository extends JpaRepository<Specialty, Long> {
|
||||
Page<Specialty> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||
}
|
||||
|
||||
+317
-42
@@ -1,56 +1,185 @@
|
||||
package com.sisvietnamvn.web.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.repository.DoctorRepository;
|
||||
import com.sisvietnamvn.web.repository.MediaRepository;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
@Service
|
||||
public class DoctorApiSyncService {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DoctorApiSyncService.class);
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MediaRepository mediaRepository;
|
||||
|
||||
private final DoctorRepository doctorRepository;
|
||||
private final SettingService settingService;
|
||||
private final com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository;
|
||||
|
||||
// In-memory cache
|
||||
private List<Map<String, Object>> cachedDoctors = new ArrayList<>();
|
||||
|
||||
public DoctorApiSyncService(ObjectMapper objectMapper, MediaRepository mediaRepository) {
|
||||
public DoctorApiSyncService(ObjectMapper objectMapper, MediaRepository mediaRepository,
|
||||
DoctorRepository doctorRepository, SettingService settingService, com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.doctorRepository = doctorRepository;
|
||||
this.settingService = settingService;
|
||||
this.doctorScheduleRepository = doctorScheduleRepository;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getCachedDoctors() {
|
||||
return cachedDoctors;
|
||||
}
|
||||
|
||||
// Run immediately after startup and every 30 seconds
|
||||
@PostConstruct
|
||||
@Scheduled(fixedRate = 30000)
|
||||
public void syncDoctorsFromApi() {
|
||||
public void updateManualScheduleInCache(String doctorCode, List<Map<String, Object>> newDays) {
|
||||
for (Map<String, Object> doc : cachedDoctors) {
|
||||
if (doctorCode.equals(doc.get("doctorCode"))) {
|
||||
doc.put("days", new ArrayList<>(newDays));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getDoctorSchedule(String doctorCode) {
|
||||
// ALWAYS check manual schedule first
|
||||
List<com.sisvietnamvn.web.domain.DoctorSchedule> schedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
|
||||
if (!schedules.isEmpty()) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (com.sisvietnamvn.web.domain.DoctorSchedule s : schedules) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("date", s.getDate());
|
||||
map.put("dayOfWeek", s.getDayOfWeek());
|
||||
result.add(map);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String mode = settingService.getValue("doctor.sync.mode", "SCHEDULED");
|
||||
|
||||
if ("MANUAL".equals(mode)) {
|
||||
return new ArrayList<>(); // Already checked manual above, so return empty
|
||||
}
|
||||
|
||||
if ("REALTIME".equals(mode)) {
|
||||
return fetchRealtimeSchedule(doctorCode);
|
||||
}
|
||||
|
||||
// SCHEDULED mode
|
||||
for (Map<String, Object> doc : cachedDoctors) {
|
||||
if (doctorCode.equals(doc.get("doctorCode"))) {
|
||||
Object daysObj = doc.get("days");
|
||||
if (daysObj instanceof List) {
|
||||
return (List<Map<String, Object>>) daysObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> fetchRealtimeSchedule(String doctorCode) {
|
||||
List<Map<String, Object>> daysList = new ArrayList<>();
|
||||
try {
|
||||
LOG.info("Starting API sync for doctors...");
|
||||
|
||||
// 1. Get Token
|
||||
// Get token
|
||||
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
|
||||
HttpHeaders tokenHeaders = new HttpHeaders();
|
||||
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
tokenHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
org.springframework.util.MultiValueMap<String, String> tokenBody = new org.springframework.util.LinkedMultiValueMap<>();
|
||||
tokenBody.add("grant_type", "client_credentials");
|
||||
tokenBody.add("client_id", "DigitalHealthSolutions_LabConn");
|
||||
tokenBody.add("client_secret", "AcEnBCaphERGOGYMaStATITEnaPETYpR");
|
||||
tokenBody.add("scope", "LabConn");
|
||||
HttpEntity<org.springframework.util.MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
|
||||
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
|
||||
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
|
||||
String accessToken = tokenNode.get("access_token").asText();
|
||||
|
||||
HttpHeaders apiHeaders = new HttpHeaders();
|
||||
apiHeaders.setBearerAuth(accessToken);
|
||||
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
apiHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
|
||||
|
||||
String step3Url = "https://dhs.sisvietnam.vn/api/app/his/doctors/" + doctorCode + "/work-schedule-days";
|
||||
ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET, apiRequest, String.class);
|
||||
JsonNode step3Root = objectMapper.readTree(step3Response.getBody());
|
||||
JsonNode daysArrayNode = null;
|
||||
if (step3Root.has("data") && step3Root.get("data").has("days")
|
||||
&& step3Root.get("data").get("days").isArray()) {
|
||||
daysArrayNode = step3Root.get("data").get("days");
|
||||
}
|
||||
if (daysArrayNode != null && daysArrayNode.isArray()) {
|
||||
for (JsonNode dayNode : daysArrayNode) {
|
||||
Map<String, Object> dayInfo = new HashMap<>();
|
||||
if (dayNode.has("dayOfWeek")) {
|
||||
dayInfo.put("dayOfWeek", dayNode.get("dayOfWeek").asInt());
|
||||
}
|
||||
if (dayNode.has("date")) {
|
||||
dayInfo.put("date", dayNode.get("date").asText());
|
||||
}
|
||||
if (!dayInfo.isEmpty()) {
|
||||
daysList.add(dayInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to fetch realtime schedule for doctor: {}", doctorCode, e);
|
||||
}
|
||||
return daysList;
|
||||
}
|
||||
|
||||
private long lastSyncTime = 0;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
syncDoctorsFromApi();
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void checkAndSync() {
|
||||
int intervalMinutes = 480; // default 8 hours
|
||||
try {
|
||||
intervalMinutes = Integer.parseInt(settingService.getValue("doctor.sync.interval", "480"));
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warn("Invalid doctor.sync.interval, using default 480");
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() - lastSyncTime >= intervalMinutes * 60L * 1000L) {
|
||||
syncDoctorsFromApi();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void syncDoctorsFromApi() {
|
||||
lastSyncTime = System.currentTimeMillis();
|
||||
try {
|
||||
LOG.info("Starting API sync for doctors...");
|
||||
|
||||
// 1. Get Token
|
||||
String tokenUrl = "https://dhs.sisvietnam.vn/connect/token";
|
||||
HttpHeaders tokenHeaders = new HttpHeaders();
|
||||
tokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
tokenHeaders.add("Cookie",
|
||||
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
|
||||
MultiValueMap<String, String> tokenBody = new LinkedMultiValueMap<>();
|
||||
tokenBody.add("grant_type", "client_credentials");
|
||||
@@ -60,7 +189,7 @@ public class DoctorApiSyncService {
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(tokenBody, tokenHeaders);
|
||||
ResponseEntity<String> tokenResponse = restTemplate.postForEntity(tokenUrl, tokenRequest, String.class);
|
||||
|
||||
|
||||
JsonNode tokenNode = objectMapper.readTree(tokenResponse.getBody());
|
||||
String accessToken = tokenNode.get("access_token").asText();
|
||||
|
||||
@@ -69,33 +198,51 @@ public class DoctorApiSyncService {
|
||||
HttpHeaders apiHeaders = new HttpHeaders();
|
||||
apiHeaders.setBearerAuth(accessToken);
|
||||
apiHeaders.add("__tenant", "3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
apiHeaders.add("Cookie", ".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
apiHeaders.add("Cookie",
|
||||
".AspNetCore.Culture=c%3Den%7Cuic%3Den; __tenant=3a0f747c-57b6-753b-ba21-3987e8e93b9a");
|
||||
|
||||
HttpEntity<Void> apiRequest = new HttpEntity<>(apiHeaders);
|
||||
ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest, String.class);
|
||||
ResponseEntity<String> apiResponse = restTemplate.exchange(apiUrl, HttpMethod.GET, apiRequest,
|
||||
String.class);
|
||||
|
||||
JsonNode root = objectMapper.readTree(apiResponse.getBody());
|
||||
JsonNode doctorsNode = root.at("/data/doctors");
|
||||
|
||||
try {
|
||||
java.nio.file.Files.writeString(java.nio.file.Paths.get("/tmp/debug_api.json"), root.toPrettyString());
|
||||
} catch (Exception e) {
|
||||
}
|
||||
JsonNode doctorsNode = root.at("/data/doctors");
|
||||
|
||||
List<Map<String, Object>> newDoctorsList = new ArrayList<>();
|
||||
if (doctorsNode.isArray()) {
|
||||
for (JsonNode node : doctorsNode) {
|
||||
Map<String, Object> doc = new HashMap<>();
|
||||
doc.put("title", node.get("title") != null && !node.get("title").isNull() ? node.get("title").asText("") : "BS");
|
||||
String fullName = node.get("fullName") != null && !node.get("fullName").isNull() ? node.get("fullName").asText("") : "";
|
||||
if (newDoctorsList.isEmpty()) {
|
||||
LOG.info("DEBUG API DOCTOR NODE: {}", node.toString());
|
||||
}
|
||||
doc.put("title",
|
||||
node.get("title") != null && !node.get("title").isNull() ? node.get("title").asText("")
|
||||
: "BS");
|
||||
String fullName = node.get("fullName") != null && !node.get("fullName").isNull()
|
||||
? node.get("fullName").asText("")
|
||||
: "";
|
||||
String title = doc.get("title").toString();
|
||||
if (fullName.startsWith(title)) {
|
||||
fullName = fullName.substring(title.length()).trim();
|
||||
if (fullName.startsWith(".")) fullName = fullName.substring(1).trim();
|
||||
if (fullName.startsWith("."))
|
||||
fullName = fullName.substring(1).trim();
|
||||
}
|
||||
doc.put("name", fullName);
|
||||
String doctorCode = node.get("doctorCode") != null && !node.get("doctorCode").isNull() ? node.get("doctorCode").asText() : "";
|
||||
String doctorCode = node.get("doctorCode") != null && !node.get("doctorCode").isNull()
|
||||
? node.get("doctorCode").asText()
|
||||
: "";
|
||||
doc.put("doctorCode", doctorCode);
|
||||
doc.put("bookingUrl", "#");
|
||||
|
||||
|
||||
String avatarUrl = "/images/default-avatar.png";
|
||||
if (!doctorCode.isEmpty()) {
|
||||
org.springframework.data.domain.Page<com.sisvietnamvn.web.domain.Media> mediaPage = mediaRepository.findByOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(doctorCode, org.springframework.data.domain.PageRequest.of(0, 1));
|
||||
org.springframework.data.domain.Page<com.sisvietnamvn.web.domain.Media> mediaPage = mediaRepository
|
||||
.findByOriginalFilenameContainingIgnoreCaseOrderByCreatedDateDesc(doctorCode,
|
||||
org.springframework.data.domain.PageRequest.of(0, 1));
|
||||
if (mediaPage.hasContent()) {
|
||||
avatarUrl = mediaPage.getContent().get(0).getFileUrl();
|
||||
LOG.info("Matched avatar for doctor {}: {}", doctorCode, avatarUrl);
|
||||
@@ -104,30 +251,158 @@ public class DoctorApiSyncService {
|
||||
}
|
||||
}
|
||||
doc.put("avatarUrl", avatarUrl);
|
||||
|
||||
|
||||
Map<String, String> spec = new HashMap<>();
|
||||
spec.put("name", "Đa khoa");
|
||||
spec.put("iconUrl", "");
|
||||
doc.put("specialty", spec);
|
||||
|
||||
List<Map<String, Object>> daysList = new ArrayList<>();
|
||||
|
||||
// Check manual schedule first
|
||||
List<com.sisvietnamvn.web.domain.DoctorSchedule> manualSchedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
|
||||
if (!manualSchedules.isEmpty()) {
|
||||
for (com.sisvietnamvn.web.domain.DoctorSchedule s : manualSchedules) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("date", s.getDate());
|
||||
map.put("dayOfWeek", s.getDayOfWeek());
|
||||
daysList.add(map);
|
||||
}
|
||||
} else {
|
||||
String mode = settingService.getValue("doctor.sync.mode", "SCHEDULED");
|
||||
if ("SCHEDULED".equals(mode) && !doctorCode.isEmpty()) {
|
||||
try {
|
||||
String step3Url = "https://dhs.sisvietnam.vn/api/app/his/doctors/" + doctorCode
|
||||
+ "/work-schedule-days";
|
||||
ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET,
|
||||
apiRequest, String.class);
|
||||
String rawJson = step3Response.getBody();
|
||||
|
||||
JsonNode step3Root = objectMapper.readTree(rawJson);
|
||||
JsonNode daysArrayNode = null;
|
||||
if (step3Root.has("data") && step3Root.get("data").has("days")
|
||||
&& step3Root.get("data").get("days").isArray()) {
|
||||
daysArrayNode = step3Root.get("data").get("days");
|
||||
}
|
||||
|
||||
if (daysArrayNode != null && daysArrayNode.isArray()) {
|
||||
for (JsonNode dayNode : daysArrayNode) {
|
||||
Map<String, Object> dayInfo = new HashMap<>();
|
||||
if (dayNode.has("dayOfWeek")) {
|
||||
dayInfo.put("dayOfWeek", dayNode.get("dayOfWeek").asInt());
|
||||
}
|
||||
if (dayNode.has("date")) {
|
||||
dayInfo.put("date", dayNode.get("date").asText());
|
||||
}
|
||||
if (!dayInfo.isEmpty()) {
|
||||
daysList.add(dayInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to fetch work-schedule-days for doctor: {}", doctorCode, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.put("days", daysList);
|
||||
|
||||
newDoctorsList.add(doc);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Atomically swap the reference
|
||||
if (!newDoctorsList.isEmpty()) {
|
||||
newDoctorsList.sort((d1, d2) -> {
|
||||
String c1 = (String) d1.get("doctorCode");
|
||||
String c2 = (String) d2.get("doctorCode");
|
||||
int r1 = "00001".equals(c1) ? 0 : ("00002".equals(c1) ? 1 : 2);
|
||||
int r2 = "00001".equals(c2) ? 0 : ("00002".equals(c2) ? 1 : 2);
|
||||
return Integer.compare(r1, r2);
|
||||
});
|
||||
this.cachedDoctors = newDoctorsList;
|
||||
LOG.info("Successfully synced {} doctors to cache.", newDoctorsList.size());
|
||||
LOG.info("Successfully refreshed doctor cache. Total doctors: {}", cachedDoctors.size());
|
||||
|
||||
// Update active status in database
|
||||
updateActiveStatusInDatabase(newDoctorsList);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to sync doctors from API: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
protected void updateActiveStatusInDatabase(List<Map<String, Object>> newDoctorsList) {
|
||||
List<com.sisvietnamvn.web.domain.Doctor> allDbDoctors = doctorRepository.findAll();
|
||||
Map<String, com.sisvietnamvn.web.domain.Doctor> dbDoctorMap = new HashMap<>();
|
||||
for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) {
|
||||
if (dbDoc.getDoctorCode() != null && !dbDoc.getDoctorCode().isEmpty()) {
|
||||
dbDoctorMap.put(dbDoc.getDoctorCode(), dbDoc);
|
||||
}
|
||||
}
|
||||
|
||||
List<com.sisvietnamvn.web.domain.Doctor> doctorsToSave = new ArrayList<>();
|
||||
List<String> activeCodes = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> apiDoc : newDoctorsList) {
|
||||
String code = (String) apiDoc.get("doctorCode");
|
||||
if (code == null || code.isEmpty())
|
||||
continue;
|
||||
activeCodes.add(code);
|
||||
|
||||
com.sisvietnamvn.web.domain.Doctor dbDoc = dbDoctorMap.get(code);
|
||||
boolean isNew = false;
|
||||
if (dbDoc == null) {
|
||||
dbDoc = new com.sisvietnamvn.web.domain.Doctor();
|
||||
dbDoc.setDoctorCode(code);
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to sync doctors from API", e);
|
||||
boolean needsUpdate = isNew;
|
||||
|
||||
String name = (String) apiDoc.get("name");
|
||||
if (name != null && !name.equals(dbDoc.getName())) {
|
||||
dbDoc.setName(name);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
String title = (String) apiDoc.get("title");
|
||||
if (title != null && !title.equals(dbDoc.getTitle())) {
|
||||
dbDoc.setTitle(title);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
String avatarUrl = (String) apiDoc.get("avatarUrl");
|
||||
if (avatarUrl != null && !avatarUrl.equals(dbDoc.getAvatarUrl())) {
|
||||
dbDoc.setAvatarUrl(avatarUrl);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
String bookingUrl = (String) apiDoc.get("bookingUrl");
|
||||
if (bookingUrl != null && !bookingUrl.equals(dbDoc.getBookingUrl())) {
|
||||
dbDoc.setBookingUrl(bookingUrl);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (dbDoc.getActive() == null || !dbDoc.getActive()) {
|
||||
dbDoc.setActive(true);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
doctorsToSave.add(dbDoc);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle inactive doctors
|
||||
for (com.sisvietnamvn.web.domain.Doctor dbDoc : allDbDoctors) {
|
||||
if (dbDoc.getDoctorCode() != null && !dbDoc.getDoctorCode().isEmpty()
|
||||
&& !activeCodes.contains(dbDoc.getDoctorCode())) {
|
||||
if (dbDoc.getActive() == null || dbDoc.getActive()) {
|
||||
dbDoc.setActive(false);
|
||||
doctorsToSave.add(dbDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG.info("DEBUG: newDoctorsList.size()={}, allDbDoctors.size()={}, doctorsToSave.size()={}",
|
||||
newDoctorsList.size(), allDbDoctors.size(), doctorsToSave.size());
|
||||
|
||||
if (!doctorsToSave.isEmpty()) {
|
||||
doctorRepository.saveAll(doctorsToSave);
|
||||
LOG.info("Upserted {} doctors in the database.", doctorsToSave.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ public class DoctorService {
|
||||
return doctorRepository.findById(id);
|
||||
}
|
||||
|
||||
public Optional<Doctor> findByDoctorCode(String doctorCode) {
|
||||
return doctorRepository.findByDoctorCode(doctorCode);
|
||||
}
|
||||
|
||||
public Doctor save(Doctor doctor) {
|
||||
return doctorRepository.save(doctor);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.sisvietnamvn.web.repository.SpecialtyRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -22,6 +24,13 @@ public class SpecialtyService {
|
||||
return specialtyRepository.findAll();
|
||||
}
|
||||
|
||||
public Page<Specialty> findAll(String search, Pageable pageable) {
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
return specialtyRepository.findByNameContainingIgnoreCase(search, pageable);
|
||||
}
|
||||
return specialtyRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
public Optional<Specialty> findById(Long id) {
|
||||
return specialtyRepository.findById(id);
|
||||
}
|
||||
|
||||
@@ -108,5 +108,3 @@ jhipster:
|
||||
# More documentation is available at:
|
||||
# https://www.jhipster.tech/common-application-properties/
|
||||
# ===================================================================
|
||||
|
||||
# application:
|
||||
|
||||
+15
@@ -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>
|
||||
+13
@@ -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>
|
||||
+13
@@ -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>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="20260723185500-1" author="antigravity">
|
||||
<createTable tableName="sis_doctor_schedule">
|
||||
<column name="id" type="bigint">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="doctor_id" type="bigint">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="schedule_date" type="varchar(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="day_of_week" type="int">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseColumnNames="doctor_id"
|
||||
baseTableName="sis_doctor_schedule"
|
||||
constraintName="fk_doctor_schedule_doctor_id"
|
||||
referencedColumnNames="id"
|
||||
referencedTableName="sis_doctor"/>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
@@ -41,6 +41,11 @@
|
||||
<include file="config/liquibase/changelog/20260714113000_update_events_list.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260718100000_add_doctor_and_specialty.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260718110000_seed_sample_doctors.xml" relativeToChangelogFile="false"/>
|
||||
<!-- Custom extensions -->
|
||||
<include file="config/liquibase/changelog/20260720181500_seed_contact_us_page.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260722000000_add_fields_to_menu_item.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260723100000_add_detail_fields_to_doctor.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260723110000_add_active_to_doctor.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260723115000_delete_sample_doctors.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='doctor-detail-page')}">
|
||||
<head>
|
||||
<th:block layout:fragment="head">
|
||||
<title th:text="${doctor.title + ' ' + doctor.name + ' | UMC'}">Chi tiết Bác sĩ</title>
|
||||
<style>
|
||||
.doctor-detail-page {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
.section-doctor-shadow {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.doctor-avatar-container {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: linear-gradient(to top left, #f0faff, #e0f2fe);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.doctor-avatar-container {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.doctor-avatar-container {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" th:href="@{/css/umc_doctor.css}" />
|
||||
<link rel="stylesheet" th:href="@{/css/umc_doctor_2.css}" />
|
||||
<link rel="stylesheet" th:href="@{/css/umc_doctor_3.css}" />
|
||||
</th:block>
|
||||
</head>
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<main class="">
|
||||
<section class="xl:py-8 md:py-6 py-4 bg-gray-50">
|
||||
<div class="container">
|
||||
<div class="section-doctor-shadow rounded-lg">
|
||||
<div class="grid grid-cols-12 xl:gap-x-6 md:gap-x-4">
|
||||
<div class="lg:col-span-6 col-span-full bg-[#fbfbfb] xl:py-6 md:py-3 py-3 xl:pl-6 md:pl-3 max-lg:pr-3 max-md:pr-0 rounded-lg">
|
||||
<div>
|
||||
<div class="max-md:border-b max-md:border-gray-100 max-md:pb-3">
|
||||
<div class="flex md:items-center xl:space-x-3 md:space-x-2 space-x-2 xl:pb-6 md:pb-4 max-md:mb-3 md:border-b md:border-gray-100">
|
||||
<div class="xl:size-[170px] md:size-[160px] size-[120px] aspect-[1/1] relative rounded-lg border border-primary-400 overflow-hidden flex-shrink-0 relative bg-gradient-to-tl from-primary-75 to-primary-100">
|
||||
<img th:src="${doctor.avatarUrl != null and !doctor.avatarUrl.isEmpty() ? doctor.avatarUrl : '/images/default-avatar.png'}" style="width: 100%; height: 100%; object-fit: contain;" alt="Avatar Bác sĩ" />
|
||||
</div>
|
||||
<div class="space-y-[5px]">
|
||||
<p class="title-5 text-primary-500 mb-0 max-md:!text-[12.8px]">
|
||||
<span th:text="${doctor.title}">Học hàm/Học vị</span>
|
||||
</p>
|
||||
<h1 class="heading-3 text-[#1E293B] mb-1 max-md:!text-[18px]">
|
||||
<span th:text="${doctor.name}">Tên Bác sĩ</span>
|
||||
</h1>
|
||||
<p class="body-3 text-gray-900 max-md:!text-[12px]">
|
||||
<span th:text="${specialtyName}">Chuyên khoa</span>
|
||||
</p>
|
||||
<div class="max-md:hidden">
|
||||
<div class="flex items-center space-x-2 body-3 max-md:!text-[12px] text-gray-900 flex-wrap mb-[5px]">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div>Đánh giá
|
||||
<!-- -->:
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-0">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-0">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-0)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-1">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-1">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-1)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-2">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-2">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-2)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-3">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-3">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-3)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-4">
|
||||
<stop offset="50%" stop-color="#facc15"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="100%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-4">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-4)"></path>
|
||||
</svg>
|
||||
<span class="text-primary-600 font-semibold ml-2">4.5
|
||||
<!-- -->/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-primary-600 lg:hover:underline lg:hover:text-primary-400 lg:duration-150 cursor-pointer underline-offset-2">(
|
||||
<!-- -->1
|
||||
<!-- -->
|
||||
<!-- -->đánh giá
|
||||
<!-- -->)
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn space-x-2 text-center label-3 w-[286px] !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" style="background-color: var(--color-brand);" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
|
||||
<div>Đặt lịch khám</div>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_83_297)">
|
||||
<path d="M0 16.26C0 17.3 0.366667 18.18 1.1 18.9C1.83333 19.62 2.72 19.9867 3.76 20H16.26C17.2867 20 18.1667 19.6333 18.9 18.9C19.6333 18.1667 20 17.2867 20 16.26V3.76C20 2.94667 19.7667 2.22 19.3 1.58C18.8333 0.94 18.2333 0.493333 17.5 0.24V1.88C17.5 2.4 17.32 2.84667 16.96 3.22C16.6 3.59333 16.1533 3.77333 15.62 3.76C15.0867 3.74667 14.6467 3.56667 14.3 3.22C13.9533 2.87333 13.7733 2.42667 13.76 1.88V0H6.26V1.88C6.26 2.4 6.07333 2.84667 5.7 3.22C5.32667 3.59333 4.88667 3.77333 4.38 3.76C3.87333 3.74667 3.42667 3.56667 3.04 3.22C2.65333 2.87333 2.47333 2.42667 2.5 1.88V0.24C1.76667 0.506667 1.16667 0.953333 0.7 1.58C0.233333 2.20667 0 2.93333 0 3.76L0 16.26ZM2.5 16.26V6.26H17.5V16.26C17.5 16.6067 17.38 16.9 17.14 17.14C16.9 17.38 16.6067 17.5 16.26 17.5H3.76C3.41333 17.5 3.11333 17.38 2.86 17.14C2.60667 16.9 2.48667 16.6067 2.5 16.26ZM3.76 1.88C3.76 2.05333 3.82 2.2 3.94 2.32C4.06 2.44 4.20667 2.5 4.38 2.5C4.55333 2.5 4.7 2.44 4.82 2.32C4.94 2.2 5 2.05333 5 1.88V0H3.76V1.88ZM5 15H7.5V12.5H5V15ZM5 11.26H7.5V8.76H5V11.26ZM8.76 15H11.26V12.5H8.76V15ZM8.76 11.26H11.26V8.76H8.76V11.26ZM12.5 15H15V12.5H12.5V15ZM12.5 11.26H15V8.76H12.5V11.26ZM15 1.88C15 2.05333 15.06 2.2 15.18 2.32C15.3 2.44 15.4467 2.5 15.62 2.5C15.7933 2.5 15.94 2.44 16.06 2.32C16.18 2.2 16.2467 2.05333 16.26 1.88V0H15V1.88Z" fill="white"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_83_297">
|
||||
<rect width="20" height="20" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:hidden">
|
||||
<div class="flex items-center mb-2 space-x-2 body-3 max-md:!text-[12px] text-gray-900 flex-wrap">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div>Đánh giá
|
||||
<!-- -->:
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-0">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-0">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-0)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-1">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-1">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-1)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-2">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-2">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-2)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-3">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-3">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-3)"></path>
|
||||
</svg>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-banner-4.5-4">
|
||||
<stop offset="50%" stop-color="#facc15"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="100%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-banner-4.5-4">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-4)"></path>
|
||||
</svg>
|
||||
<span class="text-primary-600 font-semibold ml-2">4.5
|
||||
<!-- -->/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-primary-600 lg:hover:underline lg:hover:text-primary-400 lg:duration-150 cursor-pointer underline-offset-2">(
|
||||
<!-- -->1
|
||||
<!-- -->
|
||||
<!-- -->đánh giá
|
||||
<!-- -->)
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn space-x-2 text-center label-3 w-full !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" style="background-color: var(--color-brand);" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
|
||||
<div>Đặt lịch khám</div>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_83_297)">
|
||||
<path d="M0 16.26C0 17.3 0.366667 18.18 1.1 18.9C1.83333 19.62 2.72 19.9867 3.76 20H16.26C17.2867 20 18.1667 19.6333 18.9 18.9C19.6333 18.1667 20 17.2867 20 16.26V3.76C20 2.94667 19.7667 2.22 19.3 1.58C18.8333 0.94 18.2333 0.493333 17.5 0.24V1.88C17.5 2.4 17.32 2.84667 16.96 3.22C16.6 3.59333 16.1533 3.77333 15.62 3.76C15.0867 3.74667 14.6467 3.56667 14.3 3.22C13.9533 2.87333 13.7733 2.42667 13.76 1.88V0H6.26V1.88C6.26 2.4 6.07333 2.84667 5.7 3.22C5.32667 3.59333 4.88667 3.77333 4.38 3.76C3.87333 3.74667 3.42667 3.56667 3.04 3.22C2.65333 2.87333 2.47333 2.42667 2.5 1.88V0.24C1.76667 0.506667 1.16667 0.953333 0.7 1.58C0.233333 2.20667 0 2.93333 0 3.76L0 16.26ZM2.5 16.26V6.26H17.5V16.26C17.5 16.6067 17.38 16.9 17.14 17.14C16.9 17.38 16.6067 17.5 16.26 17.5H3.76C3.41333 17.5 3.11333 17.38 2.86 17.14C2.60667 16.9 2.48667 16.6067 2.5 16.26ZM3.76 1.88C3.76 2.05333 3.82 2.2 3.94 2.32C4.06 2.44 4.20667 2.5 4.38 2.5C4.55333 2.5 4.7 2.44 4.82 2.32C4.94 2.2 5 2.05333 5 1.88V0H3.76V1.88ZM5 15H7.5V12.5H5V15ZM5 11.26H7.5V8.76H5V11.26ZM8.76 15H11.26V12.5H8.76V15ZM8.76 11.26H11.26V8.76H8.76V11.26ZM12.5 15H15V12.5H12.5V15ZM12.5 11.26H15V8.76H12.5V11.26ZM15 1.88C15 2.05333 15.06 2.2 15.18 2.32C15.3 2.44 15.4467 2.5 15.62 2.5C15.7933 2.5 15.94 2.44 16.06 2.32C16.18 2.2 16.2467 2.05333 16.26 1.88V0H15V1.88Z" fill="white"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_83_297">
|
||||
<rect width="20" height="20" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-primary-600 xl:p-3 p-2">
|
||||
<div class="title-3 text-white">Lịch khám</div>
|
||||
</div>
|
||||
<div class="xl:m-3 m-2">
|
||||
<div th:if="${scheduleDays != null and !scheduleDays.isEmpty()}">
|
||||
<div class="flex gap-1 mb-1">
|
||||
<span class="w-1 h-1 bg-primary-600 rounded-full md:mt-[11px] mt-2.5 flex-shrink-0"></span>
|
||||
<div class="text-primary-600">
|
||||
<span class="uppercase max-md:!text-[16px] heading-5" th:text="${specialtyName != null and !specialtyName.isEmpty() ? specialtyName : 'Chung'}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-none space-y-1">
|
||||
<li th:each="day : ${scheduleDays}" class="text-gray-900 flex items-center justify-between space-x-2 relative pl-3 before:content-[''] before:w-1 before:h-1 before:bg-primary-600 before:rounded-full before:absolute md:before:top-[12px] before:top-3 before:left-1">
|
||||
<div>
|
||||
<span class="body-3 max-md:!text-[14px]" th:text="'Sáng thứ ' + ${day.dayOfWeek} + (${day.date != null} ? ' (' + ${day.date} + ')' : '')"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div th:if="${scheduleDays == null or scheduleDays.isEmpty()}">
|
||||
<p class="text-gray-500 italic">Chưa có lịch khám</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 scroll-mt-[132px]" style="display: none;">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-primary-600 xl:p-3 p-2">
|
||||
<div class="flex md:items-center md:justify-between md:space-x-2 max-md:flex-col max-md:items-start max-md:space-y-2">
|
||||
<div class="title-3 text-white">Cảm nhận của người bệnh</div>
|
||||
<div class="flex items-center max-md:flex-1 gap-2 text-white body-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-0.5">
|
||||
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-4.5-0">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-4.5-0">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-0)"></path>
|
||||
</svg>
|
||||
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-4.5-1">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-4.5-1">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-1)"></path>
|
||||
</svg>
|
||||
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-4.5-2">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-4.5-2">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-2)"></path>
|
||||
</svg>
|
||||
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-4.5-3">
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
<stop offset="100%" stop-color="#facc15"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-4.5-3">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-3)"></path>
|
||||
</svg>
|
||||
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="star-gradient-star-4.5-4">
|
||||
<stop offset="50%" stop-color="#facc15"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="100%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<mask id="star-mask-star-4.5-4">
|
||||
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
|
||||
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-4)"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold">4.5
|
||||
<!-- -->/5
|
||||
</span>
|
||||
</div>
|
||||
<div>(
|
||||
<!-- -->1
|
||||
<!-- -->
|
||||
<!-- -->đánh giá
|
||||
<!-- -->)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xl:m-3 m-2">
|
||||
<div class="mb-4 xl:p-4 md:p-3 p-2 bg-gray-50 rounded-lg">
|
||||
<h4 class="title-1 text-primary-600 xl:mb-2 md:mb-1.5 mb-1">Viết đánh giá của bạn</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="label-3 text-gray-900">Đánh giá
|
||||
<!-- -->:
|
||||
</span>
|
||||
<div class="flex space-x-1">
|
||||
<div class="relative cursor-pointer">
|
||||
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="interactive-star-gradient-1">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="interactive-star-stroke-1">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-1)" stroke="url(#interactive-star-stroke-1)" stroke-width="1"></path>
|
||||
</svg>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
</div>
|
||||
<div class="relative cursor-pointer">
|
||||
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="interactive-star-gradient-2">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="interactive-star-stroke-2">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-2)" stroke="url(#interactive-star-stroke-2)" stroke-width="1"></path>
|
||||
</svg>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
</div>
|
||||
<div class="relative cursor-pointer">
|
||||
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="interactive-star-gradient-3">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="interactive-star-stroke-3">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-3)" stroke="url(#interactive-star-stroke-3)" stroke-width="1"></path>
|
||||
</svg>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
</div>
|
||||
<div class="relative cursor-pointer">
|
||||
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="interactive-star-gradient-4">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="interactive-star-stroke-4">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-4)" stroke="url(#interactive-star-stroke-4)" stroke-width="1"></path>
|
||||
</svg>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
</div>
|
||||
<div class="relative cursor-pointer">
|
||||
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="interactive-star-gradient-5">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="interactive-star-stroke-5">
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
<stop offset="50%" stop-color="#d1d5db"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-5)" stroke="url(#interactive-star-stroke-5)" stroke-width="1"></path>
|
||||
</svg>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
<div class="absolute inset-0 cursor-pointer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="body-3 text-gray-900">Chọn sao đánh giá</span>
|
||||
</div>
|
||||
<div>
|
||||
<textarea placeholder="Chia sẻ trải nghiệm khám bệnh của bạn..." class="w-full xl:p-3 p-2 border body-3 border-gray-300 rounded-lg focus:outline-none focus:border-primary-600 resize-none" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button class="btn btn-light label-2" disabled="">Gửi đánh giá</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="title-1 text-primary-600">Đánh giá từ người bệnh</h4>
|
||||
<div class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="body-2 text-gray-600">Chưa có đánh giá</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xl:p-6 lg:p-4 max-lg:py-3 max-lg:px-4 max-md:px-3 bg-white lg:col-span-6 col-span-full rounded-lg overflow-hidden space-y-4">
|
||||
<div class="space-y-4"></div>
|
||||
<div class="space-y-4">
|
||||
<div class="jam-accordion active" th:if="${doctor.education != null and !doctor.education.isEmpty()}">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="education-content">
|
||||
<div class="title-3 text-white">Quá trình đào tạo</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down w-4 h-4 text-white duration-150 -rotate-180">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="jam-accordion-content">
|
||||
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2" th:utext="${doctor.education}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jam-accordion active" th:if="${doctor.workExperience != null and !doctor.workExperience.isEmpty()}">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="experience-content">
|
||||
<div class="title-3 text-white">Quá trình công tác</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down w-4 h-4 text-white duration-150 -rotate-180">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="jam-accordion-content">
|
||||
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] space-y-2 xl:m-3 m-2" th:utext="${doctor.workExperience}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jam-accordion active" th:if="${doctor.achievements != null and !doctor.achievements.isEmpty()}">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="association-content">
|
||||
<div class="title-3 text-white">Hiệp hội chuyên môn & 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>
|
||||
@@ -38,7 +38,7 @@
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<div class="text-primary-300 label-3 max-md:!text-[14px] !leading-[150%]" th:text="${doc.title}"></div>
|
||||
<a class="text-[16px] leading-[150%] font-display font-bold text-[#1E293B] lg:hover:text-primary-600 lg:duration-150 mb-1 line-clamp-2" th:href="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() ? doc.bookingUrl : '#'}" th:text="${doc.name}"></a>
|
||||
<a class="text-[16px] leading-[150%] font-display font-bold text-[#1E293B] lg:hover:text-primary-600 lg:duration-150 mb-1 line-clamp-2" th:href="@{/bac-si/{code}(code=${doc.doctorCode})}" th:text="${doc.name}"></a>
|
||||
<div class="text-primary-300 label-3 !leading-[150%]" th:text="${doc.specialty != null ? doc.specialty.name : ''}"></div>
|
||||
</div>
|
||||
<div class="absolute xl:top-4 top-4 xl:right-4 right-4 size-[40px] rounded-lg bg-white card-icon-shadow p-1" th:if="${doc.specialty != null and doc.specialty.iconUrl != null and !doc.specialty.iconUrl.isEmpty()}">
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="xl:px-6 px-4 xl:pb-6 pb-4 xl:mt-3 mt-2 flex items-center justify-between gap-x-2">
|
||||
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center label-2 !px-0 group items-center justify-center gap-1.5" th:href="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() ? doc.bookingUrl : '#'}">
|
||||
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center label-2 !px-0 group items-center justify-center gap-1.5" th:href="@{/bac-si/{code}(code=${doc.doctorCode})}">
|
||||
<div>Xem hồ sơ</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m10 8 4 4-4 4"/></svg>
|
||||
</a>
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
<a class="collapse-item" th:href="@{/manage/tools/import-export}">Import / Export</a>
|
||||
<a class="collapse-item" th:href="@{/manage/tools/site-health}">Site Health</a>
|
||||
<a class="collapse-item" th:href="@{/manage/tools/personal-data}">Personal Data (GDPR)</a>
|
||||
<a class="collapse-item" th:href="@{/manage/doctor-scheduler}">Doctor Scheduler</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{fragments/manage-layout}">
|
||||
|
||||
<head>
|
||||
<title>Quản lý lịch khám</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<!-- Page Heading -->
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800">Quản lý Lịch Khám</h1>
|
||||
<form th:if="${syncMode == 'SCHEDULED'}" th:action="@{/manage/doctor-scheduler/force-sync}" method="post">
|
||||
<button type="submit" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-sync-alt fa-sm text-white-50"></i> Force Update
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4 border-left-primary">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Cấu hình đồng bộ</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form th:action="@{/manage/doctor-scheduler/mode}" method="post" class="form-inline">
|
||||
<label class="mr-sm-2 font-weight-bold" for="syncMode">Chế độ hoạt động:</label>
|
||||
<select class="custom-select mb-2 mr-sm-2 mb-sm-0" id="syncMode" name="mode">
|
||||
<option value="SCHEDULED" th:selected="${syncMode == 'SCHEDULED'}">Lấy định kỳ toàn hệ thống (Scheduled)</option>
|
||||
<option value="REALTIME" th:selected="${syncMode == 'REALTIME'}">Lấy trực tiếp từng bác sĩ (Real-time)</option>
|
||||
<option value="MANUAL" th:selected="${syncMode == 'MANUAL'}">Nhập thủ công hoàn toàn (Manual)</option>
|
||||
</select>
|
||||
<label class="mr-sm-2 ml-sm-3 font-weight-bold interval-group" th:classappend="${syncMode != 'SCHEDULED'} ? 'd-none' : ''" for="syncInterval">Chu kỳ (phút):</label>
|
||||
<input type="number" class="form-control mb-2 mr-sm-2 mb-sm-0 interval-group" th:classappend="${syncMode != 'SCHEDULED'} ? 'd-none' : ''" id="syncInterval" name="interval" min="1" th:value="${syncInterval}" style="width: 100px;">
|
||||
<button type="submit" class="btn btn-primary mb-2 mb-sm-0">Lưu cấu hình</button>
|
||||
</form>
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span th:if="${syncMode == 'SCHEDULED'}">Hệ thống sẽ tự động gọi API tải lịch của tất cả bác sĩ mỗi <strong class="text-danger" th:text="${syncInterval}"></strong> phút và lưu vào bộ nhớ đệm. Bảng bên dưới hiển thị lịch từ bộ nhớ đệm.</span>
|
||||
<span th:if="${syncMode == 'REALTIME'}">Hệ thống sẽ KHÔNG chạy ngầm. Lịch khám lấy trực tiếp từ API ngay tại thời điểm khách truy cập. Cấu hình Chu kỳ phút không có tác dụng ở chế độ này.</span>
|
||||
<span th:if="${syncMode == 'MANUAL'}">Lịch API sẽ bị bỏ qua. Hệ thống chỉ lấy lịch từ Database do bạn tự nhập tay. Nhấn nút "Sửa lịch" ở bảng dưới để cập nhật.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Danh sách bác sĩ</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<!-- DataTables CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap4.min.css">
|
||||
|
||||
<table class="table table-bordered" id="doctorTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã BS</th>
|
||||
<th>Tên Bác Sĩ</th>
|
||||
<th>Chuyên Khoa</th>
|
||||
<th>Lịch Khám</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="doc : ${cachedDoctors}">
|
||||
<td th:text="${doc.doctorCode}"></td>
|
||||
<td class="font-weight-bold text-dark" th:text="${doc.title + ' ' + doc.name}"></td>
|
||||
<td th:text="${doc.specialty}"></td>
|
||||
<td>
|
||||
<!-- SCHEDULED MODE -->
|
||||
<th:block th:if="${syncMode == 'SCHEDULED'}">
|
||||
<ul th:if="${doc.days != null and !doc.days.isEmpty()}">
|
||||
<li th:each="day : ${doc.days}">
|
||||
<strong th:if="${day.dayOfWeek == 1}">Chủ nhật</strong>
|
||||
<strong th:if="${day.dayOfWeek != 1}" th:text="'Thứ ' + ${day.dayOfWeek}"></strong>
|
||||
<span class="text-muted" th:if="${day.date != null}" th:text="' (' + ${day.date} + ')'"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<span th:if="${doc.days == null or doc.days.isEmpty()}" class="text-muted font-italic">Chưa có lịch cache</span>
|
||||
</th:block>
|
||||
|
||||
<!-- REALTIME MODE -->
|
||||
<th:block th:if="${syncMode == 'REALTIME'}">
|
||||
<span class="text-muted font-italic">Tải trực tiếp khi khách truy cập</span>
|
||||
</th:block>
|
||||
|
||||
<!-- SỬA LỊCH BUTTON -->
|
||||
<button th:if="${syncMode != 'REALTIME'}" type="button" class="btn btn-sm btn-info mt-1" th:attr="onclick=|openManualModal('${doc.doctorCode}', '${doc.title} ${doc.name}')|">
|
||||
<i class="fas fa-edit"></i> Sửa lịch
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${cachedDoctors == null or cachedDoctors.isEmpty()}">
|
||||
<td colspan="4" class="text-center text-danger py-4">
|
||||
<p class="font-weight-bold mb-1">Không có dữ liệu bác sĩ nào được load từ API!</p>
|
||||
<p class="small">API có thể đang bị lỗi hoặc trả về rỗng. Hãy kiểm tra cấu hình Token.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Schedule Modal -->
|
||||
<div class="modal fade" id="manualScheduleModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Quản lý lịch: <span id="modalDoctorName" class="font-weight-bold text-primary"></span></h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="manualScheduleForm">
|
||||
<input type="hidden" id="modalDoctorCode">
|
||||
<div class="alert alert-warning small">Lưu ý: Dữ liệu này sẽ thay thế hoàn toàn dữ liệu từ API.</div>
|
||||
|
||||
<div class="row font-weight-bold mb-2">
|
||||
<div class="col-8">Ngày (YYYY-MM-DD)</div>
|
||||
<div class="col-4">Thứ</div>
|
||||
</div>
|
||||
|
||||
<div id="scheduleEntries">
|
||||
<!-- rows of Date + Day of week will be added here -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary mt-3" onclick="addScheduleRow()">+ Thêm ngày khám</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Đóng</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveManualSchedule()">Lưu thay đổi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
function openManualModal(doctorCode, doctorName) {
|
||||
document.getElementById('modalDoctorCode').value = doctorCode;
|
||||
document.getElementById('modalDoctorName').innerText = doctorName;
|
||||
document.getElementById('scheduleEntries').innerHTML = '<div class="text-center py-2"><i class="fas fa-spinner fa-spin"></i> Đang tải dữ liệu...</div>';
|
||||
$('#manualScheduleModal').modal('show');
|
||||
|
||||
fetch('/manage/doctor-scheduler/manual/' + doctorCode)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('scheduleEntries').innerHTML = '';
|
||||
if (data && data.length > 0) {
|
||||
data.forEach(item => {
|
||||
addScheduleRow(item.date, item.dayOfWeek);
|
||||
});
|
||||
} else {
|
||||
addScheduleRow(); // add an empty row by default if no data
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('scheduleEntries').innerHTML = '<div class="text-danger">Lỗi tải dữ liệu. Vui lòng thử lại.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function getDayOfWeekText(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const dateObj = new Date(dateStr);
|
||||
const day = dateObj.getDay() + 1; // 1 = Chủ nhật, 2 = Thứ 2...
|
||||
return day === 1 ? 'Chủ nhật' : 'Thứ ' + day;
|
||||
}
|
||||
|
||||
function addScheduleRow(existingDate = '') {
|
||||
const container = document.getElementById('scheduleEntries');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'form-row mb-2 schedule-row align-items-center';
|
||||
|
||||
const dayText = getDayOfWeekText(existingDate);
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="col-7">
|
||||
<input type="date" class="form-control manual-date" required value="${existingDate}" onchange="updateDayText(this)">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<span class="manual-day-text font-weight-bold text-primary">${dayText}</span>
|
||||
</div>
|
||||
<div class="col-2 text-right">
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.parentElement.remove()">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function updateDayText(inputElem) {
|
||||
const textElem = inputElem.parentElement.nextElementSibling.querySelector('.manual-day-text');
|
||||
textElem.innerText = getDayOfWeekText(inputElem.value);
|
||||
}
|
||||
|
||||
function saveManualSchedule() {
|
||||
const doctorCode = document.getElementById('modalDoctorCode').value;
|
||||
const rows = document.querySelectorAll('#scheduleEntries .schedule-row');
|
||||
const schedules = [];
|
||||
|
||||
rows.forEach(row => {
|
||||
const date = row.querySelector('.manual-date').value;
|
||||
if (date) {
|
||||
const dateObj = new Date(date);
|
||||
const dayOfWeek = dateObj.getDay() + 1; // 1 to 7
|
||||
schedules.push({ date: date, dayOfWeek: dayOfWeek });
|
||||
}
|
||||
});
|
||||
|
||||
if (schedules.length === 0 && !confirm("Bạn đang lưu danh sách trống. Bạn có chắc chắn muốn xóa hết lịch?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfToken = /*[[${_csrf.token}]]*/ '';
|
||||
const csrfHeader = /*[[${_csrf.headerName}]]*/ 'X-CSRF-TOKEN';
|
||||
|
||||
fetch('/manage/doctor-scheduler/manual/' + doctorCode, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[csrfHeader]: csrfToken
|
||||
},
|
||||
body: JSON.stringify(schedules)
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
alert('Lưu lịch thủ công thành công!');
|
||||
$('#manualScheduleModal').modal('hide');
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Có lỗi xảy ra khi lưu.');
|
||||
}
|
||||
}).catch(err => {
|
||||
alert('Lỗi kết nối.');
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle interval input based on sync mode
|
||||
function toggleIntervalInput() {
|
||||
var mode = document.getElementById('syncMode').value;
|
||||
var intervalEls = document.querySelectorAll('.interval-group');
|
||||
if (mode === 'SCHEDULED') {
|
||||
intervalEls.forEach(el => el.classList.remove('d-none'));
|
||||
} else {
|
||||
intervalEls.forEach(el => el.classList.add('d-none'));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('syncMode').addEventListener('change', toggleIntervalInput);
|
||||
|
||||
// Initializing DataTables
|
||||
$(document).ready(function() {
|
||||
$('#doctorTable').DataTable({
|
||||
"language": {
|
||||
"url": "https://cdn.datatables.net/plug-ins/1.13.6/i18n/vi.json"
|
||||
},
|
||||
"pageLength": 25,
|
||||
"lengthMenu": [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "Tất cả"] ]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form th:action="${isNew} ? @{/manage/doctors} : @{/manage/doctors/{id}(id=${doctor.id})}"
|
||||
th:object="${doctor}" method="post">
|
||||
th:object="${doctor}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<!-- Validation errors -->
|
||||
@@ -77,11 +77,42 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Avatar URL -->
|
||||
<!-- Doctor Code (for API sync) -->
|
||||
<div class="form-group">
|
||||
<label for="doctorCode" class="font-weight-bold">Doctor Code (from API)</label>
|
||||
<input type="text" class="form-control" id="doctorCode" th:field="*{doctorCode}"
|
||||
placeholder="e.g. 01074">
|
||||
<small class="form-text text-muted">Mã bác sĩ dùng để đồng bộ với API (nếu có)</small>
|
||||
</div>
|
||||
|
||||
<!-- Work Experience -->
|
||||
<div class="form-group">
|
||||
<label for="workExperience" class="font-weight-bold">Quá trình công tác</label>
|
||||
<textarea class="form-control" id="workExperience" th:field="*{workExperience}" rows="4"
|
||||
placeholder="Nhập dưới dạng HTML hoặc văn bản..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Education -->
|
||||
<div class="form-group">
|
||||
<label for="education" class="font-weight-bold">Quá trình đào tạo</label>
|
||||
<textarea class="form-control" id="education" th:field="*{education}" rows="4"
|
||||
placeholder="Nhập dưới dạng HTML hoặc văn bản..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<div class="form-group">
|
||||
<label for="achievements" class="font-weight-bold">Thành tựu y khoa</label>
|
||||
<textarea class="form-control" id="achievements" th:field="*{achievements}" rows="4"
|
||||
placeholder="Nhập dưới dạng HTML hoặc văn bản..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Avatar URL & Upload -->
|
||||
<div class="form-group">
|
||||
<label for="doctorAvatar" class="font-weight-bold">Avatar URL</label>
|
||||
<input type="text" class="form-control" id="doctorAvatar" th:field="*{avatarUrl}"
|
||||
<input type="text" class="form-control mb-2" id="doctorAvatar" th:field="*{avatarUrl}"
|
||||
placeholder="/uploads/images/bac-si/avatar.png">
|
||||
<label for="avatarFile" class="font-weight-bold mb-1">Upload New Avatar</label>
|
||||
<input type="file" class="form-control-file" id="avatarFile" name="avatarFile" accept="image/*">
|
||||
</div>
|
||||
|
||||
<!-- Booking URL -->
|
||||
@@ -110,23 +141,32 @@
|
||||
<!-- Right Column: Doctors Table -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Existing Doctors</h6>
|
||||
<form th:action="@{/manage/doctors/sync}" method="post" class="mb-0" onsubmit="return confirm('Bạn có chắc chắn muốn ép đồng bộ dữ liệu từ API ngay lập tức?');">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<button type="submit" class="btn btn-sm btn-primary shadow-sm">
|
||||
<i class="fas fa-sync fa-sm text-white-50"></i> Force Sync API
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover" id="doctorsTable" width="100%" cellspacing="0">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th width="5%">ID</th>
|
||||
<th width="30%">Name</th>
|
||||
<th width="15%">Title</th>
|
||||
<th width="25%">Specialty</th>
|
||||
<th width="15%">Booking</th>
|
||||
<th width="20%">Specialty</th>
|
||||
<th width="10%">Active</th>
|
||||
<th width="5%">Booking</th>
|
||||
<th width="15%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="doc : ${allDoctors}">
|
||||
<td th:text="${doc.doctorCode}"></td>
|
||||
<td>
|
||||
<strong th:text="${doc.name}"></strong>
|
||||
</td>
|
||||
@@ -137,6 +177,10 @@
|
||||
<span th:if="${doc.specialty != null}" th:text="${doc.specialty.name}" class="badge badge-info"></span>
|
||||
<span th:if="${doc.specialty == null}" class="text-muted">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span th:if="${doc.active}" class="badge badge-success">Active</span>
|
||||
<span th:if="${!doc.active}" class="badge badge-secondary">Inactive</span>
|
||||
</td>
|
||||
<td>
|
||||
<a th:if="${doc.bookingUrl != null && !doc.bookingUrl.isEmpty()}" th:href="${doc.bookingUrl}" target="_blank" class="badge badge-success">Link</a>
|
||||
<span th:if="${doc.bookingUrl == null || doc.bookingUrl.isEmpty()}" class="text-muted">—</span>
|
||||
|
||||
@@ -96,6 +96,26 @@
|
||||
<h6 class="m-0 font-weight-bold text-primary">Existing Specialties</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Search and Page Size controls -->
|
||||
<div class="d-flex justify-content-between mb-3 align-items-center">
|
||||
<form method="get" th:action="@{/manage/specialties}" class="form-inline">
|
||||
<input type="hidden" name="search" th:value="${search}"/>
|
||||
<label class="mr-2">Show</label>
|
||||
<select name="size" class="form-control form-control-sm mr-2" onchange="this.form.submit()">
|
||||
<option value="5" th:selected="${specialtyPage.size == 5}">5</option>
|
||||
<option value="10" th:selected="${specialtyPage.size == 10}">10</option>
|
||||
<option value="20" th:selected="${specialtyPage.size == 20}">20</option>
|
||||
<option value="50" th:selected="${specialtyPage.size == 50}">50</option>
|
||||
</select>
|
||||
<label>entries</label>
|
||||
</form>
|
||||
<form method="get" th:action="@{/manage/specialties}" class="form-inline">
|
||||
<input type="hidden" name="size" th:value="${specialtyPage.size}"/>
|
||||
<input type="text" class="form-control form-control-sm mr-2" name="search" th:value="${search}" placeholder="Search name...">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover" id="specialtiesTable" width="100%" cellspacing="0">
|
||||
<thead class="thead-light">
|
||||
@@ -107,7 +127,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="spec : ${allSpecialties}">
|
||||
<tr th:each="spec : ${specialtyPage.content}">
|
||||
<td>
|
||||
<strong th:text="${spec.name}"></strong>
|
||||
</td>
|
||||
@@ -132,7 +152,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(allSpecialties)}">
|
||||
<tr th:if="${#lists.isEmpty(specialtyPage.content)}">
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
<i class="fas fa-folder-open fa-2x mb-2 d-block"></i>
|
||||
No specialties yet. Use the form on the left to create one.
|
||||
@@ -141,6 +161,27 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-3" th:if="${specialtyPage.totalElements > 0}">
|
||||
<div>
|
||||
Showing <span th:text="${specialtyPage.number * specialtyPage.size + 1}"></span> to
|
||||
<span th:text="${specialtyPage.number * specialtyPage.size + specialtyPage.numberOfElements}"></span> of
|
||||
<span th:text="${specialtyPage.totalElements}"></span> entries
|
||||
</div>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item" th:classappend="${specialtyPage.first} ? 'disabled' : ''">
|
||||
<a class="page-link" th:href="@{/manage/specialties(page=${specialtyPage.number - 1}, size=${specialtyPage.size}, search=${search})}">Previous</a>
|
||||
</li>
|
||||
<li class="page-item" th:each="i : ${#numbers.sequence(0, specialtyPage.totalPages - 1)}"
|
||||
th:classappend="${specialtyPage.number == i} ? 'active' : ''">
|
||||
<a class="page-link" th:href="@{/manage/specialties(page=${i}, size=${specialtyPage.size}, search=${search})}" th:text="${i + 1}"></a>
|
||||
</li>
|
||||
<li class="page-item" th:classappend="${specialtyPage.last} ? 'disabled' : ''">
|
||||
<a class="page-link" th:href="@{/manage/specialties(page=${specialtyPage.number + 1}, size=${specialtyPage.size}, search=${search})}">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.sisvietnamvn.web;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Doctor;
|
||||
import com.sisvietnamvn.web.domain.Specialty;
|
||||
import com.sisvietnamvn.web.service.DoctorService;
|
||||
import com.sisvietnamvn.web.service.SpecialtyService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@SpringBootTest(classes = SisvietnamvnApp.class)
|
||||
public class CsvImportTest {
|
||||
|
||||
@Autowired
|
||||
private DoctorService doctorService;
|
||||
|
||||
@Autowired
|
||||
private SpecialtyService specialtyService;
|
||||
|
||||
@Test
|
||||
public void runImport() throws Exception {
|
||||
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";
|
||||
List<String> lines = Files.readAllLines(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()) {
|
||||
Optional<Doctor> docOpt = doctorService.findByDoctorCode(doctorCode);
|
||||
if (docOpt.isPresent()) {
|
||||
Doctor doctor = docOpt.get();
|
||||
|
||||
// Find or create specialty
|
||||
List<Specialty> specs = specialtyService.findAll();
|
||||
Specialty spec = specs.stream().filter(s -> s.getName().equalsIgnoreCase(specialtyName)).findFirst().orElse(null);
|
||||
if (spec == null) {
|
||||
spec = new Specialty();
|
||||
spec.setName(specialtyName);
|
||||
spec = specialtyService.save(spec);
|
||||
}
|
||||
|
||||
doctor.setSpecialty(spec);
|
||||
doctorService.save(doctor);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("=========================================");
|
||||
System.out.println("IMPORT SUCCESS! UPDATED " + updatedCount + " DOCTORS.");
|
||||
System.out.println("=========================================");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ class WebConfigurerTest {
|
||||
env = new MockEnvironment();
|
||||
props = new JHipsterProperties();
|
||||
|
||||
webConfigurer = new WebConfigurer(env, props);
|
||||
webConfigurer = new WebConfigurer(env, props, org.mockito.Mockito.mock(com.sisvietnamvn.web.hook.HookManager.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user