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
168
app.py
168
app.py
|
|
@ -287,52 +287,154 @@ def save_notes():
|
|||
@app.route('/user_loans')
|
||||
def user_loans():
|
||||
"""
|
||||
Show all loans grouped by user.
|
||||
This demonstrates the one-to-many relationship: one user can loan multiple tablets.
|
||||
Show all users with their loan information.
|
||||
Supports search, filtering, and pagination via HTMX.
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
# Pagination settings
|
||||
per_page = 20
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build base query for users with loan counts
|
||||
query = """
|
||||
SELECT
|
||||
u.id, u.name, u.identification, u.email, u.phone,
|
||||
COUNT(l.id) as loan_count,
|
||||
MAX(l.loan_date) as last_loan_date
|
||||
FROM users u
|
||||
LEFT JOIN loans l ON u.id = l.user_id AND l.status = 'active'
|
||||
"""
|
||||
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
# Add search filter
|
||||
if search:
|
||||
conditions.append("(u.name LIKE ? OR u.identification LIKE ? OR u.email LIKE ?)")
|
||||
search_param = f"%{search}%"
|
||||
params.extend([search_param, search_param, search_param])
|
||||
|
||||
# Add status filter
|
||||
if status_filter == 'with_loans':
|
||||
conditions.append("COUNT(l.id) > 0")
|
||||
elif status_filter == 'no_loans':
|
||||
conditions.append("COUNT(l.id) = 0")
|
||||
|
||||
# Combine conditions
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
# Group by and order
|
||||
query += " GROUP BY u.id, u.name, u.identification, u.email, u.phone"
|
||||
query += " ORDER BY u.name COLLATE NOCASE"
|
||||
|
||||
# Get total count for pagination
|
||||
count_query = f"SELECT COUNT(*) FROM ({query})"
|
||||
cursor.execute(count_query, params)
|
||||
total_users = cursor.fetchone()[0]
|
||||
total_pages = (total_users + per_page - 1) // per_page
|
||||
|
||||
# Add pagination to query
|
||||
query += f" LIMIT {per_page} OFFSET {offset}"
|
||||
|
||||
# Execute main query
|
||||
cursor.execute(query, params)
|
||||
users = cursor.fetchall()
|
||||
|
||||
# Convert to list of dicts for template
|
||||
users_list = []
|
||||
for user in users:
|
||||
users_list.append({
|
||||
'id': user[0],
|
||||
'name': user[1],
|
||||
'identification': user[2],
|
||||
'email': user[3],
|
||||
'phone': user[4],
|
||||
'loan_count': user[5],
|
||||
'last_loan_date': user[6]
|
||||
})
|
||||
|
||||
# Check if this is an HTMX request
|
||||
is_htmx = request.headers.get('HX-Request') == 'true'
|
||||
|
||||
if is_htmx:
|
||||
# Return just the results partial for HTMX swap
|
||||
return render_template('components/user_loans_results.html',
|
||||
users=users_list,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_users=total_users,
|
||||
search=search,
|
||||
status=status_filter,
|
||||
per_page=per_page)
|
||||
else:
|
||||
# Full page render
|
||||
return render_template('user_loans.html',
|
||||
users=users_list,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_users=total_users,
|
||||
search=search,
|
||||
status=status_filter,
|
||||
per_page=per_page)
|
||||
|
||||
|
||||
@app.route('/user_loans/<int:user_id>')
|
||||
def user_loans_detail(user_id):
|
||||
"""
|
||||
Show detailed loan history for a specific user.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all users
|
||||
cursor.execute("SELECT * FROM users")
|
||||
users = cursor.fetchall()
|
||||
# Get user info
|
||||
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
# Get all loans with tablet and user details
|
||||
if not user:
|
||||
flash('User not found', 'error')
|
||||
return redirect(url_for('user_loans'))
|
||||
|
||||
# Get all loans for this user
|
||||
cursor.execute('''
|
||||
SELECT l.id, l.tablet_id, l.user_id, l.loan_date, l.return_date, l.status,
|
||||
t.brand, t.model, t.serial_number,
|
||||
u.name, u.identification
|
||||
SELECT l.id, l.tablet_id, l.loan_date, l.return_date, l.status,
|
||||
t.brand, t.model, t.serial_number, t.status as tablet_status
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
ORDER BY u.name, l.loan_date DESC
|
||||
''')
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY l.loan_date DESC
|
||||
''', (user_id,))
|
||||
loans = cursor.fetchall()
|
||||
|
||||
# Group loans by user
|
||||
user_loans_map = {}
|
||||
for loan in loans:
|
||||
user_id = loan['user_id']
|
||||
if user_id not in user_loans_map:
|
||||
user_loans_map[user_id] = {
|
||||
'user': None,
|
||||
'loans': []
|
||||
}
|
||||
user_loans_map[user_id]['loans'].append(loan)
|
||||
# Get active loans count
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM loans
|
||||
WHERE user_id = ? AND status = 'active'
|
||||
''', (user_id,))
|
||||
active_count = cursor.fetchone()[0]
|
||||
|
||||
# Attach user info
|
||||
for user in users:
|
||||
if user['id'] in user_loans_map:
|
||||
user_loans_map[user['id']]['user'] = user
|
||||
# Get returned loans count
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM loans
|
||||
WHERE user_id = ? AND status = 'returned'
|
||||
''', (user_id,))
|
||||
returned_count = cursor.fetchone()[0]
|
||||
|
||||
# Filter out users with no loans (optional - keep all users)
|
||||
# Convert to list for template
|
||||
user_loans_list = []
|
||||
for user in users:
|
||||
user_data = user_loans_map.get(user['id'], {'user': user, 'loans': []})
|
||||
user_loans_list.append(user_data)
|
||||
return render_template('user_loans_detail.html',
|
||||
user=user,
|
||||
loans=loans,
|
||||
active_count=active_count,
|
||||
returned_count=returned_count)
|
||||
|
||||
return render_template('user_loans.html', users_with_loans=user_loans_list)
|
||||
|
||||
|
||||
@app.route('/non_loanable_devices')
|
||||
|
|
|
|||
|
|
@ -4,328 +4,137 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tablet Management System</title>
|
||||
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Custom styles -->
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
overflow-x: hidden; /* Prevent horizontal overflow on zoom */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav a {
|
||||
padding: 8px 16px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nav a:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.section h2 {
|
||||
color: #4CAF50;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
/* HTMX loading spinner */
|
||||
.htmx-indicator {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(0,0,0,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #10b981;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
.btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #f44336;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #CC00CC;
|
||||
}
|
||||
.flash-message {
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.flash-success {
|
||||
background-color: #dff0d8;
|
||||
color: #3c763d;
|
||||
border: 1px solid #d6e9c6;
|
||||
}
|
||||
.flash-error {
|
||||
background-color: #f2dede;
|
||||
color: #a94442;
|
||||
border: 1px solid #ebccd1;
|
||||
}
|
||||
form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea {
|
||||
height: 100px;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE DESIGN - Mobile First
|
||||
Added for internal technical staff mobile access
|
||||
============================================ */
|
||||
|
||||
/* Mobile-first base styles */
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 1rem;
|
||||
margin: 0 auto;
|
||||
overflow: hidden; /* Prevent horizontal overflow */
|
||||
/* Hide HTMX indicator by default */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Navigation - stack vertically on mobile */
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav a {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Tables - responsive with horizontal scroll */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
table {
|
||||
min-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Forms - full width on mobile */
|
||||
form {
|
||||
max-width: 100%;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Buttons - full width on mobile */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Cards for mobile display */
|
||||
.tablet-card,
|
||||
.user-card,
|
||||
.loan-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* Flash messages */
|
||||
.flash-message {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Section spacing */
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BREAKPOINTS - Tablet and Desktop
|
||||
============================================ */
|
||||
|
||||
/* Small devices (landscape phones, 576px and up) */
|
||||
@media (min-width: 576px) {
|
||||
.container {
|
||||
max-width: 540px;
|
||||
}
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav a {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
.btn {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium devices (tablets, 768px and up) */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 720px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
table {
|
||||
min-width: auto;
|
||||
}
|
||||
.table-container {
|
||||
overflow-x: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large devices (desktops, 992px and up) */
|
||||
@media (min-width: 992px) {
|
||||
.container {
|
||||
max-width: 960px;
|
||||
}
|
||||
.nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra large devices (large desktops, 1200px and up) */
|
||||
@media (min-width: 1200px) {
|
||||
.container {
|
||||
max-width: 1140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.nav,
|
||||
.btn,
|
||||
.flash-message {
|
||||
display: none !important;
|
||||
}
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Tailwind config for custom colors -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Tablet Management System</h1>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="max-w-7xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<!-- Header -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-800 text-center mb-6">
|
||||
Tablet Management System
|
||||
</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="p-4 rounded-md
|
||||
{% if category == 'success' %}bg-green-100 text-green-800 border border-green-200
|
||||
{% elif category == 'error' %}bg-red-100 text-red-800 border border-red-200
|
||||
{% else %}bg-blue-100 text-blue-800 border border-blue-200
|
||||
{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/add_tablet">Add Tablet</a>
|
||||
<a href="/add_user">Add User</a>
|
||||
<a href="/loan_tablet">Loan Tablet</a>
|
||||
<a href="/history">Loan History</a>
|
||||
<a href="/user_loans">User Loans</a>
|
||||
<a href="/non_loanable_devices">Non-Loanable Devices</a>
|
||||
<a href="/project_management">Project Management</a>
|
||||
</div>
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-green-600 rounded-lg shadow-md mb-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-wrap justify-center sm:justify-start gap-1 sm:gap-2 py-3">
|
||||
<a href="/"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Home
|
||||
</a>
|
||||
<a href="/add_tablet"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Add Tablet
|
||||
</a>
|
||||
<a href="/add_user"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Add User
|
||||
</a>
|
||||
<a href="/loan_tablet"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Loan Tablet
|
||||
</a>
|
||||
<a href="/history"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Loan History
|
||||
</a>
|
||||
<a href="/user_loans"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
User Loans
|
||||
</a>
|
||||
<a href="/non_loanable_devices"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Non-Loanable Devices
|
||||
</a>
|
||||
<a href="/project_management"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-green-700 transition-colors whitespace-nowrap">
|
||||
Project Management
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
<!-- Main Content -->
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
206
templates/components/user_loans_results.html
Normal file
206
templates/components/user_loans_results.html
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
{# templates/components/user_loans_results.html #}
|
||||
|
||||
{%- if users %}
|
||||
{# Results Table Card #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-green-600 text-white">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Identification
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Active Loans
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Last Loan Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{%- for user in users %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ user.name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{{ user.identification or 'N/A' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{% if user.loan_count and user.loan_count > 0 %}bg-green-100 text-green-800
|
||||
{% else %}bg-gray-100 text-gray-800
|
||||
{% endif %}">
|
||||
{{ user.loan_count or 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ user.last_loan_date or 'Never' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<a href="/user_loans/{{ user.id }}"
|
||||
class="text-green-600 hover:text-green-800 font-medium">
|
||||
View Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{%- if total_pages > 1 %}
|
||||
<nav class="flex items-center justify-between pt-4" aria-label="Table navigation">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-500">
|
||||
Showing <span class="font-medium text-gray-900">{{ (page - 1) * per_page + 1 }}</span>
|
||||
to <span class="font-medium text-gray-900">{{ min(page * per_page, total_users) }}</span>
|
||||
of <span class="font-medium text-gray-900">{{ total_users }}</span> users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
{# Previous Button #}
|
||||
{%- if page > 1 %}
|
||||
<a
|
||||
href="/user_loans?page={{ page - 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ page - 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
{%- else %}
|
||||
<span class="px-3 py-2 ml-0 leading-tight text-gray-300 bg-white border border-gray-300 rounded-l-lg cursor-not-allowed">
|
||||
Previous
|
||||
</span>
|
||||
{%- endif %}
|
||||
|
||||
{# Page Numbers #}
|
||||
{%- if total_pages <= 7 %}
|
||||
{%- for p in range(1, total_pages + 1) %}
|
||||
{%- if p == page %}
|
||||
<span class="px-3 py-2 leading-tight text-white bg-green-600 border border-green-600 rounded">
|
||||
{{ p }}
|
||||
</span>
|
||||
{%- else %}
|
||||
<a
|
||||
href="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ p }}
|
||||
</a>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
{# Show first page #}
|
||||
<a
|
||||
href="/user_loans?page=1&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page=1&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
{%- if page > 3 %}
|
||||
<span class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300">...</span>
|
||||
{%- endif %}
|
||||
|
||||
{# Show pages around current #}
|
||||
{%- for p in range(max(2, page - 2), min(total_pages - 1, page + 2) + 1) %}
|
||||
{%- if p == page %}
|
||||
<span class="px-3 py-2 leading-tight text-white bg-green-600 border border-green-600 rounded">
|
||||
{{ p }}
|
||||
</span>
|
||||
{%- else %}
|
||||
<a
|
||||
href="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ p }}
|
||||
</a>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
|
||||
{%- if page < total_pages - 2 %}
|
||||
<span class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300">...</span>
|
||||
{%- endif %}
|
||||
|
||||
{# Show last page #}
|
||||
<a
|
||||
href="/user_loans?page={{ total_pages }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ total_pages }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ total_pages }}
|
||||
</a>
|
||||
{%- endif %}
|
||||
|
||||
{# Next Button #}
|
||||
{%- if page < total_pages %}
|
||||
<a
|
||||
href="/user_loans?page={{ page + 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ page + 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
{%- else %}
|
||||
<span class="px-3 py-2 leading-tight text-gray-300 bg-white border border-gray-300 rounded-r-lg cursor-not-allowed">
|
||||
Next
|
||||
</span>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{%- endif %}
|
||||
|
||||
{%- else %}
|
||||
{# No Results #}
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No users found</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
{% if search %}
|
||||
No users match your search for "{{ search }}"
|
||||
{% elif status == 'with_loans' %}
|
||||
No users have active loans
|
||||
{% elif status == 'no_loans' %}
|
||||
All users have at least one active loan
|
||||
{% else %}
|
||||
There are no users in the system yet
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="/user_loans"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 transition-colors">
|
||||
Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
{%- endif %}
|
||||
|
|
@ -1,67 +1,167 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section">
|
||||
<h2>Available Tablets</h2>
|
||||
<div class="space-y-8">
|
||||
{# Available Tablets Section #}
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
|
||||
</svg>
|
||||
Available Tablets
|
||||
<span class="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
||||
{{ available_tablets|length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if available_tablets %}
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<div class="p-6 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th>Brand</th>
|
||||
<th>Model</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Notes</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Brand
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Model
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Serial Number
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for tablet in available_tablets %}
|
||||
<tr>
|
||||
<td>{{ tablet.brand }}</td>
|
||||
<td>{{ tablet.model }}</td>
|
||||
<td>{{ tablet.serial_number }}</td>
|
||||
<td>{{ tablet.notes or '-' }}</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ tablet.brand }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{{ tablet.model }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ tablet.serial_number }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{{ tablet.notes or '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<a href="/loan_tablet?tablet_id={{ tablet.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Loan
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No available tablets.</p>
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
<p>No available tablets</p>
|
||||
<a href="/add_tablet"
|
||||
class="mt-4 inline-block px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Add Tablet
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Active Loans</h2>
|
||||
{# Active Loans Section #}
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Active Loans
|
||||
<span class="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
||||
{{ active_loans|length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if active_loans %}
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<div class="p-6 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th>Tablet</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Borrower</th>
|
||||
<th>Loan Date</th>
|
||||
<th>Actions</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tablet
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Serial Number
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Borrower
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Loan Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for loan in active_loans %}
|
||||
<tr>
|
||||
<td>{{ loan.brand }} {{ loan.model }}</td>
|
||||
<td>{{ loan.serial_number }}</td>
|
||||
<td>{{ loan.name }}</td>
|
||||
<td>{{ loan.loan_date }}</td>
|
||||
<td>
|
||||
<a href="/return_tablet/{{ loan.id }}" class="btn btn-danger">Return</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ loan.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.identification }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ loan.loan_date[:10] if loan.loan_date else 'N/A' }}
|
||||
{{ loan.loan_date[11:16] if loan.loan_date else '' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<a href="/return_tablet/{{ loan.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
Return
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No active loans.</p>
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p>No active loans</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -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>
|
||||
|
||||
<script>
|
||||
function filterUsers() {
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
const cards = document.querySelectorAll('.user-card');
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loading" class="htmx-indicator hidden"></div>
|
||||
|
||||
cards.forEach(card => {
|
||||
const searchText = card.getAttribute('data-search') || '';
|
||||
card.style.display = searchText.includes(search) ? '' : 'none';
|
||||
});
|
||||
<!-- Results Container -->
|
||||
<div id="results-container">
|
||||
{% include 'components/user_loans_results.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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 %}
|
||||
|
|
|
|||
126
templates/user_loans_detail.html
Normal file
126
templates/user_loans_detail.html
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Back Navigation -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/user_loans"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Back to All Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- User Header -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-green-600">{{ user.name[:1].upper() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ user.name }}</h1>
|
||||
<p class="text-gray-500">
|
||||
ID: {{ user.id }} | Identification: {{ user.identification or 'N/A' }}
|
||||
</p>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
<span class="text-sm text-gray-600">{{ active_count }} Active Loans</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-gray-400 rounded-full"></span>
|
||||
<span class="text-sm text-gray-600">{{ returned_count }} Returned</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if user.email %}
|
||||
<a href="mailto:{{ user.email }}"
|
||||
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
|
||||
Email
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.phone %}
|
||||
<a href="tel:{{ user.phone }}"
|
||||
class="px-3 py-1 bg-purple-100 text-purple-700 rounded-lg text-sm hover:bg-purple-200">
|
||||
Call
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loan History -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Loan History</h2>
|
||||
</div>
|
||||
|
||||
{% if loans %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tablet
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Loan Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Return Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for loan in loans %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.serial_number }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ loan.loan_date[:10] if loan.loan_date else 'N/A' }}
|
||||
{{ loan.loan_date[11:16] if loan.loan_date else '' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{% if loan.return_date %}
|
||||
{{ loan.return_date[:10] }} {{ loan.return_date[11:16] }}
|
||||
{% else %}
|
||||
<span class="text-gray-400 italic">Not returned</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{% if loan.status == 'active' %}bg-green-100 text-green-800
|
||||
{% elif loan.status == 'returned' %}bg-gray-100 text-gray-800
|
||||
{% else %}bg-yellow-100 text-yellow-800
|
||||
{% endif %}">
|
||||
{{ loan.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<p>No loan history for this user</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue