- Added SimpleAnalyticsPopulator class for populating project, employee, customer analytics, and daily metrics. - Integrated MySQL database connections for tilbudgivern and ordrestyring_local databases. - Developed sync_ordrestyring_data_old.sh script for comprehensive data synchronization from Ordrestyring API. - Created test scripts for analytics API and services to validate functionality and data integrity. - Introduced FixedHistoricalQuoteCalculator and HistoricalQuoteCalculator for generating quotes based on historical data. - Implemented vary-profit-margins.js to introduce variability in profit margins based on project size and customer type.
304 lines
14 KiB
React
304 lines
14 KiB
React
import React, { useState, useEffect } from 'react';
|
|
|
|
const ANALYTICS_API_BASE = 'http://localhost:3001/api/analytics';
|
|
|
|
const AnalyticsDashboard = () => {
|
|
const [kpis, setKpis] = useState(null);
|
|
const [employees, setEmployees] = useState([]);
|
|
const [customers, setCustomers] = useState([]);
|
|
const [materials, setMaterials] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [activeTab, setActiveTab] = useState('overview');
|
|
|
|
// Fetch analytics data
|
|
useEffect(() => {
|
|
const fetchAnalyticsData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
const [kpisRes, employeesRes, customersRes, materialsRes] = await Promise.all([
|
|
fetch(`${ANALYTICS_API_BASE}/kpis`),
|
|
fetch(`${ANALYTICS_API_BASE}/employees`),
|
|
fetch(`${ANALYTICS_API_BASE}/customers?top=10`),
|
|
fetch(`${ANALYTICS_API_BASE}/materials`)
|
|
]);
|
|
|
|
const [kpisData, employeesData, customersData, materialsData] = await Promise.all([
|
|
kpisRes.json(),
|
|
employeesRes.json(),
|
|
customersRes.json(),
|
|
materialsRes.json()
|
|
]);
|
|
|
|
setKpis(kpisData.data);
|
|
setEmployees(employeesData.data?.slice(0, 10) || []);
|
|
setCustomers(customersData.data || []);
|
|
setMaterials(materialsData.data?.slice(0, 8) || []);
|
|
|
|
} catch (err) {
|
|
setError(err.message);
|
|
console.error('Analytics fetch error:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchAnalyticsData();
|
|
}, []);
|
|
|
|
const formatCurrency = (amount) => {
|
|
if (!amount) return '0 kr';
|
|
|
|
if (amount >= 1000000) {
|
|
return `${(amount / 1000000).toFixed(1)}M kr`;
|
|
} else if (amount >= 1000) {
|
|
return `${(amount / 1000).toFixed(0)}k kr`;
|
|
}
|
|
return `${amount.toLocaleString()} kr`;
|
|
};
|
|
|
|
const formatNumber = (num) => {
|
|
if (!num) return '0';
|
|
return num.toLocaleString();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-lg">📊 Indlæser nøgletal...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-red-500">❌ Fejl ved indlæsning: {error}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const KPICard = ({ title, value, icon, color = 'blue' }) => (
|
|
<div className={`bg-white rounded-lg shadow-md p-6 border-l-4 border-${color}-500`}>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">{title}</p>
|
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
|
</div>
|
|
<div className="text-3xl">{icon}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const TabButton = ({ id, label, isActive, onClick }) => (
|
|
<button
|
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
|
isActive
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
onClick={() => onClick(id)}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
|
|
const DataTable = ({ title, data, columns }) => (
|
|
<div className="bg-white rounded-lg shadow-md p-6">
|
|
<h3 className="text-lg font-semibold mb-4">{title}</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
{columns.map((col) => (
|
|
<th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
{col.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{data.map((item, index) => (
|
|
<tr key={index} className="hover:bg-gray-50">
|
|
{columns.map((col) => (
|
|
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{col.format ? col.format(item[col.key]) : item[col.key]}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-6">
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-3xl font-bold text-gray-900">📊 Business Nøgletal</h1>
|
|
<div className="text-sm text-gray-500">
|
|
Sidst opdateret: {new Date().toLocaleString('da-DK')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<KPICard
|
|
title="Samlet Projekt Værdi"
|
|
value={formatCurrency(kpis?.total_project_value)}
|
|
icon="💰"
|
|
color="green"
|
|
/>
|
|
<KPICard
|
|
title="Aktive Projekter"
|
|
value={formatNumber(kpis?.total_projects)}
|
|
icon="📋"
|
|
color="blue"
|
|
/>
|
|
<KPICard
|
|
title="Profit Margin"
|
|
value={`${kpis?.overall_profit_margin}%`}
|
|
icon="📈"
|
|
color="green"
|
|
/>
|
|
<KPICard
|
|
title="Aktive Medarbejdere"
|
|
value={formatNumber(kpis?.active_employees)}
|
|
icon="👥"
|
|
color="purple"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white rounded-lg shadow-md p-6">
|
|
<div className="flex space-x-2 mb-6 border-b">
|
|
<TabButton
|
|
id="overview"
|
|
label="Oversigt"
|
|
isActive={activeTab === 'overview'}
|
|
onClick={setActiveTab}
|
|
/>
|
|
<TabButton
|
|
id="employees"
|
|
label="Medarbejdere"
|
|
isActive={activeTab === 'employees'}
|
|
onClick={setActiveTab}
|
|
/>
|
|
<TabButton
|
|
id="customers"
|
|
label="Kunder"
|
|
isActive={activeTab === 'customers'}
|
|
onClick={setActiveTab}
|
|
/>
|
|
<TabButton
|
|
id="materials"
|
|
label="Materialer"
|
|
isActive={activeTab === 'materials'}
|
|
onClick={setActiveTab}
|
|
/>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'overview' && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-gray-50 rounded-lg p-6">
|
|
<h3 className="text-lg font-semibold mb-4">📈 Business Metrics</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Unikke Kunder</span>
|
|
<span className="font-semibold">{formatNumber(kpis?.unique_customers)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Gennemsnit Projekt Værdi</span>
|
|
<span className="font-semibold">{formatCurrency(kpis?.avg_project_value)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Total Timer Logget</span>
|
|
<span className="font-semibold">{formatNumber(kpis?.total_hours_logged)}t</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Gennemsnit Timer/Projekt</span>
|
|
<span className="font-semibold">{kpis?.avg_hours_per_project}t</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-6">
|
|
<h3 className="text-lg font-semibold mb-4">🔨 Material Overview</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Total Material Indkøb</span>
|
|
<span className="font-semibold">{formatCurrency(kpis?.total_material_costs)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Total Material Salg</span>
|
|
<span className="font-semibold">{formatCurrency(kpis?.total_material_sales)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Material Profit</span>
|
|
<span className="font-semibold text-green-600">
|
|
{formatCurrency(kpis?.total_material_sales - kpis?.total_material_costs)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Unikke Leverandører</span>
|
|
<span className="font-semibold">{formatNumber(kpis?.unique_suppliers)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'employees' && (
|
|
<DataTable
|
|
title="👥 Top Medarbejder Performance"
|
|
data={employees}
|
|
columns={[
|
|
{ key: 'full_name', label: 'Navn' },
|
|
{ key: 'total_hours', label: 'Timer', format: (val) => `${val}t` },
|
|
{ key: 'projects_worked', label: 'Projekter' },
|
|
{ key: 'total_labor_value', label: 'Værdi', format: formatCurrency },
|
|
{ key: 'avg_daily_hours', label: 'Ø Timer/Dag', format: (val) => `${val}t` }
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'customers' && (
|
|
<DataTable
|
|
title="🏢 Top Kunder (Værdi)"
|
|
data={customers}
|
|
columns={[
|
|
{ key: 'customer_number', label: 'Kunde Nr.' },
|
|
{ key: 'total_projects', label: 'Projekter' },
|
|
{ key: 'total_customer_value', label: 'Total Værdi', format: formatCurrency },
|
|
{ key: 'avg_project_total_value', label: 'Ø Projekt Værdi', format: formatCurrency },
|
|
{ key: 'total_hours', label: 'Timer', format: (val) => `${val}t` }
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'materials' && (
|
|
<DataTable
|
|
title="🔨 Top Leverandører"
|
|
data={materials}
|
|
columns={[
|
|
{ key: 'supplier', label: 'Leverandør' },
|
|
{ key: 'projects_used', label: 'Projekter' },
|
|
{ key: 'total_purchases', label: 'Indkøb', format: formatCurrency },
|
|
{ key: 'total_profit', label: 'Profit', format: formatCurrency },
|
|
{ key: 'profit_margin_percent', label: 'Margin %', format: (val) => `${val}%` }
|
|
]}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AnalyticsDashboard; |