feat(ui): implement HTMX + Tailwind frontend revamp
- Add Tailwind CSS via CDN for modern styling - Add HTMX for AJAX functionality without full page reloads - Revamp user_loans interface with: - Real-time search (debounced 500ms) - Status filtering (all users, with loans, no loans) - Pagination (20 users per page) - Responsive table design - Clean, modern UI with Tailwind classes - Create user_loans_detail.html for individual user loan history - Update index.html with Tailwind styling - Update base.html with Tailwind + HTMX setup - Add components/user_loans_results.html for HTMX partial updates - Update app.py user_loans route to support search, filter, pagination - Add user_loans_detail route for detailed user view This addresses the issues: - Navigation overflow (fixed with responsive Tailwind classes) - Basic look (modern, professional UI) - User loans UX (fast search, filtering, pagination for 500+ users)
This commit is contained in:
parent
e70738c792
commit
c9f3382c7c
6 changed files with 837 additions and 649 deletions
|
|
@ -1,279 +1,124 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>User Loans</h2>
|
||||
<p class="relationship-note">
|
||||
<strong>Relationship:</strong> One user can loan multiple tablets (one-to-many).
|
||||
This page demonstrates the many-to-many relationship between users and tablets via the loans table.
|
||||
</p>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="Search by user name, identification, tablet brand, model, or serial number..."
|
||||
onkeyup="filterUsers()">
|
||||
<button onclick="clearSearch()" class="btn btn-clear">Clear</button>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-800">User Loans</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="result-count" hx-swap-oob="true">{{ total_users }} users</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-loans-container" id="userLoansContainer">
|
||||
{% for user_data in users_with_loans %}
|
||||
{% set user = user_data.user %}
|
||||
{% set loans = user_data.loans %}
|
||||
{% set active_loans = loans|selectattr('status', 'equalto', 'active')|list %}
|
||||
{% set returned_loans = loans|selectattr('status', 'equalto', 'returned')|list %}
|
||||
<!-- Search and Filters Card -->
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<form id="search-form"
|
||||
hx-get="/user_loans"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status'], [name='page']"
|
||||
class="space-y-4">
|
||||
|
||||
<div class="user-card"
|
||||
data-search="{{ user.name|lower }} {{ user.identification|lower }} {% for loan in loans %}{{ loan.brand|lower }} {{ loan.model|lower }} {{ loan.serial_number|lower }} {% endfor %}">
|
||||
<div class="user-header">
|
||||
<h3>{{ user.name }} ({{ user.identification }})</h3>
|
||||
<span class="loan-count">
|
||||
{{ active_loans|length }} active, {{ returned_loans|length }} past
|
||||
</span>
|
||||
<!-- Search Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search Users
|
||||
</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
value="{{ search or '' }}"
|
||||
placeholder="Search by name or identification..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if active_loans %}
|
||||
<div class="loans-section current-loans">
|
||||
<h4>Current Loans</h4>
|
||||
<div class="table-container">
|
||||
<table class="loans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tablet</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Loan Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loan in active_loans %}
|
||||
<tr class="loan-row status-{{ loan.status }}">
|
||||
<td>{{ loan.brand }} {{ loan.model }}</td>
|
||||
<td>{{ loan.serial_number }}</td>
|
||||
<td>{{ loan.loan_date }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ loan.status }}">
|
||||
{{ loan.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if returned_loans %}
|
||||
<div class="loans-section past-loans">
|
||||
<h4>
|
||||
<button class="expand-toggle" onclick="togglePastLoans(this)">
|
||||
▶ Past Loans ({{ returned_loans|length }})
|
||||
</button>
|
||||
</h4>
|
||||
<div class="past-loans-content" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table class="loans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tablet</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Loan Date</th>
|
||||
<th>Return Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loan in returned_loans %}
|
||||
<tr class="loan-row status-{{ loan.status }}">
|
||||
<td>{{ loan.brand }} {{ loan.model }}</td>
|
||||
<td>{{ loan.serial_number }}</td>
|
||||
<td>{{ loan.loan_date }}</td>
|
||||
<td>{{ loan.return_date or '-' }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ loan.status }}">
|
||||
{{ loan.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not active_loans and not returned_loans %}
|
||||
<p class="no-loans">No loans recorded for this user.</p>
|
||||
{% endif %}
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Loan Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
<option value="" {% if not status %}selected{% endif %}>All Users</option>
|
||||
<option value="with_loans" {% if status == 'with_loans' %}selected{% endif %}>With Active Loans</option>
|
||||
<option value="no_loans" {% if status == 'no_loans' %}selected{% endif %}>No Active Loans</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if users_with_loans|length == 0 %}
|
||||
<p class="no-data">No users found. Add users and loan tablets to see data here.</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loading" class="htmx-indicator hidden"></div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container">
|
||||
{% include 'components/user_loans_results.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterUsers() {
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
const cards = document.querySelectorAll('.user-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const searchText = card.getAttribute('data-search') || '';
|
||||
card.style.display = searchText.includes(search) ? '' : 'none';
|
||||
});
|
||||
<!-- HTMX Script to handle URL updates -->
|
||||
<script>
|
||||
// Update browser URL when HTMX does a GET request
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.requestConfig.method === 'get' && evt.detail.successful) {
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
const form = document.getElementById('search-form');
|
||||
if (form) {
|
||||
const formData = new FormData(form);
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
filterUsers();
|
||||
}
|
||||
|
||||
function togglePastLoans(button) {
|
||||
const content = button.closest('.loans-section').querySelector('.past-loans-content');
|
||||
const isExpanded = content.style.display !== 'none';
|
||||
|
||||
content.style.display = isExpanded ? 'none' : 'block';
|
||||
const count = button.textContent.match(/\d+/)[0];
|
||||
button.textContent = isExpanded ? '▶ Past Loans (' + count + ')' : '▼ Past Loans (' + count + ') ';
|
||||
}
|
||||
</script>
|
||||
}
|
||||
});
|
||||
|
||||
<style>
|
||||
.relationship-note {
|
||||
background-color: #e8f5e9;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #4CAF50;
|
||||
// Restore form state from URL on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const form = document.getElementById('search-form');
|
||||
if (form) {
|
||||
for (let [key, value] of urlParams.entries()) {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = input.value === value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background-color: #999;
|
||||
}
|
||||
|
||||
.user-loans-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.user-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loan-count {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.loans-section {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.loans-section h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.expand-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4CAF50;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.expand-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loans-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.loans-table th,
|
||||
.loans-table td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.loans-table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loan-row.status-active {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
|
||||
.loan-row.status-returned {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.status-active {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-badge.status-returned {
|
||||
background-color: #9E9E9E;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.no-loans {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue