3374 lines
148 KiB
JavaScript
3374 lines
148 KiB
JavaScript
// Dashboard Service Provider JavaScript
|
|
// This file handles the dynamic charts and data visualization for the service provider dashboard
|
|
|
|
class ServiceProviderDashboard {
|
|
constructor(serviceProviderData) {
|
|
this.serviceProviderData = serviceProviderData;
|
|
// Initialize service requests from the main API data
|
|
this.allRequests = serviceProviderData.client_requests || [];
|
|
console.log('🔍 CONSTRUCTOR DEBUG: Initialized with service requests from API:', this.allRequests.length);
|
|
console.log('🔍 CONSTRUCTOR DEBUG: Raw serviceProviderData:', serviceProviderData);
|
|
console.log('🔍 CONSTRUCTOR DEBUG: client_requests array:', serviceProviderData.client_requests);
|
|
console.log('🔍 CONSTRUCTOR DEBUG: services array:', serviceProviderData.services);
|
|
console.log('🔍 CONSTRUCTOR DEBUG: services count:', (serviceProviderData.services || []).length);
|
|
console.log('🔍 CONSTRUCTOR DEBUG: All request details:', this.allRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
this.initializeCharts();
|
|
this.populateServicesList();
|
|
this.populateClientRequests();
|
|
}
|
|
|
|
initializeCharts() {
|
|
// Set global chart defaults
|
|
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
|
|
Chart.defaults.font.size = 12;
|
|
Chart.defaults.responsive = true;
|
|
Chart.defaults.maintainAspectRatio = false;
|
|
|
|
this.createRevenueChart();
|
|
this.createServiceDistributionChart();
|
|
this.createClientGrowthChart();
|
|
this.createServicePerformanceChart();
|
|
}
|
|
|
|
createRevenueChart() {
|
|
const ctx = document.getElementById('serviceRevenueChart');
|
|
if (!ctx) return;
|
|
|
|
// Destroy existing chart if it exists to prevent "Canvas already in use" error
|
|
if (window.revenueChart && typeof window.revenueChart.destroy === 'function') {
|
|
window.revenueChart.destroy();
|
|
}
|
|
|
|
const revenueHistory = this.serviceProviderData.revenue_history || [];
|
|
|
|
// If no revenue history from API, create mock data based on monthly revenue
|
|
let labels, amounts;
|
|
if (revenueHistory.length > 0) {
|
|
// Sort by date and get data
|
|
const sortedHistory = revenueHistory.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
labels = sortedHistory.map(record => {
|
|
const date = new Date(record.date);
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
});
|
|
amounts = sortedHistory.map(record => record.amount);
|
|
} else {
|
|
// Generate mock revenue data based on monthly revenue (prefer persistent *_usd field)
|
|
const monthlyRevenue = (this.serviceProviderData.monthly_revenue_usd ?? this.serviceProviderData.monthly_revenue) || 0;
|
|
const dailyAverage = Math.floor(monthlyRevenue / 30);
|
|
labels = [];
|
|
amounts = [];
|
|
|
|
// Generate last 7 days of data
|
|
for (let i = 6; i >= 0; i--) {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - i);
|
|
labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
|
|
|
|
// Add some variation to daily amounts (±30%)
|
|
const variation = (Math.random() - 0.5) * 0.6;
|
|
const dailyAmount = Math.max(0, Math.floor(dailyAverage * (1 + variation)));
|
|
amounts.push(dailyAmount);
|
|
}
|
|
}
|
|
|
|
window.revenueChart = new Chart(ctx.getContext('2d'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Daily Revenue',
|
|
data: amounts,
|
|
borderColor: '#28a745',
|
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
|
borderWidth: 3,
|
|
tension: 0.3,
|
|
fill: true,
|
|
pointRadius: 5,
|
|
pointHoverRadius: 7,
|
|
pointBackgroundColor: '#28a745',
|
|
pointBorderColor: '#ffffff',
|
|
pointBorderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
return `Revenue: ${context.raw} TFP`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Revenue (TFP)'
|
|
}
|
|
},
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Date'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
createServiceDistributionChart() {
|
|
const ctx = document.getElementById('serviceDistributionChart');
|
|
if (!ctx) return;
|
|
|
|
// Destroy existing chart if it exists
|
|
if (window.serviceDistributionChart && typeof window.serviceDistributionChart.destroy === 'function') {
|
|
window.serviceDistributionChart.destroy();
|
|
}
|
|
|
|
const services = this.serviceProviderData.services || [];
|
|
const serviceNames = services.map(service => service.name);
|
|
const totalHours = services.map(service => service.total_hours);
|
|
const colors = [
|
|
'rgba(0, 123, 255, 0.8)',
|
|
'rgba(40, 167, 69, 0.8)',
|
|
'rgba(255, 193, 7, 0.8)',
|
|
'rgba(220, 53, 69, 0.8)',
|
|
'rgba(108, 117, 125, 0.8)',
|
|
'rgba(102, 16, 242, 0.8)'
|
|
];
|
|
|
|
window.serviceDistributionChart = new Chart(ctx.getContext('2d'), {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: serviceNames,
|
|
datasets: [{
|
|
data: totalHours,
|
|
backgroundColor: colors.slice(0, serviceNames.length),
|
|
borderColor: colors.slice(0, serviceNames.length).map(color => color.replace('0.8', '1')),
|
|
borderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
|
const percentage = ((context.raw / total) * 100).toFixed(1);
|
|
return `${context.label}: ${context.raw} hours (${percentage}%)`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
createClientGrowthChart() {
|
|
const ctx = document.getElementById('clientGrowthChart');
|
|
if (!ctx) return;
|
|
|
|
// Destroy existing chart if it exists
|
|
if (window.clientGrowthChart && typeof window.clientGrowthChart.destroy === 'function') {
|
|
window.clientGrowthChart.destroy();
|
|
}
|
|
|
|
// Generate realistic client growth data based on current total clients
|
|
const currentClients = this.serviceProviderData.total_clients || 0;
|
|
const labels = [];
|
|
const clientData = [];
|
|
|
|
// Generate 6 months of growth data leading to current client count
|
|
for (let i = 5; i >= 0; i--) {
|
|
const date = new Date();
|
|
date.setMonth(date.getMonth() - i);
|
|
labels.push(date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }));
|
|
|
|
// Calculate progressive growth to current client count
|
|
const growthFactor = (6 - i) / 6;
|
|
const monthlyClients = Math.floor(currentClients * growthFactor * (0.7 + Math.random() * 0.3));
|
|
clientData.push(Math.max(0, monthlyClients));
|
|
}
|
|
|
|
// Ensure the last month shows current client count
|
|
if (clientData.length > 0) {
|
|
clientData[clientData.length - 1] = currentClients;
|
|
}
|
|
|
|
window.clientGrowthChart = new Chart(ctx.getContext('2d'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Total Clients',
|
|
data: clientData,
|
|
backgroundColor: 'rgba(255, 193, 7, 0.7)',
|
|
borderColor: 'rgba(255, 193, 7, 1)',
|
|
borderWidth: 1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
return `Clients: ${context.raw}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Number of Clients'
|
|
}
|
|
},
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Time Period'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
createServicePerformanceChart() {
|
|
const ctx = document.getElementById('servicePerformanceChart');
|
|
if (!ctx) return;
|
|
|
|
// Destroy existing chart if it exists
|
|
if (window.servicePerformanceChart && typeof window.servicePerformanceChart.destroy === 'function') {
|
|
window.servicePerformanceChart.destroy();
|
|
}
|
|
|
|
const services = this.serviceProviderData.services || [];
|
|
const serviceNames = services.map(service => service.name);
|
|
const ratings = services.map(service => service.rating);
|
|
const pricePerHour = services.map(service => (service.price_per_hour ?? service.price_per_hour_usd ?? service.hourly_rate ?? service.price_amount ?? 0));
|
|
|
|
window.servicePerformanceChart = new Chart(ctx.getContext('2d'), {
|
|
type: 'scatter',
|
|
data: {
|
|
datasets: [{
|
|
label: 'Service Performance',
|
|
data: serviceNames.map((name, index) => ({
|
|
x: ratings[index],
|
|
y: pricePerHour[index],
|
|
label: name
|
|
})),
|
|
backgroundColor: 'rgba(220, 53, 69, 0.7)',
|
|
borderColor: 'rgba(220, 53, 69, 1)',
|
|
borderWidth: 2,
|
|
pointRadius: 8,
|
|
pointHoverRadius: 10
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
const point = context.raw;
|
|
return `${point.label}: Rating ${point.x}, ${point.y} TFP/hour`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Average Rating'
|
|
},
|
|
min: 0,
|
|
max: 5
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: 'Price per Hour (TFP)'
|
|
},
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async populateServicesList() {
|
|
const tbody = document.getElementById('services-list-tbody');
|
|
if (!tbody) {
|
|
console.error('🔍 SERVICES DEBUG: services-list-tbody element not found!');
|
|
return;
|
|
}
|
|
|
|
// PHASE 2 FIX: Enhanced service loading with better error handling and consistency
|
|
let allServices = this.serviceProviderData.services || [];
|
|
|
|
console.log('🔍 PHASE 2 FIX: Initial services from main API:', allServices.length);
|
|
console.log('🔍 PHASE 2 FIX: serviceProviderData:', this.serviceProviderData);
|
|
|
|
// PHASE 2 FIX: Always try to get fresh services data to ensure consistency
|
|
try {
|
|
console.log('🔍 PHASE 2 FIX: Fetching fresh services data...');
|
|
const timestamp = new Date().getTime();
|
|
const data = await window.apiJson(`/api/dashboard/products?_t=${timestamp}`);
|
|
console.log('🔍 PRODUCTS FIX: Unwrapped data:', data);
|
|
|
|
// Support both new products-based and legacy services-based responses
|
|
let freshServices = null;
|
|
if (Array.isArray(data.products)) {
|
|
freshServices = data.products;
|
|
console.log('🔍 PRODUCTS FIX: Found products array:', freshServices.length);
|
|
} else if (Array.isArray(data.services)) {
|
|
freshServices = data.services;
|
|
console.log('🔍 PRODUCTS FIX: Found services array:', freshServices.length);
|
|
} else if (Array.isArray(data)) {
|
|
freshServices = data;
|
|
console.log('🔍 PRODUCTS FIX: Found direct array:', freshServices.length);
|
|
}
|
|
|
|
if (freshServices) {
|
|
allServices = freshServices;
|
|
console.log('🔍 PRODUCTS FIX: Loaded', allServices.length, 'fresh services/products from API');
|
|
// Update the main data to keep in sync
|
|
this.serviceProviderData.services = allServices;
|
|
} else {
|
|
console.warn('🔍 PRODUCTS FIX: Fresh API returned no services/products or failed:', data);
|
|
}
|
|
} catch (error) {
|
|
console.error('🔍 PHASE 2 FIX: Fresh API call failed:', error);
|
|
// Fall back to existing data if API call fails
|
|
}
|
|
console.log('🔍 PRODUCTS FIX: Final services array:', allServices);
|
|
console.log('🔍 PRODUCTS FIX: Final services count:', allServices.length);
|
|
|
|
if (allServices.length > 0) {
|
|
console.log('🔍 PRODUCTS FIX: Populating table with', allServices.length, 'services/products');
|
|
allServices.forEach((item, index) => {
|
|
console.log(`🔍 PRODUCTS FIX: Item ${index}:`, item);
|
|
});
|
|
|
|
tbody.innerHTML = allServices.map(item => {
|
|
// Handle both legacy service format and new product format
|
|
const service = this.convertProductToService(item);
|
|
|
|
const statusClass = service.status === 'Active' ? 'success' :
|
|
service.status === 'Paused' ? 'warning' : 'secondary';
|
|
|
|
const stars = this.generateStarRating(service.rating || 0);
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<div class="bg-light p-2 rounded me-3">
|
|
<i class="bi bi-gear fs-4"></i>
|
|
</div>
|
|
<div>
|
|
<h6 class="mb-0">${service.name || 'Unnamed Service'}</h6>
|
|
<small class="text-muted">${service.category || 'General'}</small>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>${service.category || 'General'}</td>
|
|
<td><span class="badge bg-${statusClass}">${service.status || 'Active'}</span></td>
|
|
<td>${service.hourly_rate || service.price_per_hour || service.price_per_hour_usd || service.price_amount || 0} TFC/hour</td>
|
|
<td>${service.clients || 0}</td>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<span class="me-2">${service.rating || 0}</span>
|
|
<div>${stars}</div>
|
|
</div>
|
|
</td>
|
|
<td>${service.total_hours || 0} hours</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-primary" data-action="service.manage" data-service-id="${service.id}" data-service-name="${service.name}">
|
|
<i class="bi bi-gear me-1"></i>Manage
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
console.log('🔍 PHASE 2 FIX: No services found, showing empty message');
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="text-center text-muted">
|
|
<div class="py-4">
|
|
<i class="bi bi-gear fs-1 text-muted mb-3"></i>
|
|
<p class="mb-0">No services available</p>
|
|
<small class="text-muted">Create your first service to get started</small>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
populateClientRequests() {
|
|
// Use service requests from the main API data ONLY (already loaded in constructor)
|
|
console.log('🔍 populateClientRequests called with allRequests:', this.allRequests.length);
|
|
console.log('🔍 API request statuses:', this.allRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
// REMOVED: No fallback to mock data - only use API data for consistency
|
|
// This ensures tab counts match the displayed content
|
|
|
|
// Populate all tabs with API data only
|
|
this.populateOpenRequests();
|
|
this.populateInProgressRequests();
|
|
this.populateCompletedRequests();
|
|
this.updateTabCounts(this.allRequests);
|
|
}
|
|
|
|
// PHASE 1 FIX: Enhanced method to refresh service requests data after status changes
|
|
async refreshServiceRequestsData() {
|
|
console.log('🔄 PHASE 1 FIX: Refreshing service requests data from main API...');
|
|
try {
|
|
// Add cache-busting parameter to ensure fresh data
|
|
const timestamp = new Date().getTime();
|
|
const data = await window.apiJson(`/api/dashboard/service-provider-data?_t=${timestamp}`);
|
|
console.log('🔄 PHASE 1 FIX: Fresh data received:', data);
|
|
|
|
// PHASE 1 FIX: Update both services and service requests from fresh API data
|
|
this.serviceProviderData = data;
|
|
this.allRequests = data.client_requests || [];
|
|
|
|
console.log('🔄 PHASE 1 FIX: Updated serviceProviderData with fresh data');
|
|
console.log('🔄 PHASE 1 FIX: Updated allRequests array:', this.allRequests.length, 'requests');
|
|
console.log('🔄 PHASE 1 FIX: Updated services array:', (data.services || []).length, 'services');
|
|
console.log('🔄 PHASE 1 FIX: Request statuses after refresh:', this.allRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
// PHASE 1 FIX: Refresh both services list and service requests
|
|
await this.populateServicesList();
|
|
this.populateOpenRequests();
|
|
this.populateInProgressRequests();
|
|
this.populateCompletedRequests();
|
|
this.updateTabCounts(this.allRequests);
|
|
|
|
console.log('🔄 PHASE 1 FIX: Successfully refreshed all dashboard data');
|
|
|
|
} catch (error) {
|
|
console.error('❌ PHASE 1 FIX: Error refreshing dashboard data:', error);
|
|
|
|
// PHASE 1 FIX: Show user-friendly error message
|
|
const errorMessage = document.createElement('div');
|
|
errorMessage.className = 'alert alert-warning alert-dismissible fade show';
|
|
errorMessage.innerHTML = `
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
<strong>Refresh Failed:</strong> Unable to load latest data. Please refresh the page.
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
`;
|
|
|
|
// Insert error message at the top of the dashboard
|
|
const dashboardContent = document.querySelector('.dashboard-content') || document.querySelector('.container-fluid');
|
|
if (dashboardContent) {
|
|
dashboardContent.insertBefore(errorMessage, dashboardContent.firstChild);
|
|
}
|
|
}
|
|
}
|
|
|
|
populateOpenRequests() {
|
|
const tbody = document.getElementById('client-requests-tbody');
|
|
if (!tbody) return;
|
|
|
|
console.log('🔍 populateOpenRequests - Total allRequests:', this.allRequests.length);
|
|
console.log('🔍 All request statuses:', this.allRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
const openRequests = this.allRequests.filter(r =>
|
|
r.status === 'Pending' || r.status === 'Awaiting Details' || r.status === 'Quote Requested' || r.status === 'Open'
|
|
);
|
|
|
|
console.log('🔍 Filtered Open requests:', openRequests.length);
|
|
console.log('🔍 Open request details:', openRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
if (openRequests.length > 0) {
|
|
tbody.innerHTML = openRequests.map(request => {
|
|
const statusClass = request.status === 'Quote Requested' ? 'info' :
|
|
request.status === 'Awaiting Details' ? 'warning' :
|
|
request.status === 'Pending' ? 'warning' : 'secondary';
|
|
|
|
const priorityClass = request.priority === 'High' ? 'danger' :
|
|
request.priority === 'Medium' ? 'warning' : 'success';
|
|
|
|
return `
|
|
<tr>
|
|
<td><small>${request.id}</small></td>
|
|
<td>${request.client_name}</td>
|
|
<td>${request.service_name}</td>
|
|
<td><span class="badge bg-${statusClass}">${request.status}</span></td>
|
|
<td>${request.requested_date}</td>
|
|
<td>${request.estimated_hours}</td>
|
|
<td>${request.budget} TFP</td>
|
|
<td><span class="badge bg-${priorityClass}">${request.priority}</span></td>
|
|
<td>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" data-action="request.view" data-request-id="${request.id}" data-client-name="${request.client_name}">Review</button>
|
|
${request.status === 'Quote Requested'
|
|
? `<button class="btn btn-sm btn-outline-info" data-action="request.send_quote" data-request-id="${request.id}" data-client-name="${request.client_name}">Send Quote</button>` :
|
|
`<button class="btn btn-sm btn-outline-success" data-action="request.accept" data-request-id="${request.id}" data-client-name="${request.client_name}" data-service-name="${request.service_name}">Accept</button>
|
|
<button class="btn btn-sm btn-outline-danger" data-action="request.decline" data-request-id="${request.id}" data-client-name="${request.client_name}">Decline</button>`
|
|
}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9" class="text-center text-muted">No open requests</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
populateInProgressRequests() {
|
|
const tbody = document.getElementById('progress-requests-tbody');
|
|
if (!tbody) return;
|
|
|
|
console.log('🔍 populateInProgressRequests - Total allRequests:', this.allRequests.length);
|
|
console.log('🔍 All request statuses:', this.allRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
const inProgressRequests = this.allRequests.filter(r =>
|
|
r.status === 'In Progress' || r.status === 'Active'
|
|
);
|
|
|
|
console.log('🔍 Filtered In Progress requests:', inProgressRequests.length);
|
|
console.log('🔍 In Progress request details:', inProgressRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
if (inProgressRequests.length > 0) {
|
|
tbody.innerHTML = inProgressRequests.map(request => {
|
|
const priorityClass = request.priority === 'High' ? 'danger' :
|
|
request.priority === 'Medium' ? 'warning' : 'success';
|
|
|
|
// Calculate progress (default to 50% for in-progress requests, can be enhanced later)
|
|
const progress = request.progress || 50;
|
|
const progressBarClass = progress >= 75 ? 'bg-success' : progress >= 50 ? 'bg-info' : progress >= 25 ? 'bg-warning' : 'bg-danger';
|
|
|
|
return `
|
|
<tr>
|
|
<td><small>${request.id}</small></td>
|
|
<td>${request.client_name}</td>
|
|
<td>${request.service_name}</td>
|
|
<td><span class="badge bg-primary">${request.status}</span></td>
|
|
<td>${request.requested_date}</td>
|
|
<td>
|
|
<div class="progress" style="height: 20px; min-width: 80px;">
|
|
<div class="progress-bar ${progressBarClass}" role="progressbar" style="width: ${progress}%" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100">
|
|
${progress}%
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>${request.budget} TFP</td>
|
|
<td><span class="badge bg-${priorityClass}">${request.priority}</span></td>
|
|
<td>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" data-action="request.view" data-request-id="${request.id}" data-client-name="${request.client_name}">View</button>
|
|
<button class="btn btn-sm btn-outline-success" data-action="request.complete" data-request-id="${request.id}" data-client-name="${request.client_name}">Complete</button>
|
|
<button class="btn btn-sm btn-outline-info" data-action="request.update" data-request-id="${request.id}" data-client-name="${request.client_name}">Update</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9" class="text-center text-muted">No requests in progress</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
populateCompletedRequests() {
|
|
const tbody = document.getElementById('completed-requests-tbody');
|
|
const paginationDiv = document.getElementById('completed-pagination');
|
|
if (!tbody || !paginationDiv) return;
|
|
|
|
console.log('🔍 populateCompletedRequests - Total allRequests:', this.allRequests.length);
|
|
console.log('🔍 All request statuses:', this.allRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
// Filter completed requests from actual service requests data (API only)
|
|
const completedRequests = this.allRequests.filter(r =>
|
|
r.status === 'Completed' || r.status === 'Delivered'
|
|
);
|
|
|
|
console.log('🔍 Filtered completed requests from API:', completedRequests.length);
|
|
console.log('🔍 Completed request details:', completedRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
// ONLY use API data - no fallback to mock data to ensure consistency
|
|
const requestsToShow = completedRequests;
|
|
|
|
console.log('🔍 Final requests to show in completed tab (API only):', requestsToShow.length);
|
|
|
|
// Pagination settings
|
|
const itemsPerPage = 5;
|
|
const totalItems = requestsToShow.length;
|
|
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
|
const currentPage = this._currentCompletedPage || 1; // Use delegated pagination state
|
|
|
|
// Calculate items for current page
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
const endIndex = startIndex + itemsPerPage;
|
|
const currentItems = requestsToShow.slice(startIndex, endIndex);
|
|
|
|
// Populate table
|
|
if (currentItems.length > 0) {
|
|
tbody.innerHTML = currentItems.map(request => {
|
|
// Handle both API format and mock format
|
|
const completedDate = request.completed_date || request.requested_date;
|
|
const totalHours = request.total_hours || request.estimated_hours || 'N/A';
|
|
const totalAmount = request.total_amount || request.budget || 'N/A';
|
|
const rating = request.rating || '4.5';
|
|
|
|
return `
|
|
<tr>
|
|
<td><small>${request.id}</small></td>
|
|
<td>${request.client_name}</td>
|
|
<td>${request.service_name}</td>
|
|
<td><span class="badge bg-success">${request.status}</span></td>
|
|
<td>${completedDate}</td>
|
|
<td>${totalHours}${typeof totalHours === 'number' ? ' hours' : ''}</td>
|
|
<td>${totalAmount}${typeof totalAmount === 'number' ? ' TFP' : ''}</td>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<span class="me-2">${rating}</span>
|
|
<div>${this.generateStarRating(parseFloat(rating))}</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" data-action="request.view_completed" data-request-id="${request.id}" data-service-name="${request.service_name}" data-client-name="${request.client_name}">View</button>
|
|
<button class="btn btn-sm btn-outline-success" data-action="request.view_report" data-request-id="${request.id}">Report</button>
|
|
<button class="btn btn-sm btn-outline-info" data-action="request.view_invoice" data-request-id="${request.id}">Invoice</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9" class="text-center text-muted">No completed requests</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
// Populate pagination
|
|
this.populatePagination(paginationDiv, currentPage, totalPages, totalItems, startIndex + 1, Math.min(endIndex, totalItems));
|
|
}
|
|
|
|
populatePagination(container, currentPage, totalPages, totalItems, startItem, endItem) {
|
|
if (totalPages <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let paginationHTML = `
|
|
<div class="d-flex justify-content-between align-items-center w-100">
|
|
<div class="text-muted">
|
|
Showing ${startItem}-${endItem} of ${totalItems} completed requests
|
|
</div>
|
|
<nav aria-label="Completed requests pagination">
|
|
<ul class="pagination pagination-sm mb-0">
|
|
`;
|
|
|
|
// Previous button
|
|
paginationHTML += `
|
|
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" data-action="pagination.change" data-page="${currentPage - 1}">Previous</a>
|
|
</li>
|
|
`;
|
|
|
|
// Page numbers
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
if (i === currentPage) {
|
|
paginationHTML += `<li class="page-item active"><a class="page-link" href="#">${i}</a></li>`;
|
|
} else if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
|
|
paginationHTML += `<li class=\"page-item\"><a class=\"page-link\" href=\"#\" data-action=\"pagination.change\" data-page=\"${i}\">${i}</a></li>`;
|
|
} else if (i === currentPage - 2 || i === currentPage + 2) {
|
|
paginationHTML += `<li class="page-item disabled"><a class="page-link" href="#">...</a></li>`;
|
|
}
|
|
}
|
|
|
|
// Next button
|
|
paginationHTML += `
|
|
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" data-action="pagination.change" data-page="${currentPage + 1}">Next</a>
|
|
</li>
|
|
`;
|
|
|
|
paginationHTML += `
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = paginationHTML;
|
|
}
|
|
|
|
updateTabCounts(requests) {
|
|
console.log('🔍 PHASE 2 FIX: Updating tab counts with requests:', requests.length);
|
|
console.log('🔍 PHASE 2 FIX: All request details:', requests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
// PHASE 2 FIX: Enhanced status filtering with better validation
|
|
const openRequests = requests.filter(r => {
|
|
const status = r.status || '';
|
|
return status === 'Pending' || status === 'Awaiting Details' || status === 'Quote Requested' || status === 'Open';
|
|
});
|
|
const openCount = openRequests.length;
|
|
console.log('🔍 PHASE 2 FIX: Open requests:', openRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
const inProgressRequests = requests.filter(r => {
|
|
const status = r.status || '';
|
|
return status === 'In Progress' || status === 'Active';
|
|
});
|
|
const inProgressCount = inProgressRequests.length;
|
|
console.log('🔍 PHASE 2 FIX: In Progress requests:', inProgressRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
const completedRequests = requests.filter(r => {
|
|
const status = r.status || '';
|
|
return status === 'Completed' || status === 'Delivered';
|
|
});
|
|
const completedCount = completedRequests.length;
|
|
console.log('🔍 PHASE 2 FIX: Completed requests:', completedRequests.map(r => `${r.id}: ${r.status}`));
|
|
|
|
// PHASE 2 FIX: Validation check - ensure all requests are accounted for
|
|
const totalCounted = openCount + inProgressCount + completedCount;
|
|
if (totalCounted !== requests.length) {
|
|
console.warn(`🔍 PHASE 2 FIX: Tab count mismatch! Total requests: ${requests.length}, Counted: ${totalCounted}`);
|
|
const uncategorized = requests.filter(r => {
|
|
const status = r.status || '';
|
|
return !['Pending', 'Awaiting Details', 'Quote Requested', 'Open', 'In Progress', 'Active', 'Completed', 'Delivered'].includes(status);
|
|
});
|
|
console.warn('🔍 PHASE 2 FIX: Uncategorized requests:', uncategorized.map(r => `${r.id}: ${r.status}`));
|
|
}
|
|
|
|
console.log(`🔍 PHASE 2 FIX: Final counts - Open: ${openCount}, In Progress: ${inProgressCount}, Completed: ${completedCount}, Total: ${totalCounted}/${requests.length}`);
|
|
|
|
// PHASE 2 FIX: Enhanced DOM updates with error handling
|
|
const openCountElement = document.getElementById('open-count');
|
|
if (openCountElement) {
|
|
openCountElement.textContent = openCount;
|
|
openCountElement.setAttribute('data-count', openCount); // For debugging
|
|
} else {
|
|
console.warn('🔍 PHASE 2 FIX: open-count element not found');
|
|
}
|
|
|
|
const progressCountElement = document.getElementById('progress-count');
|
|
if (progressCountElement) {
|
|
progressCountElement.textContent = inProgressCount;
|
|
progressCountElement.setAttribute('data-count', inProgressCount); // For debugging
|
|
} else {
|
|
console.warn('🔍 PHASE 2 FIX: progress-count element not found');
|
|
}
|
|
|
|
const completedCountElement = document.getElementById('completed-count');
|
|
if (completedCountElement) {
|
|
completedCountElement.textContent = completedCount;
|
|
completedCountElement.setAttribute('data-count', completedCount); // For debugging
|
|
} else {
|
|
console.warn('🔍 PHASE 2 FIX: completed-count element not found');
|
|
}
|
|
|
|
// PHASE 2 FIX: Update tab badges for better visual feedback
|
|
this.updateTabBadges(openCount, inProgressCount, completedCount);
|
|
}
|
|
|
|
// PHASE 2 FIX: New method to update tab badges
|
|
updateTabBadges(openCount, inProgressCount, completedCount) {
|
|
const openTab = document.querySelector('[data-bs-target="#open-requests"]');
|
|
const progressTab = document.querySelector('[data-bs-target="#in-progress-requests"]');
|
|
const completedTab = document.querySelector('[data-bs-target="#completed-requests"]');
|
|
|
|
if (openTab) {
|
|
const badge = openTab.querySelector('.badge') || this.createTabBadge();
|
|
badge.textContent = openCount;
|
|
badge.className = `badge ${openCount > 0 ? 'bg-warning' : 'bg-secondary'} ms-2`;
|
|
if (!openTab.querySelector('.badge')) {
|
|
openTab.appendChild(badge);
|
|
}
|
|
}
|
|
|
|
if (progressTab) {
|
|
const badge = progressTab.querySelector('.badge') || this.createTabBadge();
|
|
badge.textContent = inProgressCount;
|
|
badge.className = `badge ${inProgressCount > 0 ? 'bg-primary' : 'bg-secondary'} ms-2`;
|
|
if (!progressTab.querySelector('.badge')) {
|
|
progressTab.appendChild(badge);
|
|
}
|
|
}
|
|
|
|
if (completedTab) {
|
|
const badge = completedTab.querySelector('.badge') || this.createTabBadge();
|
|
badge.textContent = completedCount;
|
|
badge.className = `badge ${completedCount > 0 ? 'bg-success' : 'bg-secondary'} ms-2`;
|
|
if (!completedTab.querySelector('.badge')) {
|
|
completedTab.appendChild(badge);
|
|
}
|
|
}
|
|
}
|
|
|
|
// PHASE 2 FIX: Helper method to create tab badges
|
|
createTabBadge() {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge bg-secondary ms-2';
|
|
return badge;
|
|
}
|
|
|
|
// Helper function to convert product format to service format for display
|
|
convertProductToService(item) {
|
|
// If it's already in service format, return as-is
|
|
if (item.hourly_rate || item.price_per_hour || item.service_type) {
|
|
return item;
|
|
}
|
|
|
|
// Convert product format to service format
|
|
return {
|
|
id: item.id,
|
|
name: item.name,
|
|
description: item.description,
|
|
category: item.category_id || item.category || 'General',
|
|
status: item.availability === 'Available' ? 'Active' :
|
|
item.availability === 'OutOfStock' ? 'Paused' : 'Active',
|
|
hourly_rate: item.base_price || 0,
|
|
price_per_hour: item.base_price || 0,
|
|
price_amount: item.base_price || 0,
|
|
rating: item.metadata?.rating || 0,
|
|
clients: item.metadata?.client_count || 0,
|
|
total_hours: item.metadata?.total_hours || 0,
|
|
provider_name: item.provider_name || 'Unknown Provider',
|
|
created_at: item.created_at,
|
|
updated_at: item.updated_at
|
|
};
|
|
}
|
|
|
|
generateStarRating(rating) {
|
|
const fullStars = Math.floor(rating);
|
|
const hasHalfStar = rating % 1 !== 0;
|
|
const emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
|
|
|
|
let stars = '';
|
|
for (let i = 0; i < fullStars; i++) {
|
|
stars += '<i class="bi bi-star-fill text-warning"></i>';
|
|
}
|
|
if (hasHalfStar) {
|
|
stars += '<i class="bi bi-star-half text-warning"></i>';
|
|
}
|
|
for (let i = 0; i < emptyStars; i++) {
|
|
stars += '<i class="bi bi-star text-warning"></i>';
|
|
}
|
|
|
|
return stars;
|
|
}
|
|
}
|
|
|
|
// Initialize dashboard when DOM is loaded - SINGLE INITIALIZATION
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Single-initialization guard for main Service Provider dashboard
|
|
if (window.__serviceProviderInitialized) {
|
|
return;
|
|
}
|
|
window.__serviceProviderInitialized = true;
|
|
// Prevent multiple initializations
|
|
if (window.dashboardInitialized) {
|
|
console.log('🔧 Dashboard already initialized, skipping...');
|
|
return;
|
|
}
|
|
|
|
// Initialize modal management for better UX
|
|
initializeModalManagement();
|
|
|
|
// Prevent default form submissions inside modals to avoid page reload/flicker on Enter
|
|
document.addEventListener('submit', function(e) {
|
|
const form = e.target;
|
|
// Create Service modal
|
|
if (form.closest('#createServiceModal')) {
|
|
e.preventDefault();
|
|
try { createNewService(); } catch (err) { console.warn('createNewService failed:', err); }
|
|
return;
|
|
}
|
|
// Create SLA modal
|
|
if (form.closest('#createSLAModal')) {
|
|
e.preventDefault();
|
|
try { saveSLA(); } catch (err) { console.warn('saveSLA failed:', err); }
|
|
return;
|
|
}
|
|
// Service Management modal (edit)
|
|
if (form.closest('#serviceManagementModal')) {
|
|
e.preventDefault();
|
|
if (typeof saveServiceChanges === 'function') {
|
|
try { saveServiceChanges(); } catch (err) { console.warn('saveServiceChanges failed:', err); }
|
|
}
|
|
return;
|
|
}
|
|
}, true);
|
|
|
|
// Try hydration first
|
|
let hydrated = false;
|
|
const hydrationEl = document.getElementById('sp-dashboard-hydration');
|
|
if (hydrationEl && typeof hydrationEl.textContent === 'string') {
|
|
const raw = hydrationEl.textContent.trim();
|
|
if (raw.length === 0) {
|
|
console.log('🔍 INIT DEBUG: No hydration payload found; will fetch via API.');
|
|
} else {
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
console.log('🔍 INIT DEBUG: Hydration data parsed:', data);
|
|
|
|
const isUsableObject = data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length > 0;
|
|
if (!isUsableObject) {
|
|
console.log('🔍 INIT DEBUG: Hydration is null/empty or not a usable object; will fetch via API.');
|
|
} else {
|
|
// Destroy existing instance if it exists
|
|
if (window.dashboardInstance) {
|
|
console.log('🔧 Destroying existing dashboard instance...');
|
|
window.dashboardInstance = null;
|
|
}
|
|
|
|
window.dashboardInstance = new ServiceProviderDashboard(data);
|
|
window.dashboardInitialized = true;
|
|
hydrated = true;
|
|
|
|
// Load SLAs after dashboard initialization
|
|
loadUserSLAs();
|
|
}
|
|
} catch (e) {
|
|
console.warn('⚠️ Hydration JSON parse failed, falling back to API fetch:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hydrated) {
|
|
return;
|
|
}
|
|
|
|
console.log('🔍 INIT DEBUG: No hydration data, fetching service provider data...');
|
|
// Fetch service provider data from API (standardized via apiJson)
|
|
window.apiJson('/api/dashboard/service-provider-data')
|
|
.then(payload => {
|
|
console.log('🔍 INIT DEBUG: Unwrapped payload:', payload);
|
|
console.log('🔍 INIT DEBUG: Services in payload:', payload && payload.services);
|
|
console.log('🔍 INIT DEBUG: Services count in payload:', ((payload && payload.services) || []).length);
|
|
|
|
// Destroy existing instance if it exists
|
|
if (window.dashboardInstance) {
|
|
console.log('🔧 Destroying existing dashboard instance...');
|
|
window.dashboardInstance = null;
|
|
}
|
|
|
|
window.dashboardInstance = new ServiceProviderDashboard(payload || {});
|
|
window.dashboardInitialized = true;
|
|
|
|
// Load SLAs after dashboard initialization
|
|
loadUserSLAs();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading service provider dashboard data:', error);
|
|
// Initialize with empty data as fallback
|
|
if (!window.dashboardInstance) {
|
|
window.dashboardInstance = new ServiceProviderDashboard({
|
|
active_services: 0,
|
|
total_clients: 0,
|
|
monthly_revenue_usd: 0,
|
|
total_revenue_usd: 0,
|
|
service_rating: 0.0,
|
|
services: [],
|
|
client_requests: [],
|
|
revenue_history: []
|
|
});
|
|
window.dashboardInitialized = true;
|
|
}
|
|
|
|
// Load SLAs even in fallback case
|
|
loadUserSLAs();
|
|
});
|
|
});
|
|
|
|
// Global Functions (accessible from HTML onclick attributes)
|
|
|
|
// Modal Management
|
|
function showModal(modalId) {
|
|
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
|
modal.show();
|
|
}
|
|
|
|
function hideModal(modalId) {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById(modalId));
|
|
if (modal) modal.hide();
|
|
}
|
|
|
|
// Service Management Functions
|
|
let currentServiceId = null;
|
|
let currentServiceData = null;
|
|
|
|
window.manageService = async function manageService(serviceId, serviceName) {
|
|
try {
|
|
showNotification(`Loading ${serviceName} management...`, 'info');
|
|
|
|
// Fetch comprehensive service data (via apiJson)
|
|
const data = await window.apiJson(`/api/dashboard/services/${serviceId}/details`);
|
|
const payload = (data && data.data) ? data.data : data;
|
|
|
|
if (payload && payload.service) {
|
|
currentServiceId = serviceId;
|
|
currentServiceData = payload.service;
|
|
|
|
// Show modal first
|
|
showModal('serviceManagementModal');
|
|
|
|
// Wait for modal to be fully shown before populating
|
|
const modal = document.getElementById('serviceManagementModal');
|
|
modal.addEventListener('shown.bs.modal', function modalShownHandler() {
|
|
// Remove this event listener after first use
|
|
modal.removeEventListener('shown.bs.modal', modalShownHandler);
|
|
|
|
// Now populate the modal content
|
|
populateServiceManagementModal(payload.service);
|
|
showNotification('Service management loaded', 'success');
|
|
});
|
|
} else {
|
|
throw new Error((data && data.message) || 'Failed to load service');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading service management:', error);
|
|
showNotification(`Failed to load service: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Show the service management modal
|
|
function showServiceManagementModal(serviceId, serviceName) {
|
|
console.log('🔧 SERVICE PROVIDER: Opening management modal for service:', serviceId, serviceName);
|
|
|
|
// Fetch service details first (via apiJson)
|
|
window.apiJson(`/api/dashboard/services/${serviceId}`)
|
|
.then(result => {
|
|
const data = (result && result.data) ? result.data : result;
|
|
if (!data || data.success === false) {
|
|
showNotification('Failed to load service details', 'error');
|
|
return;
|
|
}
|
|
// Show modal first
|
|
showModal('serviceManagementModal');
|
|
|
|
// Wait for modal to be fully shown before populating
|
|
const modal = document.getElementById('serviceManagementModal');
|
|
modal.addEventListener('shown.bs.modal', function modalShownHandler() {
|
|
// Remove this specific listener to avoid memory leaks
|
|
modal.removeEventListener('shown.bs.modal', modalShownHandler);
|
|
|
|
// Now populate the modal content
|
|
populateServiceManagementModal((data && data.service) || { id: serviceId, name: serviceName });
|
|
showNotification('Service management loaded', 'success');
|
|
});
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading service details:', error);
|
|
showNotification('Error loading service details', 'error');
|
|
});
|
|
}
|
|
|
|
// Populate the service management modal with data
|
|
function populateServiceManagementModal(service) {
|
|
// Update modal title
|
|
document.getElementById('serviceManagementModalLabel').textContent = `Manage Service: ${service.name}`;
|
|
|
|
// Ensure the Details tab is active and visible before populating
|
|
const detailsTab = document.getElementById('service-details');
|
|
const detailsTabButton = document.getElementById('service-details-tab');
|
|
|
|
if (detailsTab && detailsTabButton) {
|
|
// Make sure the details tab is active
|
|
detailsTabButton.classList.add('active');
|
|
detailsTab.classList.add('show', 'active');
|
|
|
|
// Remove active class from other tabs
|
|
document.querySelectorAll('.nav-link').forEach(tab => {
|
|
if (tab.id !== 'service-details-tab') {
|
|
tab.classList.remove('active');
|
|
}
|
|
});
|
|
document.querySelectorAll('.tab-pane').forEach(pane => {
|
|
if (pane.id !== 'service-details') {
|
|
pane.classList.remove('show', 'active');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Use setTimeout to ensure DOM is ready before populating
|
|
setTimeout(() => {
|
|
// Populate details tab
|
|
populateDetailsTab(service);
|
|
|
|
// Populate settings tab (even if not visible)
|
|
populateSettingsTab(service);
|
|
}, 100);
|
|
|
|
// Load analytics data
|
|
loadServiceAnalytics(service.id);
|
|
|
|
// Load client data
|
|
loadServiceClients(service.id);
|
|
}
|
|
|
|
// Populate the details tab with service information
|
|
function populateDetailsTab(service) {
|
|
// Safely populate form fields with null checks
|
|
const setFieldValue = (fieldId, value) => {
|
|
const field = document.getElementById(fieldId);
|
|
if (field) {
|
|
field.value = value;
|
|
} else {
|
|
console.warn(`Field not found: ${fieldId}`);
|
|
}
|
|
};
|
|
|
|
setFieldValue('serviceManageName', service.name || '');
|
|
setFieldValue('serviceManageCategory', service.category || 'Infrastructure');
|
|
setFieldValue('serviceManageDescription', service.description || '');
|
|
setFieldValue('serviceManagePrice', service.price_per_hour || 0);
|
|
setFieldValue('serviceManageResponseTime', service.availability?.response_time || '2 hours');
|
|
setFieldValue('serviceManageAvailability', service.availability?.weekly_hours || 20);
|
|
setFieldValue('serviceManageExperience', service.experience_level || 'Expert');
|
|
|
|
// Handle skills array
|
|
if (service.skills && Array.isArray(service.skills)) {
|
|
setFieldValue('serviceManageSkills', service.skills.join(', '));
|
|
} else {
|
|
setFieldValue('serviceManageSkills', '');
|
|
}
|
|
}
|
|
|
|
// Load and display service analytics
|
|
async function loadServiceAnalytics(serviceId) {
|
|
try {
|
|
const data = await window.apiJson(`/api/dashboard/services/${serviceId}/analytics`);
|
|
const analytics = (data && data.analytics) || (data && data.data && data.data.analytics) || data;
|
|
if (analytics) {
|
|
|
|
// Update analytics display
|
|
document.getElementById('serviceAvgRating').textContent = analytics.performance.avg_rating.toFixed(1);
|
|
document.getElementById('serviceOnTimeDelivery').textContent = `${analytics.performance.on_time_delivery.toFixed(0)}%`;
|
|
document.getElementById('serviceCompletionRate').textContent = `${analytics.performance.completion_rate.toFixed(0)}%`;
|
|
|
|
document.getElementById('serviceTotalRevenue').textContent = `${analytics.revenue.total} TFP`;
|
|
document.getElementById('serviceMonthlyRevenue').textContent = `${analytics.revenue.monthly} TFP`;
|
|
document.getElementById('serviceTotalProjects').textContent = analytics.project_metrics.total_projects;
|
|
document.getElementById('serviceActiveClients').textContent = analytics.client_metrics.total_clients;
|
|
|
|
// Create revenue chart
|
|
createServiceRevenueAnalyticsChart(analytics.revenue.trend);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading service analytics:', error);
|
|
showNotification('Failed to load analytics data', 'error');
|
|
}
|
|
}
|
|
|
|
// Create revenue analytics chart
|
|
function createServiceRevenueAnalyticsChart(revenueData) {
|
|
const ctx = document.getElementById('serviceRevenueAnalyticsChart');
|
|
if (!ctx) return;
|
|
|
|
// Destroy existing chart if it exists
|
|
if (window.serviceRevenueAnalyticsChart && typeof window.serviceRevenueAnalyticsChart.destroy === 'function') {
|
|
window.serviceRevenueAnalyticsChart.destroy();
|
|
}
|
|
|
|
// Generate labels for last 6 months
|
|
const labels = [];
|
|
for (let i = 5; i >= 0; i--) {
|
|
const date = new Date();
|
|
date.setMonth(date.getMonth() - i);
|
|
labels.push(date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }));
|
|
}
|
|
|
|
window.serviceRevenueAnalyticsChart = new Chart(ctx.getContext('2d'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Monthly Revenue',
|
|
data: revenueData,
|
|
borderColor: '#28a745',
|
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
|
borderWidth: 3,
|
|
tension: 0.3,
|
|
fill: true,
|
|
pointRadius: 5,
|
|
pointHoverRadius: 7,
|
|
pointBackgroundColor: '#28a745',
|
|
pointBorderColor: '#ffffff',
|
|
pointBorderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
return `Revenue: ${context.raw} TFP`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Revenue (TFP)'
|
|
}
|
|
},
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Month'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load and display service clients
|
|
async function loadServiceClients(serviceId) {
|
|
try {
|
|
const data = await window.apiJson(`/api/dashboard/services/${serviceId}/clients`);
|
|
const clients = (data && data.clients) || (data && data.data && data.data.clients) || [];
|
|
const summary = (data && data.summary) || (data && data.data && data.data.summary) || {};
|
|
if (clients && summary) {
|
|
|
|
// Update client counts
|
|
document.getElementById('clientsTotalCount').textContent = summary.total_clients;
|
|
document.getElementById('clientsActiveCount').textContent = summary.active_clients;
|
|
|
|
// Populate clients table
|
|
const tbody = document.getElementById('serviceClientsTableBody');
|
|
const emptyDiv = document.getElementById('serviceClientsEmpty');
|
|
|
|
if (clients.length > 0) {
|
|
tbody.style.display = '';
|
|
emptyDiv.style.display = 'none';
|
|
|
|
tbody.innerHTML = clients.map(client => {
|
|
const stars = generateStarRating(client.avg_rating);
|
|
const statusClass = client.status === 'Active' ? 'success' : 'secondary';
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<div>
|
|
<strong>${client.name}</strong>
|
|
<br><small class="text-muted">${client.projects.length} projects</small>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>
|
|
${client.email ? `<div><i class="bi bi-envelope me-1"></i>${client.email}</div>` : ''}
|
|
${client.phone ? `<div><i class="bi bi-phone me-1"></i>${client.phone}</div>` : ''}
|
|
</div>
|
|
</td>
|
|
<td>${client.projects.length}</td>
|
|
<td>${client.total_revenue} TFP</td>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<span class="me-2">${client.avg_rating.toFixed(1)}</span>
|
|
<div>${stars}</div>
|
|
</div>
|
|
</td>
|
|
<td><span class="badge bg-${statusClass}">${client.status}</span></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
tbody.style.display = 'none';
|
|
emptyDiv.style.display = 'block';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading service clients:', error);
|
|
showNotification('Failed to load client data', 'error');
|
|
}
|
|
}
|
|
|
|
// Populate the settings tab
|
|
function populateSettingsTab(service) {
|
|
// Safely populate settings fields with null checks
|
|
const setFieldValue = (fieldId, value) => {
|
|
const field = document.getElementById(fieldId);
|
|
if (field) {
|
|
field.value = value;
|
|
} else {
|
|
console.warn(`Settings field not found: ${fieldId}`);
|
|
}
|
|
};
|
|
|
|
const setCheckboxValue = (fieldId, checked) => {
|
|
const field = document.getElementById(fieldId);
|
|
if (field) {
|
|
field.checked = checked;
|
|
} else {
|
|
console.warn(`Settings checkbox not found: ${fieldId}`);
|
|
}
|
|
};
|
|
|
|
setFieldValue('serviceStatusSelect', service.status || 'Active');
|
|
setFieldValue('serviceSLASelect', service.sla_id || '');
|
|
setFieldValue('serviceMaxConcurrentProjects', service.max_concurrent_projects || 3);
|
|
|
|
// Set checkbox values if they exist in service data
|
|
setCheckboxValue('serviceAutoAccept', service.auto_accept || false);
|
|
setCheckboxValue('serviceInstantQuotes', service.instant_quotes || false);
|
|
setCheckboxValue('serviceEmailNotifications', service.email_notifications !== false); // Default to true
|
|
|
|
// Load user SLAs for the SLA dropdown
|
|
loadUserSLAsForService();
|
|
}
|
|
|
|
// Load user SLAs for the service settings
|
|
async function loadUserSLAsForService() {
|
|
try {
|
|
const data = await window.apiJson('/api/dashboard/slas');
|
|
|
|
if (data) {
|
|
const slaSelect = document.getElementById('serviceSLASelect');
|
|
slaSelect.innerHTML = '<option value="">No SLA assigned</option>';
|
|
|
|
const slas = Array.isArray(data) ? data : (data.slas || (data.data && data.data.slas) || []);
|
|
slas.forEach(sla => {
|
|
const option = document.createElement('option');
|
|
option.value = sla.id;
|
|
option.textContent = sla.name;
|
|
slaSelect.appendChild(option);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading SLAs:', error);
|
|
}
|
|
}
|
|
|
|
// Update service status
|
|
window.updateServiceStatus = async function updateServiceStatus() {
|
|
if (!currentServiceId) return;
|
|
|
|
const newStatus = document.getElementById('serviceStatusSelect').value;
|
|
|
|
try {
|
|
showNotification('Updating service status...', 'info');
|
|
await window.apiJson(`/api/dashboard/services/${currentServiceId}/status`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ status: newStatus })
|
|
});
|
|
|
|
showNotification('Service status updated successfully', 'success');
|
|
|
|
// Update current service data
|
|
if (currentServiceData) {
|
|
currentServiceData.status = newStatus;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error updating service status:', error);
|
|
showNotification(`Failed to update status: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Save all service changes
|
|
window.saveServiceChanges = async function saveServiceChanges() {
|
|
console.log('saveServiceChanges called, currentServiceId:', currentServiceId);
|
|
|
|
if (!currentServiceId) {
|
|
console.error('No currentServiceId set');
|
|
showNotification('Error: No service selected', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showNotification('Saving changes...', 'info');
|
|
|
|
// Ensure all tabs are accessible by temporarily activating them
|
|
const allTabs = ['service-details', 'service-analytics', 'service-clients', 'service-settings'];
|
|
const originalActiveTab = document.querySelector('.nav-link.active').getAttribute('data-bs-target');
|
|
|
|
// Briefly activate each tab to ensure DOM elements are rendered
|
|
for (const tabId of allTabs) {
|
|
const tabElement = document.getElementById(tabId);
|
|
if (tabElement) {
|
|
tabElement.classList.add('show', 'active');
|
|
}
|
|
}
|
|
|
|
// Small delay to ensure DOM is ready
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Collect form data from all tabs
|
|
const nameField = document.getElementById('serviceManageName');
|
|
const categoryField = document.getElementById('serviceManageCategory');
|
|
const descriptionField = document.getElementById('serviceManageDescription');
|
|
const priceField = document.getElementById('serviceManagePrice');
|
|
const statusField = document.getElementById('serviceStatusSelect');
|
|
const responseTimeField = document.getElementById('serviceManageResponseTime');
|
|
const availabilityField = document.getElementById('serviceManageAvailability');
|
|
const experienceField = document.getElementById('serviceManageExperience');
|
|
const skillsField = document.getElementById('serviceManageSkills');
|
|
|
|
// Collect additional settings fields
|
|
const slaField = document.getElementById('serviceSLASelect');
|
|
const maxProjectsField = document.getElementById('serviceMaxConcurrentProjects');
|
|
const autoAcceptField = document.getElementById('serviceAutoAccept');
|
|
const instantQuotesField = document.getElementById('serviceInstantQuotes');
|
|
const emailNotificationsField = document.getElementById('serviceEmailNotifications');
|
|
|
|
// Debug logging to identify missing fields (only check required fields)
|
|
const requiredFieldChecks = {
|
|
'serviceManageName': nameField,
|
|
'serviceManageCategory': categoryField,
|
|
'serviceManageDescription': descriptionField,
|
|
'serviceManagePrice': priceField,
|
|
'serviceStatusSelect': statusField
|
|
};
|
|
|
|
// Optional fields (log warnings but don't fail)
|
|
const optionalFieldChecks = {
|
|
'serviceManageResponseTime': responseTimeField,
|
|
'serviceManageAvailability': availabilityField,
|
|
'serviceManageExperience': experienceField,
|
|
'serviceManageSkills': skillsField,
|
|
'serviceSLASelect': slaField,
|
|
'serviceMaxConcurrentProjects': maxProjectsField,
|
|
'serviceAutoAccept': autoAcceptField,
|
|
'serviceInstantQuotes': instantQuotesField,
|
|
'serviceEmailNotifications': emailNotificationsField
|
|
};
|
|
|
|
const missingRequiredFields = [];
|
|
for (const [fieldName, field] of Object.entries(requiredFieldChecks)) {
|
|
if (!field) {
|
|
missingRequiredFields.push(fieldName);
|
|
console.error(`Required field not found: ${fieldName}`);
|
|
}
|
|
}
|
|
|
|
if (missingRequiredFields.length > 0) {
|
|
console.error('Missing required form fields:', missingRequiredFields);
|
|
showNotification(`Error: Missing required fields: ${missingRequiredFields.join(', ')}`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Log warnings for missing optional fields
|
|
for (const [fieldName, field] of Object.entries(optionalFieldChecks)) {
|
|
if (!field) {
|
|
console.warn(`Optional field not found: ${fieldName}`);
|
|
}
|
|
}
|
|
|
|
// Collect skills array
|
|
const skillsArray = skillsField.value ? skillsField.value.split(',').map(s => s.trim()).filter(s => s) : [];
|
|
|
|
const formData = {
|
|
id: currentServiceId,
|
|
name: nameField.value || '',
|
|
category: categoryField.value || 'Infrastructure',
|
|
description: descriptionField.value || '',
|
|
price_per_hour: parseInt(priceField.value) || 0,
|
|
status: statusField.value || 'Active',
|
|
skills: skillsArray,
|
|
availability: {
|
|
response_time: responseTimeField.value || '2 hours',
|
|
weekly_hours: parseInt(availabilityField.value) || 20
|
|
},
|
|
experience_level: experienceField.value || 'Expert',
|
|
// Settings fields
|
|
sla_id: slaField ? slaField.value : '',
|
|
max_concurrent_projects: maxProjectsField ? parseInt(maxProjectsField.value) || 3 : 3,
|
|
auto_accept: autoAcceptField ? autoAcceptField.checked : false,
|
|
instant_quotes: instantQuotesField ? instantQuotesField.checked : false,
|
|
email_notifications: emailNotificationsField ? emailNotificationsField.checked : true,
|
|
// Keep existing values for fields not in the form
|
|
clients: currentServiceData?.clients || 0,
|
|
rating: currentServiceData?.rating || 0,
|
|
total_hours: currentServiceData?.total_hours || 0
|
|
};
|
|
|
|
// Restore original tab state
|
|
allTabs.forEach(tabId => {
|
|
const tabElement = document.getElementById(tabId);
|
|
if (tabElement && `#${tabId}` !== originalActiveTab) {
|
|
tabElement.classList.remove('show', 'active');
|
|
}
|
|
});
|
|
|
|
console.log('Form data collected:', formData);
|
|
|
|
await window.apiJson(`/api/dashboard/services/${currentServiceId}`, {
|
|
method: 'PUT',
|
|
body: formData
|
|
});
|
|
|
|
showNotification('Service updated successfully', 'success');
|
|
|
|
// Update the current service data with the new values
|
|
currentServiceData = { ...currentServiceData, ...formData };
|
|
|
|
// Close the modal
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('serviceManagementModal'));
|
|
if (modal) {
|
|
modal.hide();
|
|
}
|
|
|
|
// Refresh dashboard data
|
|
if (window.dashboardInstance) {
|
|
await window.dashboardInstance.populateServicesList();
|
|
window.dashboardInstance.createServiceDistributionChart();
|
|
window.dashboardInstance.createServicePerformanceChart();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error saving service changes:', error);
|
|
showNotification(`Failed to save: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Remove the old editService function since we're consolidating into manageService
|
|
// function editService() - REMOVED
|
|
|
|
// Client Request Management Functions
|
|
function viewRequest(requestId, clientName) {
|
|
showNotification(`Loading request details from ${clientName}...`, 'info');
|
|
|
|
// Fetch request details from API
|
|
window.apiJson(`/api/dashboard/service-requests/${requestId}/details`)
|
|
.then(data => {
|
|
const request = (data && data.request) || (data && data.data && data.data.request) || data;
|
|
if (request) {
|
|
populateRequestDetailsModal(request);
|
|
const modal = new bootstrap.Modal(document.getElementById('requestDetailsModal'));
|
|
modal.show();
|
|
showNotification(`Request ${requestId} details loaded`, 'success');
|
|
} else {
|
|
throw new Error((data && data.message) || 'Failed to load request details');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading request details:', error);
|
|
showNotification(`Failed to load request details: ${error.message}`, 'error');
|
|
});
|
|
}
|
|
|
|
// Populate request details modal with data
|
|
function populateRequestDetailsModal(request) {
|
|
document.getElementById('detailRequestId').textContent = request.id;
|
|
document.getElementById('detailClientName').textContent = request.client_name;
|
|
document.getElementById('detailServiceName').textContent = request.service_name;
|
|
|
|
// Status badge with appropriate color
|
|
const statusBadge = document.getElementById('detailStatus');
|
|
statusBadge.textContent = request.status;
|
|
statusBadge.className = `badge ${getStatusBadgeClass(request.status)}`;
|
|
|
|
// Priority badge with appropriate color
|
|
const priorityBadge = document.getElementById('detailPriority');
|
|
priorityBadge.textContent = request.priority;
|
|
priorityBadge.className = `badge ${getPriorityBadgeClass(request.priority)}`;
|
|
|
|
document.getElementById('detailRequestedDate').textContent = request.requested_date;
|
|
document.getElementById('detailEstimatedHours').textContent = `${request.estimated_hours} hours`;
|
|
document.getElementById('detailBudget').textContent = `${request.budget} TFP`;
|
|
|
|
// Progress bar
|
|
const progress = request.progress || 0;
|
|
const progressBar = document.getElementById('detailProgressBar');
|
|
progressBar.style.width = `${progress}%`;
|
|
progressBar.textContent = `${progress}%`;
|
|
progressBar.className = `progress-bar ${getProgressBarClass(progress)}`;
|
|
|
|
// Description and notes
|
|
document.getElementById('detailDescription').textContent = request.description || 'No description provided.';
|
|
document.getElementById('detailNotes').textContent = request.notes || 'No notes available.';
|
|
|
|
// Store request ID for progress updates
|
|
document.getElementById('progressUpdateModal').setAttribute('data-request-id', request.id);
|
|
|
|
// Show/hide update progress button based on status
|
|
const updateBtn = document.getElementById('detailUpdateProgressBtn');
|
|
updateBtn.style.display = (request.status === 'In Progress') ? 'block' : 'none';
|
|
}
|
|
|
|
// Helper functions for styling
|
|
function getStatusBadgeClass(status) {
|
|
switch(status) {
|
|
case 'Open': return 'bg-warning text-dark';
|
|
case 'In Progress': return 'bg-primary';
|
|
case 'Completed': return 'bg-success';
|
|
case 'Declined': return 'bg-danger';
|
|
default: return 'bg-secondary';
|
|
}
|
|
}
|
|
|
|
function getPriorityBadgeClass(priority) {
|
|
switch(priority) {
|
|
case 'High': return 'bg-danger';
|
|
case 'Medium': return 'bg-warning text-dark';
|
|
case 'Low': return 'bg-info';
|
|
default: return 'bg-secondary';
|
|
}
|
|
}
|
|
|
|
function getProgressBarClass(progress) {
|
|
if (progress >= 100) return 'bg-success';
|
|
if (progress >= 75) return 'bg-info';
|
|
if (progress >= 50) return 'bg-warning';
|
|
return 'bg-primary';
|
|
}
|
|
|
|
// Open progress update modal
|
|
function openProgressUpdateModal() {
|
|
const requestId = document.getElementById('progressUpdateModal').getAttribute('data-request-id');
|
|
if (requestId) {
|
|
// First, properly hide the details modal to prevent overlap
|
|
const detailsModal = bootstrap.Modal.getInstance(document.getElementById('requestDetailsModal'));
|
|
if (detailsModal) {
|
|
detailsModal.hide();
|
|
}
|
|
|
|
// Wait for the details modal to fully close before opening progress modal
|
|
setTimeout(() => {
|
|
// Reset form
|
|
document.getElementById('progressUpdateForm').reset();
|
|
document.getElementById('progressSlider').value = 50;
|
|
document.getElementById('progressValue').textContent = '50';
|
|
|
|
// Store reference to return to details modal if needed
|
|
document.getElementById('progressUpdateModal').setAttribute('data-return-to-details', 'true');
|
|
|
|
const progressModal = new bootstrap.Modal(document.getElementById('progressUpdateModal'));
|
|
progressModal.show();
|
|
}, 300); // Wait for modal transition to complete
|
|
}
|
|
}
|
|
|
|
// Update progress value display
|
|
function updateProgressValue(value) {
|
|
document.getElementById('progressValue').textContent = value;
|
|
}
|
|
|
|
// Save progress update
|
|
async function saveProgressUpdate() {
|
|
const requestId = document.getElementById('progressUpdateModal').getAttribute('data-request-id');
|
|
const progress = parseInt(document.getElementById('progressSlider').value);
|
|
const priority = document.getElementById('progressPriority').value;
|
|
const hours = parseFloat(document.getElementById('progressHours').value) || 0;
|
|
const notes = document.getElementById('progressNotes').value;
|
|
const notifyClient = document.getElementById('notifyClient').checked;
|
|
|
|
if (!requestId) {
|
|
showNotification('Error: No request ID found', 'error');
|
|
return;
|
|
}
|
|
|
|
showNotification('Updating progress...', 'info');
|
|
|
|
// Prepare update data
|
|
const updateData = {
|
|
progress: progress,
|
|
priority: priority,
|
|
hours_worked: hours,
|
|
notes: notes,
|
|
notify_client: notifyClient,
|
|
status: progress === 100 ? 'Completed' : 'In Progress'
|
|
};
|
|
|
|
try {
|
|
await window.apiJson(`/api/dashboard/service-requests/${requestId}/progress`, {
|
|
method: 'PUT',
|
|
body: updateData
|
|
});
|
|
showNotification(`Progress updated to ${progress}%`, 'success');
|
|
const progressModal = bootstrap.Modal.getInstance(document.getElementById('progressUpdateModal'));
|
|
if (progressModal) {
|
|
progressModal.hide();
|
|
}
|
|
document.getElementById('progressUpdateModal').removeAttribute('data-return-to-details');
|
|
if (window.dashboardInstance) {
|
|
setTimeout(() => {
|
|
window.dashboardInstance.refreshServiceRequestsData();
|
|
}, 500);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating progress:', error);
|
|
showNotification(`Failed to update progress: ${error.message}`, 'error');
|
|
if (document.getElementById('progressUpdateModal').getAttribute('data-return-to-details') === 'true') {
|
|
backToRequestDetails();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function to go back to request details from progress modal
|
|
function backToRequestDetails() {
|
|
const requestId = document.getElementById('progressUpdateModal').getAttribute('data-request-id');
|
|
|
|
if (requestId) {
|
|
// Close progress modal
|
|
const progressModal = bootstrap.Modal.getInstance(document.getElementById('progressUpdateModal'));
|
|
if (progressModal) {
|
|
progressModal.hide();
|
|
}
|
|
|
|
// Wait for modal to close, then reopen details modal
|
|
setTimeout(() => {
|
|
// Re-fetch and show the details modal (via apiJson)
|
|
window.apiJson(`/api/dashboard/service-requests/${requestId}/details`)
|
|
.then(data => {
|
|
const request = (data && data.request) || (data && data.data && data.data.request) || data;
|
|
if (request) {
|
|
populateRequestDetailsModal(request);
|
|
const detailsModal = new bootstrap.Modal(document.getElementById('requestDetailsModal'));
|
|
detailsModal.show();
|
|
} else {
|
|
showNotification('Failed to reload request details', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error reloading request details:', error);
|
|
showNotification('Failed to reload request details', 'error');
|
|
});
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
async function acceptRequest(requestId, clientName, serviceName) {
|
|
if (confirm(`Accept service request from ${clientName} for ${serviceName}?`)) {
|
|
showNotification(`Accepting request from ${clientName}...`, 'info');
|
|
try {
|
|
await window.apiJson(`/api/dashboard/service-requests/${requestId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ status: 'In Progress' })
|
|
});
|
|
showNotification(`Request ${requestId} accepted successfully`, 'success');
|
|
if (window.dashboardInstance) {
|
|
console.log('🔄 Immediately refreshing dashboard data after accept...');
|
|
window.dashboardInstance.refreshServiceRequestsData();
|
|
}
|
|
console.log(`Accepted request: ${requestId} from ${clientName} for ${serviceName}`);
|
|
} catch (error) {
|
|
console.error('Error accepting request:', error);
|
|
showNotification(`Failed to accept request: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
function declineRequest(requestId, clientName) {
|
|
// Set up the modal with client name and request ID
|
|
document.getElementById('declineClientName').textContent = clientName;
|
|
|
|
// Store request data for the confirmation button
|
|
const confirmBtn = document.getElementById('confirmDeclineBtn');
|
|
confirmBtn.setAttribute('data-request-id', requestId);
|
|
confirmBtn.setAttribute('data-client-name', clientName);
|
|
|
|
// Show the modal
|
|
const modal = new bootstrap.Modal(document.getElementById('declineConfirmModal'));
|
|
modal.show();
|
|
}
|
|
|
|
// Delegated confirmation handled via data-action="request.decline.confirm"
|
|
|
|
async function sendQuote(requestId, clientName) {
|
|
showNotification(`Preparing quote for ${clientName}...`, 'info');
|
|
try {
|
|
await window.apiJson(`/api/dashboard/service-requests/${requestId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ status: 'Quote Sent' })
|
|
});
|
|
showNotification(`Quote sent to ${clientName} successfully`, 'success');
|
|
if (window.dashboardInstance) {
|
|
console.log('🔄 Immediately refreshing dashboard data after quote sent...');
|
|
window.dashboardInstance.refreshServiceRequestsData();
|
|
}
|
|
console.log(`Quote sent for request: ${requestId} to ${clientName}`);
|
|
} catch (error) {
|
|
console.error('Error sending quote:', error);
|
|
showNotification(`Failed to send quote: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function completeRequest(requestId, clientName) {
|
|
// Set the client name in the modal
|
|
document.getElementById('completeClientName').textContent = clientName;
|
|
|
|
// Set attributes for delegated confirmation handler
|
|
const confirmBtn = document.getElementById('confirmCompleteBtn');
|
|
confirmBtn.setAttribute('data-request-id', requestId);
|
|
confirmBtn.setAttribute('data-client-name', clientName);
|
|
|
|
// Show the modal
|
|
const modal = new bootstrap.Modal(document.getElementById('completeConfirmModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function updateProgress(requestId, clientName) {
|
|
showNotification(`Opening progress update for ${clientName}...`, 'info');
|
|
|
|
// Set the request ID for the modal
|
|
document.getElementById('progressUpdateModal').setAttribute('data-request-id', requestId);
|
|
|
|
// Reset form to defaults
|
|
document.getElementById('progressUpdateForm').reset();
|
|
document.getElementById('progressSlider').value = 50;
|
|
document.getElementById('progressValue').textContent = '50';
|
|
|
|
// Open the progress update modal
|
|
const modal = new bootstrap.Modal(document.getElementById('progressUpdateModal'));
|
|
modal.show();
|
|
|
|
showNotification('Progress update form opened', 'success');
|
|
console.log(`Updating progress for request: ${requestId} from ${clientName}`);
|
|
}
|
|
|
|
// View completed request details
|
|
async function viewCompletedRequest(requestId, serviceType, client) {
|
|
showNotification(`Loading completed request details...`, 'info');
|
|
try {
|
|
const data = await window.apiJson(`/api/dashboard/service-requests/${requestId}/completed-details`);
|
|
const request = (data && data.request) ? data.request : data;
|
|
populateCompletedRequestModal(request);
|
|
const modal = new bootstrap.Modal(document.getElementById('completedRequestModal'));
|
|
modal.show();
|
|
showNotification(`Completed request details loaded`, 'success');
|
|
} catch (error) {
|
|
console.error('Error loading completed request details:', error);
|
|
showNotification(`Failed to load request details: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Populate completed request modal with data
|
|
function populateCompletedRequestModal(request) {
|
|
document.getElementById('completedRequestId').textContent = request.id;
|
|
document.getElementById('completedClientName').textContent = request.client_name;
|
|
document.getElementById('completedServiceType').textContent = request.service_name;
|
|
document.getElementById('completedDate').textContent = request.completed_date || 'N/A';
|
|
document.getElementById('completedHoursLogged').textContent = `${request.hours_logged || 0} hours`;
|
|
document.getElementById('completedRevenue').textContent = `${request.revenue || 0} TFP`;
|
|
document.getElementById('completedRating').textContent = request.rating ? `${request.rating}/5 ⭐` : 'Not rated';
|
|
document.getElementById('completedOnTime').textContent = request.on_time ? 'Yes ✅' : 'No ❌';
|
|
document.getElementById('completedSummary').textContent = request.summary || 'No summary available.';
|
|
document.getElementById('completedFeedback').textContent = request.client_feedback || 'No feedback provided.';
|
|
|
|
// Store request ID for invoice generation
|
|
document.getElementById('completedRequestModal').setAttribute('data-request-id', request.id);
|
|
}
|
|
|
|
// Generate invoice for completed request
|
|
function generateInvoice() {
|
|
const requestId = document.getElementById('completedRequestModal').getAttribute('data-request-id');
|
|
if (!requestId) {
|
|
showNotification('Error: No request ID found', 'error');
|
|
return;
|
|
}
|
|
|
|
showNotification('Generating invoice...', 'info');
|
|
|
|
// Make API call to generate invoice
|
|
fetch(`/api/dashboard/service-requests/${requestId}/invoice`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'text/plain',
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (response.ok) {
|
|
return response.blob();
|
|
} else {
|
|
throw new Error('Failed to generate invoice');
|
|
}
|
|
})
|
|
.then(blob => {
|
|
// Create download link
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = `invoice-${requestId}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showNotification('Invoice downloaded successfully', 'success');
|
|
})
|
|
.catch(error => {
|
|
console.error('Error generating invoice:', error);
|
|
showNotification(`Failed to generate invoice: ${error.message}`, 'error');
|
|
});
|
|
}
|
|
|
|
// Download project report
|
|
function downloadReport() {
|
|
const requestId = document.getElementById('completedRequestModal').getAttribute('data-request-id');
|
|
if (!requestId) {
|
|
showNotification('Error: No request ID found', 'error');
|
|
return;
|
|
}
|
|
|
|
showNotification('Generating project report...', 'info');
|
|
|
|
// For now, create a simple text report (can be enhanced to PDF later)
|
|
const reportContent = generateReportContent(requestId);
|
|
const blob = new Blob([reportContent], { type: 'text/plain' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = `project-report-${requestId}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showNotification('Project report downloaded', 'success');
|
|
}
|
|
|
|
// Generate report content
|
|
function generateReportContent(requestId) {
|
|
const modal = document.getElementById('completedRequestModal');
|
|
const clientName = document.getElementById('completedClientName').textContent;
|
|
const serviceType = document.getElementById('completedServiceType').textContent;
|
|
const completedDate = document.getElementById('completedDate').textContent;
|
|
const hoursLogged = document.getElementById('completedHoursLogged').textContent;
|
|
const revenue = document.getElementById('completedRevenue').textContent;
|
|
const rating = document.getElementById('completedRating').textContent;
|
|
const summary = document.getElementById('completedSummary').textContent;
|
|
const feedback = document.getElementById('completedFeedback').textContent;
|
|
|
|
return `PROJECT COMPLETION REPORT
|
|
========================
|
|
|
|
Request ID: ${requestId}
|
|
Client: ${clientName}
|
|
Service Type: ${serviceType}
|
|
Completion Date: ${completedDate}
|
|
Hours Logged: ${hoursLogged}
|
|
Revenue Generated: ${revenue}
|
|
Client Rating: ${rating}
|
|
|
|
PROJECT SUMMARY
|
|
===============
|
|
${summary}
|
|
|
|
CLIENT FEEDBACK
|
|
===============
|
|
${feedback}
|
|
|
|
Generated on: ${new Date().toLocaleString()}
|
|
`;
|
|
}
|
|
|
|
// Handle invoice download (called from table)
|
|
function downloadInvoice(requestId) {
|
|
showNotification('Generating invoice...', 'info');
|
|
|
|
// Make API call to generate invoice directly
|
|
fetch(`/api/dashboard/service-requests/${requestId}/invoice`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/pdf',
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (response.ok) {
|
|
return response.blob();
|
|
} else {
|
|
throw new Error('Failed to generate invoice');
|
|
}
|
|
})
|
|
.then(blob => {
|
|
// Create download link
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = `invoice-${requestId}.pdf`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showNotification('Invoice downloaded successfully', 'success');
|
|
})
|
|
.catch(error => {
|
|
console.error('Error generating invoice:', error);
|
|
showNotification(`Failed to generate invoice: ${error.message}`, 'error');
|
|
});
|
|
}
|
|
|
|
// Handle service report viewing (called from table)
|
|
function viewServiceReport(requestId) {
|
|
console.log('Opening service report for request:', requestId);
|
|
|
|
// Open the service report in a new tab/window
|
|
const reportUrl = `/api/dashboard/service-requests/${requestId}/report`;
|
|
window.open(reportUrl, '_blank');
|
|
|
|
showNotification('Opening service report...', 'info');
|
|
}
|
|
|
|
// Handle service invoice viewing (called from table)
|
|
function viewServiceInvoice(requestId) {
|
|
console.log('Opening service invoice for request:', requestId);
|
|
|
|
// Open the service invoice in a new tab/window
|
|
const invoiceUrl = `/api/dashboard/service-requests/${requestId}/invoice`;
|
|
window.open(invoiceUrl, '_blank');
|
|
|
|
showNotification('Opening service invoice...', 'info');
|
|
}
|
|
|
|
// Tab switching function - SINGLE IMPLEMENTATION
|
|
function switchServiceRequestTab(tabName) {
|
|
console.log(`Switching to ${tabName} tab`);
|
|
|
|
// Refresh the appropriate tab content with current data
|
|
if (window.dashboardInstance) {
|
|
console.log(`Switching to ${tabName} tab, refreshing content with ${window.dashboardInstance.allRequests.length} requests`);
|
|
switch(tabName) {
|
|
case 'open':
|
|
window.dashboardInstance.populateOpenRequests();
|
|
break;
|
|
case 'progress':
|
|
window.dashboardInstance.populateInProgressRequests();
|
|
break;
|
|
case 'completed':
|
|
window.dashboardInstance.populateCompletedRequests();
|
|
break;
|
|
}
|
|
|
|
// Update tab counts to ensure consistency
|
|
window.dashboardInstance.updateTabCounts(window.dashboardInstance.allRequests);
|
|
}
|
|
}
|
|
|
|
// Notification function - SINGLE IMPLEMENTATION
|
|
function showNotification(message, type = 'info') {
|
|
// Remove existing notifications
|
|
const existingNotifications = document.querySelectorAll('.dashboard-notification');
|
|
existingNotifications.forEach(notification => notification.remove());
|
|
|
|
// Create notification element
|
|
const notification = document.createElement('div');
|
|
notification.className = `alert alert-${getBootstrapAlertClass(type)} alert-dismissible fade show dashboard-notification`;
|
|
notification.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
z-index: 9999;
|
|
min-width: 300px;
|
|
max-width: 500px;
|
|
`;
|
|
|
|
notification.innerHTML = `
|
|
${message}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
`;
|
|
|
|
// Add to page
|
|
document.body.appendChild(notification);
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => {
|
|
if (notification.parentNode) {
|
|
notification.remove();
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function getBootstrapAlertClass(type) {
|
|
const typeMap = {
|
|
'success': 'success',
|
|
'error': 'danger',
|
|
'warning': 'warning',
|
|
'info': 'info'
|
|
};
|
|
return typeMap[type] || 'info';
|
|
}
|
|
|
|
// Modal management improvements
|
|
function initializeModalManagement() {
|
|
// Add event listeners for better modal management
|
|
const progressModal = document.getElementById('progressUpdateModal');
|
|
const detailsModal = document.getElementById('requestDetailsModal');
|
|
|
|
if (progressModal) {
|
|
// Clean up when progress modal is hidden
|
|
progressModal.addEventListener('hidden.bs.modal', function () {
|
|
// Clear any stored data
|
|
this.removeAttribute('data-request-id');
|
|
this.removeAttribute('data-return-to-details');
|
|
|
|
// Reset form
|
|
const form = document.getElementById('progressUpdateForm');
|
|
if (form) {
|
|
form.reset();
|
|
document.getElementById('progressSlider').value = 50;
|
|
document.getElementById('progressValue').textContent = '50';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (detailsModal) {
|
|
// Clean up when details modal is hidden
|
|
detailsModal.addEventListener('hidden.bs.modal', function () {
|
|
// Clear any stored data
|
|
this.removeAttribute('data-request-id');
|
|
});
|
|
}
|
|
}
|
|
|
|
// SLA Management Functions
|
|
async function loadUserSLAs() {
|
|
console.log('🔧 DEBUG: loadUserSLAs called');
|
|
try {
|
|
const data = await window.apiJson('/api/dashboard/slas');
|
|
console.log('🔧 DEBUG: API raw SLAs data:', data);
|
|
// Support both legacy {success, slas} and wrapped {success, data: {slas}} or even array payloads
|
|
let slas = [];
|
|
if (Array.isArray(data)) {
|
|
slas = data;
|
|
} else if (Array.isArray(data.slas)) {
|
|
slas = data.slas;
|
|
} else if (data && Array.isArray(data.data)) {
|
|
slas = data.data;
|
|
} else if (data && data.data && Array.isArray(data.data.slas)) {
|
|
slas = data.data.slas;
|
|
}
|
|
if (slas && slas.length >= 0) {
|
|
console.log('🔧 DEBUG: Calling refreshSLADisplay with', slas.length, 'SLAs');
|
|
refreshSLADisplay(slas);
|
|
} else {
|
|
console.log('🔧 DEBUG: API call failed or no SLAs returned');
|
|
}
|
|
} catch (error) {
|
|
console.error('🔧 DEBUG: Error loading SLAs:', error);
|
|
}
|
|
}
|
|
|
|
function refreshSLADisplay(slas) {
|
|
console.log('🔧 DEBUG: refreshSLADisplay called with', slas.length, 'SLAs');
|
|
|
|
// Prefer stable container ID if present, fallback to header scan
|
|
let slaListGroup = document.getElementById('slaListGroup');
|
|
if (!slaListGroup) {
|
|
const slaCards = document.querySelectorAll('.card-header h5');
|
|
console.log('🔧 DEBUG: Found', slaCards.length, 'card headers');
|
|
for (let header of slaCards) {
|
|
console.log('🔧 DEBUG: Checking header:', header.textContent);
|
|
if (header.textContent.includes('Service Level Agreements')) {
|
|
slaListGroup = header.closest('.card').querySelector('.list-group');
|
|
console.log('🔧 DEBUG: Found SLA list-group container via fallback:', !!slaListGroup);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!slaListGroup) {
|
|
console.log('🔧 DEBUG: SLA list-group container not found!');
|
|
return;
|
|
}
|
|
|
|
console.log('🔧 DEBUG: Current container innerHTML length:', slaListGroup.innerHTML.length);
|
|
|
|
// Simple innerHTML replacement (like wallet does)
|
|
if (slas.length === 0) {
|
|
slaListGroup.innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="bi bi-file-text fs-1 mb-3"></i>
|
|
<p class="mb-0">No SLAs created yet</p>
|
|
<small class="text-muted">Create your first SLA to get started</small>
|
|
</div>
|
|
`;
|
|
} else {
|
|
slaListGroup.innerHTML = slas.map(sla => {
|
|
const statusClass = sla.status === 'Active' ? 'primary' :
|
|
sla.status === 'Draft' ? 'secondary' : 'warning';
|
|
|
|
return `
|
|
<a href="#" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-action="sla.view" data-sla-id="${sla.id}">
|
|
<div>
|
|
<div class="fw-bold">${sla.name}</div>
|
|
<small class="text-muted">Response: ${sla.response_time_hours}h | Resolution: ${sla.resolution_time_hours}h | Availability: ${sla.availability_percentage}%</small>
|
|
</div>
|
|
<span class="badge bg-${statusClass} rounded-pill">${sla.status}</span>
|
|
</a>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
console.log('🔧 DEBUG: New innerHTML length:', slaListGroup.innerHTML.length);
|
|
console.log('🔧 DEBUG: SLA display updated successfully');
|
|
}
|
|
|
|
// Centralized delegated event handling for CSP compliance
|
|
document.addEventListener('click', function(e) {
|
|
const actionEl = e.target.closest('[data-action]');
|
|
if (!actionEl) return;
|
|
const action = actionEl.dataset.action;
|
|
switch (action) {
|
|
case 'request.view': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const clientName = actionEl.dataset.clientName;
|
|
if (requestId && clientName) viewRequest(requestId, clientName);
|
|
break;
|
|
}
|
|
case 'request.update': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const clientName = actionEl.dataset.clientName;
|
|
if (requestId && clientName) updateProgress(requestId, clientName);
|
|
break;
|
|
}
|
|
case 'request.accept': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const clientName = actionEl.dataset.clientName;
|
|
const serviceName = actionEl.dataset.serviceName;
|
|
if (requestId && clientName && serviceName) acceptRequest(requestId, clientName, serviceName);
|
|
break;
|
|
}
|
|
case 'request.decline': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const clientName = actionEl.dataset.clientName;
|
|
if (requestId && clientName) declineRequest(requestId, clientName);
|
|
break;
|
|
}
|
|
case 'request.decline.confirm': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.getAttribute('data-request-id');
|
|
const clientName = actionEl.getAttribute('data-client-name');
|
|
if (!requestId || !clientName) return;
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('declineConfirmModal'));
|
|
if (modal) modal.hide();
|
|
showNotification(`Declining request from ${clientName}...`, 'info');
|
|
(async () => {
|
|
try {
|
|
await window.apiJson(`/api/dashboard/service-requests/${requestId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ status: 'Declined', remove: true })
|
|
});
|
|
showNotification(`Request ${requestId} declined and removed`, 'warning');
|
|
if (window.dashboardInstance) {
|
|
console.log('🔄 Immediately refreshing dashboard data after decline...');
|
|
window.dashboardInstance.refreshServiceRequestsData();
|
|
}
|
|
} catch (err) {
|
|
console.error('Error declining request:', err);
|
|
showNotification(`Failed to decline request: ${err.message}`, 'error');
|
|
}
|
|
})();
|
|
break;
|
|
}
|
|
case 'service.manage': {
|
|
e.preventDefault();
|
|
const serviceId = actionEl.dataset.serviceId;
|
|
const serviceName = actionEl.dataset.serviceName;
|
|
if (serviceId && serviceName) {
|
|
showServiceManagementModal(serviceId, serviceName);
|
|
}
|
|
break;
|
|
}
|
|
case 'request.send_quote': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const clientName = actionEl.dataset.clientName;
|
|
if (requestId && clientName) sendQuote(requestId, clientName);
|
|
break;
|
|
}
|
|
case 'request.complete': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const clientName = actionEl.dataset.clientName;
|
|
if (requestId && clientName) completeRequest(requestId, clientName);
|
|
break;
|
|
}
|
|
case 'request.complete.confirm': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.getAttribute('data-request-id');
|
|
const clientName = actionEl.getAttribute('data-client-name');
|
|
if (!requestId) return;
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('completeConfirmModal'));
|
|
if (modal) modal.hide();
|
|
showNotification(`Completing request ${requestId}...`, 'info');
|
|
(async () => {
|
|
try {
|
|
await window.apiJson(`/api/dashboard/service-requests/${requestId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ status: 'Completed' })
|
|
});
|
|
showNotification(`Request ${requestId} completed successfully`, 'success');
|
|
if (window.dashboardInstance) {
|
|
console.log('🔄 Immediately refreshing dashboard data after complete...');
|
|
window.dashboardInstance.refreshServiceRequestsData();
|
|
}
|
|
} catch (err) {
|
|
console.error('Error completing request:', err);
|
|
showNotification(`Failed to complete request: ${err.message}`, 'error');
|
|
}
|
|
})();
|
|
break;
|
|
}
|
|
case 'request.view_completed': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
const serviceName = actionEl.dataset.serviceName;
|
|
const clientName = actionEl.dataset.clientName;
|
|
if (requestId) viewCompletedRequest(requestId, serviceName, clientName);
|
|
break;
|
|
}
|
|
case 'request.view_report': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
showNotification(`Report for request ${requestId} is not implemented yet.`, 'info');
|
|
break;
|
|
}
|
|
case 'request.view_invoice': {
|
|
e.preventDefault();
|
|
const requestId = actionEl.dataset.requestId;
|
|
showNotification(`Invoice for request ${requestId} is not implemented yet.`, 'info');
|
|
break;
|
|
}
|
|
case 'request.progress.open': {
|
|
e.preventDefault();
|
|
openProgressUpdateModal();
|
|
break;
|
|
}
|
|
case 'request.progress.save': {
|
|
e.preventDefault();
|
|
saveProgressUpdate();
|
|
break;
|
|
}
|
|
case 'request.progress.back': {
|
|
e.preventDefault();
|
|
backToRequestDetails();
|
|
break;
|
|
}
|
|
case 'sla.view': {
|
|
e.preventDefault();
|
|
const slaId = actionEl.dataset.slaId;
|
|
if (slaId) viewSLA(slaId);
|
|
break;
|
|
}
|
|
case 'sla.create': {
|
|
e.preventDefault();
|
|
createNewSLA();
|
|
break;
|
|
}
|
|
case 'sla.save': {
|
|
e.preventDefault();
|
|
saveSLA();
|
|
break;
|
|
}
|
|
case 'sla.edit': {
|
|
e.preventDefault();
|
|
const slaId = actionEl.dataset.slaId;
|
|
if (slaId) editSLA(slaId);
|
|
break;
|
|
}
|
|
case 'sla.saveChanges': {
|
|
e.preventDefault();
|
|
const slaId = actionEl.dataset.slaId;
|
|
if (slaId) saveSLAChanges(slaId);
|
|
break;
|
|
}
|
|
case 'agreement.view': {
|
|
e.preventDefault();
|
|
viewAgreement();
|
|
break;
|
|
}
|
|
case 'agreement.download': {
|
|
e.preventDefault();
|
|
downloadAgreement();
|
|
break;
|
|
}
|
|
case 'agreement.requestChanges': {
|
|
e.preventDefault();
|
|
requestChanges();
|
|
break;
|
|
}
|
|
case 'agreement.submitChangeRequest': {
|
|
e.preventDefault();
|
|
submitChangeRequest();
|
|
break;
|
|
}
|
|
case 'services.create': {
|
|
e.preventDefault();
|
|
createNewService();
|
|
break;
|
|
}
|
|
case 'services.saveChanges': {
|
|
e.preventDefault();
|
|
if (typeof saveServiceChanges === 'function') {
|
|
saveServiceChanges();
|
|
}
|
|
break;
|
|
}
|
|
case 'pagination.change': {
|
|
e.preventDefault();
|
|
const page = parseInt(actionEl.dataset.page || '1', 10);
|
|
if (window.dashboardInstance) {
|
|
window.dashboardInstance._currentCompletedPage = Math.max(1, page);
|
|
window.dashboardInstance.populateClientRequests();
|
|
}
|
|
break;
|
|
}
|
|
case 'availability.update': {
|
|
e.preventDefault();
|
|
updateAvailability();
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Handle delegated change events (e.g., availability toggle)
|
|
document.addEventListener('change', function(e) {
|
|
const actionEl = e.target.closest('[data-action]');
|
|
if (!actionEl) return;
|
|
const action = actionEl.dataset.action;
|
|
if (action === 'availability.toggle') {
|
|
toggleAvailability();
|
|
}
|
|
});
|
|
|
|
// Handle delegated input events (e.g., progress slider live updates)
|
|
document.addEventListener('input', function(e) {
|
|
const actionEl = e.target.closest('[data-action]');
|
|
if (!actionEl) return;
|
|
const action = actionEl.dataset.action;
|
|
switch (action) {
|
|
case 'request.progress.input': {
|
|
updateProgressValue(e.target.value);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
// SLA Management Functions (moved from template for CSP compliance)
|
|
function createNewSLA() {
|
|
console.log('Creating new SLA');
|
|
|
|
const modalHtml = `
|
|
<div class="modal fade" id="createSLAModal" tabindex="-1" aria-labelledby="createSLAModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="createSLAModalLabel">Create New Service Level Agreement</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="createSLAForm">
|
|
<div class="mb-3">
|
|
<label for="slaName" class="form-label">SLA Name</label>
|
|
<input type="text" class="form-control" id="slaName" placeholder="e.g., Premium Support SLA">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="slaServiceType" class="form-label">Service Type</label>
|
|
<select class="form-select" id="slaServiceType">
|
|
<option value="">Select service type</option>
|
|
<option value="System Administration">System Administration</option>
|
|
<option value="Custom Development">Custom Development</option>
|
|
<option value="Security Audit">Security Audit</option>
|
|
<option value="Migration Services">Migration Services</option>
|
|
<option value="Training">Training</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="responseTime" class="form-label">Response Time (hours)</label>
|
|
<input type="number" class="form-control" id="responseTime" min="1" max="72" value="24">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="resolutionTime" class="form-label">Resolution Time (hours)</label>
|
|
<input type="number" class="form-control" id="resolutionTime" min="1" max="168" value="48">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="availabilityTarget" class="form-label">Availability Target (%)</label>
|
|
<input type="number" class="form-control" id="availabilityTarget" min="90" max="100" value="99">
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" data-action="sla.save">Create SLA</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const existingModal = document.getElementById('createSLAModal');
|
|
if (existingModal) existingModal.remove();
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('createSLAModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function saveSLA() {
|
|
const name = document.getElementById('slaName').value.trim();
|
|
const serviceType = document.getElementById('slaServiceType').value;
|
|
const responseTime = document.getElementById('responseTime').value;
|
|
const resolutionTime = document.getElementById('resolutionTime').value;
|
|
const availabilityTarget = document.getElementById('availabilityTarget').value;
|
|
|
|
if (!name || !serviceType || !responseTime || !resolutionTime || !availabilityTarget) {
|
|
showNotification('Please fill in all required fields', 'error');
|
|
return;
|
|
}
|
|
|
|
// Backend expects snake_case keys like response_time_hours, resolution_time_hours, availability_percentage
|
|
// Provide minimal required fields and sensible defaults for optional fields
|
|
const slaData = {
|
|
name,
|
|
// Optional fields with defaults since create form doesn't capture them yet
|
|
description: '',
|
|
response_time_hours: parseInt(responseTime),
|
|
resolution_time_hours: parseInt(resolutionTime),
|
|
availability_percentage: parseFloat(availabilityTarget),
|
|
support_hours: 'Business Hours',
|
|
escalation_procedure: 'Standard escalation procedure',
|
|
status: 'Active'
|
|
};
|
|
|
|
try {
|
|
await window.apiJson('/api/dashboard/slas', {
|
|
method: 'POST',
|
|
body: slaData
|
|
});
|
|
{
|
|
showNotification(`SLA "${name}" created successfully!`, 'success');
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createSLAModal'));
|
|
if (modal) modal.hide();
|
|
loadUserSLAs();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating SLA:', error);
|
|
showNotification('Error creating SLA: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function viewSLA(slaId) {
|
|
console.log('Viewing SLA:', slaId);
|
|
try {
|
|
const data = await window.apiJson('/api/dashboard/slas');
|
|
const slas = Array.isArray(data) ? data : (data.slas || (data.data && data.data.slas));
|
|
if (!slas) {
|
|
showNotification('Failed to load SLAs', 'error');
|
|
return;
|
|
}
|
|
const sla = slas.find(s => s.id === slaId);
|
|
if (!sla) {
|
|
showNotification('SLA not found', 'error');
|
|
return;
|
|
}
|
|
displaySLAModal(sla);
|
|
} catch (error) {
|
|
console.error('Error loading SLA:', error);
|
|
showNotification('Error loading SLA: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
function displaySLAModal(sla) {
|
|
const statusClass = sla.status === 'Active' ? 'success' :
|
|
sla.status === 'Draft' ? 'secondary' : 'warning';
|
|
const modalHtml = `
|
|
<div class="modal fade" id="viewSLAModal" tabindex="-1" aria-labelledby="viewSLAModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="viewSLAModalLabel">${sla.name}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6>SLA Details</h6>
|
|
<table class="table table-borderless">
|
|
<tr><td><strong>Name:</strong></td><td>${sla.name}</td></tr>
|
|
<tr><td><strong>Description:</strong></td><td>${sla.description || 'No description provided'}</td></tr>
|
|
<tr><td><strong>Status:</strong></td><td><span class="badge bg-${statusClass}">${sla.status}</span></td></tr>
|
|
<tr><td><strong>Response Time:</strong></td><td>${sla.response_time_hours} hours</td></tr>
|
|
<tr><td><strong>Resolution Time:</strong></td><td>${sla.resolution_time_hours} hours</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<h6>Performance Metrics</h6>
|
|
<table class="table table-borderless">
|
|
<tr><td><strong>Availability Target:</strong></td><td>${sla.availability_percentage}%</td></tr>
|
|
<tr><td><strong>Support Hours:</strong></td><td>${sla.support_hours}</td></tr>
|
|
<tr><td><strong>Escalation:</strong></td><td>${sla.escalation_procedure}</td></tr>
|
|
<tr><td><strong>Created:</strong></td><td>${sla.created_at}</td></tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<h6>SLA Terms</h6>
|
|
<p class="text-muted">This SLA defines the service levels including response times of ${sla.response_time_hours} hours, resolution targets of ${sla.resolution_time_hours} hours, and availability commitments of ${sla.availability_percentage}%.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
<button type="button" class="btn btn-primary" data-action="sla.edit" data-sla-id="${sla.id}">Edit SLA</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const existingModal = document.getElementById('viewSLAModal');
|
|
if (existingModal) existingModal.remove();
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('viewSLAModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function editSLA(slaId) {
|
|
console.log('Editing SLA:', slaId);
|
|
try {
|
|
const data = await window.apiJson('/api/dashboard/slas');
|
|
const slas = Array.isArray(data) ? data : (data.slas || (data.data && data.data.slas));
|
|
if (!slas) {
|
|
showNotification('Failed to load SLA data', 'error');
|
|
return;
|
|
}
|
|
const sla = slas.find(s => s.id === slaId);
|
|
if (!sla) {
|
|
showNotification('SLA not found', 'error');
|
|
return;
|
|
}
|
|
const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewSLAModal'));
|
|
if (viewModal) viewModal.hide();
|
|
showEditSLAModal(sla);
|
|
} catch (error) {
|
|
console.error('Error loading SLA for editing:', error);
|
|
showNotification('Error loading SLA: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
function showEditSLAModal(sla) {
|
|
const modalHtml = `
|
|
<div class="modal fade" id="editSLAModal" tabindex="-1" aria-labelledby="editSLAModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="editSLAModalLabel">Edit SLA: ${sla.name}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="editSLAForm">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label for="editSlaName" class="form-label">SLA Name</label>
|
|
<input type="text" class="form-control" id="editSlaName" value="${sla.name}" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="editSlaDescription" class="form-label">Description</label>
|
|
<textarea class="form-control" id="editSlaDescription" rows="3">${sla.description || ''}</textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="editResponseTime" class="form-label">Response Time (hours)</label>
|
|
<input type="number" class="form-control" id="editResponseTime" value="${sla.response_time_hours}" min="1" max="168" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="editResolutionTime" class="form-label">Resolution Time (hours)</label>
|
|
<input type="number" class="form-control" id="editResolutionTime" value="${sla.resolution_time_hours}" min="1" max="720" required>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label for="editAvailability" class="form-label">Availability Percentage</label>
|
|
<input type="number" class="form-control" id="editAvailability" value="${sla.availability_percentage}" min="90" max="100" step="0.1" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="editSupportHours" class="form-label">Support Hours</label>
|
|
<select class="form-select" id="editSupportHours">
|
|
<option value="Business Hours" ${sla.support_hours === 'Business Hours' ? 'selected' : ''}>Business Hours (9 AM - 5 PM)</option>
|
|
<option value="Extended Hours" ${sla.support_hours === 'Extended Hours' ? 'selected' : ''}>Extended Hours (7 AM - 9 PM)</option>
|
|
<option value="24/7" ${sla.support_hours === '24/7' ? 'selected' : ''}>24/7 Support</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="editEscalation" class="form-label">Escalation Procedure</label>
|
|
<textarea class="form-control" id="editEscalation" rows="3">${sla.escalation_procedure}</textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="editStatus" class="form-label">Status</label>
|
|
<select class="form-select" id="editStatus">
|
|
<option value="Active" ${sla.status === 'Active' ? 'selected' : ''}>Active</option>
|
|
<option value="Draft" ${sla.status === 'Draft' ? 'selected' : ''}>Draft</option>
|
|
<option value="Inactive" ${sla.status === 'Inactive' ? 'selected' : ''}>Inactive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" data-action="sla.saveChanges" data-sla-id="${sla.id}">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const existingModal = document.getElementById('editSLAModal');
|
|
if (existingModal) existingModal.remove();
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('editSLAModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function saveSLAChanges(slaId) {
|
|
const name = document.getElementById('editSlaName').value.trim();
|
|
const description = document.getElementById('editSlaDescription').value.trim();
|
|
const responseTime = parseInt(document.getElementById('editResponseTime').value);
|
|
const resolutionTime = parseInt(document.getElementById('editResolutionTime').value);
|
|
const availability = parseFloat(document.getElementById('editAvailability').value);
|
|
const supportHours = document.getElementById('editSupportHours').value;
|
|
const escalation = document.getElementById('editEscalation').value.trim();
|
|
const status = document.getElementById('editStatus').value;
|
|
|
|
if (!name) {
|
|
showNotification('SLA name is required', 'error');
|
|
return;
|
|
}
|
|
|
|
const slaData = { name, description, response_time_hours: responseTime, resolution_time_hours: resolutionTime, availability_percentage: availability, support_hours: supportHours, escalation_procedure: escalation, status };
|
|
|
|
try {
|
|
await window.apiJson(`/api/dashboard/slas/${encodeURIComponent(slaId)}`, {
|
|
method: 'PUT',
|
|
body: slaData
|
|
});
|
|
showNotification('SLA updated successfully!', 'success');
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editSLAModal'));
|
|
if (modal) modal.hide();
|
|
loadUserSLAs();
|
|
} catch (error) {
|
|
console.error('Error updating SLA:', error);
|
|
showNotification('Error updating SLA: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Service Provider Agreement Functions (moved from template)
|
|
function viewAgreement() {
|
|
console.log('Viewing service provider agreement');
|
|
const modalHtml = `
|
|
<div class="modal fade" id="viewAgreementModal" tabindex="-1" aria-labelledby="viewAgreementModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="viewAgreementModalLabel">ThreeFold Service Provider Agreement</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
|
<div class="agreement-content">
|
|
<h6>1. Agreement Overview</h6>
|
|
<p>This Service Provider Agreement ("Agreement") is entered into between ThreeFold and the Service Provider for the provision of professional services through the ThreeFold marketplace platform.</p>
|
|
|
|
<h6>2. Service Provider Obligations</h6>
|
|
<ul>
|
|
<li>Provide services with professional competence and in accordance with industry standards</li>
|
|
<li>Maintain appropriate certifications and qualifications</li>
|
|
<li>Respond to client requests within agreed timeframes</li>
|
|
<li>Maintain confidentiality of client information</li>
|
|
</ul>
|
|
|
|
<h6>3. Payment Terms</h6>
|
|
<p>Payments will be processed through the ThreeFold platform using USD Credits. Service providers will receive payment upon successful completion of services as verified by the client.</p>
|
|
|
|
<h6>4. Quality Standards</h6>
|
|
<p>Service providers must maintain a minimum rating of 4.0 stars and respond to service requests within 24 hours unless otherwise specified in their service offerings.</p>
|
|
|
|
<h6>5. Agreement Term</h6>
|
|
<p>This agreement is effective from January 15, 2025, and will automatically renew on January 15, 2026, unless terminated by either party with 30 days written notice.</p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
<button type="button" class="btn btn-outline-primary" data-action="agreement.download">Download PDF</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const existingModal = document.getElementById('viewAgreementModal');
|
|
if (existingModal) existingModal.remove();
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('viewAgreementModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function downloadAgreement() {
|
|
const agreementContent = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Service Provider Agreement - ThreeFold</title>
|
|
<style>
|
|
@media print {
|
|
.no-print { display: none !important; }
|
|
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; }
|
|
.agreement-content {
|
|
font-size: 12pt;
|
|
line-height: 1.4;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
h1 { font-size: 18pt; margin-bottom: 20px; }
|
|
h2 { font-size: 14pt; margin-top: 20px; margin-bottom: 10px; }
|
|
h3 { font-size: 12pt; margin-top: 15px; margin-bottom: 8px; }
|
|
.header { text-align: center; margin-bottom: 30px; }
|
|
.section { margin-bottom: 20px; }
|
|
.signature-section { margin-top: 40px; }
|
|
}
|
|
@media screen {
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
.agreement-content { max-width: 800px; margin: 0 auto; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="agreement-content">
|
|
<div class="header">
|
|
<h1>ThreeFold Service Provider Agreement</h1>
|
|
<p><strong>Agreement Date:</strong> ${new Date().toLocaleDateString()}</p>
|
|
<p><strong>Provider:</strong> Service Provider</p>
|
|
<p><strong>Agreement ID:</strong> SPA-${Date.now()}</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>1. Service Provider Terms</h2>
|
|
<p>This Service Provider Agreement ("Agreement") is entered into between ThreeFold and the Service Provider for the provision of services on the ThreeFold marketplace platform.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>2. Service Obligations</h2>
|
|
<p>The Service Provider agrees to:</p>
|
|
<ul>
|
|
<li>Provide services as described in their service listings</li>
|
|
<li>Maintain professional standards and quality</li>
|
|
<li>Respond to client requests within specified timeframes</li>
|
|
<li>Complete projects according to agreed specifications</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>3. Payment Terms</h2>
|
|
<p>Payment for services will be processed through the ThreeFold platform using USD Credits. The Service Provider agrees to the platform's payment processing terms and fee structure.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>4. Quality Standards</h2>
|
|
<p>All services must meet ThreeFold's quality standards and client expectations. The Service Provider is responsible for maintaining a professional reputation and delivering high-quality work.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>5. Dispute Resolution</h2>
|
|
<p>Any disputes arising from this agreement will be resolved through ThreeFold's dispute resolution process, with mediation as the preferred method.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>6. Termination</h2>
|
|
<p>Either party may terminate this agreement with 30 days written notice. ThreeFold reserves the right to terminate immediately for violations of platform terms.</p>
|
|
</div>
|
|
|
|
<div class="signature-section">
|
|
<h2>7. Agreement Acceptance</h2>
|
|
<p>By using the ThreeFold service provider platform, you acknowledge that you have read, understood, and agree to be bound by the terms of this agreement.</p>
|
|
<br>
|
|
<p><strong>ThreeFold Foundation</strong></p>
|
|
<p>Date: ${new Date().toLocaleDateString()}</p>
|
|
<br>
|
|
<p><strong>Service Provider Signature:</strong> _________________________</p>
|
|
<p>Date: _________________________</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const printWindow = window.open('', '_blank');
|
|
printWindow.document.write(agreementContent);
|
|
printWindow.document.close();
|
|
printWindow.onload = function() {
|
|
printWindow.focus();
|
|
printWindow.print();
|
|
printWindow.onafterprint = function() {
|
|
printWindow.close();
|
|
};
|
|
};
|
|
showNotification('Opening agreement for download/print...', 'success');
|
|
}
|
|
|
|
function requestChanges() {
|
|
console.log('Requesting agreement changes');
|
|
const modalHtml = `
|
|
<div class="modal fade" id="requestChangesModal" tabindex="-1" aria-labelledby="requestChangesModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="requestChangesModalLabel">Request Agreement Changes</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="requestChangesForm">
|
|
<div class="mb-3">
|
|
<label for="changeType" class="form-label">Type of Change</label>
|
|
<select class="form-select" id="changeType">
|
|
<option value="">Select change type</option>
|
|
<option value="payment_terms">Payment Terms</option>
|
|
<option value="service_levels">Service Level Requirements</option>
|
|
<option value="termination_clause">Termination Clause</option>
|
|
<option value="liability">Liability Terms</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="changeDescription" class="form-label">Description of Requested Changes</label>
|
|
<textarea class="form-control" id="changeDescription" rows="4" placeholder="Please describe the changes you would like to request..."></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="changeReason" class="form-label">Reason for Change</label>
|
|
<textarea class="form-control" id="changeReason" rows="3" placeholder="Please explain why this change is needed..."></textarea>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" data-action="agreement.submitChangeRequest">Submit Request</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const existingModal = document.getElementById('requestChangesModal');
|
|
if (existingModal) existingModal.remove();
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('requestChangesModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function submitChangeRequest() {
|
|
const changeType = document.getElementById('changeType').value;
|
|
const description = document.getElementById('changeDescription').value.trim();
|
|
const reason = document.getElementById('changeReason').value.trim();
|
|
if (!changeType || !description || !reason) {
|
|
showNotification('Please fill in all required fields', 'error');
|
|
return;
|
|
}
|
|
showNotification('Change request submitted successfully! You will receive a response within 5 business days.', 'success');
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('requestChangesModal'));
|
|
if (modal) modal.hide();
|
|
}
|
|
|
|
// Utility functions
|
|
function loadPersistedServices() {
|
|
// Load services from API instead of sessionStorage
|
|
window.apiJson('/api/dashboard/services')
|
|
.then(data => {
|
|
if (data && data.services && data.services.length > 0) {
|
|
console.log('Found persisted services:', data.services);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading persisted services:', error);
|
|
});
|
|
}
|
|
|
|
function loadAvailabilitySettings() {
|
|
console.log('🔧 Loading availability settings...');
|
|
|
|
window.apiJson('/api/dashboard/availability')
|
|
.then(data => {
|
|
console.log('🔧 Availability API response:', data);
|
|
|
|
if (data && data.availability) {
|
|
const checkbox = document.getElementById('availabilityStatus');
|
|
const hoursInput = document.getElementById('availabilityHours');
|
|
|
|
if (checkbox) {
|
|
checkbox.checked = data.availability.available;
|
|
}
|
|
if (hoursInput) {
|
|
hoursInput.value = data.availability.weekly_hours;
|
|
}
|
|
} else {
|
|
// Set default values if no data found
|
|
const checkbox = document.getElementById('availabilityStatus');
|
|
const hoursInput = document.getElementById('availabilityHours');
|
|
|
|
if (checkbox) {
|
|
checkbox.checked = true;
|
|
}
|
|
if (hoursInput) {
|
|
hoursInput.value = 20;
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('🔧 Error loading availability settings:', error);
|
|
showNotification('Failed to load availability settings. Using defaults.', 'warning');
|
|
|
|
// Set default values on error
|
|
const checkbox = document.getElementById('availabilityStatus');
|
|
const hoursInput = document.getElementById('availabilityHours');
|
|
|
|
if (checkbox) {
|
|
checkbox.checked = true;
|
|
}
|
|
if (hoursInput) {
|
|
hoursInput.value = 20;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Availability handlers
|
|
function toggleAvailability() {
|
|
const checkbox = document.getElementById('availabilityStatus');
|
|
if (!checkbox) return;
|
|
const status = checkbox.checked ? 'Available' : 'Unavailable';
|
|
// Just a lightweight cue; saving happens via Update button
|
|
showNotification(`Availability set to: ${status} (not saved)`, 'info');
|
|
}
|
|
|
|
async function updateAvailability() {
|
|
const checkbox = document.getElementById('availabilityStatus');
|
|
const hoursInput = document.getElementById('availabilityHours');
|
|
if (!checkbox || !hoursInput) return;
|
|
|
|
const available = !!checkbox.checked;
|
|
const hours = Number(hoursInput.value);
|
|
|
|
if (!Number.isFinite(hours) || hours < 0 || hours > 168) {
|
|
showNotification('Please enter valid weekly hours between 0 and 168.', 'error');
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
available,
|
|
weekly_hours: Math.trunc(hours),
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
try {
|
|
await window.apiJson('/api/dashboard/availability', {
|
|
method: 'PUT',
|
|
body: payload
|
|
});
|
|
showNotification(`Availability updated: ${available ? 'Available' : 'Unavailable'} @ ${payload.weekly_hours}h/week`, 'success');
|
|
} catch (err) {
|
|
console.error('Error updating availability:', err);
|
|
showNotification('Error updating availability: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Initialize availability on load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (window.__serviceProviderAvailInit) return;
|
|
window.__serviceProviderAvailInit = true;
|
|
try { loadAvailabilitySettings(); } catch (e) { console.warn('loadAvailabilitySettings failed:', e); }
|
|
});
|
|
|
|
// PHASE 1 FIX: Add the missing createNewService function
|
|
async function createNewService() {
|
|
console.log(' PHASE 1 FIX: createNewService called');
|
|
console.log('🔧 PHASE 1 FIX: createNewService called');
|
|
|
|
// Get form elements
|
|
const serviceName = document.getElementById('serviceName');
|
|
const serviceDesc = document.getElementById('serviceDesc');
|
|
const serviceCategory = document.getElementById('serviceCategory');
|
|
const serviceDelivery = document.getElementById('serviceDelivery');
|
|
const pricingType = document.getElementById('pricingType');
|
|
const priceAmount = document.getElementById('priceAmount');
|
|
const serviceExperience = document.getElementById('serviceExperience');
|
|
const availableHours = document.getElementById('availableHours');
|
|
const responseTime = document.getElementById('responseTime');
|
|
const serviceSkills = document.getElementById('serviceSkills');
|
|
|
|
// PHASE 1 FIX: Comprehensive form validation
|
|
const validationErrors = [];
|
|
|
|
if (!serviceName.value.trim()) {
|
|
validationErrors.push('Service name is required');
|
|
serviceName.classList.add('is-invalid');
|
|
} else {
|
|
serviceName.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!serviceDesc.value.trim()) {
|
|
validationErrors.push('Service description is required');
|
|
serviceDesc.classList.add('is-invalid');
|
|
} else {
|
|
serviceDesc.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!serviceCategory.value || serviceCategory.value === 'Select category') {
|
|
validationErrors.push('Please select a service category');
|
|
serviceCategory.classList.add('is-invalid');
|
|
} else {
|
|
serviceCategory.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!serviceDelivery.value || serviceDelivery.value === 'Select delivery method') {
|
|
validationErrors.push('Please select a delivery method');
|
|
serviceDelivery.classList.add('is-invalid');
|
|
} else {
|
|
serviceDelivery.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!pricingType.value || pricingType.value === 'Select pricing model') {
|
|
validationErrors.push('Please select a pricing model');
|
|
pricingType.classList.add('is-invalid');
|
|
} else {
|
|
pricingType.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!priceAmount.value || parseFloat(priceAmount.value) <= 0) {
|
|
validationErrors.push('Please enter a valid price amount');
|
|
priceAmount.classList.add('is-invalid');
|
|
} else {
|
|
priceAmount.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!serviceExperience.value || serviceExperience.value === 'Select experience level') {
|
|
validationErrors.push('Please select an experience level');
|
|
serviceExperience.classList.add('is-invalid');
|
|
} else {
|
|
serviceExperience.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!availableHours.value || parseInt(availableHours.value) <= 0) {
|
|
validationErrors.push('Please enter available hours per week');
|
|
availableHours.classList.add('is-invalid');
|
|
} else {
|
|
availableHours.classList.remove('is-invalid');
|
|
}
|
|
|
|
if (!responseTime.value || responseTime.value === 'Response time') {
|
|
validationErrors.push('Please select a response time');
|
|
responseTime.classList.add('is-invalid');
|
|
} else {
|
|
responseTime.classList.remove('is-invalid');
|
|
}
|
|
|
|
// PHASE 1 FIX: Show validation errors if any
|
|
if (validationErrors.length > 0) {
|
|
showNotification(`Please fix the following errors:\n• ${validationErrors.join('\n• ')}`, 'error');
|
|
console.log('🔧 PHASE 1 FIX: Validation failed:', validationErrors);
|
|
return;
|
|
}
|
|
|
|
// PHASE 1 FIX: Show loading state
|
|
const createBtn = document.getElementById('createServiceBtn');
|
|
const originalBtnText = createBtn.textContent;
|
|
createBtn.disabled = true;
|
|
createBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Creating...';
|
|
|
|
showNotification('Creating new service...', 'info');
|
|
|
|
try {
|
|
// Create product/application data for marketplace (matching backend Product struct)
|
|
const productData = {
|
|
name: serviceName.value.trim(),
|
|
description: serviceDesc.value.trim(),
|
|
category: serviceCategory.value,
|
|
price: parseFloat(priceAmount.value),
|
|
currency: 'USD', // Default currency
|
|
availability: 'Available', // Default availability
|
|
attributes: {
|
|
delivery_method: serviceDelivery.value,
|
|
pricing_type: pricingType.value,
|
|
experience_level: serviceExperience.value,
|
|
available_hours: availableHours.value,
|
|
response_time: responseTime.value,
|
|
skills: serviceSkills.value.trim() || ''
|
|
},
|
|
tags: serviceSkills.value.trim() ? serviceSkills.value.split(',').map(s => s.trim()) : []
|
|
};
|
|
|
|
console.log('🔧 SERVICE PROVIDER: Sending product data:', productData);
|
|
|
|
// Send POST request to create product (application) that will appear on marketplace
|
|
const result = await window.apiJson('/api/dashboard/products', {
|
|
method: 'POST',
|
|
body: productData
|
|
});
|
|
console.log('🔧 SERVICE PROVIDER: API response data:', result);
|
|
const data = (result && result.data) ? result.data : result;
|
|
|
|
if (data) {
|
|
// Success handling - product/application created
|
|
showNotification(`Application "${productData.name}" created successfully and will appear on the Marketplace!`, 'success');
|
|
|
|
// Close modal and reset form
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createServiceModal'));
|
|
if (modal) {
|
|
modal.hide();
|
|
}
|
|
|
|
// Reset form
|
|
document.querySelector('#createServiceModal form').reset();
|
|
|
|
// Remove validation classes
|
|
[serviceName, serviceDesc, serviceCategory, serviceDelivery, pricingType,
|
|
priceAmount, serviceExperience, availableHours, responseTime, serviceSkills].forEach(element => {
|
|
element.classList.remove('is-invalid');
|
|
});
|
|
|
|
// Emit global event for marketplace listeners
|
|
if (typeof window.dispatchEvent === 'function') {
|
|
const serviceCreatedEvent = new CustomEvent('serviceCreated', {
|
|
detail: {
|
|
product: data.product || data,
|
|
type: 'application',
|
|
source: 'service-provider-dashboard'
|
|
}
|
|
});
|
|
window.dispatchEvent(serviceCreatedEvent);
|
|
console.log('🔧 SERVICE PROVIDER: Emitted serviceCreated event for marketplace');
|
|
}
|
|
|
|
// Refresh services list to show new application
|
|
if (window.dashboardInstance) {
|
|
console.log('🔧 SERVICE PROVIDER: Refreshing services list...');
|
|
await window.dashboardInstance.populateServicesList();
|
|
|
|
// Also refresh charts to include new service data
|
|
window.dashboardInstance.createServiceDistributionChart();
|
|
window.dashboardInstance.createServicePerformanceChart();
|
|
}
|
|
|
|
console.log('🔧 SERVICE PROVIDER: Application created successfully:', result);
|
|
|
|
} else {
|
|
throw new Error('Failed to create application');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('🔧 PHASE 1 FIX: Error creating service:', error);
|
|
showNotification(`Failed to create service: ${error.message}`, 'error');
|
|
} finally {
|
|
// PHASE 1 FIX: Restore button state
|
|
createBtn.disabled = false;
|
|
createBtn.textContent = originalBtnText;
|
|
}
|
|
}
|
|
|
|
// PHASE 1 FIX: Add form validation helpers
|
|
function validateServiceForm() {
|
|
const requiredFields = [
|
|
'serviceName', 'serviceDesc', 'serviceCategory', 'serviceDelivery',
|
|
'pricingType', 'priceAmount', 'serviceExperience', 'availableHours', 'responseTime'
|
|
];
|
|
|
|
let isValid = true;
|
|
|
|
requiredFields.forEach(fieldId => {
|
|
const field = document.getElementById(fieldId);
|
|
if (field) {
|
|
const value = field.value.trim();
|
|
if (!value || value === 'Select category' || value === 'Select delivery method' ||
|
|
value === 'Select pricing model' || value === 'Select experience level' ||
|
|
value === 'Response time') {
|
|
field.classList.add('is-invalid');
|
|
isValid = false;
|
|
} else {
|
|
field.classList.remove('is-invalid');
|
|
}
|
|
}
|
|
});
|
|
|
|
return isValid;
|
|
}
|
|
|
|
// PHASE 1 FIX: Add real-time validation
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (window.__serviceProviderValidationInit) return;
|
|
window.__serviceProviderValidationInit = true;
|
|
// Add event listeners for real-time validation
|
|
const formFields = [
|
|
'serviceName', 'serviceDesc', 'serviceCategory', 'serviceDelivery',
|
|
'pricingType', 'priceAmount', 'serviceExperience', 'availableHours', 'responseTime'
|
|
];
|
|
|
|
formFields.forEach(fieldId => {
|
|
const field = document.getElementById(fieldId);
|
|
if (field) {
|
|
field.addEventListener('input', function() {
|
|
if (this.value.trim() && this.value !== 'Select category' &&
|
|
this.value !== 'Select delivery method' && this.value !== 'Select pricing model' &&
|
|
this.value !== 'Select experience level' && this.value !== 'Response time') {
|
|
this.classList.remove('is-invalid');
|
|
}
|
|
});
|
|
|
|
field.addEventListener('change', function() {
|
|
if (this.value.trim() && this.value !== 'Select category' &&
|
|
this.value !== 'Select delivery method' && this.value !== 'Select pricing model' &&
|
|
this.value !== 'Select experience level' && this.value !== 'Response time') {
|
|
this.classList.remove('is-invalid');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}); |