feat: add doctor and specialty management modules with full CRUD support and database seeding

This commit is contained in:
2026-07-18 22:29:28 +07:00
parent a85fc6e73f
commit fdbbb7c965
196 changed files with 3955 additions and 3 deletions
@@ -6,6 +6,7 @@
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<changeSet id="20260714080000-1" author="antigravity">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM sis_component_template WHERE slug = 'news-grid'</sqlCheck>
</preConditions>
@@ -0,0 +1,55 @@
<?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="20260718100000-1" author="antigravity">
<createTable tableName="sis_specialty">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="slug" type="varchar(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="icon_url" type="varchar(500)"/>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
<createTable tableName="sis_doctor">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="title" type="varchar(100)"/>
<column name="specialty_id" type="bigint">
<constraints nullable="true"/>
</column>
<column name="avatar_url" type="varchar(500)"/>
<column name="booking_url" type="varchar(500)"/>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
<addForeignKeyConstraint baseColumnNames="specialty_id"
baseTableName="sis_doctor"
constraintName="fk_doctor_specialty_id"
referencedColumnNames="id"
referencedTableName="sis_specialty"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,241 @@
<?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="20260718110000-1" author="antigravity" context="dev, faker">
<insert tableName="sis_specialty">
<column name="id" value="1"/>
<column name="name" value="Khoa Tim mạch"/>
<column name="slug" value="khoa-tim-mach"/>
<column name="icon_url" value="https://cdn-icons-png.flaticon.com/512/883/883360.png"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_specialty">
<column name="id" value="2"/>
<column name="name" value="Khoa Thần kinh"/>
<column name="slug" value="khoa-than-kinh"/>
<column name="icon_url" value="https://cdn-icons-png.flaticon.com/512/2093/2093153.png"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_specialty">
<column name="id" value="3"/>
<column name="name" value="Khoa Tiêu hóa"/>
<column name="slug" value="khoa-tieu-hoa"/>
<column name="icon_url" value="https://cdn-icons-png.flaticon.com/512/3063/3063200.png"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_specialty">
<column name="id" value="4"/>
<column name="name" value="Khoa Nhi"/>
<column name="slug" value="khoa-nhi"/>
<column name="icon_url" value="https://cdn-icons-png.flaticon.com/512/2966/2966327.png"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="1"/>
<column name="name" value="Ngô Tùng T"/>
<column name="title" value="ThS. BS."/>
<column name="specialty_id" value="2"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=11"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="2"/>
<column name="name" value="Ngô Anh L"/>
<column name="title" value="TS. BS."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=12"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="3"/>
<column name="name" value="Huỳnh Thị B"/>
<column name="title" value="TS. BS."/>
<column name="specialty_id" value="4"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=13"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="4"/>
<column name="name" value="Hoàng Thị B"/>
<column name="title" value="ThS. BS."/>
<column name="specialty_id" value="2"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=14"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="5"/>
<column name="name" value="Lê Anh L"/>
<column name="title" value="PGS. TS. BS."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=15"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="6"/>
<column name="name" value="Võ Thị B"/>
<column name="title" value="PGS. TS. BS."/>
<column name="specialty_id" value="3"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=16"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="7"/>
<column name="name" value="Lê Ngọc I"/>
<column name="title" value="BS. CKII."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=17"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="8"/>
<column name="name" value="Trần Anh L"/>
<column name="title" value="BS. CKI."/>
<column name="specialty_id" value="2"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=18"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="9"/>
<column name="name" value="Dương Sơn S"/>
<column name="title" value="BS. CKI."/>
<column name="specialty_id" value="3"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=19"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="10"/>
<column name="name" value="Võ Minh D"/>
<column name="title" value="ThS. BS."/>
<column name="specialty_id" value="3"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=20"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="11"/>
<column name="name" value="Ngô Tuấn M"/>
<column name="title" value="BS. CKI."/>
<column name="specialty_id" value="4"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=21"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="12"/>
<column name="name" value="Hoàng Văn A"/>
<column name="title" value="ThS. BS."/>
<column name="specialty_id" value="2"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=22"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="13"/>
<column name="name" value="Hồ Minh D"/>
<column name="title" value="BS. CKII."/>
<column name="specialty_id" value="3"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=23"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="14"/>
<column name="name" value="Trần Tuấn M"/>
<column name="title" value="ThS. BS."/>
<column name="specialty_id" value="2"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=24"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="15"/>
<column name="name" value="Dương Quốc C"/>
<column name="title" value="BS. CKI."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=25"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="16"/>
<column name="name" value="Đỗ Hữu E"/>
<column name="title" value="ThS. BS."/>
<column name="specialty_id" value="3"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=26"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="17"/>
<column name="name" value="Dương Thị B"/>
<column name="title" value="PGS. TS. BS."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=27"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="18"/>
<column name="name" value="Ngô Thị B"/>
<column name="title" value="PGS. TS. BS."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=28"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="19"/>
<column name="name" value="Ngô Quốc C"/>
<column name="title" value="PGS. TS. BS."/>
<column name="specialty_id" value="1"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=29"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
<insert tableName="sis_doctor">
<column name="id" value="20"/>
<column name="name" value="Phạm Thu P"/>
<column name="title" value="PGS. TS. BS."/>
<column name="specialty_id" value="2"/>
<column name="avatar_url" value="https://i.pravatar.cc/300?img=30"/>
<column name="booking_url" value="https://booking.bvdaihoc.com.vn/"/>
<column name="created_by" value="system"/>
<column name="created_date" valueDate="2026-07-18T00:00:00"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -39,4 +39,6 @@
<include file="config/liquibase/changelog/20260714080000_seed_news_events_components.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260714112000_add_location_to_post.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260714113000_update_events_list.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718100000_add_doctor_and_specialty.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260718110000_seed_sample_doctors.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>
@@ -0,0 +1,2 @@
[vaul-drawer]{touch-action:none;will-change:transform;transition:transform .5s cubic-bezier(.32,.72,0,1)}[vaul-drawer][vaul-drawer-direction=bottom]{transform:translate3d(0,100%,0)}[vaul-drawer][vaul-drawer-direction=top]{transform:translate3d(0,-100%,0)}[vaul-drawer][vaul-drawer-direction=left]{transform:translate3d(-100%,0,0)}[vaul-drawer][vaul-drawer-direction=right]{transform:translate3d(100%,0,0)}.vaul-dragging .vaul-scrollable [vault-drawer-direction=top],.vaul-dragging .vaul-scrollable [vault-drawer-direction=bottom]{overflow-y:hidden!important}.vaul-dragging .vaul-scrollable [vault-drawer-direction=left],.vaul-dragging .vaul-scrollable [vault-drawer-direction=right]{overflow-x:hidden!important}[vaul-drawer][vaul-drawer-visible=true][vaul-drawer-direction=top],[vaul-drawer][vaul-drawer-visible=true][vaul-drawer-direction=bottom]{transform:translate3d(0,var(--snap-point-height, 0),0)}[vaul-drawer][vaul-drawer-visible=true][vaul-drawer-direction=left],[vaul-drawer][vaul-drawer-visible=true][vaul-drawer-direction=right]{transform:translate3d(var(--snap-point-height, 0),0,0)}[vaul-overlay]{opacity:0;transition:opacity .5s cubic-bezier(.32,.72,0,1)}[vaul-overlay][vaul-drawer-visible=true]{opacity:1}[vaul-drawer]:after{content:"";position:absolute;background:inherit;background-color:inherit}[vaul-drawer][vaul-drawer-direction=top]:after{top:initial;bottom:100%;left:0;right:0;height:200%}[vaul-drawer][vaul-drawer-direction=bottom]:after{top:100%;bottom:initial;left:0;right:0;height:200%}[vaul-drawer][vaul-drawer-direction=left]:after{left:initial;right:100%;top:0;bottom:0;width:200%}[vaul-drawer][vaul-drawer-direction=right]:after{left:100%;right:initial;top:0;bottom:0;width:200%}[vaul-overlay][vaul-snap-points=true]:not([vaul-snap-points-overlay=true]):not([data-state=closed]){opacity:0}[vaul-overlay][vaul-snap-points-overlay=true]:not([vaul-drawer-visible=false]){opacity:1}@keyframes fake-animation{}@media (hover: hover) and (pointer: fine){[vaul-drawer]{-webkit-user-select:none;user-select:none}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="vi" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{fragments/layout}">
<head>
<th:block layout:fragment="head">
<title>Đội ngũ bác sĩ | UMC</title>
<style>
:root {
--spacing: 0.25rem;
}
/* Fix umass.css overriding layered Tailwind classes */
h1.mb-4 { margin-bottom: calc(var(--spacing) * 4) !important; }
@media (min-width: 768px) {
h1.md\:mb-6 { margin-bottom: calc(var(--spacing) * 6) !important; }
}
@media (min-width: 1280px) {
h1.xl\:mb-8 { margin-bottom: calc(var(--spacing) * 8) !important; }
}
</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 id="doctor-content" class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6]"><div class="container"><div class="flex flex-col md:flex-row md:items-center md:justify-between xl:mb-8 md:mb-6 mb-4 gap-4"><div class="flex gap-4 w-full xl:flex-row flex-col"><div class="relative xl:w-2/4 w-full flex-shrink-0"><input id="search-input" onkeyup="filterDoctors()" placeholder="Tìm kiếm bác sĩ..." class="w-full shadow rounded-lg border border-gray-200 px-4 py-3 label-3 bg-white pr-10 focus:outline-none focus:border-primary-600 lg:hover:border-primary-600 lg:duration-150 placeholder:text-gray-500" type="text" value=""><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 w-5 h-5"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg></div><div class="flex gap-4 md:flex-row flex-col md:w-full"><div class="relative w-full "><button type="button" class="w-full group rounded-lg cursor-pointer border label-3 bg-white flex justify-between items-center border-gray-200 lg:px-5 px-4 py-3 shadow lg:hover:border-primary-600 lg:duration-150" id="btn-specialty" aria-haspopup="listbox" aria-expanded="false" onclick="toggleDropdown('list-specialty')" style="height: 100%; background-color: var(--color-white);"><span id="text-specialty" class="text-gray-900 lg:group-hover:text-primary-600 lg:duration-150">--Theo chuyên khoa--</span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down ml-2 duration-150 md:size-5 size-4 rotate-0 text-gray-900 lg:group-hover:text-primary-600 duration-150"><path d="m6 9 6 6 6-6"></path></svg></button><ul id="list-specialty" class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" style="display: none;">
<li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectSpecialty('')">--Tất cả chuyên khoa--</li>
<li th:each="spec : ${specialties}" th:text="${spec.name}" th:data-specialty="${spec.name}" onclick="selectSpecialty(this.getAttribute('data-specialty'))" class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100"></li>
</ul></div><div class="relative w-full "><button type="button" class="w-full group rounded-lg cursor-pointer border label-3 bg-white flex justify-between items-center border-gray-200 lg:px-5 px-4 py-3 shadow lg:hover:border-primary-600 lg:duration-150" id="btn-gender" aria-haspopup="listbox" aria-expanded="false" onclick="toggleDropdown('list-gender')" style="height: 100%; background-color: var(--color-white);"><span id="text-gender" class="text-gray-900 lg:group-hover:text-primary-600 lg:duration-150">--Theo giới tính--</span><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down ml-2 duration-150 md:size-5 size-4 rotate-0 text-gray-900 lg:group-hover:text-primary-600 duration-150"><path d="m6 9 6 6 6-6"></path></svg></button><ul id="list-gender" class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" style="display: none;"><li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('')">--Tất cả giới tính--</li><li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('Nam')">Nam</li><li class="text-gray-900 cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100" onclick="selectGender('Nữ')">Nữ</li></ul></div></div></div></div><h1 class="display-7 text-primary-500 xl:mb-8 md:mb-6 mb-4">Danh sách bác sĩ</h1><div id="doctors-grid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 xl:gap-4 md:gap-3 gap-2">
<!-- Thymeleaf Loop over doctors -->
<div th:each="doc : ${doctors}" class="bg-white rounded-xl overview-wrapper-shadow flex flex-col justify-between h-full doctor-card" th:data-specialty="${doc.specialty != null ? doc.specialty.name : ''}" data-gender="">
<div>
<div class="flex gap-4 items-center xl:px-6 px-4 xl:pt-6 pt-4 mb-3 relative">
<a class="flex-shrink-0 relative aspect-square overflow-hidden rounded border border-primary-400 bg-gradient-to-tl from-primary-75 to-primary-100 xl:size-[150px] size-[100px]" th:href="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() ? doc.bookingUrl : '#'}">
<img loading="lazy" decoding="async" th:src="${doc.avatarUrl != null and !doc.avatarUrl.isEmpty() ? doc.avatarUrl : '/images/default-avatar.png'}" class="lg:hover:scale-105 scale-100 lg:duration-150" style="position: absolute; height: 100%; width: 100%; inset: 0px; object-fit: contain;">
</a>
<div class="flex-1">
<div class="text-primary-300 label-3 max-md:!text-[14px] !leading-[150%]" th:text="${doc.title}"></div>
<a class="text-[16px] leading-[150%] font-display font-bold text-[#1E293B] lg:hover:text-primary-600 lg:duration-150 mb-1 line-clamp-2" th:href="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() ? doc.bookingUrl : '#'}" 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()}">
<img loading="lazy" width="40" height="38" th:src="${doc.specialty.iconUrl}" style="object-fit: cover;">
</div>
</div>
</div>
<div class="xl:px-6 px-4 xl:pb-6 pb-4 xl:mt-3 mt-2 flex items-center justify-between gap-x-2">
<a class="btn btn-outline-primary !inline-flex w-[149px] text-center label-2 !px-0 group items-center justify-center gap-1.5" th:href="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() ? doc.bookingUrl : '#'}">
<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>
<button type="button" class="btn text-center label-2 !px-0 w-[149px] text-white flex items-center justify-center gap-1.5 rounded-lg transition" style="background-color: var(--color-brand, #0054a6); height: 100%;" th:data-url="${doc.bookingUrl != null and !doc.bookingUrl.isEmpty() ? doc.bookingUrl : '#'}" onclick="window.location.href=this.getAttribute('data-url')">
<div>Đặt lịch khám</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"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>
</button>
</div>
</div>
</div>
<div id="pagination-controls" class="flex justify-center items-center space-x-2 mt-8"></div>
</section></main>
<script th:inline="javascript">
/*<![CDATA[*/
let currentSpecialty = '';
let currentGender = '';
function toggleDropdown(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function selectSpecialty(spec) {
currentSpecialty = spec;
document.getElementById('text-specialty').innerText = spec || '--Theo chuyên khoa--';
document.getElementById('list-specialty').style.display = 'none';
filterDoctors();
}
function selectGender(gender) {
currentGender = gender;
document.getElementById('text-gender').innerText = gender || '--Theo giới tính--';
document.getElementById('list-gender').style.display = 'none';
filterDoctors();
}
let currentPage = 1;
const itemsPerPage = 12;
let filteredCards = [];
function filterDoctors() {
const searchText = document.getElementById('search-input').value.toLowerCase();
const cards = document.querySelectorAll('#doctors-grid > div.doctor-card');
filteredCards = [];
cards.forEach(card => {
const textContent = card.innerText || '';
const textLower = textContent.toLowerCase();
let matchSearch = searchText === '' || textLower.includes(searchText);
let matchSpecialty = currentSpecialty === '' || textContent.includes(currentSpecialty);
if (matchSearch && matchSpecialty) {
filteredCards.push(card);
} else {
card.style.display = 'none';
}
});
currentPage = 1;
renderPagination();
}
function renderPagination() {
const totalPages = Math.ceil(filteredCards.length / itemsPerPage);
const paginationContainer = document.getElementById('pagination-controls');
paginationContainer.innerHTML = '';
if (totalPages <= 1) {
filteredCards.forEach(card => card.style.display = '');
return;
}
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
filteredCards.forEach((card, index) => {
if (index >= startIndex && index < endIndex) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
const createButton = (content, onClick, disabled, isActive) => {
const btn = document.createElement('button');
if (isActive) {
btn.className = 'lg:size-10 md:size-9 size-8 flex items-center justify-center rounded-full border body-1 cursor-pointer bg-primary-600 text-white border-primary-600';
} else {
btn.className = 'lg:size-10 md:size-9 size-8 flex items-center justify-center rounded-full border border-gray-300 bg-white text-black disabled:opacity-50 disabled:!bg-white disabled:!text-black cursor-pointer lg:hover:bg-primary-600 lg:hover:text-white lg:duration-150 transition-colors';
}
btn.innerHTML = content;
if (disabled) btn.disabled = true;
if (!disabled && !isActive) {
btn.onclick = () => {
onClick();
renderPagination();
window.scrollTo(0, document.getElementById('doctor-content').offsetTop - 50);
};
}
return btn;
};
const iconFirst = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left size-4"><path d="m11 17-5-5 5-5"></path><path d="m18 17-5-5 5-5"></path></svg>';
const iconPrev = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90"><path d="m18 15-6-6-6 6"></path></svg>';
const iconNext = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90"><path d="m6 9 6 6 6-6"></path></svg>';
const iconLast = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-right size-4"><path d="m6 17 5-5-5-5"></path><path d="m13 17 5-5-5-5"></path></svg>';
paginationContainer.appendChild(createButton(iconFirst, () => currentPage = 1, currentPage === 1, false));
paginationContainer.appendChild(createButton(iconPrev, () => currentPage--, currentPage === 1, false));
let startPage = Math.max(1, currentPage - 1);
let endPage = Math.min(totalPages, currentPage + 1);
if (currentPage === 1 && totalPages > 2) endPage = 3;
if (currentPage === totalPages && totalPages > 2) startPage = totalPages - 2;
for (let i = startPage; i <= endPage; i++) {
paginationContainer.appendChild(createButton(i, () => currentPage = i, false, i === currentPage));
}
paginationContainer.appendChild(createButton(iconNext, () => currentPage++, currentPage === totalPages, false));
paginationContainer.appendChild(createButton(iconLast, () => currentPage = totalPages, currentPage === totalPages, false));
}
// Initialize pagination on load
document.addEventListener("DOMContentLoaded", function() {
filterDoctors();
});
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) {
const specBtn = document.getElementById('btn-specialty');
const specList = document.getElementById('list-specialty');
if (specBtn && !specBtn.contains(event.target) && !specList.contains(event.target)) {
specList.style.display = 'none';
}
const genderBtn = document.getElementById('btn-gender');
const genderList = document.getElementById('list-gender');
if (genderBtn && !genderBtn.contains(event.target) && !genderList.contains(event.target)) {
genderList.style.display = 'none';
}
});
/*]]>*/
</script>
</div>
</body>
</html>
@@ -121,6 +121,22 @@
</div>
</li>
<!-- Nav Item - Medical Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapseMedical"
aria-expanded="true" aria-controls="collapseMedical">
<i class="fas fa-fw fa-user-md"></i>
<span>Medical</span>
</a>
<div id="collapseMedical" class="collapse" aria-labelledby="headingMedical" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Medical Team:</h6>
<a class="collapse-item" th:href="@{/manage/doctors}">Doctors</a>
<a class="collapse-item" th:href="@{/manage/specialties}">Specialties</a>
</div>
</div>
</li>
<!-- Nav Item - Appearance Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapseAppearance"
@@ -0,0 +1,190 @@
<!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>Doctors</title>
<!-- DataTables CSS (from CDN) -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap4.min.css">
</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">Doctors</h1>
</div>
<!-- Success Message -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Error Message -->
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<!-- Left Column: Add/Edit Doctor Form -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"
th:text="${isNew} ? 'Add New Doctor' : 'Edit Doctor'">Form</h6>
</div>
<div class="card-body">
<form th:action="${isNew} ? @{/manage/doctors} : @{/manage/doctors/{id}(id=${doctor.id})}"
th:object="${doctor}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<!-- Validation errors -->
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger alert-sm">
<ul class="mb-0 small">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
</ul>
</div>
<!-- Name -->
<div class="form-group">
<label for="doctorName" class="font-weight-bold">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="doctorName" th:field="*{name}"
th:classappend="${#fields.hasErrors('name')} ? 'is-invalid' : ''"
placeholder="e.g. Nguyễn Văn A" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
</div>
<!-- Title -->
<div class="form-group">
<label for="doctorTitle" class="font-weight-bold">Title</label>
<input type="text" class="form-control" id="doctorTitle" th:field="*{title}"
placeholder="e.g. BS. CKII">
</div>
<!-- Specialty -->
<div class="form-group">
<label for="specialtyId" class="font-weight-bold">Specialty</label>
<select class="form-control" id="specialtyId" name="specialtyId">
<option value="">— Select Specialty —</option>
<option th:each="spec : ${allSpecialties}" th:value="${spec.id}" th:text="${spec.name}"
th:selected="${doctor.specialty != null && doctor.specialty.id == spec.id}"></option>
</select>
</div>
<!-- Avatar URL -->
<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}"
placeholder="/uploads/images/bac-si/avatar.png">
</div>
<!-- Booking URL -->
<div class="form-group">
<label for="doctorBooking" class="font-weight-bold">External Booking URL</label>
<input type="text" class="form-control" id="doctorBooking" th:field="*{bookingUrl}"
placeholder="https://booking.bvdaihoc.com.vn/...">
</div>
<!-- Buttons -->
<div class="d-flex justify-content-between mt-4">
<a th:if="${!isNew}" th:href="@{/manage/doctors}" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary btn-sm"
th:classappend="${isNew} ? 'btn-block' : ''">
<i class="fas fa-save"></i>
<span th:text="${isNew} ? 'Add New Doctor' : 'Update Doctor'">Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Right Column: Doctors Table -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Existing Doctors</h6>
</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="30%">Name</th>
<th width="15%">Title</th>
<th width="25%">Specialty</th>
<th width="15%">Booking</th>
<th width="15%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="doc : ${allDoctors}">
<td>
<strong th:text="${doc.name}"></strong>
</td>
<td>
<span th:text="${doc.title}"></span>
</td>
<td>
<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>
<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>
</td>
<td class="text-center">
<a th:href="@{/manage/doctors/{id}/edit(id=${doc.id})}"
class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/doctors/{id}/delete(id=${doc.id})}" method="post"
style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this doctor?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(allDoctors)}">
<td colspan="5" class="text-center text-muted py-4">
<i class="fas fa-user-md fa-2x mb-2 d-block"></i>
No doctors yet. Use the form on the left to create one.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<section layout:fragment="scripts">
<!-- Page level plugins (from CDN) -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap4.min.js"></script>
<!-- Page level custom scripts -->
<script>
$(document).ready(function() {
$('#doctorsTable').DataTable({
"order": [],
"pageLength": 10
});
});
</script>
</section>
</body>
</html>
@@ -0,0 +1,150 @@
<!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>Specialties</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">Specialties</h1>
</div>
<!-- Success Message -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Error Message -->
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<!-- Left Column: Add/Edit Specialty Form -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"
th:text="${isNew} ? 'Add New Specialty' : 'Edit Specialty'">Form</h6>
</div>
<div class="card-body">
<form th:action="${isNew} ? @{/manage/specialties} : @{/manage/specialties/{id}(id=${specialty.id})}"
th:object="${specialty}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<!-- Validation errors -->
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger alert-sm">
<ul class="mb-0 small">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
</ul>
</div>
<!-- Name -->
<div class="form-group">
<label for="specialtyName" class="font-weight-bold">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="specialtyName" th:field="*{name}"
th:classappend="${#fields.hasErrors('name')} ? 'is-invalid' : ''"
placeholder="e.g. Khoa Tim mạch" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
</div>
<!-- Slug -->
<div class="form-group">
<label for="specialtySlug" class="font-weight-bold">Slug <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="specialtySlug" th:field="*{slug}"
placeholder="e.g. khoa-tim-mach" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('slug')}" th:errors="*{slug}"></div>
</div>
<!-- Icon URL -->
<div class="form-group">
<label for="specialtyIcon" class="font-weight-bold">Icon URL</label>
<input type="text" class="form-control" id="specialtyIcon" th:field="*{iconUrl}"
placeholder="/uploads/chuyen-khoa/icon.png">
</div>
<!-- Buttons -->
<div class="d-flex justify-content-between">
<a th:if="${!isNew}" th:href="@{/manage/specialties}" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary btn-sm"
th:classappend="${isNew} ? 'btn-block' : ''">
<i class="fas fa-save"></i>
<span th:text="${isNew} ? 'Add New Specialty' : 'Update Specialty'">Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Right Column: Specialties Table -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Existing Specialties</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover" id="specialtiesTable" width="100%" cellspacing="0">
<thead class="thead-light">
<tr>
<th width="35%">Name</th>
<th width="30%">Slug</th>
<th width="20%">Icon URL</th>
<th width="15%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="spec : ${allSpecialties}">
<td>
<strong th:text="${spec.name}"></strong>
</td>
<td>
<code th:text="${spec.slug}"></code>
</td>
<td>
<small th:text="${spec.iconUrl}" class="text-muted"></small>
</td>
<td class="text-center">
<a th:href="@{/manage/specialties/{id}/edit(id=${spec.id})}"
class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/specialties/{id}/delete(id=${spec.id})}" method="post"
style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this specialty?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(allSpecialties)}">
<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.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -4,6 +4,8 @@
<head>
<title>Users Management</title>
<!-- DataTables CSS (from CDN) -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap4.min.css">
</head>
<body>
@@ -113,6 +115,17 @@
</div>
</div>
<!-- Page level plugins (from CDN) -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap4.min.js"></script>
<!-- Page level custom scripts -->
<script>
$(document).ready(function() {
$('#dataTable').DataTable();
});
</script>
<script>
function openDeleteModal(login) {
document.getElementById('deleteUserLoginDisplay').textContent = login;