feat: add dashboard page with stats, quick actions, recent vehicles, update navigation and login redirect
This commit is contained in:
@@ -41,7 +41,7 @@ function LoginForm() {
|
|||||||
const tokens = await apiLogin(email, password);
|
const tokens = await apiLogin(email, password);
|
||||||
setTokens(tokens);
|
setTokens(tokens);
|
||||||
showToast(t('login.success'), 'success');
|
showToast(t('login.success'), 'success');
|
||||||
router.push('/de/fahrzeuge');
|
router.push('/de/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as any)?.error?.message || t('login.error.invalidCredentials');
|
const message = (err as any)?.error?.message || t('login.error.invalidCredentials');
|
||||||
showToast(message, 'error');
|
showToast(message, 'error');
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Dashboard } from '@/components/Dashboard';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Dashboard } from '@/components/Dashboard';
|
||||||
|
|
||||||
|
export default function LocaleHomePage() {
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Table } from '@/components/ui/Table';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { listVehicles, type VehicleResponse } from '@/lib/vehicles';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
total: number;
|
||||||
|
available: number;
|
||||||
|
reserved: number;
|
||||||
|
sold: number;
|
||||||
|
totalValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
testId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value, testId }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-surface rounded-xl border border-border p-6 flex flex-col gap-2"
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-text-muted text-sm font-medium">
|
||||||
|
<span className="text-primary">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-text">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-4-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckCircleIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ClockIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TagIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EuroIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [recentVehicles, setRecentVehicles] = useState<VehicleResponse[]>([]);
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
total: 0,
|
||||||
|
available: 0,
|
||||||
|
reserved: 0,
|
||||||
|
sold: 0,
|
||||||
|
totalValue: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchDashboardData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [recentResponse, allResponse] = await Promise.all([
|
||||||
|
listVehicles({ page: 1, page_size: 5, sort: '-created_at' }),
|
||||||
|
listVehicles({ page: 1, page_size: 1000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setRecentVehicles(recentResponse.items);
|
||||||
|
|
||||||
|
const allVehicles = allResponse.items;
|
||||||
|
const computedStats: DashboardStats = {
|
||||||
|
total: allResponse.total,
|
||||||
|
available: allVehicles.filter(v => v.availability === 'available').length,
|
||||||
|
reserved: allVehicles.filter(v => v.availability === 'reserved').length,
|
||||||
|
sold: allVehicles.filter(v => v.availability === 'sold').length,
|
||||||
|
totalValue: allVehicles.reduce((sum, v) => sum + v.price, 0),
|
||||||
|
};
|
||||||
|
setStats(computedStats);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || t('dashboard.error'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, [fetchDashboardData]);
|
||||||
|
|
||||||
|
const currencyFormatter = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
|
||||||
|
|
||||||
|
const vehicleColumns = [
|
||||||
|
{
|
||||||
|
key: 'make',
|
||||||
|
label: t('dashboard.make'),
|
||||||
|
render: (row: VehicleResponse) => (
|
||||||
|
<button
|
||||||
|
data-testid={`dashboard-vehicle-${row.id}`}
|
||||||
|
onClick={() => router.push(`/de/fahrzeuge/${row.id}`)}
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{row.make}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'model', label: t('dashboard.model') },
|
||||||
|
{ key: 'fin', label: t('dashboard.fin') },
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
label: t('dashboard.price'),
|
||||||
|
render: (row: VehicleResponse) => currencyFormatter.format(row.price),
|
||||||
|
},
|
||||||
|
{ key: 'availability', label: t('dashboard.availability') },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div data-testid="dashboard-loading" className="flex items-center justify-center h-64">
|
||||||
|
<span className="text-text-muted text-sm">{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div data-testid="dashboard-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="dashboard" className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-text" data-testid="dashboard-title">
|
||||||
|
{t('dashboard.title')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||||
|
data-testid="dashboard-stats"
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
icon={CarIcon}
|
||||||
|
label={t('dashboard.totalVehicles')}
|
||||||
|
value={String(stats.total)}
|
||||||
|
testId="stat-total-vehicles"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CheckCircleIcon}
|
||||||
|
label={t('dashboard.available')}
|
||||||
|
value={String(stats.available)}
|
||||||
|
testId="stat-available"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={ClockIcon}
|
||||||
|
label={t('dashboard.reserved')}
|
||||||
|
value={String(stats.reserved)}
|
||||||
|
testId="stat-reserved"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={TagIcon}
|
||||||
|
label={t('dashboard.sold')}
|
||||||
|
value={String(stats.sold)}
|
||||||
|
testId="stat-sold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="bg-surface rounded-xl border border-border p-6 flex items-center justify-between"
|
||||||
|
data-testid="stat-total-value"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-text-muted text-sm font-medium">
|
||||||
|
<span className="text-primary">{EuroIcon}</span>
|
||||||
|
{t('dashboard.totalValue')}
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-text">
|
||||||
|
{currencyFormatter.format(stats.totalValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="dashboard-quick-actions" className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-text">{t('dashboard.quickActions')}</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push('/de/fahrzeuge/neu')}
|
||||||
|
data-testid="quick-action-new-vehicle"
|
||||||
|
>
|
||||||
|
{t('dashboard.newVehicle')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push('/de/kontakte/neu')}
|
||||||
|
data-testid="quick-action-new-contact"
|
||||||
|
>
|
||||||
|
{t('dashboard.newContact')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push('/de/verkauf/neu')}
|
||||||
|
data-testid="quick-action-new-sale"
|
||||||
|
>
|
||||||
|
{t('dashboard.newSale')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="dashboard-recent-vehicles" className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-text">{t('dashboard.recentVehicles')}</h2>
|
||||||
|
<Card>
|
||||||
|
{recentVehicles.length > 0 ? (
|
||||||
|
<Table columns={vehicleColumns} data={recentVehicles} rowKey={row => row.id} />
|
||||||
|
) : (
|
||||||
|
<p className="text-text-muted text-center py-8" data-testid="no-recent-vehicles">
|
||||||
|
{t('dashboard.noVehicles')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,15 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
href: '/de/dashboard',
|
||||||
|
labelKey: 'nav.dashboard',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/de/fahrzeuge',
|
href: '/de/fahrzeuge',
|
||||||
labelKey: 'nav.vehicles',
|
labelKey: 'nav.vehicles',
|
||||||
|
|||||||
@@ -19,6 +19,24 @@
|
|||||||
"nav.retouch": "Bildretusche",
|
"nav.retouch": "Bildretusche",
|
||||||
"nav.menu": "Menü",
|
"nav.menu": "Menü",
|
||||||
"nav.appName": "ERP Nutzfahrzeuge",
|
"nav.appName": "ERP Nutzfahrzeuge",
|
||||||
|
"dashboard.title": "Dashboard",
|
||||||
|
"dashboard.totalVehicles": "Fahrzeuge gesamt",
|
||||||
|
"dashboard.available": "Verfügbar",
|
||||||
|
"dashboard.reserved": "Reserviert",
|
||||||
|
"dashboard.sold": "Verkauft",
|
||||||
|
"dashboard.totalValue": "Gesamtwert",
|
||||||
|
"dashboard.quickActions": "Schnellaktionen",
|
||||||
|
"dashboard.newVehicle": "Neues Fahrzeug",
|
||||||
|
"dashboard.newContact": "Neuer Kontakt",
|
||||||
|
"dashboard.newSale": "Neuer Verkauf",
|
||||||
|
"dashboard.recentVehicles": "Letzte Fahrzeuge",
|
||||||
|
"dashboard.noVehicles": "Keine Fahrzeuge vorhanden",
|
||||||
|
"dashboard.error": "Fehler beim Laden der Daten",
|
||||||
|
"dashboard.make": "Marke",
|
||||||
|
"dashboard.model": "Modell",
|
||||||
|
"dashboard.fin": "FIN",
|
||||||
|
"dashboard.price": "Preis",
|
||||||
|
"dashboard.availability": "Verfügbarkeit",
|
||||||
"common.save": "Speichern",
|
"common.save": "Speichern",
|
||||||
"common.cancel": "Abbrechen",
|
"common.cancel": "Abbrechen",
|
||||||
"common.delete": "Löschen",
|
"common.delete": "Löschen",
|
||||||
|
|||||||
@@ -19,6 +19,24 @@
|
|||||||
"nav.retouch": "Image Retouch",
|
"nav.retouch": "Image Retouch",
|
||||||
"nav.menu": "Menu",
|
"nav.menu": "Menu",
|
||||||
"nav.appName": "ERP Commercial Vehicles",
|
"nav.appName": "ERP Commercial Vehicles",
|
||||||
|
"dashboard.title": "Dashboard",
|
||||||
|
"dashboard.totalVehicles": "Total Vehicles",
|
||||||
|
"dashboard.available": "Available",
|
||||||
|
"dashboard.reserved": "Reserved",
|
||||||
|
"dashboard.sold": "Sold",
|
||||||
|
"dashboard.totalValue": "Total Value",
|
||||||
|
"dashboard.quickActions": "Quick Actions",
|
||||||
|
"dashboard.newVehicle": "New Vehicle",
|
||||||
|
"dashboard.newContact": "New Contact",
|
||||||
|
"dashboard.newSale": "New Sale",
|
||||||
|
"dashboard.recentVehicles": "Recent Vehicles",
|
||||||
|
"dashboard.noVehicles": "No vehicles available",
|
||||||
|
"dashboard.error": "Error loading data",
|
||||||
|
"dashboard.make": "Make",
|
||||||
|
"dashboard.model": "Model",
|
||||||
|
"dashboard.fin": "VIN",
|
||||||
|
"dashboard.price": "Price",
|
||||||
|
"dashboard.availability": "Availability",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
|
|||||||
Reference in New Issue
Block a user