All checks were successful
- Add 55 unit tests for hosting module (hosted site service, client service service, stats service) with full fixture setup - Fix table_empty_state macro: add x_message param for dynamic Alpine.js expressions rendered via x-text instead of server-side Jinja - Fix hosting templates (sites, clients) using message= with Alpine expressions that rendered as literal text - Fix prospecting templates (leads, scan-jobs, prospects) using nonexistent subtitle= param, migrated to x_message= - Align hosting and prospecting admin templates with shared design system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
9.4 KiB
HTML
188 lines
9.4 KiB
HTML
{% extends "admin/base.html" %}
|
|
{% from 'shared/macros/headers.html' import page_header %}
|
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
|
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
|
|
{% from 'shared/macros/pagination.html' import pagination %}
|
|
|
|
{% block title %}Client Services{% endblock %}
|
|
|
|
{% block alpine_data %}hostingClientsList(){% endblock %}
|
|
|
|
{% block content %}
|
|
{{ page_header('Client Services') }}
|
|
|
|
<!-- Filters -->
|
|
<div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
<div class="flex flex-wrap gap-3">
|
|
<select x-model="filterType" @change="pagination.page = 1; loadServices()"
|
|
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
|
|
<option value="">All Types</option>
|
|
<option value="domain">Domain</option>
|
|
<option value="email">Email</option>
|
|
<option value="ssl">SSL</option>
|
|
<option value="hosting">Hosting</option>
|
|
<option value="website_maintenance">Website Maintenance</option>
|
|
</select>
|
|
|
|
<select x-model="filterStatus" @change="pagination.page = 1; loadServices()"
|
|
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
|
|
<option value="">All Status</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="active">Active</option>
|
|
<option value="suspended">Suspended</option>
|
|
<option value="expired">Expired</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
|
|
<button type="button" @click="showExpiringOnly = !showExpiringOnly; pagination.page = 1; loadServices()"
|
|
:class="showExpiringOnly
|
|
? 'bg-red-600 text-white border-red-600'
|
|
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
|
|
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border transition-colors duration-150">
|
|
<span x-html="$icon('clock', 'w-4 h-4 mr-2')"></span>
|
|
Expiring Soon
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{ loading_state('Loading services...') }}
|
|
{{ error_state('Error loading services') }}
|
|
|
|
<!-- Services Table -->
|
|
<div x-show="!loading && !error">
|
|
{% call table_wrapper() %}
|
|
{{ table_header(['Service', 'Type', 'Status', 'Price', 'Expires', 'Site']) }}
|
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
|
{{ table_empty_state(6, title='No services found', x_message="filterType || filterStatus || showExpiringOnly ? 'Try adjusting your filters' : 'No client services yet'", show_condition='services.length === 0', icon='cube') }}
|
|
<template x-for="svc in services" :key="svc.id">
|
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
:class="isExpiringSoon(svc) ? 'bg-red-50 dark:bg-red-900/20' : ''">
|
|
<td class="px-4 py-3 text-sm font-semibold" x-text="svc.name"></td>
|
|
<td class="px-4 py-3 text-sm">
|
|
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
|
x-text="svc.service_type.replace('_', ' ')"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm">
|
|
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
|
|
:class="svc.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100' : svc.status === 'expired' ? 'bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
|
x-text="svc.status"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm">
|
|
<span x-text="svc.price_cents ? '€' + (svc.price_cents / 100).toFixed(2) : '—'"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm">
|
|
<span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'"
|
|
:class="isExpiringSoon(svc) ? 'text-red-600 dark:text-red-400 font-semibold' : ''"></span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<a :href="'/admin/hosting/sites/' + svc.hosted_site_id"
|
|
class="flex items-center justify-center p-2 text-teal-600 rounded-lg hover:bg-teal-50 dark:text-teal-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
|
title="View site">
|
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
{% endcall %}
|
|
|
|
{{ pagination() }}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
function hostingClientsList() {
|
|
return {
|
|
...data(),
|
|
currentPage: 'hosting-clients',
|
|
loading: true,
|
|
error: null,
|
|
services: [],
|
|
filterType: '',
|
|
filterStatus: '',
|
|
showExpiringOnly: false,
|
|
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
|
async init() {
|
|
if (window.PlatformSettings) {
|
|
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
|
}
|
|
await this.loadServices();
|
|
},
|
|
async loadServices() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: this.pagination.page,
|
|
per_page: this.pagination.per_page,
|
|
});
|
|
if (this.filterStatus) params.set('status', this.filterStatus);
|
|
|
|
const response = await apiClient.get('/admin/hosting/sites?' + params);
|
|
const sites = response.items || [];
|
|
|
|
// Collect all services from all sites
|
|
let allServices = [];
|
|
for (const site of sites) {
|
|
try {
|
|
const svcs = await apiClient.get('/admin/hosting/sites/' + site.id + '/services');
|
|
const svcList = Array.isArray(svcs) ? svcs : (svcs.items || []);
|
|
allServices = allServices.concat(svcList.map(s => ({ ...s, hosted_site_id: site.id })));
|
|
} catch (e) { /* skip */ }
|
|
}
|
|
|
|
// Apply client-side filters
|
|
if (this.filterType) allServices = allServices.filter(s => s.service_type === this.filterType);
|
|
if (this.filterStatus) allServices = allServices.filter(s => s.status === this.filterStatus);
|
|
if (this.showExpiringOnly) allServices = allServices.filter(s => this.isExpiringSoon(s));
|
|
|
|
this.services = allServices;
|
|
this.pagination.total = allServices.length;
|
|
this.pagination.pages = Math.ceil(allServices.length / this.pagination.per_page) || 1;
|
|
} catch (e) {
|
|
this.error = e.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
isExpiringSoon(svc) {
|
|
if (!svc.expires_at || svc.status !== 'active') return false;
|
|
const daysLeft = (new Date(svc.expires_at) - new Date()) / (1000 * 60 * 60 * 24);
|
|
return daysLeft <= 30 && daysLeft > 0;
|
|
},
|
|
get startIndex() {
|
|
if (this.pagination.total === 0) return 0;
|
|
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
|
},
|
|
get endIndex() {
|
|
const end = this.pagination.page * this.pagination.per_page;
|
|
return end > this.pagination.total ? this.pagination.total : end;
|
|
},
|
|
get totalPages() { return this.pagination.pages; },
|
|
get pageNumbers() {
|
|
const pages = [];
|
|
const total = this.totalPages;
|
|
const current = this.pagination.page;
|
|
if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); return pages; }
|
|
pages.push(1);
|
|
if (current > 3) pages.push('...');
|
|
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
|
|
if (current < total - 2) pages.push('...');
|
|
pages.push(total);
|
|
return pages;
|
|
},
|
|
goToPage(page) {
|
|
if (page === '...' || page < 1 || page > this.totalPages) return;
|
|
this.pagination.page = page;
|
|
this.loadServices();
|
|
},
|
|
nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadServices(); } },
|
|
previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadServices(); } },
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|