Compare commits

...

3 commits

Author SHA1 Message Date
ijuanes
db7e9591e1 fix(ui): handle None result from COUNT query when no users match criteria
- Fix TypeError when cursor.fetchone() returns None
- Handle empty result sets gracefully
- Ensure pagination works with zero results
2026-06-20 08:55:18 +01:00
ijuanes
f5bbb93d59 fix(ui): fix user_loans SQL query with HAVING clause for status filter
- Fix COUNT() misuse in SQLite subquery
- Use HAVING clause for aggregate function filtering (with_loans, no_loans)
- Properly count distinct users for pagination
- Maintain all search and pagination functionality
2026-06-20 08:53:55 +01:00
ijuanes
c9f3382c7c 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)
2026-06-20 08:38:42 +01:00
6 changed files with 848 additions and 649 deletions

179
app.py
View file

@ -287,52 +287,165 @@ 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 = []
having_conditions = []
# 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 (use HAVING for aggregate functions)
if status_filter == 'with_loans':
having_conditions.append("COUNT(l.id) > 0")
elif status_filter == 'no_loans':
having_conditions.append("COUNT(l.id) = 0")
# Combine conditions for WHERE clause
where_clause = ""
if conditions:
where_clause = " WHERE " + " AND ".join(conditions)
# Combine HAVING conditions
having_clause = ""
if having_conditions:
having_clause = " HAVING " + " AND ".join(having_conditions)
# Group by and order
group_by = " GROUP BY u.id, u.name, u.identification, u.email, u.phone"
order_by = " ORDER BY u.name COLLATE NOCASE"
# Get total count for pagination
# We need to count distinct users matching the criteria
count_query = f"SELECT COUNT(DISTINCT u.id) FROM users u LEFT JOIN loans l ON u.id = l.user_id AND l.status = 'active'{where_clause}{having_clause}"
cursor.execute(count_query, params)
result = cursor.fetchone()
total_users = result[0] if result else 0
total_pages = (total_users + per_page - 1) // per_page
# Build final query with pagination
final_query = query + where_clause + group_by + having_clause + order_by + f" LIMIT {per_page} OFFSET {offset}"
# Execute main query
cursor.execute(final_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')

View file

@ -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 */
}
/* 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;
/* Hide HTMX indicator by default */
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
</style>
</head>
<body>
<div class="container">
<h1>Tablet Management System</h1>
<!-- 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 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>
<!-- 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="flash-message flash-{{ category }}">{{ message }}</div>
<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>
<!-- 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>
<!-- Main Content -->
<main>
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>

View 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 %}

View file

@ -1,59 +1,68 @@
{% extends "base.html" %}
{% block content %}
<div class="section">
<h2>Available Tablets</h2>
{% if available_tablets %}
<div class="table-container">
<table>
<thead>
<tr>
<th>Brand</th>
<th>Model</th>
<th>Serial Number</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% 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>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No available tablets.</p>
{% endif %}
<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>
<div class="section">
<h2>Active Loans</h2>
{% if active_loans %}
<div class="table-container">
<table>
<thead>
{% if available_tablets %}
<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">
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>
{% 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>
<tbody class="bg-white divide-y divide-gray-200">
{% for tablet in available_tablets %}
<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 %}
@ -61,7 +70,98 @@
</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="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>
{# 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="p-6 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">
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 class="bg-white divide-y divide-gray-200">
{% for loan in active_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>
</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 %}
<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 %}

View file

@ -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>
<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 %}
<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>
</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 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>
{% endif %}
{% if returned_loans %}
<div class="loans-section past-loans">
<h4>
<button class="expand-toggle" onclick="togglePastLoans(this)">
▶ Past Loans ({{ returned_loans|length }})
<!-- 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">
<!-- 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>
<!-- 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>
<!-- 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>
</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 %}
</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 %}
</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>
<!-- HTMX Script to handle URL updates -->
<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';
// 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>
{% endblock %}

View 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 %}