Compare commits

..

17 Commits

Author SHA1 Message Date
Leopoldadmin 563127c75a feat: add vehicle edit page, image gallery with upload, print and CSV export 2026-07-19 19:47:39 +02:00
Leopoldadmin 58fcf4a722 feat: add dashboard page with stats, quick actions, recent vehicles, update navigation and login redirect 2026-07-19 19:33:08 +02:00
Leopoldadmin 5ae1e28ef2 feat: add navigation layout with sidebar, header, auth guard, mobile responsive 2026-07-19 17:25:35 +02:00
Leopoldadmin 2242a12094 fix: redirect login to /de/fahrzeuge instead of non-existent /dashboard 2026-07-19 16:49:50 +02:00
Leopoldadmin 4cfe8ad673 fix: add coolify external network to backend+frontend for Traefik routing 2026-07-19 02:21:14 +02:00
Leopoldadmin 3e8cf935bd fix: use network alias erp-db for postgres to avoid coolify-db DNS collision 2026-07-19 02:12:13 +02:00
Leopoldadmin 7a3c40a651 fix: remove duplicate bcrypt line 2026-07-19 00:36:41 +02:00
Leopoldadmin 14349a211d fix: correct bcrypt version spec in requirements.txt 2026-07-19 00:35:15 +02:00
Leopoldadmin 6cedbb1bcb fix: pin bcrypt<5.0 for passlib compatibility 2026-07-18 20:14:36 +02:00
Leopoldadmin 97d497ba4f feat: add seed_admin.py for initial admin user creation 2026-07-18 20:10:47 +02:00
Leopoldadmin b69c5f00aa fix: frontend healthcheck use 127.0.0.1 in docker-compose 2026-07-18 17:34:40 +02:00
Leopoldadmin abbb99fe74 fix: frontend healthcheck use 127.0.0.1 and bind to 0.0.0.0 2026-07-18 17:29:47 +02:00
Leopoldadmin 1a4444e1e8 fix: add missing email-validator dependency 2026-07-18 17:22:27 +02:00
Leopoldadmin 579049395d fix: use expose instead of ports to avoid host port conflicts 2026-07-18 17:18:30 +02:00
Leopoldadmin 265f4c35cb fix: add missing frontend/public directory for standalone build 2026-07-18 17:13:19 +02:00
Leopoldadmin 531ae4cf0b fix: correct libgdk-pixbuf-2.0-0 package name for Debian Trixie 2026-07-18 17:08:20 +02:00
Leopoldadmin 71a2b37c16 fix: use Python 3.12-slim for compatible Debian package names 2026-07-18 17:03:44 +02:00
22 changed files with 1220 additions and 17 deletions
+2 -2
View File
@@ -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 \
+3 -1
View File
@@ -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
+76
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+1 -1
View File
@@ -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');
+5
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Dashboard } from '@/components/Dashboard';
export default function LocaleHomePage() {
return <Dashboard />;
}
+68
View File
@@ -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;
}
}
+249
View File
@@ -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>
);
}
+171
View File
@@ -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" />
</>
);
}
+92
View File
@@ -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,10 +120,21 @@ 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>
<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">
<div className="w-48">
+33 -1
View File
@@ -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"
}
+33 -1
View File
@@ -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"
}
View File