Modernize tenant selection page with improved UX

- Replace list view with responsive card grid layout for better scalability
- Add real-time search functionality to filter tenants by name or ID
- Move settings to fixed gear icon in top-right corner
- Add new tenant button (plus icon) next to settings gear
- Implement modal dialog for creating new tenants
- Add hover effects and animations to tenant cards
- Improve mobile responsiveness
- Display tenant info more compactly (name, ID, username, date, AR URL)
- Add "no results" message for empty search results

The new layout handles 50+ tenants efficiently with better visual organization and easier navigation.
This commit is contained in:
2025-10-20 18:16:45 -04:00
parent f97808c7b3
commit 0888edaec5

View File

@@ -6,10 +6,62 @@
<title>Select or Create Tenant - KCAP Demo Server</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
.tenant-card {
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
}
.tenant-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.tenant-card-clickable {
text-decoration: none;
color: inherit;
display: block;
}
.settings-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.search-container {
position: sticky;
top: 0;
background: white;
z-index: 100;
padding: 20px 0;
}
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.create-tenant-card {
border: 2px dashed #dee2e6;
background: #f8f9fa;
}
.create-tenant-card:hover {
border-color: #0d6efd;
background: #e7f1ff;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">KCAP Demo Server - Multi-Tenant</h1>
<!-- Top Right Action Buttons -->
<div class="settings-btn">
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#createTenantModal" title="New Tenant">
<i class="bi bi-plus-circle-fill"></i>
</button>
<a href="/settings" class="btn btn-secondary" title="Server Settings">
<i class="bi bi-gear-fill"></i>
</a>
</div>
<div class="container-fluid px-4 py-4">
<h1 class="text-center mb-4">KCAP Demo Server</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
@@ -22,66 +74,101 @@
{% endif %}
{% endwith %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3>Select an Existing Tenant</h3>
</div>
<div class="card-body">
{% if tenants %}
<div class="list-group">
{% for tenant in tenants %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<a href="/{{ tenant.id }}/" class="text-decoration-none flex-grow-1">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ tenant.name }}</h5>
<small>{{ tenant.created_at }}</small>
</div>
<p class="mb-1">Username: {{ tenant.username }}</p>
<p class="mb-1">
<strong>Knox Capture AR Template URL:</strong>
<code>{{ server_url }}/{{ tenant.id }}/</code>
</p>
<small>ID: {{ tenant.id }}</small>
</a>
<button type="button" class="btn btn-sm btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#qrModal-{{ tenant.id }}" onclick="event.stopPropagation();">
<i class="bi bi-qr-code"></i>
<!-- Search and Filter -->
<div class="search-container">
<div class="row mb-3">
<div class="col-md-8 offset-md-2">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="searchTenants" class="form-control" placeholder="Search tenants...">
<button class="btn btn-outline-secondary" type="button" id="clearSearch">
<i class="bi bi-x-lg"></i>
</button>
<form method="POST" action="/tenant/{{ tenant.id }}/delete" class="ms-3" onsubmit="return confirmDelete('{{ tenant.name }}')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No tenants exist yet. Create one below!</p>
{% endif %}
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3>Create a New Tenant</h3>
</div>
<!-- Tenant Grid -->
<div class="tenant-grid" id="tenantGrid">
{% if tenants %}
{% for tenant in tenants %}
<div class="tenant-item" data-tenant-name="{{ tenant.name|lower }}" data-tenant-id="{{ tenant.id|lower }}">
<div class="card tenant-card">
<a href="/{{ tenant.id }}/" class="tenant-card-clickable">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0">{{ tenant.name }}</h5>
<span class="badge bg-secondary">{{ tenant.id }}</span>
</div>
<p class="card-text text-muted small mb-2">
<i class="bi bi-person"></i> {{ tenant.username }}
</p>
<p class="card-text text-muted small mb-2">
<i class="bi bi-calendar"></i> {{ tenant.created_at }}
</p>
<div class="mb-2">
<small class="text-muted">AR Template URL:</small>
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" value="{{ server_url }}/{{ tenant.id }}/" readonly onclick="this.select()">
</div>
</div>
</div>
</a>
<div class="card-footer bg-white d-flex justify-content-between" onclick="event.stopPropagation();">
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#qrModal-{{ tenant.id }}">
<i class="bi bi-qr-code"></i> QR
</button>
<form method="POST" action="/tenant/{{ tenant.id }}/delete" class="d-inline" onsubmit="return confirmDelete('{{ tenant.name }}')">
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #dee2e6;"></i>
<p class="text-muted mt-3">No tenants exist yet. Create your first tenant!</p>
</div>
</div>
{% endif %}
</div>
<!-- No Results Message -->
<div id="noResults" class="text-center py-5" style="display: none;">
<i class="bi bi-search" style="font-size: 4rem; color: #dee2e6;"></i>
<p class="text-muted mt-3">No tenants found matching your search.</p>
</div>
</div>
<!-- Create Tenant Modal -->
<div class="modal fade" id="createTenantModal" tabindex="-1" aria-labelledby="createTenantModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTenantModalLabel">Create New Tenant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="newTenantForm">
<div class="mb-3">
<label for="tenantId" class="form-label">Tenant ID</label>
<input type="text" class="form-control" id="tenantId" placeholder="e.g., demo-company" required pattern="[a-zA-Z0-9_-]+">
<small class="form-text text-muted">Only letters, numbers, hyphens, and underscores allowed</small>
</div>
<button type="submit" class="btn btn-primary">Create & Enter Tenant</button>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<small>Default credentials: <strong>admin</strong> / <strong>admin</strong></small>
</div>
</form>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-muted">
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
it will be automatically created with default credentials (admin/admin).
</p>
<a href="/settings" class="btn btn-secondary mt-3">Server Settings</a>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="newTenantForm" class="btn btn-primary">Create & Enter</button>
</div>
</div>
</div>
@@ -93,11 +180,11 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="qrModalLabel-{{ tenant.id }}">AR Template URL QR Code - {{ tenant.name }}</h5>
<h5 class="modal-title" id="qrModalLabel-{{ tenant.id }}">AR Template URL - {{ tenant.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<p class="text-muted mb-3">Scan this QR code for the Knox Capture AR Template URL:</p>
<p class="text-muted mb-3">Scan this QR code for Knox Capture AR:</p>
<div class="mb-3">
<img src="/{{ tenant.id }}/qrcode/template" alt="AR Template QR Code" class="img-fluid" style="max-width: 300px;">
</div>
@@ -117,21 +204,62 @@
'images', 'barcodes', 'static', 'assets', 'js', 'css', 'tenant',
'auth', 'oauth', 'callback', 'webhook', 'health', 'status'];
// Create tenant form submission
document.getElementById('newTenantForm').addEventListener('submit', function(e) {
e.preventDefault();
const tenantId = document.getElementById('tenantId').value;
if (tenantId) {
// Convert to lowercase for consistency
const normalizedId = tenantId.toLowerCase();
if (reservedIds.includes(normalizedId)) {
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
return;
}
// Use the normalized (lowercase) ID in the URL
window.location.href = '/' + normalizedId + '/';
}
});
// Search functionality
const searchInput = document.getElementById('searchTenants');
const clearButton = document.getElementById('clearSearch');
const tenantItems = document.querySelectorAll('.tenant-item');
const noResults = document.getElementById('noResults');
const tenantGrid = document.getElementById('tenantGrid');
function filterTenants() {
const searchTerm = searchInput.value.toLowerCase().trim();
let visibleCount = 0;
tenantItems.forEach(item => {
const tenantName = item.getAttribute('data-tenant-name');
const tenantId = item.getAttribute('data-tenant-id');
if (tenantName.includes(searchTerm) || tenantId.includes(searchTerm)) {
item.style.display = '';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0 && searchTerm !== '') {
tenantGrid.style.display = 'none';
noResults.style.display = 'block';
} else {
tenantGrid.style.display = 'grid';
noResults.style.display = 'none';
}
}
searchInput.addEventListener('input', filterTenants);
clearButton.addEventListener('click', function() {
searchInput.value = '';
filterTenants();
searchInput.focus();
});
// Delete confirmation
function confirmDelete(tenantName) {
return confirm(`Are you sure you want to delete the tenant "${tenantName}"?\n\nThis will permanently delete all products and data associated with this tenant.`);
}