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
|
# Python 3.13-slim + FastAPI + uvicorn
|
||||||
# System packages: WeasyPrint (libpango, libcairo), Pillow (libjpeg, libpng)
|
# 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 ---
|
# --- System packages for WeasyPrint and Pillow ---
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpango-1.0-0 \
|
libpango-1.0-0 \
|
||||||
libpangoft2-1.0-0 \
|
libpangoft2-1.0-0 \
|
||||||
libcairo2 \
|
libcairo2 \
|
||||||
libgdk-pixbuf2.0-0 \
|
libgdk-pixbuf-2.0-0 \
|
||||||
libjpeg62-turbo \
|
libjpeg62-turbo \
|
||||||
libpng16-16 \
|
libpng16-16 \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ sqlalchemy[asyncio]>=2.0.0
|
|||||||
asyncpg>=0.30.0
|
asyncpg>=0.30.0
|
||||||
pydantic-settings>=2.0.0
|
pydantic-settings>=2.0.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
passlib[bcrypt]>=1.7.4
|
passlib[bcrypt]
|
||||||
|
bcrypt<5.0
|
||||||
redis>=5.0.0
|
redis>=5.0.0
|
||||||
python-multipart>=0.0.12
|
python-multipart>=0.0.12
|
||||||
pytest>=8.0.0
|
pytest>=8.0.0
|
||||||
@@ -14,3 +15,4 @@ pytest-cov>=5.0.0
|
|||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
weasyprint>=62.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
|
dockerfile: Dockerfile
|
||||||
container_name: erp-backend
|
container_name: erp-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
expose:
|
||||||
- "8000:8000"
|
- "8000"
|
||||||
environment:
|
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
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_ALGORITHM=${JWT_ALGORITHM:-HS256}
|
- JWT_ALGORITHM=${JWT_ALGORITHM:-HS256}
|
||||||
@@ -46,6 +46,7 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
networks:
|
networks:
|
||||||
- erp-network
|
- erp-network
|
||||||
|
- coolify
|
||||||
|
|
||||||
# ============ Frontend (Next.js standalone) ============
|
# ============ Frontend (Next.js standalone) ============
|
||||||
frontend:
|
frontend:
|
||||||
@@ -56,16 +57,18 @@ services:
|
|||||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
||||||
container_name: erp-frontend
|
container_name: erp-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
expose:
|
||||||
- "3000:3000"
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -91,7 +94,10 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- erp-network
|
erp-network:
|
||||||
|
aliases:
|
||||||
|
- erp-db
|
||||||
|
- postgres
|
||||||
|
|
||||||
# ============ Redis 7 ============
|
# ============ Redis 7 ============
|
||||||
redis:
|
redis:
|
||||||
@@ -109,6 +115,7 @@ services:
|
|||||||
start_period: 5s
|
start_period: 5s
|
||||||
networks:
|
networks:
|
||||||
- erp-network
|
- erp-network
|
||||||
|
- coolify
|
||||||
|
|
||||||
# ============ Volumes ============
|
# ============ Volumes ============
|
||||||
volumes:
|
volumes:
|
||||||
@@ -123,3 +130,6 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
erp-network:
|
erp-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
name: coolify
|
||||||
|
external: true
|
||||||
|
|||||||
+1
-1
@@ -41,6 +41,6 @@ EXPOSE 3000
|
|||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
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"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -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('/dashboard');
|
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,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);
|
color: var(--color-text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
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,
|
type VehicleResponse,
|
||||||
} from '@/lib/vehicles';
|
} from '@/lib/vehicles';
|
||||||
import { MobileDeStatus } from './MobileDeStatus';
|
import { MobileDeStatus } from './MobileDeStatus';
|
||||||
|
import { VehicleImages } from './VehicleImages';
|
||||||
|
import { VehicleActions } from './VehicleActions';
|
||||||
|
|
||||||
interface VehicleDetailProps {
|
interface VehicleDetailProps {
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
@@ -126,6 +128,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 => (
|
{fields.map(field => (
|
||||||
<div key={field.label} className="space-y-1">
|
<div key={field.label} className="space-y-1">
|
||||||
@@ -137,6 +141,8 @@ export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VehicleImages vehicleId={vehicle.id} />
|
||||||
|
|
||||||
<MobileDeStatus vehicleId={vehicle.id} />
|
<MobileDeStatus vehicleId={vehicle.id} />
|
||||||
</div>
|
</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,
|
type VehicleListParams,
|
||||||
} from '@/lib/vehicles';
|
} from '@/lib/vehicles';
|
||||||
import type { PaginatedResponse } from '@/lib/api';
|
import type { PaginatedResponse } from '@/lib/api';
|
||||||
|
import { exportVehiclesToCsv } from './VehicleActions';
|
||||||
|
|
||||||
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||||
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
||||||
@@ -31,6 +32,7 @@ export function VehicleList() {
|
|||||||
const [pageSize] = useState(20);
|
const [pageSize] = useState(20);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<VehicleListParams>({
|
const [filters, setFilters] = useState<VehicleListParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -69,6 +71,23 @@ export function VehicleList() {
|
|||||||
setFilters(prev => ({ ...prev, page: newPage }));
|
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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'make',
|
key: 'make',
|
||||||
@@ -101,9 +120,20 @@ export function VehicleList() {
|
|||||||
<div data-testid="vehicle-list" className="space-y-4">
|
<div data-testid="vehicle-list" className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
||||||
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
<div className="flex gap-2">
|
||||||
+ Neues Fahrzeug
|
<Button
|
||||||
</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>
|
||||||
|
|
||||||
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
<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.sales": "Verkäufe",
|
||||||
"nav.settings": "Einstellungen",
|
"nav.settings": "Einstellungen",
|
||||||
"nav.logout": "Abmelden",
|
"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.save": "Speichern",
|
||||||
"common.cancel": "Abbrechen",
|
"common.cancel": "Abbrechen",
|
||||||
"common.delete": "Löschen",
|
"common.delete": "Löschen",
|
||||||
@@ -24,5 +47,14 @@
|
|||||||
"common.confirm": "Bestätigen",
|
"common.confirm": "Bestätigen",
|
||||||
"user.role.admin": "Administrator",
|
"user.role.admin": "Administrator",
|
||||||
"user.role.verkaeufer": "Verkäufer",
|
"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.sales": "Sales",
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.logout": "Logout",
|
"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.save": "Save",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
@@ -24,5 +47,14 @@
|
|||||||
"common.confirm": "Confirm",
|
"common.confirm": "Confirm",
|
||||||
"user.role.admin": "Administrator",
|
"user.role.admin": "Administrator",
|
||||||
"user.role.verkaeufer": "Sales",
|
"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