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
@@ -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;