Compare commits
17 Commits
56a6eb7be5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 563127c75a | |||
| 58fcf4a722 | |||
| 5ae1e28ef2 | |||
| 2242a12094 | |||
| 4cfe8ad673 | |||
| 3e8cf935bd | |||
| 7a3c40a651 | |||
| 14349a211d | |||
| 6cedbb1bcb | |||
| 97d497ba4f | |||
| b69c5f00aa | |||
| abbb99fe74 | |||
| 1a4444e1e8 | |||
| 579049395d | |||
| 265f4c35cb | |||
| 531ae4cf0b | |||
| 71a2b37c16 |
+2
-2
@@ -2,14 +2,14 @@
|
||||
# Python 3.13-slim + FastAPI + uvicorn
|
||||
# System packages: WeasyPrint (libpango, libcairo), Pillow (libjpeg, libpng)
|
||||
|
||||
FROM python:3.13-slim AS base
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# --- System packages for WeasyPrint and Pillow ---
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libjpeg62-turbo \
|
||||
libpng16-16 \
|
||||
libffi-dev \
|
||||
|
||||
@@ -4,7 +4,8 @@ sqlalchemy[asyncio]>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
passlib[bcrypt]
|
||||
bcrypt<5.0
|
||||
redis>=5.0.0
|
||||
python-multipart>=0.0.12
|
||||
pytest>=8.0.0
|
||||
@@ -14,3 +15,4 @@ pytest-cov>=5.0.0
|
||||
aiosqlite>=0.20.0
|
||||
Pillow>=10.0.0
|
||||
weasyprint>=62.0
|
||||
email-validator>=2.0.0
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Seed script to create initial admin user.
|
||||
|
||||
Usage:
|
||||
python scripts/seed_admin.py
|
||||
|
||||
Environment variables required:
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import hash_password
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def seed_admin():
|
||||
"""Create initial admin user if none exists."""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# Create tables if they don't exist
|
||||
from app.database import Base
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
# Check if any admin exists
|
||||
result = await session.execute(
|
||||
text("SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = true")
|
||||
)
|
||||
admin_count = result.scalar()
|
||||
|
||||
if admin_count > 0:
|
||||
print("Admin user already exists. Skipping seed.")
|
||||
return
|
||||
|
||||
# Create admin user
|
||||
admin = User(
|
||||
id=uuid.uuid4(),
|
||||
email="admin@erp.local",
|
||||
password_hash=hash_password("Admin123!ERP"),
|
||||
full_name="System Administrator",
|
||||
role="admin",
|
||||
language="de",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
|
||||
print("=" * 50)
|
||||
print("Admin user created successfully!")
|
||||
print("=" * 50)
|
||||
print(f"Email: admin@erp.local")
|
||||
print(f"Password: Admin123!ERP")
|
||||
print(f"Role: admin")
|
||||
print(f"Language: de")
|
||||
print("=" * 50)
|
||||
print("⚠️ Please change the password after first login!")
|
||||
print("=" * 50)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_admin())
|
||||
+17
-7
@@ -10,10 +10,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: erp-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@erp-db:5432/${POSTGRES_DB}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_ALGORITHM=${JWT_ALGORITHM:-HS256}
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
start_period: 15s
|
||||
networks:
|
||||
- erp-network
|
||||
- coolify
|
||||
|
||||
# ============ Frontend (Next.js standalone) ============
|
||||
frontend:
|
||||
@@ -56,16 +57,18 @@ services:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
||||
container_name: erp-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
||||
- NODE_ENV=production
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -91,7 +94,10 @@ services:
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- erp-network
|
||||
erp-network:
|
||||
aliases:
|
||||
- erp-db
|
||||
- postgres
|
||||
|
||||
# ============ Redis 7 ============
|
||||
redis:
|
||||
@@ -109,6 +115,7 @@ services:
|
||||
start_period: 5s
|
||||
networks:
|
||||
- erp-network
|
||||
- coolify
|
||||
|
||||
# ============ Volumes ============
|
||||
volumes:
|
||||
@@ -123,3 +130,6 @@ volumes:
|
||||
networks:
|
||||
erp-network:
|
||||
driver: bridge
|
||||
coolify:
|
||||
name: coolify
|
||||
external: true
|
||||
|
||||
+1
-1
@@ -41,6 +41,6 @@ EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/ || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -41,7 +41,7 @@ function LoginForm() {
|
||||
const tokens = await apiLogin(email, password);
|
||||
setTokens(tokens);
|
||||
showToast(t('login.success'), 'success');
|
||||
router.push('/dashboard');
|
||||
router.push('/de/dashboard');
|
||||
} catch (err) {
|
||||
const message = (err as any)?.error?.message || t('login.error.invalidCredentials');
|
||||
showToast(message, 'error');
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Dashboard } from '@/components/Dashboard';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { VehicleEditForm } from '@/components/vehicles/VehicleEditForm';
|
||||
|
||||
export default async function FahrzeugBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold text-text mb-6">Fahrzeug bearbeiten</h1>
|
||||
<VehicleEditForm vehicleId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { isAuthenticated } from '@/lib/api';
|
||||
|
||||
export default function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
// Auth guard: redirect to /login if no token is present
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
setChecked(true);
|
||||
}, [router]);
|
||||
|
||||
// Show nothing while redirecting (avoids flashing protected content)
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
<div className="text-text-muted text-sm">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const locale = params.locale === 'en' ? 'en' : 'de';
|
||||
|
||||
return (
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navigation />
|
||||
{/* Main content area — offset for sidebar on desktop */}
|
||||
<main className="lg:ml-64 p-4 lg:p-8" data-testid="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Dashboard } from '@/components/Dashboard';
|
||||
|
||||
export default function LocaleHomePage() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
@@ -20,3 +20,71 @@ body {
|
||||
color: var(--color-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.print\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print\:block {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
nav,
|
||||
aside,
|
||||
button[class*="print:hidden"],
|
||||
[data-testid="vehicle-actions"],
|
||||
[data-testid="vehicle-images"],
|
||||
[data-testid="upload-image-button"],
|
||||
[data-testid="delete-button"],
|
||||
[data-testid="vehicle-filters"],
|
||||
[data-testid="vehicle-pagination"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-testid="vehicle-detail-fields"] {
|
||||
border: 1px solid #ccc !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
[data-testid="vehicle-detail-fields"] dt {
|
||||
font-weight: bold !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
[data-testid="vehicle-detail-fields"] dd {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
h1[data-testid="vehicle-detail-title"] {
|
||||
font-size: 18pt !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.bg-surface {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.border-border {
|
||||
border-color: #ccc !important;
|
||||
}
|
||||
|
||||
.text-text-muted {
|
||||
color: #555 !important;
|
||||
}
|
||||
|
||||
.text-text {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
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',
|
||||
labelKey: 'nav.vehicles',
|
||||
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="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>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/kontakte',
|
||||
labelKey: 'nav.contacts',
|
||||
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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/verkauf',
|
||||
labelKey: 'nav.sales',
|
||||
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="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>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/ki-copilot',
|
||||
labelKey: 'nav.ki-copilot',
|
||||
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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/ocr',
|
||||
labelKey: 'nav.ocr',
|
||||
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/de/retouch',
|
||||
labelKey: 'nav.retouch',
|
||||
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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useI18n();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
// Extract the path segment after locale (e.g. /de/fahrzeuge -> fahrzeuge)
|
||||
const segments = href.split('/'); // ['', 'de', 'fahrzeuge']
|
||||
const section = segments[2]; // 'fahrzeuge'
|
||||
if (!section) return false;
|
||||
return pathname.includes(`/${section}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header bar with hamburger */}
|
||||
<header
|
||||
className="lg:hidden fixed top-0 left-0 right-0 z-40 bg-surface border-b border-border flex items-center justify-between px-4 h-14"
|
||||
data-testid="mobile-header"
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="p-2 rounded-lg hover:bg-primary/10 transition-colors"
|
||||
aria-label={t('nav.menu')}
|
||||
data-testid="hamburger-toggle"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-text" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileOpen ? 'M6 18L18 6M6 6l12 12' : 'M4 6h16M4 12h16M4 18h16'} />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-text" data-testid="mobile-app-name">{t('nav.appName')}</span>
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-30 bg-black/40"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-testid="mobile-overlay"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed top-0 left-0 z-30 h-full w-64 bg-surface border-r border-border flex flex-col transition-transform duration-200 lg:translate-x-0 ${mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
{/* Desktop logo / app name */}
|
||||
<div className="hidden lg:flex items-center h-14 px-6 border-b border-border">
|
||||
<span className="text-lg font-bold text-primary" data-testid="desktop-app-name">{t('nav.appName')}</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile spacer for header height */}
|
||||
<div className="lg:hidden h-14" />
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1" data-testid="nav-links">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${active ? 'bg-primary text-white' : 'text-text hover:bg-primary/10'}`}
|
||||
data-testid={`nav-link-${item.href.split('/')[2]}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Desktop user menu at bottom */}
|
||||
<div className="hidden lg:flex items-center justify-end px-4 py-3 border-t border-border">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile spacer so content starts below header */}
|
||||
<div className="lg:hidden h-14" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { clearTokens } from '@/lib/api';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* Decodes the email from a JWT access token stored in localStorage.
|
||||
* Returns null if no token is present or the payload cannot be parsed.
|
||||
*/
|
||||
function decodeEmailFromToken(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return payload.email || payload.sub || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
setEmail(decodeEmailFromToken(token));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
function handleLogout() {
|
||||
clearTokens();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef} data-testid="user-menu">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-primary/10 transition-colors text-text"
|
||||
aria-label={t('nav.logout')}
|
||||
data-testid="user-menu-toggle"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium hidden sm:inline" data-testid="user-email">
|
||||
{email || '—'}
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-56 bg-surface border border-border rounded-lg shadow-lg py-1 z-50"
|
||||
data-testid="user-menu-dropdown"
|
||||
>
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<p className="text-xs text-text-muted">{t('nav.settings')}</p>
|
||||
<p className="text-sm font-medium text-text truncate">{email || '—'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-error hover:bg-error/10 transition-colors flex items-center gap-2"
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { VehicleResponse } from '@/lib/vehicles';
|
||||
|
||||
interface VehicleActionsProps {
|
||||
vehicle: VehicleResponse;
|
||||
}
|
||||
|
||||
function escapeCsvValue(value: string | number | undefined | null): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, csvContent: string): void {
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function vehicleToCsvRow(v: VehicleResponse): string {
|
||||
const headers = [
|
||||
'Make', 'Model', 'FIN', 'Year', 'Price', 'Vehicle Type',
|
||||
'Condition', 'Availability', 'Mileage (km)', 'Power (kW)',
|
||||
'Fuel Type', 'Transmission', 'Color', 'Location',
|
||||
'First Registration', 'Description',
|
||||
];
|
||||
return headers.map(h => {
|
||||
switch (h) {
|
||||
case 'Make': return escapeCsvValue(v.make);
|
||||
case 'Model': return escapeCsvValue(v.model);
|
||||
case 'FIN': return escapeCsvValue(v.fin);
|
||||
case 'Year': return escapeCsvValue(v.year);
|
||||
case 'Price': return escapeCsvValue(v.price);
|
||||
case 'Vehicle Type': return escapeCsvValue(v.vehicle_type);
|
||||
case 'Condition': return escapeCsvValue(v.condition);
|
||||
case 'Availability': return escapeCsvValue(v.availability);
|
||||
case 'Mileage (km)': return escapeCsvValue(v.mileage_km);
|
||||
case 'Power (kW)': return escapeCsvValue(v.power_kw);
|
||||
case 'Fuel Type': return escapeCsvValue(v.fuel_type);
|
||||
case 'Transmission': return escapeCsvValue(v.transmission);
|
||||
case 'Color': return escapeCsvValue(v.color);
|
||||
case 'Location': return escapeCsvValue(v.location);
|
||||
case 'First Registration': return escapeCsvValue(v.first_registration);
|
||||
case 'Description': return escapeCsvValue(v.description);
|
||||
default: return '';
|
||||
}
|
||||
}).join(',');
|
||||
}
|
||||
|
||||
export function VehicleActions({ vehicle }: VehicleActionsProps) {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleExportPdf = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const headers = 'Make,Model,FIN,Year,Price,Vehicle Type,Condition,Availability,Mileage (km),Power (kW),Fuel Type,Transmission,Color,Location,First Registration,Description';
|
||||
const row = vehicleToCsvRow(vehicle);
|
||||
const csv = `${headers}\n${row}`;
|
||||
const filename = `vehicle_${vehicle.fin || vehicle.id}.csv`;
|
||||
downloadCsv(filename, csv);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="vehicle-actions" className="flex gap-2 print:hidden">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrint}
|
||||
data-testid="print-button"
|
||||
>
|
||||
Drucken
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExportPdf}
|
||||
data-testid="export-pdf-button"
|
||||
>
|
||||
Als PDF exportieren
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExportCsv}
|
||||
data-testid="export-csv-button"
|
||||
>
|
||||
Als CSV exportieren
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function exportVehiclesToCsv(vehicles: VehicleResponse[]): void {
|
||||
const headers = 'Make,Model,FIN,Year,Price,Vehicle Type,Condition,Availability,Mileage (km),Power (kW),Fuel Type,Transmission,Color,Location,First Registration,Description';
|
||||
const rows = vehicles.map(v => vehicleToCsvRow(v));
|
||||
const csv = [headers, ...rows].join('\n');
|
||||
const now = new Date().toISOString().split('T')[0];
|
||||
downloadCsv(`vehicles_export_${now}.csv`, csv);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type VehicleResponse,
|
||||
} from '@/lib/vehicles';
|
||||
import { MobileDeStatus } from './MobileDeStatus';
|
||||
import { VehicleImages } from './VehicleImages';
|
||||
import { VehicleActions } from './VehicleActions';
|
||||
|
||||
interface VehicleDetailProps {
|
||||
vehicleId: string;
|
||||
@@ -126,6 +128,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VehicleActions vehicle={vehicle} />
|
||||
|
||||
<div data-testid="vehicle-detail-fields" className="grid grid-cols-2 md:grid-cols-3 gap-4 p-6 bg-surface rounded-lg border border-border">
|
||||
{fields.map(field => (
|
||||
<div key={field.label} className="space-y-1">
|
||||
@@ -137,6 +141,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<VehicleImages vehicleId={vehicle.id} />
|
||||
|
||||
<MobileDeStatus vehicleId={vehicle.id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { VehicleForm } from './VehicleForm';
|
||||
import { getVehicle, type VehicleResponse } from '@/lib/vehicles';
|
||||
|
||||
interface VehicleEditFormProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
export function VehicleEditForm({ vehicleId }: VehicleEditFormProps) {
|
||||
const [vehicle, setVehicle] = useState<VehicleResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchVehicle() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getVehicle(vehicleId);
|
||||
setVehicle(data);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load vehicle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchVehicle();
|
||||
}, [vehicleId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div data-testid="vehicle-edit-loading" className="text-center py-8 text-text-muted">
|
||||
Loading vehicle...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div data-testid="vehicle-edit-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
<div data-testid="vehicle-edit-not-found" className="text-center py-8 text-text-muted">
|
||||
Vehicle not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <VehicleForm vehicle={vehicle} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import {
|
||||
listFiles,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
isImageMime,
|
||||
getThumbnailUrl,
|
||||
getFileDownloadUrl,
|
||||
type FileResponse,
|
||||
} from '@/lib/files';
|
||||
|
||||
interface VehicleImagesProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
export function VehicleImages({ vehicleId }: VehicleImagesProps) {
|
||||
const [files, setFiles] = useState<FileResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState<FileResponse | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listFiles(vehicleId, 1, 100);
|
||||
const imageFiles = data.items.filter(f => isImageMime(f.mime_type));
|
||||
setFiles(imageFiles);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load images');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
await uploadFile(vehicleId, selectedFiles[i]);
|
||||
}
|
||||
await fetchFiles();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to upload images');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (fileId: string) => {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
try {
|
||||
await deleteFile(vehicleId, fileId);
|
||||
setFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
if (selectedImage?.id === fileId) {
|
||||
setSelectedImage(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="vehicle-images" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-text">Bilder</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
data-testid="image-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
data-testid="upload-image-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={uploading}
|
||||
disabled={uploading}
|
||||
>
|
||||
Bild hochladen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div data-testid="images-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="images-loading" className="text-center py-8 text-text-muted">
|
||||
Loading images...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div data-testid="no-images" className="text-center py-8 text-text-muted">
|
||||
Keine Bilder vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="image-grid"
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{files.map(file => {
|
||||
const thumbUrl = getThumbnailUrl(file) || getFileDownloadUrl(vehicleId, file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
data-testid={`image-item-${file.id}`}
|
||||
className="relative group rounded-lg overflow-hidden border border-border bg-surface"
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedImage(file)}
|
||||
className="block w-full aspect-square"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={file.original_filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
data-testid={`delete-image-${file.id}`}
|
||||
onClick={() => handleDelete(file.id)}
|
||||
className="absolute top-2 right-2 bg-error text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Delete image"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={selectedImage !== null}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
title={selectedImage?.original_filename}
|
||||
>
|
||||
{selectedImage && (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getFileDownloadUrl(vehicleId, selectedImage.id)}
|
||||
alt={selectedImage.original_filename}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
||||
/>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(selectedImage.id)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type VehicleListParams,
|
||||
} from '@/lib/vehicles';
|
||||
import type { PaginatedResponse } from '@/lib/api';
|
||||
import { exportVehiclesToCsv } from './VehicleActions';
|
||||
|
||||
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
||||
@@ -31,6 +32,7 @@ export function VehicleList() {
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const [filters, setFilters] = useState<VehicleListParams>({
|
||||
page: 1,
|
||||
@@ -69,6 +71,23 @@ export function VehicleList() {
|
||||
setFilters(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handleExportAllCsv = async () => {
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: PaginatedResponse<VehicleResponse> = await listVehicles({
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
});
|
||||
exportVehiclesToCsv(data.items);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to export vehicles');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'make',
|
||||
@@ -101,9 +120,20 @@ export function VehicleList() {
|
||||
<div data-testid="vehicle-list" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
||||
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
||||
+ Neues Fahrzeug
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExportAllCsv}
|
||||
loading={exporting}
|
||||
disabled={exporting}
|
||||
data-testid="export-all-csv-button"
|
||||
>
|
||||
Alle als CSV exportieren
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
||||
+ Neues Fahrzeug
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||
|
||||
@@ -14,6 +14,29 @@
|
||||
"nav.sales": "Verkäufe",
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.ki-copilot": "KI-Copilot",
|
||||
"nav.ocr": "OCR",
|
||||
"nav.retouch": "Bildretusche",
|
||||
"nav.menu": "Menü",
|
||||
"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.cancel": "Abbrechen",
|
||||
"common.delete": "Löschen",
|
||||
@@ -24,5 +47,14 @@
|
||||
"common.confirm": "Bestätigen",
|
||||
"user.role.admin": "Administrator",
|
||||
"user.role.verkaeufer": "Verkäufer",
|
||||
"user.role.buchhaltung": "Buchhaltung"
|
||||
"user.role.buchhaltung": "Buchhaltung",
|
||||
"vehicle.edit": "Bearbeiten",
|
||||
"vehicle.print": "Drucken",
|
||||
"vehicle.exportPdf": "Als PDF exportieren",
|
||||
"vehicle.exportCsv": "Als CSV exportieren",
|
||||
"vehicle.images": "Bilder",
|
||||
"vehicle.uploadImage": "Bild hochladen",
|
||||
"vehicle.deleteImage": "Bild löschen",
|
||||
"vehicle.noImages": "Keine Bilder vorhanden",
|
||||
"vehicle.exportAllCsv": "Alle als CSV exportieren"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,29 @@
|
||||
"nav.sales": "Sales",
|
||||
"nav.settings": "Settings",
|
||||
"nav.logout": "Logout",
|
||||
"nav.ki-copilot": "AI Copilot",
|
||||
"nav.ocr": "OCR",
|
||||
"nav.retouch": "Image Retouch",
|
||||
"nav.menu": "Menu",
|
||||
"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.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
@@ -24,5 +47,14 @@
|
||||
"common.confirm": "Confirm",
|
||||
"user.role.admin": "Administrator",
|
||||
"user.role.verkaeufer": "Sales",
|
||||
"user.role.buchhaltung": "Accounting"
|
||||
"user.role.buchhaltung": "Accounting",
|
||||
"vehicle.edit": "Edit",
|
||||
"vehicle.print": "Print",
|
||||
"vehicle.exportPdf": "Export as PDF",
|
||||
"vehicle.exportCsv": "Export as CSV",
|
||||
"vehicle.images": "Images",
|
||||
"vehicle.uploadImage": "Upload Image",
|
||||
"vehicle.deleteImage": "Delete Image",
|
||||
"vehicle.noImages": "No images available",
|
||||
"vehicle.exportAllCsv": "Export All as CSV"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user