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

This commit is contained in:
2026-07-23 19:34:02 +07:00
parent 107e191e93
commit 6d84979f03
111 changed files with 5138 additions and 288 deletions
@@ -108,5 +108,3 @@ jhipster:
# More documentation is available at:
# https://www.jhipster.tech/common-application-properties/
# ===================================================================
# application:
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260723100000-1" author="antigravity">
<addColumn tableName="sis_doctor">
<column name="doctor_code" type="varchar(50)"/>
<column name="work_experience" type="${clobType}"/>
<column name="education" type="${clobType}"/>
<column name="achievements" type="${clobType}"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260723110000-1" author="antigravity">
<addColumn tableName="sis_doctor">
<column name="active" type="boolean" defaultValueBoolean="true"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.5.xsd">
<changeSet id="20260723115000-1" author="antigravity">
<delete tableName="sis_doctor">
<where>doctor_code IS NULL</where>
</delete>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260723185500-1" author="antigravity">
<createTable tableName="sis_doctor_schedule">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="doctor_id" type="bigint">
<constraints nullable="false"/>
</column>
<column name="schedule_date" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="day_of_week" type="int">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseColumnNames="doctor_id"
baseTableName="sis_doctor_schedule"
constraintName="fk_doctor_schedule_doctor_id"
referencedColumnNames="id"
referencedTableName="sis_doctor"/>
</changeSet>
</databaseChangeLog>
@@ -41,6 +41,11 @@
<include file="config/liquibase/changelog/20260714113000_update_events_list.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718100000_add_doctor_and_specialty.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718110000_seed_sample_doctors.xml" relativeToChangelogFile="false"/>
<!-- Custom extensions -->
<include file="config/liquibase/changelog/20260720181500_seed_contact_us_page.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260722000000_add_fields_to_menu_item.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723100000_add_detail_fields_to_doctor.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723110000_add_active_to_doctor.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723115000_delete_sample_doctors.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>
@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='doctor-detail-page')}">
<head>
<th:block layout:fragment="head">
<title th:text="${doctor.title + ' ' + doctor.name + ' | UMC'}">Chi tiết Bác sĩ</title>
<style>
.doctor-detail-page {
background-color: #f6f6f6;
}
.section-doctor-shadow {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.doctor-avatar-container {
width: 250px;
height: 250px;
background: linear-gradient(to top left, #f0faff, #e0f2fe);
}
@media (max-width: 1024px) {
.doctor-avatar-container {
width: 200px;
height: 200px;
}
}
@media (max-width: 768px) {
.doctor-avatar-container {
width: 150px;
height: 150px;
}
}
</style>
<link rel="stylesheet" th:href="@{/css/umc_doctor.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_2.css}" />
<link rel="stylesheet" th:href="@{/css/umc_doctor_3.css}" />
</th:block>
</head>
<body>
<div layout:fragment="content">
<main class="">
<section class="xl:py-8 md:py-6 py-4 bg-gray-50">
<div class="container">
<div class="section-doctor-shadow rounded-lg">
<div class="grid grid-cols-12 xl:gap-x-6 md:gap-x-4">
<div class="lg:col-span-6 col-span-full bg-[#fbfbfb] xl:py-6 md:py-3 py-3 xl:pl-6 md:pl-3 max-lg:pr-3 max-md:pr-0 rounded-lg">
<div>
<div class="max-md:border-b max-md:border-gray-100 max-md:pb-3">
<div class="flex md:items-center xl:space-x-3 md:space-x-2 space-x-2 xl:pb-6 md:pb-4 max-md:mb-3 md:border-b md:border-gray-100">
<div class="xl:size-[170px] md:size-[160px] size-[120px] aspect-[1/1] relative rounded-lg border border-primary-400 overflow-hidden flex-shrink-0 relative bg-gradient-to-tl from-primary-75 to-primary-100">
<img th:src="${doctor.avatarUrl != null and !doctor.avatarUrl.isEmpty() ? doctor.avatarUrl : '/images/default-avatar.png'}" style="width: 100%; height: 100%; object-fit: contain;" alt="Avatar Bác sĩ" />
</div>
<div class="space-y-[5px]">
<p class="title-5 text-primary-500 mb-0 max-md:!text-[12.8px]">
<span th:text="${doctor.title}">Học hàm/Học vị</span>
</p>
<h1 class="heading-3 text-[#1E293B] mb-1 max-md:!text-[18px]">
<span th:text="${doctor.name}">Tên Bác sĩ</span>
</h1>
<p class="body-3 text-gray-900 max-md:!text-[12px]">
<span th:text="${specialtyName}">Chuyên khoa</span>
</p>
<div class="max-md:hidden">
<div class="flex items-center space-x-2 body-3 max-md:!text-[12px] text-gray-900 flex-wrap mb-[5px]">
<div class="flex items-center space-x-1">
<div>Đánh giá
<!-- -->:
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-0">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-0">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-0)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-1">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-1">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-1)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-2">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-2">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-2)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-3">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-3">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-3)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-4">
<stop offset="50%" stop-color="#facc15"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="100%" stop-color="#d1d5db"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-4">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-4)"></path>
</svg>
<span class="text-primary-600 font-semibold ml-2">4.5
<!-- -->/5
</span>
</div>
</div>
<div class="text-primary-600 lg:hover:underline lg:hover:text-primary-400 lg:duration-150 cursor-pointer underline-offset-2">(
<!-- -->1
<!-- -->
<!-- -->đánh giá
<!-- -->)
</div>
</div>
<button type="button" class="btn space-x-2 text-center label-3 w-[286px] !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" style="background-color: var(--color-brand);" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<div>Đặt lịch khám</div>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_83_297)">
<path d="M0 16.26C0 17.3 0.366667 18.18 1.1 18.9C1.83333 19.62 2.72 19.9867 3.76 20H16.26C17.2867 20 18.1667 19.6333 18.9 18.9C19.6333 18.1667 20 17.2867 20 16.26V3.76C20 2.94667 19.7667 2.22 19.3 1.58C18.8333 0.94 18.2333 0.493333 17.5 0.24V1.88C17.5 2.4 17.32 2.84667 16.96 3.22C16.6 3.59333 16.1533 3.77333 15.62 3.76C15.0867 3.74667 14.6467 3.56667 14.3 3.22C13.9533 2.87333 13.7733 2.42667 13.76 1.88V0H6.26V1.88C6.26 2.4 6.07333 2.84667 5.7 3.22C5.32667 3.59333 4.88667 3.77333 4.38 3.76C3.87333 3.74667 3.42667 3.56667 3.04 3.22C2.65333 2.87333 2.47333 2.42667 2.5 1.88V0.24C1.76667 0.506667 1.16667 0.953333 0.7 1.58C0.233333 2.20667 0 2.93333 0 3.76L0 16.26ZM2.5 16.26V6.26H17.5V16.26C17.5 16.6067 17.38 16.9 17.14 17.14C16.9 17.38 16.6067 17.5 16.26 17.5H3.76C3.41333 17.5 3.11333 17.38 2.86 17.14C2.60667 16.9 2.48667 16.6067 2.5 16.26ZM3.76 1.88C3.76 2.05333 3.82 2.2 3.94 2.32C4.06 2.44 4.20667 2.5 4.38 2.5C4.55333 2.5 4.7 2.44 4.82 2.32C4.94 2.2 5 2.05333 5 1.88V0H3.76V1.88ZM5 15H7.5V12.5H5V15ZM5 11.26H7.5V8.76H5V11.26ZM8.76 15H11.26V12.5H8.76V15ZM8.76 11.26H11.26V8.76H8.76V11.26ZM12.5 15H15V12.5H12.5V15ZM12.5 11.26H15V8.76H12.5V11.26ZM15 1.88C15 2.05333 15.06 2.2 15.18 2.32C15.3 2.44 15.4467 2.5 15.62 2.5C15.7933 2.5 15.94 2.44 16.06 2.32C16.18 2.2 16.2467 2.05333 16.26 1.88V0H15V1.88Z" fill="white"></path>
</g>
<defs>
<clipPath id="clip0_83_297">
<rect width="20" height="20" fill="white"></rect>
</clipPath>
</defs>
</svg>
</button>
</div>
</div>
</div>
<div class="md:hidden">
<div class="flex items-center mb-2 space-x-2 body-3 max-md:!text-[12px] text-gray-900 flex-wrap">
<div class="flex items-center space-x-1">
<div>Đánh giá
<!-- -->:
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-0">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-0">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-0)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-1">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-1">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-1)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-2">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-2">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-2)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-3">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-3">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-3)"></path>
</svg>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-banner-4.5-4">
<stop offset="50%" stop-color="#facc15"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="100%" stop-color="#d1d5db"></stop>
</linearGradient>
<mask id="star-mask-star-banner-4.5-4">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-banner-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-banner-4.5-4)"></path>
</svg>
<span class="text-primary-600 font-semibold ml-2">4.5
<!-- -->/5
</span>
</div>
</div>
<div class="text-primary-600 lg:hover:underline lg:hover:text-primary-400 lg:duration-150 cursor-pointer underline-offset-2">(
<!-- -->1
<!-- -->
<!-- -->đánh giá
<!-- -->)
</div>
</div>
<button type="button" class="btn space-x-2 text-center label-3 w-full !py-2 disabled:opacity-50 disabled:cursor-not-allowed text-white hover:opacity-90 transition-opacity" style="background-color: var(--color-brand);" th:data-url="${doctor.bookingUrl != null and !doctor.bookingUrl.isEmpty() ? doctor.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<div>Đặt lịch khám</div>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_83_297)">
<path d="M0 16.26C0 17.3 0.366667 18.18 1.1 18.9C1.83333 19.62 2.72 19.9867 3.76 20H16.26C17.2867 20 18.1667 19.6333 18.9 18.9C19.6333 18.1667 20 17.2867 20 16.26V3.76C20 2.94667 19.7667 2.22 19.3 1.58C18.8333 0.94 18.2333 0.493333 17.5 0.24V1.88C17.5 2.4 17.32 2.84667 16.96 3.22C16.6 3.59333 16.1533 3.77333 15.62 3.76C15.0867 3.74667 14.6467 3.56667 14.3 3.22C13.9533 2.87333 13.7733 2.42667 13.76 1.88V0H6.26V1.88C6.26 2.4 6.07333 2.84667 5.7 3.22C5.32667 3.59333 4.88667 3.77333 4.38 3.76C3.87333 3.74667 3.42667 3.56667 3.04 3.22C2.65333 2.87333 2.47333 2.42667 2.5 1.88V0.24C1.76667 0.506667 1.16667 0.953333 0.7 1.58C0.233333 2.20667 0 2.93333 0 3.76L0 16.26ZM2.5 16.26V6.26H17.5V16.26C17.5 16.6067 17.38 16.9 17.14 17.14C16.9 17.38 16.6067 17.5 16.26 17.5H3.76C3.41333 17.5 3.11333 17.38 2.86 17.14C2.60667 16.9 2.48667 16.6067 2.5 16.26ZM3.76 1.88C3.76 2.05333 3.82 2.2 3.94 2.32C4.06 2.44 4.20667 2.5 4.38 2.5C4.55333 2.5 4.7 2.44 4.82 2.32C4.94 2.2 5 2.05333 5 1.88V0H3.76V1.88ZM5 15H7.5V12.5H5V15ZM5 11.26H7.5V8.76H5V11.26ZM8.76 15H11.26V12.5H8.76V15ZM8.76 11.26H11.26V8.76H8.76V11.26ZM12.5 15H15V12.5H12.5V15ZM12.5 11.26H15V8.76H12.5V11.26ZM15 1.88C15 2.05333 15.06 2.2 15.18 2.32C15.3 2.44 15.4467 2.5 15.62 2.5C15.7933 2.5 15.94 2.44 16.06 2.32C16.18 2.2 16.2467 2.05333 16.26 1.88V0H15V1.88Z" fill="white"></path>
</g>
<defs>
<clipPath id="clip0_83_297">
<rect width="20" height="20" fill="white"></rect>
</clipPath>
</defs>
</svg>
</button>
</div>
</div>
<div class="mt-4">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2">
<div class="title-3 text-white">Lịch khám</div>
</div>
<div class="xl:m-3 m-2">
<div th:if="${scheduleDays != null and !scheduleDays.isEmpty()}">
<div class="flex gap-1 mb-1">
<span class="w-1 h-1 bg-primary-600 rounded-full md:mt-[11px] mt-2.5 flex-shrink-0"></span>
<div class="text-primary-600">
<span class="uppercase max-md:!text-[16px] heading-5" th:text="${specialtyName != null and !specialtyName.isEmpty() ? specialtyName : 'Chung'}"></span>
</div>
</div>
<ul class="list-none space-y-1">
<li th:each="day : ${scheduleDays}" class="text-gray-900 flex items-center justify-between space-x-2 relative pl-3 before:content-[''] before:w-1 before:h-1 before:bg-primary-600 before:rounded-full before:absolute md:before:top-[12px] before:top-3 before:left-1">
<div>
<span class="body-3 max-md:!text-[14px]" th:text="'Sáng thứ ' + ${day.dayOfWeek} + (${day.date != null} ? ' (' + ${day.date} + ')' : '')"></span>
</div>
</li>
</ul>
</div>
<div th:if="${scheduleDays == null or scheduleDays.isEmpty()}">
<p class="text-gray-500 italic">Chưa có lịch khám</p>
</div>
</div>
</div>
</div>
<div class="mt-4 scroll-mt-[132px]" style="display: none;">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2">
<div class="flex md:items-center md:justify-between md:space-x-2 max-md:flex-col max-md:items-start max-md:space-y-2">
<div class="title-3 text-white">Cảm nhận của người bệnh</div>
<div class="flex items-center max-md:flex-1 gap-2 text-white body-2">
<div class="flex items-center gap-2">
<div class="flex gap-0.5">
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-0">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-0">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-0)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-0)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-1">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-1">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-1)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-1)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-2">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-2">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-2)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-2)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-3">
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
<stop offset="100%" stop-color="#facc15"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-3">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="100%" y="0" width="0%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-3)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-3)"></path>
</svg>
<svg class="md:w-4 w-3 md:h-4 h-3" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="star-gradient-star-4.5-4">
<stop offset="50%" stop-color="#facc15"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="100%" stop-color="#d1d5db"></stop>
</linearGradient>
<mask id="star-mask-star-4.5-4">
<rect x="0" y="0" width="24" height="24" fill="white"></rect>
<rect x="50%" y="0" width="50%" height="24" fill="black"></rect>
</mask>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#star-gradient-star-4.5-4)" stroke="#d1d5db" stroke-width="1"></path>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="none" stroke="#facc15" stroke-width="1" mask="url(#star-mask-star-4.5-4)"></path>
</svg>
</div>
<span class="font-semibold">4.5
<!-- -->/5
</span>
</div>
<div>(
<!-- -->1
<!-- -->
<!-- -->đánh giá
<!-- -->)
</div>
</div>
</div>
</div>
<div class="xl:m-3 m-2">
<div class="mb-4 xl:p-4 md:p-3 p-2 bg-gray-50 rounded-lg">
<h4 class="title-1 text-primary-600 xl:mb-2 md:mb-1.5 mb-1">Viết đánh giá của bạn</h4>
<div class="space-y-3">
<div class="flex items-center space-x-2">
<span class="label-3 text-gray-900">Đánh giá
<!-- -->:
</span>
<div class="flex space-x-1">
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-1">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-1">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-1)" stroke="url(#interactive-star-stroke-1)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-2">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-2">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-2)" stroke="url(#interactive-star-stroke-2)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-3">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-3">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-3)" stroke="url(#interactive-star-stroke-3)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-4">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-4">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-4)" stroke="url(#interactive-star-stroke-4)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
<div class="relative cursor-pointer">
<svg class="md:w-5 w-4 md:h-5 h-4 transition-all duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="interactive-star-gradient-5">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
<linearGradient id="interactive-star-stroke-5">
<stop offset="50%" stop-color="#d1d5db"></stop>
<stop offset="50%" stop-color="#d1d5db"></stop>
</linearGradient>
</defs>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#interactive-star-gradient-5)" stroke="url(#interactive-star-stroke-5)" stroke-width="1"></path>
</svg>
<div class="absolute inset-0 cursor-pointer"></div>
<div class="absolute inset-0 cursor-pointer"></div>
</div>
</div>
<span class="body-3 text-gray-900">Chọn sao đánh giá</span>
</div>
<div>
<textarea placeholder="Chia sẻ trải nghiệm khám bệnh của bạn..." class="w-full xl:p-3 p-2 border body-3 border-gray-300 rounded-lg focus:outline-none focus:border-primary-600 resize-none" rows="3"></textarea>
</div>
<div class="flex justify-end">
<button class="btn btn-light label-2" disabled="">Gửi đánh giá</button>
</div>
</div>
</div>
<div class="space-y-2">
<h4 class="title-1 text-primary-600">Đánh giá từ người bệnh</h4>
<div class="text-center py-8">
<div class="mb-4">
<svg class="w-16 h-16 mx-auto text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"></path>
</svg>
</div>
<p class="body-2 text-gray-600">Chưa có đánh giá</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="xl:p-6 lg:p-4 max-lg:py-3 max-lg:px-4 max-md:px-3 bg-white lg:col-span-6 col-span-full rounded-lg overflow-hidden space-y-4">
<div class="space-y-4"></div>
<div class="space-y-4">
<div class="jam-accordion active" th:if="${doctor.education != null and !doctor.education.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="education-content">
<div class="title-3 text-white">Quá trình đào tạo</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down w-4 h-4 text-white duration-150 -rotate-180">
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<div class="jam-accordion-content">
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2" th:utext="${doctor.education}"></div>
</div>
</div>
</div>
</div>
<div class="jam-accordion active" th:if="${doctor.workExperience != null and !doctor.workExperience.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="experience-content">
<div class="title-3 text-white">Quá trình công tác</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down w-4 h-4 text-white duration-150 -rotate-180">
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<div class="jam-accordion-content">
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] space-y-2 xl:m-3 m-2" th:utext="${doctor.workExperience}"></div>
</div>
</div>
</div>
</div>
</div>
<div class="jam-accordion active" th:if="${doctor.achievements != null and !doctor.achievements.isEmpty()}">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-primary-600 xl:p-3 p-2 flex justify-between items-center cursor-pointer jam-accordion-title" tabindex="0" aria-expanded="true" aria-controls="association-content">
<div class="title-3 text-white">Hiệp hội chuyên môn &amp; Thành tựu</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down w-4 h-4 text-white duration-150 -rotate-180">
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<div class="jam-accordion-content">
<div class="prose doctor-info-prose transition-all duration-200 !text-[14px] xl:m-3 m-2" th:utext="${doctor.achievements}"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>undefined </div>undefined </section>undefined
<!--$-->undefined
<!--/$-->undefined
<!--$-->undefined
<!--/$-->undefined </main>undefined</div>undefined
</body>undefined
</html>
@@ -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">&times;</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>