Initial commit - SaveTheMoment Atlas Basis-Setup

This commit is contained in:
Dennis Forte
2025-11-12 20:21:32 +01:00
commit 0b6e429329
167 changed files with 30843 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const locationSlug = searchParams.get('location');
const model = searchParams.get('model');
const date = searchParams.get('date');
if (!locationSlug || !model || !date) {
return NextResponse.json(
{ error: 'Missing required parameters: location, model, date' },
{ status: 400 }
);
}
const location = await prisma.location.findUnique({
where: { slug: locationSlug },
});
if (!location) {
return NextResponse.json(
{ error: 'Location not found' },
{ status: 404 }
);
}
const eventDate = new Date(date);
const startOfDay = new Date(eventDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(eventDate);
endOfDay.setHours(23, 59, 59, 999);
const totalPhotoboxes = await prisma.photobox.count({
where: {
locationId: location.id,
model: model as any,
active: true,
},
});
const bookedPhotoboxes = await prisma.booking.count({
where: {
locationId: location.id,
photobox: {
model: model as any,
},
eventDate: {
gte: startOfDay,
lte: endOfDay,
},
status: {
in: ['RESERVED', 'CONFIRMED'],
},
},
});
const available = totalPhotoboxes - bookedPhotoboxes;
const isAvailable = available > 0;
const isLastOne = available === 1;
return NextResponse.json({
available: isAvailable,
count: available,
total: totalPhotoboxes,
isLastOne,
message: isAvailable
? isLastOne
? 'Nur noch 1 Fotobox verfügbar!'
: `${available} Fotoboxen verfügbar`
: 'Leider ausgebucht',
});
} catch (error) {
console.error('Availability check error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { aiService } from '@/lib/ai-service';
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const booking = await prisma.booking.findUnique({
where: { id: params.id },
include: {
emails: {
orderBy: { receivedAt: 'desc' },
take: 1,
},
},
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
if (!booking.emails || booking.emails.length === 0) {
return NextResponse.json({ error: 'Keine E-Mail gefunden' }, { status: 400 });
}
const email = booking.emails[0];
const result = await aiService.parseBookingEmail(
email.htmlBody || email.textBody || '',
email.subject
);
await prisma.booking.update({
where: { id: params.id },
data: {
aiParsed: true,
aiResponseDraft: result.responseDraft,
aiProcessedAt: new Date(),
customerName: result.parsed.customerName || booking.customerName,
customerEmail: result.parsed.customerEmail || booking.customerEmail,
customerPhone: result.parsed.customerPhone || booking.customerPhone,
customerAddress: result.parsed.customerAddress,
customerCity: result.parsed.customerCity,
customerZip: result.parsed.customerZip,
companyName: result.parsed.companyName,
invoiceType: result.parsed.invoiceType,
eventAddress: result.parsed.eventAddress || booking.eventAddress,
eventCity: result.parsed.eventCity || booking.eventCity,
eventZip: result.parsed.eventZip || booking.eventZip,
eventLocation: result.parsed.eventLocation,
eventDate: new Date(result.parsed.eventDate || booking.eventDate),
setupTimeStart: new Date(result.parsed.setupTimeStart || booking.setupTimeStart),
},
});
return NextResponse.json({
success: true,
parsed: result.parsed,
responseDraft: result.responseDraft,
confidence: result.confidence,
});
} catch (error) {
console.error('AI Analysis error:', error);
return NextResponse.json(
{ error: 'KI-Analyse fehlgeschlagen' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,82 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
function generateTourNumber(): string {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
return `T${year}${month}${day}-${random}`;
}
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { driverId } = await request.json();
if (!driverId) {
return NextResponse.json({ error: 'Fahrer-ID erforderlich' }, { status: 400 });
}
const booking = await prisma.booking.findUnique({
where: { id: params.id },
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
if (booking.status !== 'OPEN_FOR_DRIVERS') {
return NextResponse.json({ error: 'Buchung ist nicht für Zuweisung bereit' }, { status: 400 });
}
let tour = await prisma.tour.findFirst({
where: {
tourDate: booking.eventDate,
driverId: driverId,
status: 'PLANNED',
},
});
if (!tour) {
tour = await prisma.tour.create({
data: {
tourDate: booking.eventDate,
tourNumber: generateTourNumber(),
driverId: driverId,
status: 'PLANNED',
},
});
}
await prisma.booking.update({
where: { id: params.id },
data: {
tourId: tour.id,
status: 'ASSIGNED',
},
});
return NextResponse.json({
success: true,
tour,
message: `Buchung wurde Tour ${tour.tourNumber} zugewiesen`,
});
} catch (error) {
console.error('Assign driver error:', error);
return NextResponse.json(
{ error: 'Zuweisung fehlgeschlagen' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { available, message } = await request.json();
const booking = await prisma.booking.findUnique({
where: { id: params.id },
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
if (booking.status !== 'OPEN_FOR_DRIVERS') {
return NextResponse.json({ error: 'Buchung ist nicht für Fahrer freigegeben' }, { status: 400 });
}
const availability = await prisma.driverAvailability.upsert({
where: {
bookingId_driverId: {
bookingId: params.id,
driverId: session.user.id,
},
},
update: {
available,
message,
},
create: {
bookingId: params.id,
driverId: session.user.id,
available,
message,
},
});
return NextResponse.json({ success: true, availability });
} catch (error) {
console.error('Availability update error:', error);
return NextResponse.json(
{ error: 'Verfügbarkeit konnte nicht gespeichert werden' },
{ status: 500 }
);
}
}
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const availabilities = await prisma.driverAvailability.findMany({
where: {
bookingId: params.id,
available: true,
},
include: {
driver: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
vehicleModel: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
});
return NextResponse.json({ availabilities });
} catch (error) {
console.error('Get availability error:', error);
return NextResponse.json(
{ error: 'Fehler beim Laden der Verfügbarkeiten' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { generateContractPDF, generateSignedContractPDF } from '@/lib/pdf-service';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const booking = await prisma.booking.findUnique({
where: { id },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
// Generate PDF
const pdfBuffer = await generateContractPDF(booking, booking.location, booking.photobox);
// Save PDF to public/contracts folder
const contractsDir = path.join(process.cwd(), 'public', 'contracts');
await mkdir(contractsDir, { recursive: true });
const filename = `contract-${booking.bookingNumber}.pdf`;
const filepath = path.join(contractsDir, filename);
await writeFile(filepath, pdfBuffer);
const contractUrl = `/contracts/${filename}`;
// Update booking
await prisma.booking.update({
where: { id },
data: {
contractGenerated: true,
contractGeneratedAt: new Date(),
contractPdfUrl: contractUrl,
},
});
return NextResponse.json({
success: true,
contractUrl,
filename,
});
} catch (error: any) {
console.error('Contract generation error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to generate contract' },
{ status: 500 }
);
}
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const booking = await prisma.booking.findUnique({
where: { id },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
// Generate PDF in memory for download
const pdfBuffer = await generateSignedContractPDF(
booking,
booking.location,
booking.photobox,
booking.contractSignatureData || '',
booking.contractSignedBy || '',
booking.contractSignedAt || new Date(),
booking.contractSignedIp || ''
);
return new NextResponse(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="Vertrag-${booking.bookingNumber}.pdf"`,
},
});
} catch (error: any) {
console.error('Contract download error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to download contract' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { sendContractEmail } from '@/lib/email-service';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const booking = await prisma.booking.findUnique({
where: { id },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
if (!booking.contractGenerated || !booking.contractPdfUrl) {
return NextResponse.json(
{ error: 'Contract not generated yet. Please generate contract first.' },
{ status: 400 }
);
}
try {
await sendContractEmail(booking, booking.contractPdfUrl);
await prisma.booking.update({
where: { id },
data: {
contractSentAt: new Date(),
},
});
return NextResponse.json({
success: true,
message: `Contract sent to ${booking.customerEmail}`,
});
} catch (emailError: any) {
console.error('Email send error:', emailError);
if (emailError.message?.includes('SMTP not configured')) {
return NextResponse.json({
success: false,
error: 'E-Mail-Service nicht konfiguriert. Bitte SMTP-Einstellungen in .env hinzufügen.',
}, { status: 503 });
}
return NextResponse.json({
success: false,
error: emailError.message || 'Failed to send email',
}, { status: 500 });
}
} catch (error: any) {
console.error('Contract send error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to send contract' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
// Note: Google Vision API can be added later for automatic signature detection
// For now, we trust admin verification
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const booking = await prisma.booking.findUnique({
where: { id },
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
// Save uploaded file
const buffer = Buffer.from(await file.arrayBuffer());
const contractsDir = path.join(process.cwd(), 'public', 'contracts');
await mkdir(contractsDir, { recursive: true });
const filename = `contract-uploaded-${booking.bookingNumber}-${Date.now()}.pdf`;
const filepath = path.join(contractsDir, filename);
await writeFile(filepath, buffer);
const contractUrl = `/contracts/${filename}`;
// Update booking
await prisma.booking.update({
where: { id },
data: {
contractSigned: true,
contractSignedAt: new Date(),
contractSignedOnline: false,
contractPdfUrl: contractUrl,
contractSignedBy: booking.customerName,
contractUploadedBy: session.user.id,
},
});
return NextResponse.json({
success: true,
message: 'Contract uploaded successfully',
});
} catch (error: any) {
console.error('Contract upload error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to upload contract' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { LexOfficeService } from '@/lib/lexoffice';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const booking = await prisma.booking.findUnique({
where: { id: params.id },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
if (booking.lexofficeOfferId) {
return NextResponse.json(
{ error: 'Quotation already exists', offerId: booking.lexofficeOfferId },
{ status: 400 }
);
}
const lexoffice = new LexOfficeService();
// 1. Create or get contact
let contactId = booking.lexofficeContactId;
if (!contactId) {
contactId = await lexoffice.createContactFromBooking(booking);
await prisma.booking.update({
where: { id: booking.id },
data: { lexofficeContactId: contactId },
});
}
// 2. Create quotation
const quotationId = await lexoffice.createQuotationFromBooking(booking, contactId);
// 3. Update booking with offer ID
await prisma.booking.update({
where: { id: booking.id },
data: { lexofficeOfferId: quotationId },
});
return NextResponse.json({
success: true,
quotation: {
id: quotationId,
},
contactId,
});
} catch (error: any) {
console.error('LexOffice quotation creation error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to create quotation' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const booking = await prisma.booking.findUnique({
where: { id: params.id },
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
if (booking.status !== 'READY_FOR_ASSIGNMENT') {
return NextResponse.json({ error: 'Buchung muss im Status READY_FOR_ASSIGNMENT sein' }, { status: 400 });
}
await prisma.booking.update({
where: { id: params.id },
data: {
status: 'OPEN_FOR_DRIVERS',
openForDrivers: true,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Release to drivers error:', error);
return NextResponse.json(
{ error: 'Freigabe fehlgeschlagen' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { nextcloudCalendar } from '@/lib/nextcloud-calendar';
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id } = params;
const booking = await prisma.booking.update({
where: { id },
data: {
customerName: body.customerName,
customerEmail: body.customerEmail,
customerPhone: body.customerPhone,
customerAddress: body.customerAddress,
customerCity: body.customerCity,
customerZip: body.customerZip,
notes: body.notes,
internalNotes: body.internalNotes,
},
include: {
location: true,
photobox: true,
},
});
try {
await nextcloudCalendar.syncBookingToCalendar(booking);
} catch (calError) {
console.error('Calendar sync error after booking update:', calError);
}
return NextResponse.json({ success: true, booking });
} catch (error) {
console.error('Booking update error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id } = params;
const { status } = body;
if (!status) {
return NextResponse.json({ error: 'Status is required' }, { status: 400 });
}
const booking = await prisma.booking.update({
where: { id },
data: { status },
include: {
location: true,
photobox: true,
},
});
try {
if (status === 'CANCELLED') {
await nextcloudCalendar.removeBookingFromCalendar(booking.id);
} else {
await nextcloudCalendar.syncBookingToCalendar(booking);
}
} catch (calError) {
console.error('Calendar sync error after status change:', calError);
}
return NextResponse.json({ success: true, booking });
} catch (error) {
console.error('Booking status update error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const setupWindows = await prisma.setupWindow.findMany({
where: { bookingId: params.id },
orderBy: { setupDate: 'asc' },
});
return NextResponse.json({ setupWindows });
} catch (error: any) {
console.error('Setup windows fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch setup windows' },
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { setupDate, setupTimeStart, setupTimeEnd, preferred, notes } = body;
if (!setupDate || !setupTimeStart || !setupTimeEnd) {
return NextResponse.json(
{ error: 'Setup date and times are required' },
{ status: 400 }
);
}
const booking = await prisma.booking.findUnique({
where: { id: params.id },
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
const setupWindow = await prisma.setupWindow.create({
data: {
bookingId: params.id,
setupDate: new Date(setupDate),
setupTimeStart: new Date(setupTimeStart),
setupTimeEnd: new Date(setupTimeEnd),
preferred: preferred || false,
notes,
},
});
return NextResponse.json({ setupWindow }, { status: 201 });
} catch (error: any) {
console.error('Setup window creation error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to create setup window' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { windowId, selected, preferred, notes } = body;
if (!windowId) {
return NextResponse.json(
{ error: 'Window ID is required' },
{ status: 400 }
);
}
if (selected) {
await prisma.setupWindow.updateMany({
where: { bookingId: params.id },
data: { selected: false },
});
}
const setupWindow = await prisma.setupWindow.update({
where: { id: windowId },
data: {
selected: selected !== undefined ? selected : undefined,
preferred: preferred !== undefined ? preferred : undefined,
notes: notes !== undefined ? notes : undefined,
},
});
return NextResponse.json({ setupWindow });
} catch (error: any) {
console.error('Setup window update error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update setup window' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const windowId = searchParams.get('windowId');
if (!windowId) {
return NextResponse.json(
{ error: 'Window ID is required' },
{ status: 400 }
);
}
await prisma.setupWindow.delete({
where: { id: windowId },
});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Setup window deletion error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to delete setup window' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { status } = await request.json();
const { id } = params;
const booking = await prisma.booking.update({
where: { id },
data: { status },
});
// Create notification
await prisma.notification.create({
data: {
type: 'BOOKING_STATUS_CHANGED',
title: 'Buchungsstatus geändert',
message: `Buchung ${booking.bookingNumber}${status}`,
metadata: {
bookingId: booking.id,
newStatus: status,
},
},
});
return NextResponse.json({ success: true, booking });
} catch (error) {
console.error('Status update error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { nextcloudCalendar } from '@/lib/nextcloud-calendar';
function generateBookingNumber(): string {
const date = new Date();
const year = date.getFullYear().toString().slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `STM-${year}${month}-${random}`;
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const eventDate = new Date(body.eventDate);
const startOfDay = new Date(eventDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(eventDate);
endOfDay.setHours(23, 59, 59, 999);
const availablePhotobox = await prisma.photobox.findFirst({
where: {
locationId: body.locationId,
model: body.model,
active: true,
NOT: {
bookings: {
some: {
eventDate: {
gte: startOfDay,
lte: endOfDay,
},
status: {
in: ['RESERVED', 'CONFIRMED'],
},
},
},
},
},
});
if (!availablePhotobox) {
return NextResponse.json(
{ error: 'Keine Fotobox verfügbar für dieses Datum' },
{ status: 409 }
);
}
const booking = await prisma.booking.create({
data: {
bookingNumber: generateBookingNumber(),
locationId: body.locationId,
photoboxId: availablePhotobox.id,
status: 'RESERVED',
customerName: body.customerName,
customerEmail: body.customerEmail,
customerPhone: body.customerPhone,
customerAddress: body.customerAddress,
customerCity: body.customerCity,
customerZip: body.customerZip,
invoiceType: body.invoiceType,
companyName: body.companyName,
eventDate: new Date(body.eventDate),
eventAddress: body.eventAddress,
eventCity: body.eventCity,
eventZip: body.eventZip,
eventLocation: body.eventLocation,
setupTimeStart: new Date(body.setupTimeStart),
setupTimeLatest: new Date(body.setupTimeLatest),
dismantleTimeEarliest: body.dismantleTimeEarliest ? new Date(body.dismantleTimeEarliest) : null,
dismantleTimeLatest: body.dismantleTimeLatest ? new Date(body.dismantleTimeLatest) : null,
calculatedPrice: body.calculatedPrice,
notes: body.notes,
},
include: {
location: true,
photobox: true,
},
});
await prisma.notification.create({
data: {
type: 'NEW_BOOKING_MANUAL',
title: 'Neue manuelle Buchung',
message: `${body.customerName} - ${eventDate.toLocaleDateString('de-DE')}`,
metadata: {
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
},
},
});
try {
await nextcloudCalendar.syncBookingToCalendar(booking);
} catch (calError) {
console.error('Calendar sync error after booking creation:', calError);
}
return NextResponse.json({
success: true,
booking,
});
} catch (error: any) {
console.error('Booking creation error:', error);
return NextResponse.json(
{ error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}

212
app/api/bookings/route.ts Normal file
View File

@@ -0,0 +1,212 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const bookingSchema = z.object({
locationSlug: z.string(),
model: z.enum(['VINTAGE_SMILE', 'VINTAGE_PHOTOS', 'NOSTALGIE', 'MAGIC_MIRROR']),
customerName: z.string().min(2),
customerEmail: z.string().email(),
customerPhone: z.string().min(5),
customerAddress: z.string().optional(),
customerCity: z.string().optional(),
customerZip: z.string().optional(),
invoiceType: z.enum(['PRIVATE', 'BUSINESS']),
companyName: z.string().optional(),
eventDate: z.string(),
eventAddress: z.string().min(5),
eventCity: z.string().min(2),
eventZip: z.string().min(4),
eventLocation: z.string().optional(),
setupTimeStart: z.string(),
setupTimeLatest: z.string(),
dismantleTimeEarliest: z.string().optional(),
dismantleTimeLatest: z.string().optional(),
notes: z.string().optional(),
});
function generateBookingNumber(): string {
const date = new Date();
const year = date.getFullYear().toString().slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `STM-${year}${month}-${random}`;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = bookingSchema.parse(body);
const location = await prisma.location.findUnique({
where: { slug: data.locationSlug },
});
if (!location) {
return NextResponse.json(
{ error: 'Standort nicht gefunden' },
{ status: 404 }
);
}
const eventDate = new Date(data.eventDate);
const startOfDay = new Date(eventDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(eventDate);
endOfDay.setHours(23, 59, 59, 999);
const availablePhotobox = await prisma.photobox.findFirst({
where: {
locationId: location.id,
model: data.model,
active: true,
NOT: {
bookings: {
some: {
eventDate: {
gte: startOfDay,
lte: endOfDay,
},
status: {
in: ['RESERVED', 'CONFIRMED'],
},
},
},
},
},
});
if (!availablePhotobox) {
return NextResponse.json(
{ error: 'Keine Fotobox verfügbar für dieses Datum' },
{ status: 409 }
);
}
const priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: location.id,
model: data.model,
},
},
});
const calculatedPrice = priceConfig ? priceConfig.basePrice : 0;
const booking = await prisma.booking.create({
data: {
bookingNumber: generateBookingNumber(),
locationId: location.id,
photoboxId: availablePhotobox.id,
status: 'RESERVED',
customerName: data.customerName,
customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
customerAddress: data.customerAddress,
customerCity: data.customerCity,
customerZip: data.customerZip,
invoiceType: data.invoiceType,
companyName: data.companyName,
eventDate: new Date(data.eventDate),
eventAddress: data.eventAddress,
eventCity: data.eventCity,
eventZip: data.eventZip,
eventLocation: data.eventLocation,
setupTimeStart: new Date(data.setupTimeStart),
setupTimeLatest: new Date(data.setupTimeLatest),
dismantleTimeEarliest: data.dismantleTimeEarliest ? new Date(data.dismantleTimeEarliest) : null,
dismantleTimeLatest: data.dismantleTimeLatest ? new Date(data.dismantleTimeLatest) : null,
calculatedPrice,
notes: data.notes,
},
include: {
location: true,
photobox: true,
},
});
await prisma.notification.create({
data: {
type: 'NEW_BOOKING',
title: 'Neue Buchungsanfrage',
message: `${data.customerName} hat eine ${data.model} für ${data.eventCity} am ${eventDate.toLocaleDateString('de-DE')} angefragt.`,
metadata: {
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
},
},
});
return NextResponse.json({
success: true,
booking: {
id: booking.id,
bookingNumber: booking.bookingNumber,
status: booking.status,
eventDate: booking.eventDate,
calculatedPrice: booking.calculatedPrice,
},
message: 'Buchungsanfrage erfolgreich erstellt! Wir melden uns in Kürze bei Ihnen.',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Ungültige Daten', details: error.errors },
{ status: 400 }
);
}
console.error('Booking creation error:', error);
return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const status = searchParams.get('status');
const locationSlug = searchParams.get('location');
const where: any = {};
if (status) {
where.status = status;
}
if (locationSlug) {
const location = await prisma.location.findUnique({
where: { slug: locationSlug },
});
if (location) {
where.locationId = location.id;
}
}
const bookings = await prisma.booking.findMany({
where,
include: {
location: true,
photobox: true,
setupWindows: {
orderBy: { setupDate: 'asc' },
},
},
orderBy: {
createdAt: 'desc',
},
take: 100,
});
return NextResponse.json({ bookings });
} catch (error) {
console.error('Bookings fetch error:', error);
return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 }
);
}
}

62
app/api/calendar/route.ts Normal file
View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const searchParams = req.nextUrl.searchParams;
const start = searchParams.get('start');
const end = searchParams.get('end');
const where: any = {};
if (start && end) {
where.eventDate = {
gte: new Date(start),
lte: new Date(end),
};
}
const bookings = await prisma.booking.findMany({
where,
include: {
location: true,
photobox: true,
tour: true,
},
orderBy: { eventDate: 'asc' },
});
const events = bookings.map((booking) => ({
id: booking.id,
title: `${booking.customerName} - ${booking.location.name}`,
start: booking.eventDate,
end: new Date(new Date(booking.eventDate).getTime() + 4 * 60 * 60 * 1000),
resource: {
bookingId: booking.id,
status: booking.status,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
locationName: booking.location.name,
photoboxName: booking.photobox?.name || 'Keine Box',
tourId: booking.tourId,
eventType: booking.eventType,
},
}));
return NextResponse.json({ events });
} catch (error) {
console.error('Error fetching calendar events:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { nextcloudCalendar } from '@/lib/nextcloud-calendar';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { action, bookingId } = await req.json();
if (action === 'test-connection') {
try {
const calendars = await nextcloudCalendar.getCalendars();
return NextResponse.json({
success: true,
calendars: calendars.map((cal: any) => ({
displayName: cal.displayName,
url: cal.url,
description: cal.description,
}))
});
} catch (error: any) {
return NextResponse.json({
success: false,
error: error.message
}, { status: 500 });
}
}
if (action === 'sync-booking' && bookingId) {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
try {
await nextcloudCalendar.syncBookingToCalendar(booking);
return NextResponse.json({ success: true, message: 'Booking synced to calendar' });
} catch (error: any) {
return NextResponse.json({
success: false,
error: error.message
}, { status: 500 });
}
}
if (action === 'sync-all') {
const bookings = await prisma.booking.findMany({
where: {
status: {
in: ['RESERVED', 'CONFIRMED', 'TOUR_CREATED'],
},
},
include: {
location: true,
photobox: true,
},
});
let synced = 0;
let failed = 0;
for (const booking of bookings) {
try {
await nextcloudCalendar.syncBookingToCalendar(booking);
synced++;
} catch (error) {
console.error(`Failed to sync booking ${booking.id}:`, error);
failed++;
}
}
return NextResponse.json({
success: true,
synced,
failed,
total: bookings.length
});
}
if (action === 'remove-booking' && bookingId) {
try {
await nextcloudCalendar.removeBookingFromCalendar(bookingId);
return NextResponse.json({ success: true, message: 'Booking removed from calendar' });
} catch (error: any) {
return NextResponse.json({
success: false,
error: error.message
}, { status: 500 });
}
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error) {
console.error('Error in calendar sync:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { generateSignedContractPDF } from '@/lib/pdf-service';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, signatureData, name, email } = body;
// Decode token to get booking ID
const decoded = Buffer.from(token, 'base64url').toString();
const bookingId = decoded.split('-')[0];
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
if (booking.contractSigned) {
return NextResponse.json(
{ error: 'Contract already signed' },
{ status: 400 }
);
}
// Get client IP
const ip = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'unknown';
const now = new Date();
// Update booking with signature
await prisma.booking.update({
where: { id: bookingId },
data: {
contractSigned: true,
contractSignedAt: now,
contractSignedOnline: true,
contractSignatureData: signatureData,
contractSignedBy: name,
contractSignedIp: ip,
},
});
// Generate signed PDF
const pdfBuffer = await generateSignedContractPDF(
booking,
booking.location,
booking.photobox,
signatureData,
name,
now,
ip
);
// Save signed PDF
const contractsDir = path.join(process.cwd(), 'public', 'contracts');
await mkdir(contractsDir, { recursive: true });
const filename = `contract-signed-${booking.bookingNumber}.pdf`;
const filepath = path.join(contractsDir, filename);
await writeFile(filepath, pdfBuffer);
const contractUrl = `/contracts/${filename}`;
await prisma.booking.update({
where: { id: bookingId },
data: {
contractPdfUrl: contractUrl,
},
});
// TODO: Send email with signed contract to customer and admin
return NextResponse.json({
success: true,
message: 'Contract signed successfully',
});
} catch (error: any) {
console.error('Contract signing error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to sign contract' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const token = formData.get('token') as string;
if (!file || !token) {
return NextResponse.json(
{ error: 'File and token required' },
{ status: 400 }
);
}
// Decode token to get booking ID
const decoded = Buffer.from(token, 'base64url').toString();
const bookingId = decoded.split('-')[0];
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
if (booking.contractSigned) {
return NextResponse.json(
{ error: 'Contract already signed' },
{ status: 400 }
);
}
// Save uploaded file
const buffer = Buffer.from(await file.arrayBuffer());
const contractsDir = path.join(process.cwd(), 'public', 'contracts');
await mkdir(contractsDir, { recursive: true });
const filename = `contract-uploaded-${booking.bookingNumber}-${Date.now()}.pdf`;
const filepath = path.join(contractsDir, filename);
await writeFile(filepath, buffer);
const contractUrl = `/contracts/${filename}`;
// Update booking
await prisma.booking.update({
where: { id: bookingId },
data: {
contractSigned: true,
contractSignedAt: new Date(),
contractSignedOnline: false,
contractPdfUrl: contractUrl,
contractSignedBy: booking.customerName,
},
});
// TODO: Send email with confirmation to customer and admin
return NextResponse.json({
success: true,
message: 'Contract uploaded successfully',
});
} catch (error: any) {
console.error('Contract upload error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to upload contract' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { lexofficeService } from '@/lib/lexoffice';
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const bookings = await prisma.booking.findMany({
where: {
status: 'CONFIRMED',
contractSigned: true,
lexofficeConfirmationId: null,
},
});
let processed = 0;
const results = [];
for (const booking of bookings) {
try {
let contactId = booking.lexofficeContactId;
if (!contactId) {
contactId = await lexofficeService.createContactFromBooking(booking);
await prisma.booking.update({
where: { id: booking.id },
data: { lexofficeContactId: contactId },
});
}
const confirmationId = await lexofficeService.createConfirmationFromBooking(
booking,
contactId
);
await prisma.booking.update({
where: { id: booking.id },
data: {
status: 'READY_FOR_ASSIGNMENT',
readyForAssignment: true,
lexofficeConfirmationId: confirmationId,
confirmationSentAt: new Date(),
},
});
processed++;
results.push({
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
confirmationId,
status: 'success',
});
console.log(`✅ Auftragsbestätigung für ${booking.bookingNumber} erstellt`);
} catch (error) {
console.error(`❌ Fehler bei ${booking.bookingNumber}:`, error);
results.push({
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return NextResponse.json({
success: true,
processed,
total: bookings.length,
results,
});
} catch (error) {
console.error('Contract check cron error:', error);
return NextResponse.json(
{ error: 'Cron job failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { emailSyncService } from '@/lib/email-sync';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET || 'development-secret';
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const locations = await prisma.location.findMany({
where: {
active: true,
emailSyncEnabled: true,
},
});
const results: any[] = [];
for (const location of locations) {
try {
const result = await emailSyncService.syncLocationEmails(location.id);
results.push({
locationId: location.id,
locationName: location.name,
...result,
});
} catch (error: any) {
results.push({
locationId: location.id,
locationName: location.name,
success: false,
newEmails: 0,
newBookings: 0,
errors: [error.message],
});
}
}
const summary = {
totalLocations: locations.length,
totalEmails: results.reduce((sum, r) => sum + (r.newEmails || 0), 0),
totalBookings: results.reduce((sum, r) => sum + (r.newBookings || 0), 0),
results,
};
console.log('Cron email sync completed:', summary);
return NextResponse.json(summary);
} catch (error: any) {
console.error('Cron email sync error:', error);
return NextResponse.json(
{ error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { AIService } from '@/lib/ai-service';
import { LexOfficeService } from '@/lib/lexoffice';
import { getCalendarService } from '@/lib/nextcloud-calendar';
/**
* AUTO-WORKFLOW CRON-JOB
*
* Läuft alle 5 Minuten und:
* 1. Findet Buchungen mit `aiParsed=false` (neue E-Mails)
* 2. Startet KI-Analyse
* 3. Generiert Entwürfe (E-Mail, Angebot, Vertrag)
* 4. Setzt Status auf PENDING_REVIEW
*/
export async function GET(request: NextRequest) {
try {
// Cron-Secret validieren
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.log('🔄 Auto-Workflow Cron-Job gestartet...');
// 1. Finde neue Buchungen (noch nicht von KI analysiert)
const pendingBookings = await prisma.booking.findMany({
where: {
aiParsed: false,
status: 'RESERVED', // Nur neue Reservierungen
},
include: {
location: true,
photobox: true,
},
take: 10, // Max 10 pro Lauf
});
if (pendingBookings.length === 0) {
console.log('✅ Keine neuen Buchungen gefunden');
return NextResponse.json({
message: 'No pending bookings',
processed: 0
});
}
console.log(`📋 ${pendingBookings.length} neue Buchungen gefunden`);
const aiService = new AIService();
const lexoffice = new LexOfficeService();
const calendar = getCalendarService();
let processed = 0;
let errors = 0;
for (const booking of pendingBookings) {
try {
console.log(`\n🤖 Verarbeite Buchung: ${booking.bookingNumber}`);
// 2. KI-Analyse (falls noch nicht vorhanden)
if (!booking.aiResponseDraft) {
console.log(' → Generiere E-Mail-Entwurf...');
// Generiere einfache Antwort (später kann man das erweitern)
const emailDraft = `Hallo ${booking.customerName},
vielen Dank für Ihre Anfrage für eine Fotobox-Vermietung am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}!
Wir freuen uns sehr über Ihr Interesse. Anbei finden Sie unser Angebot sowie den Mietvertrag zur Durchsicht.
Falls Sie Fragen haben, stehen wir Ihnen jederzeit gerne zur Verfügung!
Mit freundlichen Grüßen
Ihr ${booking.location?.name || 'SaveTheMoment'} Team`;
await prisma.booking.update({
where: { id: booking.id },
data: {
aiResponseDraft: emailDraft,
aiProcessedAt: new Date(),
},
});
}
// 3. LexOffice Kontakt & Angebot (Entwurf)
if (!booking.lexofficeContactId) {
console.log(' → Erstelle LexOffice Kontakt...');
try {
const contactId = await lexoffice.createContactFromBooking(booking);
await prisma.booking.update({
where: { id: booking.id },
data: { lexofficeContactId: contactId },
});
console.log(' → Erstelle LexOffice Angebot-Entwurf...');
const quotationId = await lexoffice.createQuotationFromBooking(booking, contactId);
await prisma.booking.update({
where: { id: booking.id },
data: { lexofficeOfferId: quotationId },
});
} catch (lexError) {
console.error(' ⚠️ LexOffice-Fehler (wird übersprungen):', lexError);
// Weiter machen, auch wenn LexOffice fehlschlägt
}
}
// 4. Kalender-Eintrag erstellen (Reservierung)
if (!booking.calendarEventId) {
console.log(' → Erstelle Kalender-Eintrag...');
try {
const eventId = await calendar.createBookingEvent(booking);
if (eventId) {
await prisma.booking.update({
where: { id: booking.id },
data: {
calendarEventId: eventId,
calendarSynced: true,
calendarSyncedAt: new Date(),
},
});
}
} catch (calError) {
console.error(' ⚠️ Kalender-Fehler (wird übersprungen):', calError);
}
}
// 5. Status aktualisieren: Bereit für Admin-Review
await prisma.booking.update({
where: { id: booking.id },
data: {
aiParsed: true,
readyForAssignment: true, // Admin kann jetzt prüfen
},
});
console.log(`✅ Buchung ${booking.bookingNumber} erfolgreich verarbeitet`);
processed++;
} catch (error) {
console.error(`❌ Fehler bei Buchung ${booking.bookingNumber}:`, error);
errors++;
// Markiere als fehlerhaft, damit es beim nächsten Lauf erneut versucht wird
await prisma.booking.update({
where: { id: booking.id },
data: {
internalNotes: `Auto-Workflow Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`,
},
});
}
}
console.log(`\n📊 Cron-Job abgeschlossen:`);
console.log(` ✅ Erfolgreich: ${processed}`);
console.log(` ❌ Fehler: ${errors}`);
return NextResponse.json({
success: true,
processed,
errors,
total: pendingBookings.length,
});
} catch (error: any) {
console.error('❌ Cron-Job Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Cron job failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const driver = await prisma.user.findUnique({
where: { id: params.id, role: 'DRIVER' },
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
vehicleModel: true,
active: true,
available: true,
createdAt: true,
driverTours: {
include: {
bookings: {
select: {
id: true,
bookingNumber: true,
eventDate: true,
eventLocation: true,
customerName: true,
},
},
},
orderBy: {
tourDate: 'desc',
},
take: 10,
},
},
});
if (!driver) {
return NextResponse.json({ error: 'Driver not found' }, { status: 404 });
}
return NextResponse.json({ driver });
} catch (error: any) {
console.error('Driver fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch driver' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const {
name,
email,
password,
phoneNumber,
vehiclePlate,
vehicleModel,
active,
available,
} = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (email !== undefined) updateData.email = email;
if (phoneNumber !== undefined) updateData.phoneNumber = phoneNumber;
if (vehiclePlate !== undefined) updateData.vehiclePlate = vehiclePlate;
if (vehicleModel !== undefined) updateData.vehicleModel = vehicleModel;
if (active !== undefined) updateData.active = active;
if (available !== undefined) updateData.available = available;
if (password) {
updateData.password = await bcrypt.hash(password, 10);
}
const driver = await prisma.user.update({
where: { id: params.id },
data: updateData,
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
vehicleModel: true,
active: true,
available: true,
},
});
return NextResponse.json({ driver });
} catch (error: any) {
console.error('Driver update error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update driver' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.user.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Driver deletion error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to delete driver' },
{ status: 500 }
);
}
}

124
app/api/drivers/route.ts Normal file
View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const available = searchParams.get('available');
const where: any = { role: 'DRIVER' };
if (available === 'true') where.available = true;
if (available === 'false') where.available = false;
const drivers = await prisma.user.findMany({
where,
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
vehicleModel: true,
active: true,
available: true,
createdAt: true,
_count: {
select: {
driverTours: true,
},
},
},
orderBy: {
name: 'asc',
},
});
return NextResponse.json({ drivers });
} catch (error: any) {
console.error('Driver fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch drivers' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const {
name,
email,
password,
phoneNumber,
vehiclePlate,
vehicleModel,
} = body;
if (!name || !email || !password) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'Email already in use' },
{ status: 400 }
);
}
const hashedPassword = await bcrypt.hash(password, 10);
const driver = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
phoneNumber,
vehiclePlate,
vehicleModel,
role: 'DRIVER',
active: true,
available: true,
},
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
vehicleModel: true,
active: true,
available: true,
createdAt: true,
},
});
return NextResponse.json({ driver }, { status: 201 });
} catch (error: any) {
console.error('Driver creation error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to create driver' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { emailSyncService } from '@/lib/email-sync';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { locationId } = await request.json();
if (!locationId) {
return NextResponse.json({ error: 'Location ID required' }, { status: 400 });
}
const result = await emailSyncService.syncLocationEmails(locationId);
return NextResponse.json(result);
} catch (error: any) {
console.error('Email sync API error:', error);
return NextResponse.json(
{ error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const equipment = await prisma.equipment.findUnique({
where: { id: params.id },
include: {
location: true,
project: true,
bookingEquipment: {
include: {
booking: true,
},
},
},
});
if (!equipment) {
return NextResponse.json({ error: 'Equipment not found' }, { status: 404 });
}
return NextResponse.json({ equipment });
} catch (error) {
console.error('Error fetching equipment:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const data = await request.json();
const equipment = await prisma.equipment.update({
where: { id: params.id },
data: {
name: data.name,
type: data.type,
brand: data.brand,
model: data.model,
serialNumber: data.serialNumber,
quantity: data.quantity,
status: data.status,
locationId: data.locationId,
projectId: data.projectId,
notes: data.notes,
purchaseDate: data.purchaseDate ? new Date(data.purchaseDate) : null,
purchasePrice: data.purchasePrice,
minStockLevel: data.minStockLevel,
currentStock: data.currentStock,
},
include: {
location: true,
project: true,
},
});
return NextResponse.json({ equipment });
} catch (error) {
console.error('Error updating equipment:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.equipment.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting equipment:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const equipment = await prisma.equipment.findMany({
include: {
location: true,
project: true,
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ equipment });
} catch (error) {
console.error('Error fetching equipment:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const data = await request.json();
const equipment = await prisma.equipment.create({
data: {
name: data.name,
type: data.type,
brand: data.brand,
model: data.model,
serialNumber: data.serialNumber,
quantity: data.quantity || 1,
status: data.status || 'AVAILABLE',
locationId: data.locationId,
projectId: data.projectId,
notes: data.notes,
purchaseDate: data.purchaseDate ? new Date(data.purchaseDate) : null,
purchasePrice: data.purchasePrice,
minStockLevel: data.minStockLevel,
currentStock: data.currentStock,
},
include: {
location: true,
project: true,
},
});
return NextResponse.json({ equipment });
} catch (error) {
console.error('Error creating equipment:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id } = params;
const location = await prisma.location.update({
where: { id },
data: {
imapHost: body.imapHost || null,
imapPort: body.imapPort || null,
imapUser: body.imapUser || null,
imapPassword: body.imapPassword || null,
imapSecure: body.imapSecure ?? true,
smtpHost: body.smtpHost || null,
smtpPort: body.smtpPort || null,
smtpUser: body.smtpUser || null,
smtpPassword: body.smtpPassword || null,
smtpSecure: body.smtpSecure ?? true,
emailSyncEnabled: body.emailSyncEnabled ?? false,
},
});
return NextResponse.json({ success: true, location });
} catch (error) {
console.error('Email settings update error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const locations = await prisma.location.findMany({
where: {
active: true,
},
orderBy: {
name: 'asc',
},
});
return NextResponse.json({ locations });
} catch (error) {
console.error('Locations fetch error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const photobox = await prisma.photobox.findUnique({
where: { id: params.id },
include: {
location: true,
bookings: {
select: {
id: true,
bookingNumber: true,
eventDate: true,
status: true,
customerName: true,
},
orderBy: {
eventDate: 'desc',
},
},
},
});
if (!photobox) {
return NextResponse.json({ error: 'Photobox not found' }, { status: 404 });
}
return NextResponse.json({ photobox });
} catch (error: any) {
console.error('Photobox fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch photobox' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const {
model,
serialNumber,
status,
active,
description,
lastMaintenance,
} = body;
const updateData: any = {};
if (model !== undefined) updateData.model = model;
if (serialNumber !== undefined) updateData.serialNumber = serialNumber;
if (status !== undefined) updateData.status = status;
if (active !== undefined) updateData.active = active;
if (description !== undefined) updateData.description = description;
if (lastMaintenance !== undefined) {
updateData.lastMaintenance = lastMaintenance ? new Date(lastMaintenance) : null;
}
const photobox = await prisma.photobox.update({
where: { id: params.id },
data: updateData,
include: {
location: true,
},
});
return NextResponse.json({ photobox });
} catch (error: any) {
console.error('Photobox update error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update photobox' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.photobox.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Photobox deletion error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to delete photobox' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const locationId = searchParams.get('locationId');
const status = searchParams.get('status');
const where: any = {};
if (locationId) where.locationId = locationId;
if (status) where.status = status;
const photoboxes = await prisma.photobox.findMany({
where,
include: {
location: {
select: {
id: true,
name: true,
city: true,
},
},
_count: {
select: {
bookings: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ photoboxes });
} catch (error: any) {
console.error('Photobox fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch photoboxes' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const {
locationId,
model,
serialNumber,
description,
purchaseDate,
} = body;
if (!locationId || !model || !serialNumber) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const photobox = await prisma.photobox.create({
data: {
locationId,
model,
serialNumber,
description,
purchaseDate: purchaseDate ? new Date(purchaseDate) : null,
status: 'AVAILABLE',
active: true,
},
include: {
location: true,
},
});
return NextResponse.json({ photobox }, { status: 201 });
} catch (error: any) {
console.error('Photobox creation error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to create photobox' },
{ status: 500 }
);
}
}

41
app/api/prices/route.ts Normal file
View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const locationSlug = searchParams.get('location');
if (!locationSlug) {
return NextResponse.json(
{ error: 'Location parameter required' },
{ status: 400 }
);
}
const location = await prisma.location.findUnique({
where: { slug: locationSlug },
});
if (!location) {
return NextResponse.json(
{ error: 'Location not found' },
{ status: 404 }
);
}
const prices = await prisma.priceConfig.findMany({
where: {
locationId: location.id,
},
});
return NextResponse.json({ prices });
} catch (error) {
console.error('Prices fetch error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

27
app/api/projects/route.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const projects = await prisma.project.findMany({
where: {
active: true,
},
orderBy: {
name: 'asc',
},
});
return NextResponse.json({ projects });
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { routeService } from '@/lib/route-optimization';
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tour = await prisma.tour.findUnique({
where: { id: params.id },
include: {
bookings: {
include: {
location: true,
},
orderBy: {
setupTimeStart: 'asc',
},
},
driver: true,
},
});
if (!tour) {
return NextResponse.json({ error: 'Tour nicht gefunden' }, { status: 404 });
}
if (tour.bookings.length === 0) {
return NextResponse.json({ error: 'Keine Buchungen in dieser Tour' }, { status: 400 });
}
const stops = tour.bookings.map((booking) => ({
address: `${booking.eventAddress}, ${booking.eventZip} ${booking.eventCity}`,
bookingId: booking.id,
setupTime: booking.setupTimeStart.toISOString(),
}));
const startLocation = tour.bookings[0].location.city;
const optimizedRoute = await routeService.optimizeRouteWithTimeWindows(
stops,
startLocation
);
if (!optimizedRoute) {
return NextResponse.json(
{ error: 'Routenoptimierung fehlgeschlagen' },
{ status: 500 }
);
}
const updatedTour = await prisma.tour.update({
where: { id: params.id },
data: {
routeOptimized: optimizedRoute as any,
totalDistance: optimizedRoute.totalDistance,
estimatedDuration: optimizedRoute.totalDuration,
},
include: {
bookings: {
include: {
location: true,
},
},
driver: true,
},
});
return NextResponse.json({
success: true,
tour: updatedTour,
route: optimizedRoute,
});
} catch (error) {
console.error('Route optimization error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

175
app/api/tours/[id]/route.ts Normal file
View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { optimizeRoute } from '@/lib/google-maps';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tour = await prisma.tour.findUnique({
where: { id: params.id },
include: {
driver: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
vehicleModel: true,
},
},
bookings: {
include: {
location: {
select: {
name: true,
city: true,
},
},
photobox: {
select: {
model: true,
serialNumber: true,
},
},
},
orderBy: {
setupTimeStart: 'asc',
},
},
},
});
if (!tour) {
return NextResponse.json({ error: 'Tour not found' }, { status: 404 });
}
if (session.user.role !== 'ADMIN' && session.user.id !== tour.driverId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ tour });
} catch (error: any) {
console.error('Tour fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch tour' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { driverId, status, notes, bookingIds } = body;
const updateData: any = {};
if (driverId !== undefined) updateData.driverId = driverId;
if (status !== undefined) {
updateData.status = status;
if (status === 'IN_PROGRESS' && !updateData.startedAt) {
updateData.startedAt = new Date();
}
if (status === 'COMPLETED' && !updateData.completedAt) {
updateData.completedAt = new Date();
}
}
if (notes !== undefined) updateData.notes = notes;
if (bookingIds !== undefined) {
await prisma.booking.updateMany({
where: { tourId: params.id },
data: { tourId: null },
});
if (bookingIds.length > 0) {
await prisma.booking.updateMany({
where: { id: { in: bookingIds } },
data: { tourId: params.id },
});
const bookings = await prisma.booking.findMany({
where: { id: { in: bookingIds } },
select: {
eventAddress: true,
eventCity: true,
eventZip: true,
setupTimeStart: true,
},
});
try {
const routeData = await optimizeRoute(bookings);
updateData.routeOptimized = routeData;
updateData.totalDistance = routeData.totalDistance;
updateData.estimatedDuration = routeData.totalDuration;
} catch (routeError) {
console.error('Route optimization error:', routeError);
}
}
}
const tour = await prisma.tour.update({
where: { id: params.id },
data: updateData,
include: {
driver: true,
bookings: true,
},
});
return NextResponse.json({ tour });
} catch (error: any) {
console.error('Tour update error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update tour' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.booking.updateMany({
where: { tourId: params.id },
data: { tourId: null },
});
await prisma.tour.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Tour deletion error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to delete tour' },
{ status: 500 }
);
}
}

206
app/api/tours/route.ts Normal file
View File

@@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { optimizeRoute, optimizeRouteBySchedule } from '@/lib/google-maps';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const driverId = searchParams.get('driverId');
const status = searchParams.get('status');
const date = searchParams.get('date');
const where: any = {};
if (driverId) where.driverId = driverId;
if (status) where.status = status;
if (date) {
const startDate = new Date(date);
const endDate = new Date(date);
endDate.setDate(endDate.getDate() + 1);
where.tourDate = {
gte: startDate,
lt: endDate,
};
}
const tours = await prisma.tour.findMany({
where,
include: {
driver: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
vehiclePlate: true,
},
},
bookings: {
select: {
id: true,
bookingNumber: true,
eventDate: true,
eventAddress: true,
eventCity: true,
eventZip: true,
eventLocation: true,
customerName: true,
setupTimeStart: true,
setupTimeLatest: true,
},
},
},
orderBy: {
tourDate: 'desc',
},
});
return NextResponse.json({ tours });
} catch (error: any) {
console.error('Tour fetch error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch tours' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { tourDate, driverId, bookingIds, optimizationType = 'fastest' } = body;
if (!tourDate) {
return NextResponse.json(
{ error: 'Tour date is required' },
{ status: 400 }
);
}
const tourNumber = `TOUR-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
const tour = await prisma.tour.create({
data: {
tourDate: new Date(tourDate),
tourNumber,
driverId,
status: 'PLANNED',
},
});
if (bookingIds && bookingIds.length > 0) {
await prisma.booking.updateMany({
where: {
id: { in: bookingIds },
},
data: {
tourId: tour.id,
},
});
// Mark setup windows as selected for bookings with flexible setup times
for (const bookingId of bookingIds) {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { setupWindows: true },
});
if (booking && booking.setupWindows.length > 0) {
const tourDateStr = new Date(tourDate).toISOString().split('T')[0];
const matchingWindow = booking.setupWindows.find((w: any) => {
const windowDateStr = new Date(w.setupDate).toISOString().split('T')[0];
return windowDateStr === tourDateStr && !w.selected;
});
if (matchingWindow) {
await prisma.setupWindow.update({
where: { id: matchingWindow.id },
data: { selected: true },
});
}
}
}
const bookings = await prisma.booking.findMany({
where: { id: { in: bookingIds } },
include: { setupWindows: true },
select: {
eventAddress: true,
eventCity: true,
eventZip: true,
setupTimeStart: true,
setupTimeLatest: true,
setupWindows: true,
},
});
try {
// For route optimization, use the selected setup window time if available
const stopsWithSetupTimes = bookings.map((booking: any) => {
const tourDateStr = new Date(tourDate).toISOString().split('T')[0];
const selectedWindow = booking.setupWindows?.find((w: any) => w.selected);
if (selectedWindow) {
return {
eventAddress: booking.eventAddress,
eventCity: booking.eventCity,
eventZip: booking.eventZip,
setupTimeStart: selectedWindow.setupTimeStart,
setupTimeLatest: selectedWindow.setupTimeEnd,
};
}
return {
eventAddress: booking.eventAddress,
eventCity: booking.eventCity,
eventZip: booking.eventZip,
setupTimeStart: booking.setupTimeStart,
setupTimeLatest: booking.setupTimeLatest,
};
});
const routeData = optimizationType === 'schedule'
? await optimizeRouteBySchedule(stopsWithSetupTimes)
: await optimizeRoute(stopsWithSetupTimes);
await prisma.tour.update({
where: { id: tour.id },
data: {
routeOptimized: routeData as any,
totalDistance: routeData.totalDistance,
estimatedDuration: routeData.totalDuration,
},
});
} catch (routeError) {
console.error('Route optimization error:', routeError);
}
}
const updatedTour = await prisma.tour.findUnique({
where: { id: tour.id },
include: {
driver: true,
bookings: true,
},
});
return NextResponse.json({ tour: updatedTour }, { status: 201 });
} catch (error: any) {
console.error('Tour creation error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to create tour' },
{ status: 500 }
);
}
}

582
app/booking-page-backup.txt Normal file
View File

@@ -0,0 +1,582 @@
'use client';
import { useState, useEffect } from 'react';
import { FiCalendar, FiMapPin, FiUser, FiMail, FiPhone, FiCamera, FiCheck, FiAlertCircle } from 'react-icons/fi';
interface Location {
id: string;
name: string;
slug: string;
city: string;
}
interface PriceConfig {
model: string;
basePrice: number;
pricePerKm: number;
includedKm: number;
}
const photoboxModels = [
{ value: 'VINTAGE_SMILE', label: 'Vintage Smile', description: 'Klassische Vintage-Box mit Sofortdruck' },
{ value: 'VINTAGE_PHOTOS', label: 'Vintage Photos', description: 'Vintage-Box mit erweiterten Funktionen' },
{ value: 'NOSTALGIE', label: 'Nostalgie', description: 'Nostalgische Fotobox im Retro-Design' },
{ value: 'MAGIC_MIRROR', label: 'Magic Mirror', description: 'Interaktiver Fotospiegel' },
];
export default function BookingFormPage() {
const [step, setStep] = useState(1);
const [locations, setLocations] = useState<Location[]>([]);
const [priceConfigs, setPriceConfigs] = useState<PriceConfig[]>([]);
const [availability, setAvailability] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
locationSlug: '',
model: '',
customerName: '',
customerEmail: '',
customerPhone: '',
customerAddress: '',
customerCity: '',
customerZip: '',
invoiceType: 'PRIVATE',
companyName: '',
eventDate: '',
eventAddress: '',
eventCity: '',
eventZip: '',
eventLocation: '',
setupTimeStart: '',
setupTimeLatest: '',
dismantleTimeEarliest: '',
dismantleTimeLatest: '',
notes: '',
});
useEffect(() => {
fetch('/api/locations')
.then(res => res.json())
.then(data => setLocations(data.locations || []));
}, []);
useEffect(() => {
if (formData.locationSlug) {
fetch(`/api/prices?location=${formData.locationSlug}`)
.then(res => res.json())
.then(data => setPriceConfigs(data.prices || []));
}
}, [formData.locationSlug]);
useEffect(() => {
if (formData.locationSlug && formData.model && formData.eventDate) {
checkAvailability();
}
}, [formData.locationSlug, formData.model, formData.eventDate]);
const checkAvailability = async () => {
try {
const res = await fetch(
`/api/availability?location=${formData.locationSlug}&model=${formData.model}&date=${formData.eventDate}`
);
const data = await res.json();
setAvailability(data);
} catch (err) {
console.error('Availability check failed:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Buchung fehlgeschlagen');
}
setSuccess(true);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const getPrice = () => {
const config = priceConfigs.find(p => p.model === formData.model);
return config ? config.basePrice : 0;
};
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FiCheck className="text-3xl text-green-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Anfrage erfolgreich!</h2>
<p className="text-gray-600 mb-6">
Vielen Dank für Ihre Buchungsanfrage. Wir melden uns in Kürze bei Ihnen mit allen Details.
</p>
<a
href="/booking"
className="inline-block px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Weitere Buchung
</a>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 to-red-100 py-12 px-4">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="bg-red-600 text-white p-6">
<h1 className="text-3xl font-bold">Fotobox buchen</h1>
<p className="text-red-100 mt-2">Save the Moment - Ihr Event, unvergesslich</p>
</div>
<div className="p-8">
<div className="flex items-center justify-between mb-8">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold ${
step >= s ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-500'
}`}
>
{s}
</div>
{s < 3 && <div className={`h-1 w-20 mx-2 ${step > s ? 'bg-red-600' : 'bg-gray-200'}`} />}
</div>
))}
</div>
<form onSubmit={handleSubmit}>
{step === 1 && (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Standort & Fotobox wählen</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<FiMapPin className="inline mr-2" />
Standort
</label>
<select
value={formData.locationSlug}
onChange={(e) => setFormData({ ...formData, locationSlug: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="">Bitte wählen...</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.slug}>
{loc.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<FiCamera className="inline mr-2" />
Fotobox-Modell
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{photoboxModels.map((model) => {
const price = priceConfigs.find(p => p.model === model.value);
return (
<label
key={model.value}
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
formData.model === model.value
? 'border-red-600 bg-red-50'
: 'border-gray-200 hover:border-red-300'
}`}
>
<input
type="radio"
name="model"
value={model.value}
checked={formData.model === model.value}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
className="sr-only"
/>
<div className="font-semibold text-gray-900">{model.label}</div>
<div className="text-sm text-gray-600 mt-1">{model.description}</div>
{price && (
<div className="text-red-600 font-bold mt-2">
ab {price.basePrice}€
</div>
)}
</label>
);
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<FiCalendar className="inline mr-2" />
Event-Datum
</label>
<input
type="date"
value={formData.eventDate}
onChange={(e) => setFormData({ ...formData, eventDate: e.target.value })}
required
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
{availability && (
<div className={`mt-2 p-3 rounded-lg ${
availability.available
? availability.isLastOne
? 'bg-yellow-50 text-yellow-800 border border-yellow-200'
: 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
<FiAlertCircle className="inline mr-2" />
{availability.message}
</div>
)}
</div>
<button
type="button"
onClick={() => setStep(2)}
disabled={!formData.locationSlug || !formData.model || !formData.eventDate || (availability && !availability.available)}
className="w-full bg-red-600 text-white py-3 rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Ihre Kontaktdaten</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Rechnungsart
</label>
<div className="flex gap-4">
<label className="flex-1">
<input
type="radio"
name="invoiceType"
value="PRIVATE"
checked={formData.invoiceType === 'PRIVATE'}
onChange={(e) => setFormData({ ...formData, invoiceType: e.target.value as any })}
className="mr-2"
/>
Privat
</label>
<label className="flex-1">
<input
type="radio"
name="invoiceType"
value="BUSINESS"
checked={formData.invoiceType === 'BUSINESS'}
onChange={(e) => setFormData({ ...formData, invoiceType: e.target.value as any })}
className="mr-2"
/>
Geschäftlich
</label>
</div>
</div>
{formData.invoiceType === 'BUSINESS' && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Firmenname
</label>
<input
type="text"
value={formData.companyName}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
)}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
<FiUser className="inline mr-2" />
Name
</label>
<input
type="text"
value={formData.customerName}
onChange={(e) => setFormData({ ...formData, customerName: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<FiMail className="inline mr-2" />
E-Mail
</label>
<input
type="email"
value={formData.customerEmail}
onChange={(e) => setFormData({ ...formData, customerEmail: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<FiPhone className="inline mr-2" />
Telefon
</label>
<input
type="tel"
value={formData.customerPhone}
onChange={(e) => setFormData({ ...formData, customerPhone: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Adresse (optional)
</label>
<input
type="text"
value={formData.customerAddress}
onChange={(e) => setFormData({ ...formData, customerAddress: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
PLZ (optional)
</label>
<input
type="text"
value={formData.customerZip}
onChange={(e) => setFormData({ ...formData, customerZip: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stadt (optional)
</label>
<input
type="text"
value={formData.customerCity}
onChange={(e) => setFormData({ ...formData, customerCity: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setStep(1)}
className="flex-1 bg-gray-200 text-gray-800 py-3 rounded-lg font-semibold hover:bg-gray-300 transition-colors"
>
Zurück
</button>
<button
type="button"
onClick={() => setStep(3)}
disabled={!formData.customerName || !formData.customerEmail || !formData.customerPhone}
className="flex-1 bg-red-600 text-white py-3 rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Event-Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Event-Adresse
</label>
<input
type="text"
value={formData.eventAddress}
onChange={(e) => setFormData({ ...formData, eventAddress: e.target.value })}
required
placeholder="Straße und Hausnummer"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
PLZ
</label>
<input
type="text"
value={formData.eventZip}
onChange={(e) => setFormData({ ...formData, eventZip: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stadt
</label>
<input
type="text"
value={formData.eventCity}
onChange={(e) => setFormData({ ...formData, eventCity: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Location-Name (optional)
</label>
<input
type="text"
value={formData.eventLocation}
onChange={(e) => setFormData({ ...formData, eventLocation: e.target.value })}
placeholder="z.B. Hotel Maritim, Villa Rosengarten"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Aufbau möglich ab
</label>
<input
type="datetime-local"
value={formData.setupTimeStart}
onChange={(e) => setFormData({ ...formData, setupTimeStart: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Aufbau spätestens bis
</label>
<input
type="datetime-local"
value={formData.setupTimeLatest}
onChange={(e) => setFormData({ ...formData, setupTimeLatest: e.target.value })}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Abbau frühestens ab (optional)
</label>
<input
type="datetime-local"
value={formData.dismantleTimeEarliest}
onChange={(e) => setFormData({ ...formData, dismantleTimeEarliest: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Abbau spätestens bis (optional)
</label>
<input
type="datetime-local"
value={formData.dismantleTimeLatest}
onChange={(e) => setFormData({ ...formData, dismantleTimeLatest: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Besondere Wünsche / Anmerkungen (optional)
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={4}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold text-gray-900 mb-2">Zusammenfassung</h3>
<div className="space-y-1 text-sm text-gray-600">
<p>Modell: {photoboxModels.find(m => m.value === formData.model)?.label}</p>
<p>Datum: {new Date(formData.eventDate).toLocaleDateString('de-DE')}</p>
<p>Standort: {locations.find(l => l.slug === formData.locationSlug)?.name}</p>
<p className="text-lg font-bold text-gray-900 mt-2">
Basispreis: {getPrice()}€ *
</p>
<p className="text-xs text-gray-500">* zzgl. Anfahrtskosten</p>
</div>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setStep(2)}
className="flex-1 bg-gray-200 text-gray-800 py-3 rounded-lg font-semibold hover:bg-gray-300 transition-colors"
>
Zurück
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-red-600 text-white py-3 rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Wird gesendet...' : 'Verbindlich anfragen'}
</button>
</div>
</div>
)}
</form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import ContractSigningForm from '@/components/ContractSigningForm';
export default async function ContractSignPage({ params }: { params: { token: string } }) {
// Decode token to get booking ID
const decoded = Buffer.from(params.token, 'base64url').toString();
const bookingId = decoded.split('-')[0];
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
notFound();
}
// Check if already signed
if (booking.contractSigned) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-xl shadow-lg p-8 text-center">
<div className="text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Vertrag bereits unterschrieben
</h1>
<p className="text-gray-600">
Dieser Vertrag wurde bereits am {booking.contractSignedAt?.toLocaleDateString('de-DE')} unterschrieben.
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<ContractSigningForm booking={booking} location={booking.location} photobox={booking.photobox} token={params.token} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link';
export default function ContractSuccessPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-xl shadow-lg p-8 text-center">
<div className="text-6xl mb-6"></div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Vielen Dank!
</h1>
<p className="text-lg text-gray-600 mb-4">
Ihr Vertrag wurde erfolgreich unterschrieben.
</p>
<p className="text-gray-600 mb-8">
Sie erhalten in Kürze eine Bestätigung per E-Mail mit dem signierten Vertrag als PDF-Anhang.
</p>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-8">
<p className="text-green-800 font-semibold">
Ihre Unterschrift wurde rechtsverbindlich gespeichert und dokumentiert.
</p>
</div>
<Link
href="/"
className="inline-block px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-semibold"
>
Zur Startseite
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import BookingDetail from '@/components/BookingDetail';
import DashboardSidebar from '@/components/DashboardSidebar';
export default async function BookingDetailPage({ params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/login');
}
const booking = await prisma.booking.findUnique({
where: { id: params.id },
include: {
location: true,
photobox: true,
tour: {
include: {
driver: true,
},
},
bookingEquipment: {
include: {
equipment: true,
},
},
},
});
if (!booking) {
redirect('/dashboard/bookings');
}
const emails = await prisma.email.findMany({
where: { bookingId: booking.id },
orderBy: { receivedAt: 'desc' },
});
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<BookingDetail booking={booking} emails={emails} user={session?.user} />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import NewBookingForm from '@/components/NewBookingForm';
import DashboardSidebar from '@/components/DashboardSidebar';
export default async function NewBookingPage() {
const session = await getServerSession(authOptions);
const locations = await prisma.location.findMany({
where: { active: true },
include: {
photoboxes: {
where: { active: true },
},
},
orderBy: { name: 'asc' },
});
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<NewBookingForm locations={locations} user={session?.user} />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import BookingsTable from '@/components/BookingsTable';
import DashboardSidebar from '@/components/DashboardSidebar';
export default async function BookingsPage() {
const session = await getServerSession(authOptions);
const bookings = await prisma.booking.findMany({
include: {
location: true,
photobox: true,
},
orderBy: {
createdAt: 'desc',
},
});
const locations = await prisma.location.findMany({
where: { active: true },
orderBy: { name: 'asc' },
});
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<BookingsTable
bookings={bookings}
locations={locations}
user={session?.user}
/>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,336 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date-utils";
import DashboardSidebar from "@/components/DashboardSidebar";
import { useSession } from "next-auth/react";
export default function DriverDetailPage({
params,
}: {
params: { id: string };
}) {
const router = useRouter();
const { data: session } = useSession();
const [driver, setDriver] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({
name: "",
email: "",
phoneNumber: "",
vehiclePlate: "",
vehicleModel: "",
available: true,
});
useEffect(() => {
fetchDriver();
}, [params.id]);
const fetchDriver = async () => {
try {
const res = await fetch(`/api/drivers/${params.id}`);
const data = await res.json();
setDriver(data.driver);
setFormData({
name: data.driver.name,
email: data.driver.email,
phoneNumber: data.driver.phoneNumber || "",
vehiclePlate: data.driver.vehiclePlate || "",
vehicleModel: data.driver.vehicleModel || "",
available: data.driver.available,
});
} catch (error) {
console.error("Fetch error:", error);
} finally {
setLoading(false);
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch(`/api/drivers/${params.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
setEditing(false);
fetchDriver();
} else {
alert("Fehler beim Aktualisieren");
}
} catch (error) {
console.error("Update error:", error);
alert("Fehler beim Aktualisieren");
}
};
const handleDelete = async () => {
if (!confirm("Möchten Sie diesen Fahrer wirklich löschen?")) return;
try {
const res = await fetch(`/api/drivers/${params.id}`, {
method: "DELETE",
});
if (res.ok) {
router.push("/dashboard/drivers");
} else {
alert("Fehler beim Löschen");
}
} catch (error) {
console.error("Delete error:", error);
alert("Fehler beim Löschen");
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
if (!driver) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Fahrer nicht gefunden</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-5xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-red-400 hover:text-red-300 font-medium"
>
Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 mb-6 border border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">
{driver.name}
</h1>
<p className="text-gray-400 mt-1">{driver.email}</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setEditing(!editing)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold"
>
{editing ? "Abbrechen" : "Bearbeiten"}
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold"
>
Löschen
</button>
</div>
</div>
{editing ? (
<form onSubmit={handleUpdate} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
E-Mail
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Telefon
</label>
<input
type="tel"
value={formData.phoneNumber}
onChange={(e) =>
setFormData({
...formData,
phoneNumber: e.target.value,
})
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Kennzeichen
</label>
<input
type="text"
value={formData.vehiclePlate}
onChange={(e) =>
setFormData({
...formData,
vehiclePlate: e.target.value,
})
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Fahrzeug
</label>
<input
type="text"
value={formData.vehicleModel}
onChange={(e) =>
setFormData({
...formData,
vehicleModel: e.target.value,
})
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Verfügbarkeit
</label>
<select
value={formData.available ? "true" : "false"}
onChange={(e) =>
setFormData({
...formData,
available: e.target.value === "true",
})
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg"
>
<option value="true">Verfügbar</option>
<option value="false">Nicht verfügbar</option>
</select>
</div>
</div>
<button
type="submit"
className="px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 font-semibold"
>
Speichern
</button>
</form>
) : (
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-sm text-gray-400">Telefon</p>
<p className="text-lg font-semibold text-white">
{driver.phoneNumber || "Nicht angegeben"}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Kennzeichen</p>
<p className="text-lg font-semibold text-white">
{driver.vehiclePlate || "Nicht angegeben"}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Fahrzeug</p>
<p className="text-lg font-semibold text-white">
{driver.vehicleModel || "Nicht angegeben"}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Verfügbarkeit</p>
<p className="text-lg font-semibold text-white">
{driver.available ? "✓ Verfügbar" : "✗ Nicht verfügbar"}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Erstellt</p>
<p className="text-lg font-semibold text-white">
{formatDate(driver.createdAt)}
</p>
</div>
</div>
)}
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">
Letzte Touren ({driver.driverTours?.length || 0})
</h2>
{driver.driverTours && driver.driverTours.length > 0 ? (
<div className="space-y-3">
{driver.driverTours.map((tour: any) => (
<div
key={tour.id}
onClick={() => router.push(`/dashboard/tours/${tour.id}`)}
className="p-4 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 cursor-pointer transition-colors"
>
<div className="flex justify-between items-center">
<div>
<p className="font-semibold text-white">
{tour.tourNumber}
</p>
<p className="text-sm text-gray-400">
{tour.bookings.length} Buchungen
</p>
</div>
<div className="text-right">
<p className="font-medium text-white">
{formatDate(tour.tourDate)}
</p>
<p className="text-sm text-gray-400">{tour.status}</p>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Touren vorhanden</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function DriversPage() {
const router = useRouter();
const { data: session } = useSession();
const [drivers, setDrivers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
phoneNumber: '',
vehiclePlate: '',
vehicleModel: '',
});
useEffect(() => {
fetchDrivers();
}, []);
const fetchDrivers = async () => {
try {
const res = await fetch('/api/drivers');
const data = await res.json();
setDrivers(data.drivers || []);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch('/api/drivers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) {
setShowForm(false);
setFormData({
name: '',
email: '',
password: '',
phoneNumber: '',
vehiclePlate: '',
vehicleModel: '',
});
fetchDrivers();
} else {
const data = await res.json();
alert(data.error || 'Fehler beim Erstellen');
}
} catch (error) {
console.error('Create error:', error);
alert('Fehler beim Erstellen');
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-red-400 to-pink-500 bg-clip-text text-transparent">
Fahrer
</h1>
<p className="text-gray-400 mt-1">Verwalten Sie alle Fahrer</p>
</div>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold shadow-lg"
>
+ Neuer Fahrer
</button>
</div>
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl max-w-2xl w-full p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">Neuen Fahrer anlegen</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
E-Mail *
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Passwort *
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Telefon
</label>
<input
type="tel"
value={formData.phoneNumber}
onChange={(e) => setFormData({ ...formData, phoneNumber: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Fahrzeugkennzeichen
</label>
<input
type="text"
value={formData.vehiclePlate}
onChange={(e) => setFormData({ ...formData, vehiclePlate: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Fahrzeugmodell
</label>
<input
type="text"
value={formData.vehicleModel}
onChange={(e) => setFormData({ ...formData, vehicleModel: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setShowForm(false)}
className="flex-1 px-6 py-3 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 font-semibold"
>
Abbrechen
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold"
>
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{drivers.map((driver) => (
<div
key={driver.id}
onClick={() => router.push(`/dashboard/drivers/${driver.id}`)}
className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg shadow-xl p-6 hover:shadow-2xl transition-all cursor-pointer border border-gray-700 hover:border-red-500"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-bold text-white">{driver.name}</h3>
<p className="text-sm text-gray-400">{driver.email}</p>
</div>
<div className="flex flex-col gap-1">
{driver.available ? (
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-green-500/20 text-green-400 border border-green-500/50">
Verfügbar
</span>
) : (
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-red-500/20 text-red-400 border border-red-500/50">
Beschäftigt
</span>
)}
</div>
</div>
<div className="text-sm text-gray-400 space-y-2">
{driver.phoneNumber && (
<p><span className="font-medium text-gray-300">Telefon:</span> {driver.phoneNumber}</p>
)}
{driver.vehiclePlate && (
<p><span className="font-medium text-gray-300">Kennzeichen:</span> {driver.vehiclePlate}</p>
)}
{driver.vehicleModel && (
<p><span className="font-medium text-gray-300">Fahrzeug:</span> {driver.vehicleModel}</p>
)}
<p className="pt-2 border-t border-gray-700">
<span className="font-medium text-gray-300">Touren:</span> {driver._count.driverTours}
</p>
</div>
</div>
))}
</div>
{drivers.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">Noch keine Fahrer vorhanden</p>
</div>
)}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,546 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
import { FiPackage, FiArrowLeft, FiSave, FiEdit, FiX, FiTrash2, FiMapPin, FiCalendar } from 'react-icons/fi';
import Link from 'next/link';
import { formatDate } from '@/lib/date-utils';
export default function EquipmentDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { data: session } = useSession();
const [equipment, setEquipment] = useState<any>(null);
const [locations, setLocations] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<any>(null);
useEffect(() => {
fetchEquipment();
fetchLocationsAndProjects();
}, [params.id]);
const fetchEquipment = async () => {
try {
const res = await fetch(`/api/inventory/${params.id}`);
if (res.ok) {
const data = await res.json();
setEquipment(data.equipment);
setFormData(data.equipment);
}
} catch (err) {
console.error('Error fetching equipment:', err);
} finally {
setLoading(false);
}
};
const fetchLocationsAndProjects = async () => {
try {
const [locRes, projRes] = await Promise.all([
fetch('/api/locations'),
fetch('/api/projects'),
]);
if (locRes.ok) {
const locData = await locRes.json();
setLocations(locData.locations || []);
}
if (projRes.ok) {
const projData = await projRes.json();
setProjects(projData.projects || []);
}
} catch (err) {
console.error('Error fetching data:', err);
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/inventory/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
purchaseDate: formData.purchaseDate || null,
purchasePrice: formData.purchasePrice ? parseFloat(formData.purchasePrice) : null,
minStockLevel: formData.minStockLevel ? parseInt(formData.minStockLevel) : null,
currentStock: formData.currentStock ? parseInt(formData.currentStock) : null,
locationId: formData.locationId || null,
projectId: formData.projectId || null,
}),
});
if (res.ok) {
alert('Gespeichert!');
setEditing(false);
fetchEquipment();
} else {
alert('Fehler beim Speichern');
}
} catch (error) {
alert('Fehler beim Speichern');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('Equipment wirklich löschen?')) return;
try {
const res = await fetch(`/api/inventory/${params.id}`, {
method: 'DELETE',
});
if (res.ok) {
alert('Equipment gelöscht!');
router.push('/dashboard/inventory');
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
alert('Fehler beim Löschen');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'AVAILABLE': return 'bg-green-500/20 text-green-400 border-green-500/50';
case 'IN_USE': return 'bg-blue-500/20 text-blue-400 border-blue-500/50';
case 'MAINTENANCE': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
case 'DAMAGED': return 'bg-red-500/20 text-red-400 border-red-500/50';
case 'RESERVED': return 'bg-purple-500/20 text-purple-400 border-purple-500/50';
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
}
};
const getStatusLabel = (status: string) => {
const labels: any = {
AVAILABLE: 'Verfügbar',
IN_USE: 'Im Einsatz',
MAINTENANCE: 'Wartung',
DAMAGED: 'Beschädigt',
RESERVED: 'Reserviert',
};
return labels[status] || status;
};
const getTypeLabel = (type: string) => {
const labels: any = {
PRINTER: 'Drucker',
CARPET: 'Roter Teppich',
VIP_BARRIER: 'VIP Absperrband',
ACCESSORIES_KIT: 'Accessoires-Koffer',
PRINTER_PAPER: 'Druckerpapier',
TRIPOD: 'Stativ',
OTHER: 'Sonstiges',
};
return labels[type] || type;
};
if (!session) {
return null;
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center">
<div className="text-white text-xl">Lade...</div>
</div>
);
}
if (!equipment) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center">
<div className="text-gray-400 text-xl">Equipment nicht gefunden</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session.user} />
<main className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<Link href="/dashboard/inventory" className="text-sm text-gray-400 hover:text-gray-300 mb-2 inline-flex items-center gap-2 transition-colors">
<FiArrowLeft /> Zurück zum Inventar
</Link>
<h2 className="text-3xl font-bold text-white mt-2">{equipment.name}</h2>
<p className="text-gray-400 mt-1">{getTypeLabel(equipment.type)}</p>
</div>
<div className="flex gap-3">
{!editing ? (
<>
<button
onClick={() => setEditing(true)}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-gray-600 to-gray-700 text-white rounded-lg hover:from-gray-700 hover:to-gray-800 shadow-lg transition-all"
>
<FiEdit /> Bearbeiten
</button>
<button
onClick={handleDelete}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 shadow-lg transition-all"
>
<FiTrash2 /> Löschen
</button>
</>
) : (
<>
<button
onClick={() => {
setEditing(false);
setFormData(equipment);
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 transition-colors"
>
<FiX /> Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 disabled:opacity-50 shadow-lg transition-all"
>
<FiSave /> {saving ? 'Speichern...' : 'Speichern'}
</button>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white">Status</h3>
<div className={`px-4 py-2 border-2 rounded-lg font-semibold ${getStatusColor(equipment.status)}`}>
{getStatusLabel(equipment.status)}
</div>
</div>
{editing && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Status ändern</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="AVAILABLE">Verfügbar</option>
<option value="IN_USE">Im Einsatz</option>
<option value="MAINTENANCE">Wartung</option>
<option value="DAMAGED">Beschädigt</option>
<option value="RESERVED">Reserviert</option>
</select>
</div>
)}
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4">Details</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Name/Bezeichnung</label>
{editing ? (
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Typ</label>
{editing ? (
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
>
<option value="PRINTER">Drucker</option>
<option value="CARPET">Roter Teppich</option>
<option value="VIP_BARRIER">VIP Absperrband</option>
<option value="ACCESSORIES_KIT">Accessoires-Koffer</option>
<option value="PRINTER_PAPER">Druckerpapier</option>
<option value="TRIPOD">Stativ</option>
<option value="OTHER">Sonstiges</option>
</select>
) : (
<p className="text-white">{getTypeLabel(equipment.type)}</p>
)}
</div>
{equipment.brand && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Hersteller</label>
{editing ? (
<input
type="text"
value={formData.brand || ''}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.brand}</p>
)}
</div>
)}
{equipment.model && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Modell</label>
{editing ? (
<input
type="text"
value={formData.model || ''}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.model}</p>
)}
</div>
)}
{equipment.serialNumber && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Seriennummer</label>
{editing ? (
<input
type="text"
value={formData.serialNumber || ''}
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.serialNumber}</p>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Anzahl</label>
{editing ? (
<input
type="number"
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) })}
min="1"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.quantity}</p>
)}
</div>
</div>
</div>
{(equipment.currentStock !== null || equipment.minStockLevel !== null || editing) && (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4">Bestandsverwaltung</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Aktueller Bestand</label>
{editing ? (
<input
type="number"
value={formData.currentStock || ''}
onChange={(e) => setFormData({ ...formData, currentStock: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.currentStock || '-'}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Mindestbestand</label>
{editing ? (
<input
type="number"
value={formData.minStockLevel || ''}
onChange={(e) => setFormData({ ...formData, minStockLevel: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-white">{equipment.minStockLevel || '-'}</p>
)}
</div>
</div>
{equipment.currentStock !== null && equipment.minStockLevel !== null && equipment.currentStock < equipment.minStockLevel && (
<div className="mt-4 bg-yellow-500/20 border border-yellow-500/50 text-yellow-400 px-4 py-3 rounded-lg">
Niedriger Bestand! Nachbestellen erforderlich.
</div>
)}
</div>
)}
{equipment.notes && (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4">Notizen</h3>
{editing ? (
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={4}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : (
<p className="text-gray-300 whitespace-pre-wrap">{equipment.notes}</p>
)}
</div>
)}
{equipment.bookingEquipment && equipment.bookingEquipment.length > 0 && (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<FiCalendar /> Buchungen
</h3>
<div className="space-y-3">
{equipment.bookingEquipment.map((be: any) => (
<Link
key={be.id}
href={`/dashboard/bookings/${be.booking.id}`}
className="block p-4 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 hover:border-gray-500 transition-all"
>
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-white">{be.booking.customerName}</p>
<p className="text-sm text-gray-400">{be.booking.eventCity}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-400">{formatDate(be.booking.eventDate)}</p>
<p className="text-xs text-gray-500">Menge: {be.quantity}</p>
</div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<FiMapPin /> Standort
</h3>
{editing ? (
<select
value={formData.locationId || ''}
onChange={(e) => setFormData({ ...formData, locationId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="">Kein Standort</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name}</option>
))}
</select>
) : equipment.location ? (
<div>
<p className="text-white font-semibold">{equipment.location.name}</p>
<p className="text-sm text-gray-400">{equipment.location.city}</p>
</div>
) : (
<p className="text-gray-400">Kein Standort zugewiesen</p>
)}
</div>
{(equipment.project || editing) && (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<FiPackage /> Projekt
</h3>
{editing ? (
<select
value={formData.projectId || ''}
onChange={(e) => setFormData({ ...formData, projectId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="">Kein Projekt</option>
{projects.map((proj) => (
<option key={proj.id} value={proj.id}>{proj.name}</option>
))}
</select>
) : equipment.project ? (
<div>
<p className="text-white font-semibold">{equipment.project.name}</p>
{equipment.project.description && (
<p className="text-sm text-gray-400 mt-2">{equipment.project.description}</p>
)}
</div>
) : (
<p className="text-gray-400">Kein Projekt zugewiesen</p>
)}
</div>
)}
{(equipment.purchaseDate || equipment.purchasePrice || editing) && (
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4">Kaufinformationen</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Kaufdatum</label>
{editing ? (
<input
type="date"
value={formData.purchaseDate ? new Date(formData.purchaseDate).toISOString().split('T')[0] : ''}
onChange={(e) => setFormData({ ...formData, purchaseDate: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : equipment.purchaseDate ? (
<p className="text-white">{formatDate(equipment.purchaseDate)}</p>
) : (
<p className="text-gray-400">-</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Kaufpreis</label>
{editing ? (
<input
type="number"
step="0.01"
value={formData.purchasePrice || ''}
onChange={(e) => setFormData({ ...formData, purchasePrice: e.target.value })}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
/>
) : equipment.purchasePrice ? (
<p className="text-white text-2xl font-bold">{equipment.purchasePrice}</p>
) : (
<p className="text-gray-400">-</p>
)}
</div>
</div>
</div>
)}
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-white mb-4">Erstellt</h3>
<p className="text-gray-400">{formatDate(equipment.createdAt)}</p>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,332 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
import { FiPackage, FiArrowLeft, FiSave } from 'react-icons/fi';
import Link from 'next/link';
export default function NewEquipmentPage() {
const router = useRouter();
const { data: session } = useSession();
const [locations, setLocations] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
type: 'PRINTER',
brand: '',
model: '',
serialNumber: '',
quantity: 1,
status: 'AVAILABLE',
locationId: '',
projectId: '',
notes: '',
purchaseDate: '',
purchasePrice: '',
minStockLevel: '',
currentStock: '',
});
useEffect(() => {
fetchLocationsAndProjects();
}, []);
const fetchLocationsAndProjects = async () => {
try {
const [locRes, projRes] = await Promise.all([
fetch('/api/locations'),
fetch('/api/projects'),
]);
if (locRes.ok) {
const locData = await locRes.json();
setLocations(locData.locations || []);
}
if (projRes.ok) {
const projData = await projRes.json();
setProjects(projData.projects || []);
}
} catch (err) {
console.error('Error fetching data:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/inventory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
quantity: parseInt(formData.quantity.toString()),
purchasePrice: formData.purchasePrice ? parseFloat(formData.purchasePrice) : null,
minStockLevel: formData.minStockLevel ? parseInt(formData.minStockLevel.toString()) : null,
currentStock: formData.currentStock ? parseInt(formData.currentStock.toString()) : null,
locationId: formData.locationId || null,
projectId: formData.projectId || null,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Fehler beim Erstellen');
}
alert('Equipment erfolgreich erstellt!');
router.push('/dashboard/inventory');
} catch (err: any) {
setError(err.message);
setLoading(false);
}
};
if (!session) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session.user} />
<main className="flex-1 p-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<Link href="/dashboard/inventory" className="text-sm text-gray-400 hover:text-gray-300 mb-2 inline-flex items-center gap-2 transition-colors">
<FiArrowLeft /> Zurück zum Inventar
</Link>
<h2 className="text-3xl font-bold text-white mt-2">Neues Equipment anlegen</h2>
<p className="text-gray-400 mt-1">Equipment zum Inventar hinzufügen</p>
</div>
<form onSubmit={handleSubmit} className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-sm p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold text-white mb-4">Grunddaten</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-300 mb-2">Name/Bezeichnung</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Citizen CX-02 #1"
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Typ</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
required
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="PRINTER">Drucker</option>
<option value="CARPET">Roter Teppich</option>
<option value="VIP_BARRIER">VIP Absperrband</option>
<option value="ACCESSORIES_KIT">Accessoires-Koffer</option>
<option value="PRINTER_PAPER">Druckerpapier</option>
<option value="TRIPOD">Stativ</option>
<option value="OTHER">Sonstiges</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
required
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="AVAILABLE">Verfügbar</option>
<option value="IN_USE">Im Einsatz</option>
<option value="MAINTENANCE">Wartung</option>
<option value="DAMAGED">Beschädigt</option>
<option value="RESERVED">Reserviert</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Hersteller</label>
<input
type="text"
value={formData.brand}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
placeholder="z.B. Citizen"
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Modell</label>
<input
type="text"
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
placeholder="z.B. CX-02"
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Seriennummer</label>
<input
type="text"
value={formData.serialNumber}
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Anzahl</label>
<input
type="number"
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) })}
required
min="1"
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-4">Zuordnung</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Standort (optional)</label>
<select
value={formData.locationId}
onChange={(e) => setFormData({ ...formData, locationId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="">Kein Standort</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Projekt (optional)</label>
<select
value={formData.projectId}
onChange={(e) => setFormData({ ...formData, projectId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
>
<option value="">Kein Projekt</option>
{projects.map((proj) => (
<option key={proj.id} value={proj.id}>{proj.name}</option>
))}
</select>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-4">Bestandsverwaltung (für Verbrauchsmaterial)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Aktueller Bestand</label>
<input
type="number"
value={formData.currentStock}
onChange={(e) => setFormData({ ...formData, currentStock: e.target.value })}
placeholder="Nur für Verbrauchsmaterial"
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Mindestbestand</label>
<input
type="number"
value={formData.minStockLevel}
onChange={(e) => setFormData({ ...formData, minStockLevel: e.target.value })}
placeholder="Warnung bei Unterschreitung"
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-500"
/>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-4">Kaufinformationen (optional)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Kaufdatum</label>
<input
type="date"
value={formData.purchaseDate}
onChange={(e) => setFormData({ ...formData, purchaseDate: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Kaufpreis ()</label>
<input
type="number"
step="0.01"
value={formData.purchasePrice}
onChange={(e) => setFormData({ ...formData, purchasePrice: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Notizen</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={4}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="flex gap-4 pt-4 border-t border-gray-700">
<Link
href="/dashboard/inventory"
className="flex-1 px-4 py-3 bg-gray-700 text-gray-300 rounded-lg font-semibold text-center hover:bg-gray-600 transition-colors"
>
Abbrechen
</Link>
<button
type="submit"
disabled={loading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg font-semibold hover:from-red-700 hover:to-pink-700 transition-all shadow-lg disabled:opacity-50"
>
<FiSave /> {loading ? 'Wird erstellt...' : 'Equipment anlegen'}
</button>
</div>
</form>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
import { FiPackage, FiAlertCircle, FiPlus } from 'react-icons/fi';
export default function InventoryPage() {
const router = useRouter();
const { data: session } = useSession();
const [equipment, setEquipment] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState('ALL');
const [statusFilter, setStatusFilter] = useState('ALL');
const [locationFilter, setLocationFilter] = useState('ALL');
useEffect(() => {
fetchEquipment();
}, []);
const fetchEquipment = async () => {
try {
const res = await fetch('/api/inventory');
const data = await res.json();
setEquipment(data.equipment || []);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const filteredEquipment = equipment.filter((item) => {
if (typeFilter !== 'ALL' && item.type !== typeFilter) return false;
if (statusFilter !== 'ALL' && item.status !== statusFilter) return false;
if (locationFilter !== 'ALL' && item.locationId !== locationFilter) return false;
return true;
});
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
PRINTER: 'Drucker',
CARPET: 'Roter Teppich',
VIP_BARRIER: 'VIP-Absperrung',
ACCESSORIES_KIT: 'Accessoires-Koffer',
PRINTER_PAPER: 'Druckerpapier',
TRIPOD: 'Stativ',
OTHER: 'Sonstiges',
};
return labels[type] || type;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
AVAILABLE: 'bg-green-500/20 text-green-400 border-green-500/50',
IN_USE: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
MAINTENANCE: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
DAMAGED: 'bg-red-500/20 text-red-400 border-red-500/50',
RESERVED: 'bg-purple-500/20 text-purple-400 border-purple-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
AVAILABLE: 'Verfügbar',
IN_USE: 'Im Einsatz',
MAINTENANCE: 'Wartung',
DAMAGED: 'Defekt',
RESERVED: 'Reserviert',
};
return labels[status] || status;
};
const stats = {
total: equipment.length,
available: equipment.filter((e) => e.status === 'AVAILABLE').length,
inUse: equipment.filter((e) => e.status === 'IN_USE').length,
lowStock: equipment.filter((e) => e.currentStock && e.minStockLevel && e.currentStock < e.minStockLevel).length,
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-purple-400 to-pink-500 bg-clip-text text-transparent">
Inventar
</h1>
<p className="text-gray-400 mt-1">Verwalten Sie Drucker, Zubehör & Verbrauchsmaterial</p>
</div>
<button
onClick={() => router.push('/dashboard/inventory/new')}
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 font-semibold shadow-lg flex items-center gap-2"
>
<FiPlus /> Neues Equipment
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-6 rounded-xl">
<p className="text-sm text-gray-400">Gesamt</p>
<p className="text-3xl font-bold text-white mt-2">{stats.total}</p>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-6 rounded-xl">
<p className="text-sm text-gray-400">Verfügbar</p>
<p className="text-3xl font-bold text-green-400 mt-2">{stats.available}</p>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-6 rounded-xl">
<p className="text-sm text-gray-400">Im Einsatz</p>
<p className="text-3xl font-bold text-blue-400 mt-2">{stats.inUse}</p>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-6 rounded-xl">
<p className="text-sm text-gray-400">Niedriger Bestand</p>
<p className="text-3xl font-bold text-yellow-400 mt-2">{stats.lowStock}</p>
</div>
</div>
{/* Filters */}
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl p-6 mb-6">
<div className="flex gap-4">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="ALL">Alle Typen</option>
<option value="PRINTER">Drucker</option>
<option value="CARPET">Roter Teppich</option>
<option value="VIP_BARRIER">VIP-Absperrung</option>
<option value="ACCESSORIES_KIT">Accessoires-Koffer</option>
<option value="PRINTER_PAPER">Druckerpapier</option>
<option value="TRIPOD">Stativ</option>
<option value="OTHER">Sonstiges</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="ALL">Alle Status</option>
<option value="AVAILABLE">Verfügbar</option>
<option value="IN_USE">Im Einsatz</option>
<option value="MAINTENANCE">Wartung</option>
<option value="DAMAGED">Defekt</option>
<option value="RESERVED">Reserviert</option>
</select>
</div>
</div>
{/* Equipment Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredEquipment.map((item) => (
<div
key={item.id}
onClick={() => router.push(`/dashboard/inventory/${item.id}`)}
className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg shadow-xl p-6 hover:shadow-2xl transition-all cursor-pointer border border-gray-700 hover:border-purple-500"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-bold text-white">{item.name}</h3>
<p className="text-sm text-gray-400">{getTypeLabel(item.type)}</p>
{item.brand && item.model && (
<p className="text-xs text-gray-500 mt-1">{item.brand} {item.model}</p>
)}
</div>
<span className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusBadge(item.status)}`}>
{getStatusLabel(item.status)}
</span>
</div>
<div className="text-sm text-gray-400 space-y-2">
{item.location && (
<p><span className="font-medium text-gray-300">Standort:</span> {item.location.name}</p>
)}
{item.project && (
<p><span className="font-medium text-gray-300">Projekt:</span> {item.project.name}</p>
)}
{item.quantity > 1 && (
<p><span className="font-medium text-gray-300">Menge:</span> {item.quantity}x</p>
)}
{item.serialNumber && (
<p><span className="font-medium text-gray-300">SN:</span> {item.serialNumber}</p>
)}
{item.currentStock !== null && item.minStockLevel !== null && (
<div className="pt-2 border-t border-gray-700">
<p className="font-medium text-gray-300 flex items-center gap-2">
Bestand: {item.currentStock} / {item.minStockLevel}
{item.currentStock < item.minStockLevel && (
<FiAlertCircle className="text-yellow-400" />
)}
</p>
</div>
)}
</div>
</div>
))}
</div>
{filteredEquipment.length === 0 && (
<div className="text-center py-12">
<FiPackage className="text-6xl text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">Kein Equipment gefunden</p>
</div>
)}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,410 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Calendar, dateFnsLocalizer, View } from 'react-big-calendar';
import { format, parse, startOfWeek, getDay, addMonths, subMonths } from 'date-fns';
import { de } from 'date-fns/locale';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
import { FiChevronLeft, FiChevronRight, FiCalendar } from 'react-icons/fi';
const locales = {
de: de,
};
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { locale: de }),
getDay,
locales,
});
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
resource: {
bookingId: string;
status: string;
customerName: string;
customerEmail: string;
locationName: string;
photoboxName: string;
tourId?: string;
eventType: string;
};
}
export default function KalenderPage() {
const { data: session } = useSession();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [view, setView] = useState<View>('month');
const [date, setDate] = useState(new Date());
const fetchEvents = useCallback(async (start?: Date, end?: Date) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (start) params.append('start', start.toISOString());
if (end) params.append('end', end.toISOString());
const response = await fetch(`/api/calendar?${params.toString()}`);
const data = await response.json();
const parsedEvents = data.events.map((event: any) => ({
...event,
start: new Date(event.start),
end: new Date(event.end),
}));
setEvents(parsedEvents);
} catch (error) {
console.error('Error fetching calendar events:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEvents();
}, [fetchEvents]);
const handleNavigate = (newDate: Date) => {
setDate(newDate);
};
const handleViewChange = (newView: View) => {
setView(newView);
};
const eventStyleGetter = (event: CalendarEvent) => {
let backgroundColor = '#6b7280';
switch (event.resource.status) {
case 'PENDING':
backgroundColor = '#f59e0b';
break;
case 'RESERVED':
backgroundColor = '#3b82f6';
break;
case 'CONFIRMED':
backgroundColor = '#10b981';
break;
case 'TOUR_CREATED':
backgroundColor = '#8b5cf6';
break;
case 'COMPLETED':
backgroundColor = '#6b7280';
break;
case 'CANCELLED':
backgroundColor = '#ef4444';
break;
}
return {
style: {
backgroundColor,
borderRadius: '4px',
opacity: 0.9,
color: 'white',
border: '0px',
display: 'block',
},
};
};
const CustomToolbar = ({ label, onNavigate }: any) => {
return (
<div className="flex items-center justify-between mb-6 bg-gray-800 p-4 rounded-lg">
<div className="flex items-center gap-2">
<FiCalendar className="text-2xl text-red-400" />
<h2 className="text-2xl font-bold text-white">{label}</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onNavigate('PREV')}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<FiChevronLeft />
</button>
<button
onClick={() => onNavigate('TODAY')}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg font-medium transition-colors"
>
Heute
</button>
<button
onClick={() => onNavigate('NEXT')}
className="p-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<FiChevronRight />
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => setView('month')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
view === 'month'
? 'bg-red-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Monat
</button>
<button
onClick={() => setView('week')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
view === 'week'
? 'bg-red-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Woche
</button>
<button
onClick={() => setView('day')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
view === 'day'
? 'bg-red-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Tag
</button>
</div>
</div>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="bg-gray-800/50 rounded-lg p-6 shadow-lg">
{loading ? (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-500"></div>
</div>
) : (
<>
<div style={{ height: '700px' }} className="calendar-container">
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
style={{ height: '100%' }}
onSelectEvent={(event) => setSelectedEvent(event)}
eventPropGetter={eventStyleGetter}
view={view}
onView={handleViewChange}
date={date}
onNavigate={handleNavigate}
components={{
toolbar: CustomToolbar,
}}
messages={{
next: 'Weiter',
previous: 'Zurück',
today: 'Heute',
month: 'Monat',
week: 'Woche',
day: 'Tag',
agenda: 'Agenda',
date: 'Datum',
time: 'Zeit',
event: 'Event',
noEventsInRange: 'Keine Events in diesem Zeitraum',
showMore: (total) => `+ ${total} mehr`,
}}
/>
</div>
<div className="mt-6 flex gap-4 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-amber-500"></div>
<span className="text-sm text-gray-300">Pending</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-blue-500"></div>
<span className="text-sm text-gray-300">Reserviert</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
<span className="text-sm text-gray-300">Bestätigt</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-purple-500"></div>
<span className="text-sm text-gray-300">Tour erstellt</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm text-gray-300">Abgeschlossen</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-500"></div>
<span className="text-sm text-gray-300">Storniert</span>
</div>
</div>
</>
)}
</div>
{selectedEvent && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setSelectedEvent(null)}
>
<div
className="bg-gray-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-white">Buchungsdetails</h3>
<button
onClick={() => setSelectedEvent(null)}
className="text-gray-400 hover:text-white text-2xl"
>
×
</button>
</div>
<div className="space-y-3 text-gray-300">
<div>
<span className="font-semibold text-white">Kunde:</span>{' '}
{selectedEvent.resource.customerName}
</div>
<div>
<span className="font-semibold text-white">E-Mail:</span>{' '}
{selectedEvent.resource.customerEmail}
</div>
<div>
<span className="font-semibold text-white">Standort:</span>{' '}
{selectedEvent.resource.locationName}
</div>
<div>
<span className="font-semibold text-white">Fotobox:</span>{' '}
{selectedEvent.resource.photoboxName}
</div>
<div>
<span className="font-semibold text-white">Event-Typ:</span>{' '}
{selectedEvent.resource.eventType}
</div>
<div>
<span className="font-semibold text-white">Status:</span>{' '}
<span
className={`px-2 py-1 rounded text-sm ${
selectedEvent.resource.status === 'PENDING'
? 'bg-amber-500/20 text-amber-300'
: selectedEvent.resource.status === 'RESERVED'
? 'bg-blue-500/20 text-blue-300'
: selectedEvent.resource.status === 'CONFIRMED'
? 'bg-green-500/20 text-green-300'
: selectedEvent.resource.status === 'TOUR_CREATED'
? 'bg-purple-500/20 text-purple-300'
: selectedEvent.resource.status === 'COMPLETED'
? 'bg-gray-500/20 text-gray-300'
: 'bg-red-500/20 text-red-300'
}`}
>
{selectedEvent.resource.status}
</span>
</div>
<div>
<span className="font-semibold text-white">Datum:</span>{' '}
{format(selectedEvent.start, 'PPP', { locale: de })}
</div>
<div>
<span className="font-semibold text-white">Zeit:</span>{' '}
{format(selectedEvent.start, 'HH:mm', { locale: de })} -{' '}
{format(selectedEvent.end, 'HH:mm', { locale: de })}
</div>
</div>
<div className="mt-6 flex gap-3">
<a
href={`/dashboard/bookings?id=${selectedEvent.id}`}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-center font-medium transition-colors"
>
Buchung öffnen
</a>
{selectedEvent.resource.tourId && (
<a
href={`/dashboard/tours?id=${selectedEvent.resource.tourId}`}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-center font-medium transition-colors"
>
Tour anzeigen
</a>
)}
</div>
</div>
</div>
)}
</main>
</div>
<style jsx global>{`
.calendar-container .rbc-calendar {
background: transparent;
color: white;
}
.rbc-header {
padding: 12px 4px;
font-weight: 600;
color: white;
background: rgba(31, 41, 55, 0.5);
border-color: rgba(75, 85, 99, 0.5) !important;
}
.rbc-month-view,
.rbc-time-view {
background: rgba(31, 41, 55, 0.3);
border-color: rgba(75, 85, 99, 0.5) !important;
}
.rbc-day-bg,
.rbc-time-slot {
border-color: rgba(75, 85, 99, 0.5) !important;
}
.rbc-today {
background-color: rgba(239, 68, 68, 0.1) !important;
}
.rbc-off-range-bg {
background: rgba(17, 24, 39, 0.5) !important;
}
.rbc-date-cell {
padding: 6px;
color: #d1d5db;
}
.rbc-now .rbc-button-link {
color: #ef4444;
font-weight: 700;
}
.rbc-event {
padding: 2px 5px;
font-size: 0.875rem;
cursor: pointer;
}
.rbc-event:hover {
opacity: 1 !important;
}
.rbc-time-content {
border-color: rgba(75, 85, 99, 0.5) !important;
}
.rbc-time-header-content {
border-color: rgba(75, 85, 99, 0.5) !important;
}
.rbc-timeslot-group {
border-color: rgba(75, 85, 99, 0.5) !important;
}
.rbc-current-time-indicator {
background-color: #ef4444;
}
`}</style>
</div>
);
}

21
app/dashboard/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { authOptions } from '@/lib/auth';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/login');
}
if (session.user.role !== 'ADMIN') {
redirect('/driver');
}
return <>{children}</>;
}

View File

@@ -0,0 +1,34 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import LocationsManager from '@/components/LocationsManager';
import DashboardSidebar from '@/components/DashboardSidebar';
export default async function LocationsPage() {
const session = await getServerSession(authOptions);
const locations = await prisma.location.findMany({
include: {
_count: {
select: {
photoboxes: true,
bookings: true,
},
},
},
orderBy: {
name: 'asc',
},
});
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<LocationsManager locations={locations} user={session?.user} />
</main>
</div>
</div>
);
}

53
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import DashboardContent from '@/components/DashboardContent';
import DashboardSidebar from '@/components/DashboardSidebar';
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
const stats = {
totalBookings: await prisma.booking.count(),
reservedBookings: await prisma.booking.count({
where: { status: 'RESERVED' },
}),
confirmedBookings: await prisma.booking.count({
where: { status: 'CONFIRMED' },
}),
completedBookings: await prisma.booking.count({
where: { status: 'COMPLETED' },
}),
totalLocations: await prisma.location.count(),
totalPhotoboxes: await prisma.photobox.count({
where: { active: true },
}),
totalDrivers: await prisma.user.count({
where: { role: 'DRIVER', active: true },
}),
};
const recentBookings = await prisma.booking.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: {
location: true,
photobox: true,
},
});
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<DashboardContent
user={session?.user}
stats={stats}
recentBookings={recentBookings}
/>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date-utils";
import DashboardSidebar from "@/components/DashboardSidebar";
import { useSession } from "next-auth/react";
export default function PhotoboxDetailPage({
params,
}: {
params: { id: string };
}) {
const router = useRouter();
const { data: session } = useSession();
const [photobox, setPhotobox] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({
status: "",
description: "",
});
useEffect(() => {
fetchPhotobox();
}, [params.id]);
const fetchPhotobox = async () => {
try {
const res = await fetch(`/api/photoboxes/${params.id}`);
const data = await res.json();
setPhotobox(data.photobox);
setFormData({
status: data.photobox.status,
description: data.photobox.description || "",
});
} catch (error) {
console.error("Fetch error:", error);
} finally {
setLoading(false);
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch(`/api/photoboxes/${params.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
setEditing(false);
fetchPhotobox();
} else {
alert("Fehler beim Aktualisieren");
}
} catch (error) {
console.error("Update error:", error);
alert("Fehler beim Aktualisieren");
}
};
const handleDelete = async () => {
if (!confirm("Möchten Sie diese Fotobox wirklich löschen?")) return;
try {
const res = await fetch(`/api/photoboxes/${params.id}`, {
method: "DELETE",
});
if (res.ok) {
router.push("/dashboard/photoboxes");
} else {
alert("Fehler beim Löschen");
}
} catch (error) {
console.error("Delete error:", error);
alert("Fehler beim Löschen");
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-white">Lädt...</p>
</div>
</div>
</div>
);
}
if (!photobox) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-white">Fotobox nicht gefunden</p>
</div>
</div>
</div>
);
}
const getModelLabel = (model: string) => {
const labels: Record<string, string> = {
VINTAGE_SMILE: "Vintage Smile",
VINTAGE_PHOTOS: "Vintage Photos",
NOSTALGIE: "Nostalgie",
MAGIC_MIRROR: "Magic Mirror",
};
return labels[model] || model;
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-5xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-red-400 hover:text-red-300 font-medium transition-colors"
>
Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-lg p-8 mb-6">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">
{getModelLabel(photobox.model)}
</h1>
<p className="text-gray-400 mt-1">
SN: {photobox.serialNumber}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setEditing(!editing)}
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg hover:from-blue-700 hover:to-cyan-700 font-semibold shadow-lg transition-all"
>
{editing ? "Abbrechen" : "Bearbeiten"}
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold shadow-lg transition-all"
>
Löschen
</button>
</div>
</div>
{editing ? (
<form onSubmit={handleUpdate} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Status
</label>
<select
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value })
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
>
<option value="AVAILABLE">Verfügbar</option>
<option value="IN_USE">Im Einsatz</option>
<option value="MAINTENANCE">Wartung</option>
<option value="DAMAGED">Defekt</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Beschreibung
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
rows={4}
/>
</div>
<button
type="submit"
className="px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 font-semibold shadow-lg transition-all"
>
Speichern
</button>
</form>
) : (
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-sm text-gray-400">Standort</p>
<p className="text-lg font-semibold text-white">
{photobox.location.name}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Status</p>
<p className="text-lg font-semibold text-white">
{photobox.status}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Erstellt</p>
<p className="text-lg font-semibold text-white">
{formatDate(photobox.createdAt)}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Letzte Wartung</p>
<p className="text-lg font-semibold text-white">
{photobox.lastMaintenance
? formatDate(photobox.lastMaintenance)
: "Keine"}
</p>
</div>
{photobox.description && (
<div className="col-span-2">
<p className="text-sm text-gray-400">Beschreibung</p>
<p className="text-lg text-white">
{photobox.description}
</p>
</div>
)}
</div>
)}
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-white mb-6">
Buchungen ({photobox.bookings.length})
</h2>
{photobox.bookings.length > 0 ? (
<div className="space-y-3">
{photobox.bookings.map((booking: any) => (
<div
key={booking.id}
onClick={() =>
router.push(`/dashboard/bookings/${booking.id}`)
}
className="p-4 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 hover:border-gray-500 cursor-pointer transition-all"
>
<div className="flex justify-between items-center">
<div>
<p className="font-semibold text-white">
{booking.bookingNumber}
</p>
<p className="text-sm text-gray-400">
{booking.customerName}
</p>
</div>
<div className="text-right">
<p className="font-medium text-white">
{formatDate(booking.eventDate)}
</p>
<p className="text-sm text-gray-400">
{booking.status}
</p>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Buchungen vorhanden</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function PhotoboxesPage() {
const router = useRouter();
const { data: session } = useSession();
const [photoboxes, setPhotoboxes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [locations, setLocations] = useState<any[]>([]);
const [formData, setFormData] = useState({
locationId: '',
model: 'VINTAGE_SMILE',
serialNumber: '',
description: '',
});
useEffect(() => {
fetchPhotoboxes();
fetchLocations();
}, []);
const fetchPhotoboxes = async () => {
try {
const res = await fetch('/api/photoboxes');
const data = await res.json();
setPhotoboxes(data.photoboxes || []);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const fetchLocations = async () => {
try {
const res = await fetch('/api/locations');
const data = await res.json();
setLocations(data.locations || []);
} catch (error) {
console.error('Locations fetch error:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch('/api/photoboxes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) {
setShowForm(false);
setFormData({
locationId: '',
model: 'VINTAGE_SMILE',
serialNumber: '',
description: '',
});
fetchPhotoboxes();
} else {
alert('Fehler beim Erstellen');
}
} catch (error) {
console.error('Create error:', error);
alert('Fehler beim Erstellen');
}
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
AVAILABLE: 'bg-green-500/20 text-green-400 border border-green-500/50',
IN_USE: 'bg-blue-500/20 text-blue-400 border border-blue-500/50',
MAINTENANCE: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/50',
DAMAGED: 'bg-red-500/20 text-red-400 border border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border border-gray-500/50';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
AVAILABLE: 'Verfügbar',
IN_USE: 'Im Einsatz',
MAINTENANCE: 'Wartung',
DAMAGED: 'Defekt',
};
return labels[status] || status;
};
const getModelLabel = (model: string) => {
const labels: Record<string, string> = {
VINTAGE_SMILE: 'Vintage Smile',
VINTAGE_PHOTOS: 'Vintage Photos',
NOSTALGIE: 'Nostalgie',
MAGIC_MIRROR: 'Magic Mirror',
};
return labels[model] || model;
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-white">Lädt...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Fotoboxen</h1>
<p className="text-gray-400 mt-1">Verwalten Sie alle Fotoboxen</p>
</div>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold shadow-lg transition-all"
>
+ Neue Fotobox
</button>
</div>
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 className="text-2xl font-bold text-white mb-6">Neue Fotobox erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Standort
</label>
<select
value={formData.locationId}
onChange={(e) => setFormData({ ...formData, locationId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
required
>
<option value="">Standort wählen</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name} ({loc.city})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Modell
</label>
<select
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
>
<option value="VINTAGE_SMILE">Vintage Smile</option>
<option value="VINTAGE_PHOTOS">Vintage Photos</option>
<option value="NOSTALGIE">Nostalgie</option>
<option value="MAGIC_MIRROR">Magic Mirror</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Seriennummer
</label>
<input
type="text"
value={formData.serialNumber}
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Beschreibung (optional)
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
rows={3}
/>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setShowForm(false)}
className="flex-1 px-6 py-3 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 font-semibold transition-colors"
>
Abbrechen
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold transition-all"
>
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{photoboxes.map((box) => (
<div
key={box.id}
onClick={() => router.push(`/dashboard/photoboxes/${box.id}`)}
className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-lg shadow-md p-6 hover:shadow-xl hover:border-gray-600 transition-all cursor-pointer"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-bold text-white">{getModelLabel(box.model)}</h3>
<p className="text-sm text-gray-400">{box.serialNumber}</p>
</div>
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${getStatusBadge(box.status)}`}>
{getStatusLabel(box.status)}
</span>
</div>
<div className="text-sm text-gray-400 space-y-1">
<p><span className="font-medium text-gray-300">Standort:</span> {box.location.name}</p>
<p><span className="font-medium text-gray-300">Buchungen:</span> {box._count.bookings}</p>
</div>
</div>
))}
</div>
{photoboxes.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">Noch keine Fotoboxen vorhanden</p>
</div>
)}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function PhotoboxesPage() {
const router = useRouter();
const { data: session } = useSession();
const [photoboxes, setPhotoboxes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [locations, setLocations] = useState<any[]>([]);
const [formData, setFormData] = useState({
locationId: '',
model: 'VINTAGE_SMILE',
serialNumber: '',
description: '',
});
useEffect(() => {
fetchPhotoboxes();
fetchLocations();
}, []);
const fetchPhotoboxes = async () => {
try {
const res = await fetch('/api/photoboxes');
const data = await res.json();
setPhotoboxes(data.photoboxes || []);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const fetchLocations = async () => {
try {
const res = await fetch('/api/locations');
const data = await res.json();
setLocations(data.locations || []);
} catch (error) {
console.error('Locations fetch error:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch('/api/photoboxes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) {
setShowForm(false);
setFormData({
locationId: '',
model: 'VINTAGE_SMILE',
serialNumber: '',
description: '',
});
fetchPhotoboxes();
} else {
alert('Fehler beim Erstellen');
}
} catch (error) {
console.error('Create error:', error);
alert('Fehler beim Erstellen');
}
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
AVAILABLE: 'bg-green-500/20 text-green-400 border border-green-500/50',
IN_USE: 'bg-blue-500/20 text-blue-400 border border-blue-500/50',
MAINTENANCE: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/50',
DAMAGED: 'bg-red-500/20 text-red-400 border border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border border-gray-500/50';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
AVAILABLE: 'Verfügbar',
IN_USE: 'Im Einsatz',
MAINTENANCE: 'Wartung',
DAMAGED: 'Defekt',
};
return labels[status] || status;
};
const getModelLabel = (model: string) => {
const labels: Record<string, string> = {
VINTAGE_SMILE: 'Vintage Smile',
VINTAGE_PHOTOS: 'Vintage Photos',
NOSTALGIE: 'Nostalgie',
MAGIC_MIRROR: 'Magic Mirror',
};
return labels[model] || model;
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-white">Lädt...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Fotoboxen</h1>
<p className="text-gray-400 mt-1">Verwalten Sie alle Fotoboxen</p>
</div>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold shadow-lg transition-all"
>
+ Neue Fotobox
</button>
</div>
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 className="text-2xl font-bold text-white mb-6">Neue Fotobox erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Standort
</label>
<select
value={formData.locationId}
onChange={(e) => setFormData({ ...formData, locationId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
required
>
<option value="">Standort wählen</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name} ({loc.city})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Modell
</label>
<select
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
>
<option value="VINTAGE_SMILE">Vintage Smile</option>
<option value="VINTAGE_PHOTOS">Vintage Photos</option>
<option value="NOSTALGIE">Nostalgie</option>
<option value="MAGIC_MIRROR">Magic Mirror</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Seriennummer
</label>
<input
type="text"
value={formData.serialNumber}
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Beschreibung (optional)
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-red-500"
rows={3}
/>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setShowForm(false)}
className="flex-1 px-6 py-3 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 font-semibold transition-colors"
>
Abbrechen
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-red-600 to-pink-600 text-white rounded-lg hover:from-red-700 hover:to-pink-700 font-semibold transition-all"
>
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{photoboxes.map((box) => (
<div
key={box.id}
onClick={() => router.push(`/dashboard/photoboxes/${box.id}`)}
className="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-lg shadow-md p-6 hover:shadow-xl hover:border-gray-600 transition-all cursor-pointer"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-bold text-white">{getModelLabel(box.model)}</h3>
<p className="text-sm text-gray-400">{box.serialNumber}</p>
</div>
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${getStatusBadge(box.status)}`}>
{getStatusLabel(box.status)}
</span>
</div>
<div className="text-sm text-gray-400 space-y-1">
<p><span className="font-medium text-gray-300">Standort:</span> {box.location.name}</p>
<p><span className="font-medium text-gray-300">Buchungen:</span> {box._count.bookings}</p>
</div>
</div>
))}
</div>
{photoboxes.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">Noch keine Fotoboxen vorhanden</p>
</div>
)}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import DashboardSidebar from '@/components/DashboardSidebar';
import { FiCheck, FiX, FiRefreshCw, FiCloud } from 'react-icons/fi';
export default function SettingsPage() {
const { data: session } = useSession();
const [testResult, setTestResult] = useState<any>(null);
const [syncResult, setSyncResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const testConnection = async () => {
setLoading(true);
setTestResult(null);
try {
const response = await fetch('/api/calendar/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'test-connection' }),
});
const data = await response.json();
setTestResult(data);
} catch (error) {
setTestResult({ success: false, error: 'Connection failed' });
} finally {
setLoading(false);
}
};
const syncAllBookings = async () => {
setLoading(true);
setSyncResult(null);
try {
const response = await fetch('/api/calendar/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'sync-all' }),
});
const data = await response.json();
setSyncResult(data);
} catch (error) {
setSyncResult({ success: false, error: 'Sync failed' });
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-4xl">
<h1 className="text-3xl font-bold text-white mb-8">Einstellungen</h1>
<div className="bg-gray-800/50 rounded-lg p-6 shadow-lg mb-6">
<div className="flex items-center gap-3 mb-4">
<FiCloud className="text-2xl text-blue-400" />
<h2 className="text-xl font-bold text-white">Nextcloud Kalender-Synchronisation</h2>
</div>
<div className="space-y-4">
<div className="flex gap-4">
<button
onClick={testConnection}
disabled={loading}
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Teste...
</>
) : (
<>
<FiCheck />
Verbindung testen
</>
)}
</button>
<button
onClick={syncAllBookings}
disabled={loading}
className="px-6 py-3 bg-green-600 hover:bg-green-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Synchronisiere...
</>
) : (
<>
<FiRefreshCw />
Alle Buchungen synchronisieren
</>
)}
</button>
</div>
{testResult && (
<div
className={`p-4 rounded-lg ${
testResult.success
? 'bg-green-500/20 border border-green-500'
: 'bg-red-500/20 border border-red-500'
}`}
>
<div className="flex items-center gap-2 mb-2">
{testResult.success ? (
<>
<FiCheck className="text-green-400" />
<span className="font-semibold text-green-300">Verbindung erfolgreich!</span>
</>
) : (
<>
<FiX className="text-red-400" />
<span className="font-semibold text-red-300">Verbindung fehlgeschlagen</span>
</>
)}
</div>
{testResult.success && testResult.calendars && (
<div className="mt-2">
<p className="text-sm text-gray-300 mb-2">Gefundene Kalender:</p>
<ul className="space-y-1">
{testResult.calendars.map((cal: any, idx: number) => (
<li key={idx} className="text-sm text-gray-300 ml-4">
{cal.displayName} {cal.description && `(${cal.description})`}
</li>
))}
</ul>
</div>
)}
{!testResult.success && (
<p className="text-sm text-red-300 mt-2">
Fehler: {testResult.error || 'Unbekannter Fehler'}
</p>
)}
</div>
)}
{syncResult && (
<div
className={`p-4 rounded-lg ${
syncResult.success
? 'bg-green-500/20 border border-green-500'
: 'bg-red-500/20 border border-red-500'
}`}
>
<div className="flex items-center gap-2 mb-2">
{syncResult.success ? (
<>
<FiCheck className="text-green-400" />
<span className="font-semibold text-green-300">Synchronisation abgeschlossen!</span>
</>
) : (
<>
<FiX className="text-red-400" />
<span className="font-semibold text-red-300">Synchronisation fehlgeschlagen</span>
</>
)}
</div>
{syncResult.success && (
<div className="text-sm text-gray-300 space-y-1">
<p> Erfolgreich synchronisiert: {syncResult.synced}</p>
{syncResult.failed > 0 && (
<p className="text-red-300"> Fehlgeschlagen: {syncResult.failed}</p>
)}
<p>📊 Gesamt: {syncResult.total}</p>
</div>
)}
{!syncResult.success && (
<p className="text-sm text-red-300 mt-2">
Fehler: {syncResult.error || 'Unbekannter Fehler'}
</p>
)}
</div>
)}
<div className="mt-6 p-4 bg-gray-700/30 rounded-lg">
<h3 className="font-semibold text-white mb-2"> Hinweise:</h3>
<ul className="text-sm text-gray-300 space-y-1 ml-4">
<li> Stelle sicher, dass die Nextcloud-Zugangsdaten in der .env-Datei korrekt hinterlegt sind</li>
<li> Die Synchronisation erstellt/aktualisiert Events für alle RESERVED, CONFIRMED und TOUR_CREATED Buchungen</li>
<li> Bei Änderungen an Buchungen werden die Kalender-Events automatisch aktualisiert</li>
</ul>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { formatDate } from '@/lib/date-utils';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function TourDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { data: session } = useSession();
const [tour, setTour] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
fetchTour();
}, [params.id]);
const fetchTour = async () => {
try {
const res = await fetch(`/api/tours/${params.id}`);
const data = await res.json();
setTour(data.tour);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
fetchTour();
} else {
alert('Fehler beim Aktualisieren');
}
} catch (error) {
console.error('Update error:', error);
alert('Fehler beim Aktualisieren');
}
};
const handleDelete = async () => {
if (!confirm('Möchten Sie diese Tour wirklich löschen?')) return;
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'DELETE',
});
if (res.ok) {
router.push('/dashboard/tours');
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
console.error('Delete error:', error);
alert('Fehler beim Löschen');
}
};
const optimizeRoute = async () => {
if (!tour || tour.bookings.length === 0) {
alert('Keine Buchungen zum Optimieren vorhanden');
return;
}
setOptimizing(true);
try {
const res = await fetch(`/api/tours/${params.id}/optimize-route`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
alert(`Route optimiert!\nGesamtstrecke: ${data.route.totalDistance.toFixed(1)} km\nGeschätzte Dauer: ${data.route.totalDuration} Min`);
fetchTour();
} else {
alert(data.error || 'Fehler bei der Routenoptimierung');
}
} catch (error) {
console.error('Optimization error:', error);
alert('Fehler bei der Routenoptimierung');
} finally {
setOptimizing(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
if (!tour) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Tour nicht gefunden</p>
</div>
</div>
</div>
);
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-blue-400 hover:text-blue-300 font-medium"
>
Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 mb-6 border border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">{tour.tourNumber}</h1>
<p className="text-gray-400 mt-1">{formatDate(tour.tourDate)}</p>
</div>
<div className="flex gap-3 items-center">
<span className={`px-4 py-2 text-sm font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
{tour.status}
</span>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold"
>
Löschen
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-6">
<div>
<p className="text-sm text-gray-400">Fahrer</p>
<p className="text-lg font-semibold text-white">
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
</p>
{tour.driver?.phoneNumber && (
<p className="text-sm text-gray-400">{tour.driver.phoneNumber}</p>
)}
</div>
<div>
<p className="text-sm text-gray-400">Buchungen</p>
<p className="text-lg font-semibold text-white">{tour.bookings.length}</p>
</div>
{tour.totalDistance && (
<div>
<p className="text-sm text-gray-400">Gesamtstrecke</p>
<p className="text-lg font-semibold text-white">{tour.totalDistance} km</p>
</div>
)}
{tour.estimatedDuration && (
<div>
<p className="text-sm text-gray-400">Geschätzte Dauer</p>
<p className="text-lg font-semibold text-white">~{tour.estimatedDuration} Min</p>
</div>
)}
</div>
<div className="flex gap-3">
{tour.bookings.length > 0 && (
<button
onClick={optimizeRoute}
disabled={optimizing}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{optimizing ? 'Optimiere Route...' : '🗺️ Route optimieren'}
</button>
)}
{tour.status === 'PLANNED' && (
<button
onClick={() => updateStatus('IN_PROGRESS')}
className="px-6 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-semibold"
>
Tour starten
</button>
)}
{tour.status === 'IN_PROGRESS' && (
<button
onClick={() => updateStatus('COMPLETED')}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold"
>
Tour abschließen
</button>
)}
</div>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">
Buchungen ({tour.bookings.length})
</h2>
{tour.bookings.length > 0 ? (
<div className="space-y-4">
{tour.bookings.map((booking: any, index: number) => (
<div
key={booking.id}
className="p-5 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl font-bold text-blue-400">#{index + 1}</span>
<div>
<p className="font-bold text-white">{booking.bookingNumber}</p>
<p className="text-sm text-gray-400">{booking.customerName}</p>
</div>
</div>
<p className="text-white font-medium mt-2">{booking.eventLocation || 'Event'}</p>
<p className="text-gray-400 text-sm">{booking.eventAddress}, {booking.eventCity}</p>
</div>
<div className="text-right">
<p className="font-medium text-white">{formatDate(booking.eventDate)}</p>
<p className="text-sm text-gray-400">
Aufbau: {formatDate(booking.setupTimeStart)}
</p>
<button
onClick={() => router.push(`/dashboard/bookings/${booking.id}`)}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Details
</button>
</div>
</div>
{booking.photobox && (
<div className="mt-3 pt-3 border-t border-gray-600">
<p className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Fotobox:</span>{' '}
{booking.photobox.model} (SN: {booking.photobox.serialNumber})
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Buchungen zugeordnet</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { formatDate } from '@/lib/date-utils';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function TourDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { data: session } = useSession();
const [tour, setTour] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
fetchTour();
}, [params.id]);
const fetchTour = async () => {
try {
const res = await fetch(`/api/tours/${params.id}`);
const data = await res.json();
setTour(data.tour);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
fetchTour();
} else {
alert('Fehler beim Aktualisieren');
}
} catch (error) {
console.error('Update error:', error);
alert('Fehler beim Aktualisieren');
}
};
const handleDelete = async () => {
if (!confirm('Möchten Sie diese Tour wirklich löschen?')) return;
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'DELETE',
});
if (res.ok) {
router.push('/dashboard/tours');
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
console.error('Delete error:', error);
alert('Fehler beim Löschen');
}
};
const optimizeRoute = async () => {
if (!tour || tour.bookings.length === 0) {
alert('Keine Buchungen zum Optimieren vorhanden');
return;
}
setOptimizing(true);
try {
const res = await fetch(`/api/tours/${params.id}/optimize-route`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
alert(`Route optimiert!\nGesamtstrecke: ${data.route.totalDistance.toFixed(1)} km\nGeschätzte Dauer: ${data.route.totalDuration} Min`);
fetchTour();
} else {
alert(data.error || 'Fehler bei der Routenoptimierung');
}
} catch (error) {
console.error('Optimization error:', error);
alert('Fehler bei der Routenoptimierung');
} finally {
setOptimizing(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
if (!tour) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Tour nicht gefunden</p>
</div>
</div>
</div>
);
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-blue-400 hover:text-blue-300 font-medium"
>
Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 mb-6 border border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">{tour.tourNumber}</h1>
<p className="text-gray-400 mt-1">{formatDate(tour.tourDate)}</p>
</div>
<div className="flex gap-3 items-center">
<span className={`px-4 py-2 text-sm font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
{tour.status}
</span>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold"
>
Löschen
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-6">
<div>
<p className="text-sm text-gray-400">Fahrer</p>
<p className="text-lg font-semibold text-white">
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
</p>
{tour.driver?.phoneNumber && (
<p className="text-sm text-gray-400">{tour.driver.phoneNumber}</p>
)}
</div>
<div>
<p className="text-sm text-gray-400">Buchungen</p>
<p className="text-lg font-semibold text-white">{tour.bookings.length}</p>
</div>
{tour.totalDistance && (
<div>
<p className="text-sm text-gray-400">Gesamtstrecke</p>
<p className="text-lg font-semibold text-white">{tour.totalDistance} km</p>
</div>
)}
{tour.estimatedDuration && (
<div>
<p className="text-sm text-gray-400">Geschätzte Dauer</p>
<p className="text-lg font-semibold text-white">~{tour.estimatedDuration} Min</p>
</div>
)}
</div>
<div className="flex gap-3">
{tour.bookings.length > 0 && (
<button
onClick={optimizeRoute}
disabled={optimizing}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{optimizing ? 'Optimiere Route...' : '🗺️ Route optimieren'}
</button>
)}
{tour.status === 'PLANNED' && (
<button
onClick={() => updateStatus('IN_PROGRESS')}
className="px-6 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-semibold"
>
Tour starten
</button>
)}
{tour.status === 'IN_PROGRESS' && (
<button
onClick={() => updateStatus('COMPLETED')}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold"
>
Tour abschließen
</button>
)}
</div>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">
Buchungen ({tour.bookings.length})
</h2>
{tour.bookings.length > 0 ? (
<div className="space-y-4">
{tour.bookings.map((booking: any, index: number) => (
<div
key={booking.id}
className="p-5 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl font-bold text-blue-400">#{index + 1}</span>
<div>
<p className="font-bold text-white">{booking.bookingNumber}</p>
<p className="text-sm text-gray-400">{booking.customerName}</p>
</div>
</div>
<p className="text-white font-medium mt-2">{booking.eventLocation || 'Event'}</p>
<p className="text-gray-400 text-sm">{booking.eventAddress}, {booking.eventCity}</p>
</div>
<div className="text-right">
<p className="font-medium text-white">{formatDate(booking.eventDate)}</p>
<p className="text-sm text-gray-400">
Aufbau: {formatDate(booking.setupTimeStart)}
</p>
<button
onClick={() => router.push(`/dashboard/bookings/${booking.id}`)}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Details
</button>
</div>
</div>
{booking.photobox && (
<div className="mt-3 pt-3 border-t border-gray-600">
<p className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Fotobox:</span>{' '}
{booking.photobox.model} (SN: {booking.photobox.serialNumber})
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Buchungen zugeordnet</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { formatDate } from '@/lib/date-utils';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function TourDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { data: session } = useSession();
const [tour, setTour] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
fetchTour();
}, [params.id]);
const fetchTour = async () => {
try {
const res = await fetch(`/api/tours/${params.id}`);
const data = await res.json();
setTour(data.tour);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
fetchTour();
} else {
alert('Fehler beim Aktualisieren');
}
} catch (error) {
console.error('Update error:', error);
alert('Fehler beim Aktualisieren');
}
};
const handleDelete = async () => {
if (!confirm('Möchten Sie diese Tour wirklich löschen?')) return;
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'DELETE',
});
if (res.ok) {
router.push('/dashboard/tours');
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
console.error('Delete error:', error);
alert('Fehler beim Löschen');
}
};
const optimizeRoute = async () => {
if (!tour || tour.bookings.length === 0) {
alert('Keine Buchungen zum Optimieren vorhanden');
return;
}
setOptimizing(true);
try {
const res = await fetch(`/api/tours/${params.id}/optimize-route`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
alert(`Route optimiert!\nGesamtstrecke: ${data.route.totalDistance.toFixed(1)} km\nGeschätzte Dauer: ${data.route.totalDuration} Min`);
fetchTour();
} else {
alert(data.error || 'Fehler bei der Routenoptimierung');
}
} catch (error) {
console.error('Optimization error:', error);
alert('Fehler bei der Routenoptimierung');
} finally {
setOptimizing(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
if (!tour) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Tour nicht gefunden</p>
</div>
</div>
</div>
);
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-blue-400 hover:text-blue-300 font-medium"
>
← Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 mb-6 border border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">{tour.tourNumber}</h1>
<p className="text-gray-400 mt-1">{formatDate(tour.tourDate)}</p>
</div>
<div className="flex gap-3 items-center">
<span className={`px-4 py-2 text-sm font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
{tour.status}
</span>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold"
>
Löschen
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-6">
<div>
<p className="text-sm text-gray-400">Fahrer</p>
<p className="text-lg font-semibold text-white">
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
</p>
{tour.driver?.phoneNumber && (
<p className="text-sm text-gray-400">{tour.driver.phoneNumber}</p>
)}
</div>
<div>
<p className="text-sm text-gray-400">Buchungen</p>
<p className="text-lg font-semibold text-white">{tour.bookings.length}</p>
</div>
{tour.totalDistance && (
<div>
<p className="text-sm text-gray-400">Gesamtstrecke</p>
<p className="text-lg font-semibold text-white">{tour.totalDistance} km</p>
</div>
)}
{tour.estimatedDuration && (
<div>
<p className="text-sm text-gray-400">Geschätzte Dauer</p>
<p className="text-lg font-semibold text-white">~{tour.estimatedDuration} Min</p>
</div>
)}
</div>
<div className="flex gap-3">
{tour.bookings.length > 0 && (
<button
onClick={optimizeRoute}
disabled={optimizing}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{optimizing ? 'Optimiere Route...' : '🗺️ Route optimieren'}
</button>
)}
{tour.status === 'PLANNED' && (
<button
onClick={() => updateStatus('IN_PROGRESS')}
className="px-6 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-semibold"
>
Tour starten
</button>
)}
{tour.status === 'IN_PROGRESS' && (
<button
onClick={() => updateStatus('COMPLETED')}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold"
>
Tour abschließen
</button>
)}
</div>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">
Buchungen ({tour.bookings.length})
</h2>
{tour.bookings.length > 0 ? (
<div className="space-y-4">
{tour.bookings.map((booking: any, index: number) => (
<div
key={booking.id}
className="p-5 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl font-bold text-blue-400">#{index + 1}</span>
<div>
<p className="font-bold text-white">{booking.bookingNumber}</p>
<p className="text-sm text-gray-400">{booking.customerName}</p>
</div>
</div>
<p className="text-white font-medium mt-2">{booking.eventLocation || 'Event'}</p>
<p className="text-gray-400 text-sm">{booking.eventAddress}, {booking.eventCity}</p>
</div>
<div className="text-right">
<p className="font-medium text-white">{formatDate(booking.eventDate)}</p>
<p className="text-sm text-gray-400">
Aufbau: {formatDate(booking.setupTimeStart)}
</p>
<button
onClick={() => router.push(`/dashboard/bookings/${booking.id}`)}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Details →
</button>
</div>
</div>
{booking.photobox && (
<div className="mt-3 pt-3 border-t border-gray-600">
<p className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Fotobox:</span>{' '}
{booking.photobox.model} (SN: {booking.photobox.serialNumber})
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Buchungen zugeordnet</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { formatDate } from '@/lib/date-utils';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function TourDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { data: session } = useSession();
const [tour, setTour] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
fetchTour();
}, [params.id]);
const fetchTour = async () => {
try {
const res = await fetch(`/api/tours/${params.id}`);
const data = await res.json();
setTour(data.tour);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
fetchTour();
} else {
alert('Fehler beim Aktualisieren');
}
} catch (error) {
console.error('Update error:', error);
alert('Fehler beim Aktualisieren');
}
};
const handleDelete = async () => {
if (!confirm('Möchten Sie diese Tour wirklich löschen?')) return;
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'DELETE',
});
if (res.ok) {
router.push('/dashboard/tours');
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
console.error('Delete error:', error);
alert('Fehler beim Löschen');
}
};
const optimizeRoute = async () => {
if (!tour || tour.bookings.length === 0) {
alert('Keine Buchungen zum Optimieren vorhanden');
return;
}
setOptimizing(true);
try {
const res = await fetch(`/api/tours/${params.id}/optimize-route`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
alert(`Route optimiert!\nGesamtstrecke: ${data.route.totalDistance.toFixed(1)} km\nGeschätzte Dauer: ${data.route.totalDuration} Min`);
fetchTour();
} else {
alert(data.error || 'Fehler bei der Routenoptimierung');
}
} catch (error) {
console.error('Optimization error:', error);
alert('Fehler bei der Routenoptimierung');
} finally {
setOptimizing(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
if (!tour) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Tour nicht gefunden</p>
</div>
</div>
</div>
);
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-blue-400 hover:text-blue-300 font-medium"
>
← Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 mb-6 border border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">{tour.tourNumber}</h1>
<p className="text-gray-400 mt-1">{formatDate(tour.tourDate)}</p>
</div>
<div className="flex gap-3 items-center">
<span className={`px-4 py-2 text-sm font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
{tour.status}
</span>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold"
>
Löschen
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-6">
<div>
<p className="text-sm text-gray-400">Fahrer</p>
<p className="text-lg font-semibold text-white">
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
</p>
{tour.driver?.phoneNumber && (
<p className="text-sm text-gray-400">{tour.driver.phoneNumber}</p>
)}
</div>
<div>
<p className="text-sm text-gray-400">Buchungen</p>
<p className="text-lg font-semibold text-white">{tour.bookings.length}</p>
</div>
{tour.totalDistance && (
<div>
<p className="text-sm text-gray-400">Gesamtstrecke</p>
<p className="text-lg font-semibold text-white">{tour.totalDistance} km</p>
</div>
)}
{tour.estimatedDuration && (
<div>
<p className="text-sm text-gray-400">Geschätzte Dauer</p>
<p className="text-lg font-semibold text-white">~{tour.estimatedDuration} Min</p>
</div>
)}
</div>
<div className="flex gap-3">
{tour.bookings.length > 0 && (
<button
onClick={optimizeRoute}
disabled={optimizing}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{optimizing ? 'Optimiere Route...' : '🗺️ Route optimieren'}
</button>
)}
{tour.status === 'PLANNED' && (
<button
onClick={() => updateStatus('IN_PROGRESS')}
className="px-6 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-semibold"
>
Tour starten
</button>
)}
{tour.status === 'IN_PROGRESS' && (
<button
onClick={() => updateStatus('COMPLETED')}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold"
>
Tour abschließen
</button>
)}
</div>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">
Buchungen ({tour.bookings.length})
</h2>
{tour.bookings.length > 0 ? (
<div className="space-y-4">
{tour.bookings.map((booking: any, index: number) => (
<div
key={booking.id}
className="p-5 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl font-bold text-blue-400">#{index + 1}</span>
<div>
<p className="font-bold text-white">{booking.bookingNumber}</p>
<p className="text-sm text-gray-400">{booking.customerName}</p>
</div>
</div>
<p className="text-white font-medium mt-2">{booking.eventLocation || 'Event'}</p>
<p className="text-gray-400 text-sm">{booking.eventAddress}, {booking.eventCity}</p>
</div>
<div className="text-right">
<p className="font-medium text-white">{formatDate(booking.eventDate)}</p>
<p className="text-sm text-gray-400">
Aufbau: {formatDate(booking.setupTimeStart)}
</p>
<button
onClick={() => router.push(`/dashboard/bookings/${booking.id}`)}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Details →
</button>
</div>
</div>
{booking.photobox && (
<div className="mt-3 pt-3 border-t border-gray-600">
<p className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Fotobox:</span>{' '}
{booking.photobox.model} (SN: {booking.photobox.serialNumber})
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Buchungen zugeordnet</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { formatDate } from '@/lib/date-utils';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function TourDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { data: session } = useSession();
const [tour, setTour] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
fetchTour();
}, [params.id]);
const fetchTour = async () => {
try {
const res = await fetch(`/api/tours/${params.id}`);
const data = await res.json();
setTour(data.tour);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
fetchTour();
} else {
alert('Fehler beim Aktualisieren');
}
} catch (error) {
console.error('Update error:', error);
alert('Fehler beim Aktualisieren');
}
};
const handleDelete = async () => {
if (!confirm('Möchten Sie diese Tour wirklich löschen?')) return;
try {
const res = await fetch(`/api/tours/${params.id}`, {
method: 'DELETE',
});
if (res.ok) {
router.push('/dashboard/tours');
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
console.error('Delete error:', error);
alert('Fehler beim Löschen');
}
};
const optimizeRoute = async () => {
if (!tour || tour.bookings.length === 0) {
alert('Keine Buchungen zum Optimieren vorhanden');
return;
}
setOptimizing(true);
try {
const res = await fetch(`/api/tours/${params.id}/optimize-route`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
alert(`Route optimiert!\nGesamtstrecke: ${data.route.totalDistance.toFixed(1)} km\nGeschätzte Dauer: ${data.route.totalDuration} Min`);
fetchTour();
} else {
alert(data.error || 'Fehler bei der Routenoptimierung');
}
} catch (error) {
console.error('Optimization error:', error);
alert('Fehler bei der Routenoptimierung');
} finally {
setOptimizing(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
if (!tour) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Tour nicht gefunden</p>
</div>
</div>
</div>
);
}
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-6xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 text-blue-400 hover:text-blue-300 font-medium"
>
← Zurück
</button>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 mb-6 border border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-white">{tour.tourNumber}</h1>
<p className="text-gray-400 mt-1">{formatDate(tour.tourDate)}</p>
</div>
<div className="flex gap-3 items-center">
<span className={`px-4 py-2 text-sm font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
{tour.status}
</span>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold"
>
Löschen
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-6">
<div>
<p className="text-sm text-gray-400">Fahrer</p>
<p className="text-lg font-semibold text-white">
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
</p>
{tour.driver?.phoneNumber && (
<p className="text-sm text-gray-400">{tour.driver.phoneNumber}</p>
)}
</div>
<div>
<p className="text-sm text-gray-400">Buchungen</p>
<p className="text-lg font-semibold text-white">{tour.bookings.length}</p>
</div>
{tour.totalDistance && (
<div>
<p className="text-sm text-gray-400">Gesamtstrecke</p>
<p className="text-lg font-semibold text-white">{tour.totalDistance} km</p>
</div>
)}
{tour.estimatedDuration && (
<div>
<p className="text-sm text-gray-400">Geschätzte Dauer</p>
<p className="text-lg font-semibold text-white">~{tour.estimatedDuration} Min</p>
</div>
)}
</div>
<div className="flex gap-3">
{tour.bookings.length > 0 && (
<button
onClick={optimizeRoute}
disabled={optimizing}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{optimizing ? 'Optimiere Route...' : '🗺️ Route optimieren'}
</button>
)}
{tour.status === 'PLANNED' && (
<button
onClick={() => updateStatus('IN_PROGRESS')}
className="px-6 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-semibold"
>
Tour starten
</button>
)}
{tour.status === 'IN_PROGRESS' && (
<button
onClick={() => updateStatus('COMPLETED')}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold"
>
Tour abschließen
</button>
)}
</div>
</div>
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl p-8 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">
Buchungen ({tour.bookings.length})
</h2>
{tour.bookings.length > 0 ? (
<div className="space-y-4">
{tour.bookings.map((booking: any, index: number) => (
<div
key={booking.id}
className="p-5 bg-gray-700/50 border border-gray-600 rounded-lg hover:bg-gray-700 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl font-bold text-blue-400">#{index + 1}</span>
<div>
<p className="font-bold text-white">{booking.bookingNumber}</p>
<p className="text-sm text-gray-400">{booking.customerName}</p>
</div>
</div>
<p className="text-white font-medium mt-2">{booking.eventLocation || 'Event'}</p>
<p className="text-gray-400 text-sm">{booking.eventAddress}, {booking.eventCity}</p>
</div>
<div className="text-right">
<p className="font-medium text-white">{formatDate(booking.eventDate)}</p>
<p className="text-sm text-gray-400">
Aufbau: {formatDate(booking.setupTimeStart)}
</p>
<button
onClick={() => router.push(`/dashboard/bookings/${booking.id}`)}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Details →
</button>
</div>
</div>
{booking.photobox && (
<div className="mt-3 pt-3 border-t border-gray-600">
<p className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Fotobox:</span>{' '}
{booking.photobox.model} (SN: {booking.photobox.serialNumber})
</p>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-400">Keine Buchungen zugeordnet</p>
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,430 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { formatDate } from '@/lib/date-utils';
import DashboardSidebar from '@/components/DashboardSidebar';
import { useSession } from 'next-auth/react';
export default function ToursPage() {
const router = useRouter();
const { data: session } = useSession();
const [tours, setTours] = useState<any[]>([]);
const [drivers, setDrivers] = useState<any[]>([]);
const [bookings, setBookings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
tourDate: '',
driverId: '',
bookingIds: [] as string[],
optimizationType: 'fastest' as 'fastest' | 'schedule',
});
useEffect(() => {
fetchTours();
fetchDrivers();
fetchUnassignedBookings();
}, []);
const fetchTours = async () => {
try {
const res = await fetch('/api/tours');
const data = await res.json();
setTours(data.tours || []);
} catch (error) {
console.error('Fetch error:', error);
} finally {
setLoading(false);
}
};
const fetchDrivers = async () => {
try {
const res = await fetch('/api/drivers?available=true');
const data = await res.json();
setDrivers(data.drivers || []);
} catch (error) {
console.error('Drivers fetch error:', error);
}
};
const fetchUnassignedBookings = async () => {
try {
const res = await fetch('/api/bookings');
const data = await res.json();
const unassigned = (data.bookings || []).filter((b: any) => {
// Must be confirmed and not assigned to a tour
if (!b.tourId && b.status === 'CONFIRMED') {
// If booking has setup windows, check if any are already selected
if (b.setupWindows && b.setupWindows.length > 0) {
const hasSelectedWindow = b.setupWindows.some((w: any) => w.selected);
return !hasSelectedWindow; // Exclude if any window is already selected
}
return true; // No setup windows, just check tourId
}
return false;
});
setBookings(unassigned);
} catch (error) {
console.error('Bookings fetch error:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch('/api/tours', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) {
setShowForm(false);
setFormData({
tourDate: '',
driverId: '',
bookingIds: [],
optimizationType: 'fastest',
});
fetchTours();
fetchUnassignedBookings();
} else {
alert('Fehler beim Erstellen');
}
} catch (error) {
console.error('Create error:', error);
alert('Fehler beim Erstellen');
}
};
const toggleBooking = (bookingId: string) => {
setFormData(prev => ({
...prev,
bookingIds: prev.bookingIds.includes(bookingId)
? prev.bookingIds.filter(id => id !== bookingId)
: [...prev.bookingIds, bookingId],
}));
};
// Filter bookings by selected tour date
const availableBookings = formData.tourDate
? bookings.filter(booking => {
const bookingDate = new Date(booking.eventDate).toISOString().split('T')[0];
const tourDate = formData.tourDate;
// Check if event date matches
if (bookingDate === tourDate) return true;
// Check if any setup window date matches
if (booking.setupWindows && booking.setupWindows.length > 0) {
return booking.setupWindows.some((window: any) => {
const windowDate = new Date(window.setupDate).toISOString().split('T')[0];
return windowDate === tourDate && !window.selected;
});
}
return false;
})
: bookings;
// Group bookings by date for display
const bookingsByDate = bookings.reduce((acc: any, booking: any) => {
const date = new Date(booking.eventDate).toISOString().split('T')[0];
if (!acc[date]) acc[date] = [];
acc[date].push(booking);
return acc;
}, {});
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
PLANNED: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
IN_PROGRESS: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
COMPLETED: 'bg-green-500/20 text-green-400 border-green-500/50',
CANCELLED: 'bg-red-500/20 text-red-400 border-red-500/50',
};
return styles[status] || 'bg-gray-500/20 text-gray-400 border-gray-500/50';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PLANNED: 'Geplant',
IN_PROGRESS: 'In Arbeit',
COMPLETED: 'Abgeschlossen',
CANCELLED: 'Abgebrochen',
};
return labels[status] || status;
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<div className="flex-1 p-8">
<p className="text-gray-300">Lädt...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="flex">
<DashboardSidebar user={session?.user} />
<main className="flex-1 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-500 bg-clip-text text-transparent">
Touren
</h1>
<p className="text-gray-400 mt-1">Verwalten Sie Fahrer-Touren</p>
</div>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg hover:from-blue-700 hover:to-cyan-700 font-semibold shadow-lg"
>
+ Neue Tour
</button>
</div>
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-50 overflow-y-auto">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl shadow-2xl max-w-3xl w-full p-8 border border-gray-700 my-8">
<h2 className="text-2xl font-bold mb-6 text-white">Neue Tour erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Tour-Datum *
</label>
<input
type="date"
value={formData.tourDate}
onChange={(e) => setFormData({ ...formData, tourDate: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Fahrer
</label>
<select
value={formData.driverId}
onChange={(e) => setFormData({ ...formData, driverId: e.target.value })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 text-white rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Noch keinen Fahrer zuweisen</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name} {driver.vehiclePlate ? `(${driver.vehiclePlate})` : ''}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Routen-Optimierung
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setFormData({ ...formData, optimizationType: 'fastest' })}
className={`px-4 py-3 rounded-lg border-2 transition-all ${
formData.optimizationType === 'fastest'
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-gray-700 border-gray-600 text-gray-300 hover:border-gray-500'
}`}
>
<div className="font-semibold">🚗 Schnellste Route</div>
<div className="text-xs mt-1 opacity-80">Nach Distanz/Zeit</div>
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, optimizationType: 'schedule' })}
className={`px-4 py-3 rounded-lg border-2 transition-all ${
formData.optimizationType === 'schedule'
? 'bg-purple-600 border-purple-500 text-white'
: 'bg-gray-700 border-gray-600 text-gray-300 hover:border-gray-500'
}`}
>
<div className="font-semibold"> Nach Aufbauzeiten</div>
<div className="text-xs mt-1 opacity-80">Zeitfenster beachten</div>
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
{formData.optimizationType === 'fastest'
? 'Optimiert nach kürzester Strecke/Zeit'
: 'Berücksichtigt Aufbau-Zeitfenster der Buchungen'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Buchungen zuordnen ({formData.bookingIds.length} ausgewählt)
</label>
{!formData.tourDate && (
<div className="bg-yellow-500/20 border border-yellow-500/50 rounded-lg p-4 mb-4">
<p className="text-yellow-300 text-sm">
Bitte wähle zuerst ein Tour-Datum aus, um passende Buchungen zu sehen
</p>
</div>
)}
{formData.tourDate && availableBookings.length === 0 && (
<div className="bg-blue-500/20 border border-blue-500/50 rounded-lg p-4 mb-4">
<p className="text-blue-300 text-sm">
Keine bestätigten Buchungen für {new Date(formData.tourDate).toLocaleDateString('de-DE')} gefunden
</p>
</div>
)}
<div className="bg-gray-700/50 border border-gray-600 rounded-lg p-4 max-h-64 overflow-y-auto">
{availableBookings.length > 0 ? (
<div className="space-y-2">
{availableBookings.map((booking) => {
const bookingDate = new Date(booking.eventDate).toISOString().split('T')[0];
const isEventDate = bookingDate === formData.tourDate;
const matchingWindows = booking.setupWindows?.filter((w: any) => {
const windowDate = new Date(w.setupDate).toISOString().split('T')[0];
return windowDate === formData.tourDate && !w.selected;
}) || [];
return (
<label
key={booking.id}
className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-600"
>
<input
type="checkbox"
checked={formData.bookingIds.includes(booking.id)}
onChange={() => toggleBooking(booking.id)}
className="w-4 h-4"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-white font-medium">{booking.bookingNumber}</p>
{!isEventDate && matchingWindows.length > 0 && (
<span className="px-2 py-0.5 bg-purple-600 text-white text-xs rounded-full">
📦 Flexibler Aufbau
</span>
)}
</div>
<p className="text-sm text-gray-400">
{booking.customerName} - Event: {formatDate(booking.eventDate)}
</p>
<p className="text-xs text-gray-500">{booking.eventAddress}, {booking.eventCity}</p>
{!isEventDate && matchingWindows.length > 0 && (
<div className="mt-2 space-y-1">
{matchingWindows.map((window: any) => (
<p key={window.id} className="text-xs text-purple-400">
🕐 Aufbau-Option: {new Date(window.setupTimeStart).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{' - '}
{new Date(window.setupTimeEnd).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{window.preferred && ' ⭐'}
</p>
))}
</div>
)}
{isEventDate && booking.setupTimeStart && (
<p className="text-xs text-blue-400 mt-1">
Aufbau: {new Date(booking.setupTimeStart).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{booking.setupTimeLatest && ` - ${new Date(booking.setupTimeLatest).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`}
</p>
)}
</div>
</label>
);
})}
</div>
) : (
<p className="text-gray-400 text-center py-4">
{formData.tourDate ? 'Keine Buchungen für dieses Datum' : 'Bitte Datum auswählen'}
</p>
)}
</div>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setShowForm(false)}
className="flex-1 px-6 py-3 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 font-semibold"
>
Abbrechen
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg hover:from-blue-700 hover:to-cyan-700 font-semibold"
>
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tours.map((tour) => (
<div
key={tour.id}
onClick={() => router.push(`/dashboard/tours/${tour.id}`)}
className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg shadow-xl p-6 hover:shadow-2xl transition-all cursor-pointer border border-gray-700 hover:border-blue-500"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-bold text-white">{tour.tourNumber}</h3>
<p className="text-sm text-gray-400">{formatDate(tour.tourDate)}</p>
</div>
<span className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusBadge(tour.status)}`}>
{getStatusLabel(tour.status)}
</span>
</div>
<div className="text-sm text-gray-400 space-y-2">
<p>
<span className="font-medium text-gray-300">Fahrer:</span>{' '}
{tour.driver ? tour.driver.name : 'Nicht zugewiesen'}
</p>
<p>
<span className="font-medium text-gray-300">Buchungen:</span> {tour.bookings.length}
</p>
{tour.totalDistance && (
<p>
<span className="font-medium text-gray-300">Strecke:</span> {tour.totalDistance} km
</p>
)}
{tour.estimatedDuration && (
<p>
<span className="font-medium text-gray-300">Dauer:</span> ~{tour.estimatedDuration} Min
</p>
)}
</div>
</div>
))}
</div>
{tours.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">Noch keine Touren vorhanden</p>
</div>
)}
</div>
</main>
</div>
</div>
);
}

106
app/driver-login/page.tsx Normal file
View File

@@ -0,0 +1,106 @@
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function DriverLoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await signIn('credentials', {
redirect: false,
email,
password,
});
if (result?.error) {
setError('Ungültige Anmeldedaten');
setLoading(false);
return;
}
router.push('/driver');
router.refresh();
} catch (error) {
setError('Ein Fehler ist aufgetreten');
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Fahrer Login
</h1>
<p className="text-gray-600">SaveTheMoment Atlas</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent"
placeholder="fahrer@savethemoment.de"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-gray-800 text-white py-3 rounded-lg font-semibold hover:bg-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
<div className="mt-6 text-center">
<a href="/" className="text-sm text-gray-600 hover:text-gray-900">
Zurück zur Startseite
</a>
</div>
</div>
</div>
</div>
);
}

21
app/driver/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { authOptions } from '@/lib/auth';
export default async function DriverLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/driver-login');
}
if (session.user.role !== 'DRIVER') {
redirect('/dashboard');
}
return <>{children}</>;
}

60
app/driver/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import DriverDashboard from '@/components/DriverDashboard';
export default async function DriverPage() {
const session = await getServerSession(authOptions);
const myTours = await prisma.tour.findMany({
where: {
driverId: session?.user.id,
tourDate: {
gte: new Date(),
},
},
include: {
bookings: {
include: {
location: true,
photobox: true,
},
},
},
orderBy: {
tourDate: 'asc',
},
take: 10,
});
const availableTours = await prisma.tour.findMany({
where: {
driverId: null,
tourDate: {
gte: new Date(),
},
},
include: {
bookings: {
where: {
status: 'CONFIRMED',
},
include: {
location: true,
},
},
},
orderBy: {
tourDate: 'asc',
},
take: 10,
});
return (
<DriverDashboard
user={session?.user}
myTours={myTours}
availableTours={availableTours}
/>
);
}

27
app/globals.css Normal file
View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

24
app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
import SessionProvider from "@/components/SessionProvider";
export const metadata: Metadata = {
title: "SaveTheMoment Atlas - Buchungs- & Tourenmanagement",
description: "Internes Management-System für Save the Moment Fotoboxen",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body>
<SessionProvider session={null}>
{children}
</SessionProvider>
</body>
</html>
);
}

106
app/login/page.tsx Normal file
View File

@@ -0,0 +1,106 @@
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await signIn('credentials', {
redirect: false,
email,
password,
});
if (result?.error) {
setError('Ungültige Anmeldedaten');
setLoading(false);
return;
}
router.push('/dashboard');
router.refresh();
} catch (error) {
setError('Ein Fehler ist aufgetreten');
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-red-100">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Admin Login
</h1>
<p className="text-gray-600">SaveTheMoment Atlas</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
placeholder="admin@savethemoment.de"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-red-600 text-white py-3 rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
<div className="mt-6 text-center">
<a href="/" className="text-sm text-gray-600 hover:text-gray-900">
Zurück zur Startseite
</a>
</div>
</div>
</div>
</div>
);
}

51
app/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import Link from 'next/link';
export default function Home() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-red-100">
<div className="max-w-2xl mx-auto p-8 bg-white rounded-2xl shadow-2xl">
<div className="text-center">
<h1 className="text-5xl font-bold text-gray-900 mb-4">
SaveTheMoment <span className="text-red-600">Atlas</span>
</h1>
<p className="text-xl text-gray-600 mb-8">
Buchungs- & Tourenmanagement System
</p>
<div className="space-y-4 mt-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
href="/login"
className="px-8 py-4 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors shadow-lg text-center"
>
Admin Login
</Link>
<Link
href="/driver-login"
className="px-8 py-4 bg-gray-700 text-white rounded-lg font-semibold hover:bg-gray-800 transition-colors shadow-lg text-center"
>
Fahrer Login
</Link>
</div>
</div>
<div className="mt-12 pt-8 border-t border-gray-200">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Standorte</h2>
<div className="flex flex-wrap justify-center gap-3">
<span className="px-4 py-2 bg-red-100 text-red-800 rounded-full text-sm">Lübeck</span>
<span className="px-4 py-2 bg-red-100 text-red-800 rounded-full text-sm">Hamburg</span>
<span className="px-4 py-2 bg-red-100 text-red-800 rounded-full text-sm">Kiel</span>
<span className="px-4 py-2 bg-red-100 text-red-800 rounded-full text-sm">Berlin</span>
<span className="px-4 py-2 bg-red-100 text-red-800 rounded-full text-sm">Rostock</span>
</div>
</div>
<div className="mt-8 text-sm text-gray-500">
Version 0.2.0 - Phase 2: Buchungsmanagement
</div>
</div>
</div>
</div>
);
}