feat: Equipment-System, Buchungsbearbeitung, Kundenadresse, LexOffice-Fix

- Vintage Modell hinzugefuegt
- Equipment Multi-Select (Neue Buchung + Bearbeitung)
- Kundenadresse in Formularen
- Bearbeiten-Seite fuer Buchungen
- Abbau-Zeiten in Formularen und Uebersicht
- Vertrag PDF nur bei Privatkunden
- LexOffice Kontakt-Erstellung Fix (BUSINESS)
- Zurueck-Pfeil auf Touren-Seite
This commit is contained in:
Julia Wehden
2026-03-19 16:21:55 +01:00
parent 0b6e429329
commit a2c95c70e7
79 changed files with 7396 additions and 538 deletions

View File

@@ -0,0 +1,87 @@
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 POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.log('🔄 Synchronisiere bestehende Buchungen mit Nextcloud...\n');
// Hole alle bestätigten Buchungen
const bookings = await prisma.booking.findMany({
where: {
status: {
in: ['RESERVED', 'CONFIRMED'],
},
},
include: {
location: true,
photobox: true,
},
orderBy: {
eventDate: 'asc',
},
});
console.log(`📊 Gefunden: ${bookings.length} Buchungen`);
if (bookings.length === 0) {
return NextResponse.json({
success: true,
message: 'Keine Buchungen zum Synchronisieren gefunden.',
synced: 0,
failed: 0,
total: 0,
});
}
let synced = 0;
let failed = 0;
const errors: any[] = [];
for (const booking of bookings) {
try {
console.log(`📅 Synchronisiere: ${booking.bookingNumber} - ${booking.customerName}`);
await nextcloudCalendar.syncBookingToCalendar(booking);
synced++;
console.log(` ✅ Erfolgreich!`);
} catch (error: any) {
failed++;
console.error(` ❌ Fehler: ${error.message}`);
errors.push({
bookingNumber: booking.bookingNumber,
customerName: booking.customerName,
error: error.message,
});
}
}
console.log('─'.repeat(50));
console.log(`✅ Erfolgreich synchronisiert: ${synced}`);
console.log(`❌ Fehlgeschlagen: ${failed}`);
console.log(`📊 Gesamt: ${bookings.length}`);
return NextResponse.json({
success: true,
synced,
failed,
total: bookings.length,
errors: errors.length > 0 ? errors : undefined,
});
} catch (error: any) {
console.error('❌ Fehler beim Synchronisieren:', error);
return NextResponse.json(
{ error: error.message || 'Failed to sync bookings' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { emailSyncService } from '@/lib/email-sync';
import { prisma } from '@/lib/prisma';
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) {
// Sync specific location
console.log(`🔄 Starte E-Mail-Sync für Location: ${locationId}`);
const result = await emailSyncService.syncLocationEmails(locationId);
return NextResponse.json({
success: result.success,
location: locationId,
newEmails: result.newEmails,
newBookings: result.newBookings,
errors: result.errors,
});
} else {
// Sync all locations
console.log('🔄 Starte E-Mail-Sync für alle Locations...');
const locations = await prisma.location.findMany({
where: { emailSyncEnabled: true },
select: { id: true, name: true },
});
const results = [];
for (const location of locations) {
console.log(`📍 Sync: ${location.name}`);
const result = await emailSyncService.syncLocationEmails(location.id);
results.push({
locationId: location.id,
locationName: location.name,
...result,
});
}
const totalNewEmails = results.reduce((sum, r) => sum + r.newEmails, 0);
const totalNewBookings = results.reduce((sum, r) => sum + r.newBookings, 0);
return NextResponse.json({
success: true,
totalLocations: locations.length,
totalNewEmails,
totalNewBookings,
results,
});
}
} catch (error: any) {
console.error('❌ E-Mail-Sync Fehler:', error);
return NextResponse.json(
{ error: error.message || 'E-Mail-Sync fehlgeschlagen' },
{ status: 500 }
);
}
}
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 });
}
// Get sync status for all locations
const locations = await prisma.location.findMany({
select: {
id: true,
name: true,
slug: true,
emailSyncEnabled: true,
lastEmailSync: true,
imapHost: true,
imapUser: true,
},
});
const status = locations.map(loc => ({
id: loc.id,
name: loc.name,
slug: loc.slug,
syncEnabled: loc.emailSyncEnabled,
configured: !!(loc.imapHost && loc.imapUser),
lastSync: loc.lastEmailSync,
}));
return NextResponse.json({ locations: status });
} catch (error: any) {
console.error('❌ Fehler beim Abrufen des Sync-Status:', error);
return NextResponse.json(
{ error: error.message || 'Fehler beim Abrufen des Status' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { bookingAutomationService } from '@/lib/booking-automation';
import { prisma } from '@/lib/prisma';
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 { bookingId } = await request.json();
if (!bookingId) {
return NextResponse.json({ error: 'bookingId required' }, { status: 400 });
}
console.log(`🤖 Starte automatische Aktionen für Buchung: ${bookingId}`);
const result = await bookingAutomationService.runPostBookingActions(bookingId);
return NextResponse.json({
success: true,
emailSent: result.emailSent,
calendarSynced: result.calendarSynced,
lexofficeCreated: result.lexofficeCreated,
contractGenerated: result.contractGenerated,
errors: result.errors,
});
} catch (error: any) {
console.error('❌ Automation Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Automation fehlgeschlagen' },
{ status: 500 }
);
}
}
// GET: Hole neueste Buchung und teste Automation
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 });
}
// Hole neueste Buchung
const latestBooking = await prisma.booking.findFirst({
orderBy: { createdAt: 'desc' },
select: {
id: true,
bookingNumber: true,
customerName: true,
customerEmail: true,
eventDate: true,
calendarSynced: true,
},
});
if (!latestBooking) {
return NextResponse.json({ error: 'Keine Buchung gefunden' }, { status: 404 });
}
console.log(`🤖 Teste Automation für: ${latestBooking.bookingNumber}`);
const result = await bookingAutomationService.runPostBookingActions(latestBooking.id);
return NextResponse.json({
success: true,
booking: {
id: latestBooking.id,
bookingNumber: latestBooking.bookingNumber,
customerName: latestBooking.customerName,
customerEmail: latestBooking.customerEmail,
eventDate: latestBooking.eventDate,
},
emailSent: result.emailSent,
calendarSynced: result.calendarSynced,
lexofficeCreated: result.lexofficeCreated,
contractGenerated: result.contractGenerated,
errors: result.errors,
});
} catch (error: any) {
console.error('❌ Test Automation Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Test fehlgeschlagen' },
{ status: 500 }
);
}
}

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 { lexofficeService } from '@/lib/lexoffice';
import { nextcloudCalendar } from '@/lib/nextcloud-calendar';
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 bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
bookingEquipment: {
include: {
equipment: true,
},
},
},
});
if (!booking) {
return NextResponse.json(
{ error: 'Buchung nicht gefunden' },
{ status: 404 }
);
}
if (booking.status === 'CONFIRMED') {
return NextResponse.json(
{ error: 'Buchung ist bereits bestätigt' },
{ status: 400 }
);
}
if (!booking.contractSigned) {
return NextResponse.json(
{ error: 'Vertrag muss zuerst unterschrieben werden' },
{ status: 400 }
);
}
console.log(`🔄 Bestätige Buchung ${booking.bookingNumber}...`);
let lexofficeConfirmationId = null;
if (booking.lexofficeContactId) {
try {
console.log(' 💼 Erstelle LexOffice Auftragsbestätigung...');
lexofficeConfirmationId = await lexofficeService.createConfirmationFromBooking(
booking,
booking.lexofficeContactId
);
console.log(` ✅ Auftragsbestätigung erstellt: ${lexofficeConfirmationId}`);
} catch (error: any) {
console.error(' ❌ LexOffice Fehler:', error.message);
}
}
const updatedBooking = await prisma.booking.update({
where: { id: bookingId },
data: {
status: 'CONFIRMED',
lexofficeConfirmationId,
confirmationSentAt: new Date(),
},
});
try {
console.log(' 📅 Update Nextcloud Kalender...');
await nextcloudCalendar.syncBookingToCalendar(updatedBooking);
console.log(' ✅ Kalender aktualisiert');
} catch (error: any) {
console.error(' ❌ Kalender-Update Fehler:', error.message);
}
await prisma.notification.create({
data: {
type: 'BOOKING_CONFIRMED',
title: 'Buchung bestätigt',
message: `Buchung ${booking.bookingNumber} für ${booking.customerName} wurde von Admin bestätigt.`,
metadata: {
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
lexofficeConfirmationId,
},
},
});
console.log(`✅ Buchung bestätigt: ${booking.bookingNumber}`);
return NextResponse.json({
success: true,
booking: {
id: updatedBooking.id,
bookingNumber: updatedBooking.bookingNumber,
status: updatedBooking.status,
confirmationSentAt: updatedBooking.confirmationSentAt,
lexofficeConfirmationId: updatedBooking.lexofficeConfirmationId,
},
});
} catch (error: any) {
console.error('❌ Bestätigungs-Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Bestätigung fehlgeschlagen' },
{ status: 500 }
);
}
}
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 bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
bookingNumber: true,
status: true,
contractSigned: true,
contractSignedAt: true,
confirmationSentAt: true,
lexofficeContactId: true,
lexofficeOfferId: true,
lexofficeConfirmationId: true,
},
});
if (!booking) {
return NextResponse.json(
{ error: 'Buchung nicht gefunden' },
{ status: 404 }
);
}
const canConfirm = booking.status !== 'CONFIRMED' && booking.contractSigned;
return NextResponse.json({
booking,
canConfirm,
});
} catch (error: any) {
console.error('❌ Buchungs-Status Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Fehler beim Abrufen des Status' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
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 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 bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
bookingNumber: true,
lexofficeConfirmationId: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
if (!booking.lexofficeConfirmationId) {
return NextResponse.json({ error: 'Keine Auftragsbestätigung vorhanden' }, { status: 404 });
}
const pdfBuffer = await lexofficeService.getInvoicePDF(booking.lexofficeConfirmationId);
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="Auftragsbestaetigung_${booking.bookingNumber}.pdf"`,
},
});
} catch (error: any) {
console.error('❌ PDF-Download Fehler:', error);
return NextResponse.json(
{ error: error.message || 'PDF-Download fehlgeschlagen' },
{ 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 { generateContractFromTemplate } from '@/lib/pdf-template-service';
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 bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
let priceConfig = null;
if (booking.photobox?.model && booking.locationId) {
priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: booking.locationId,
model: booking.photobox.model,
},
},
});
}
const bookingWithPriceConfig = {
...booking,
priceConfig,
};
const signatureData = booking.contractSignedOnline ? booking.contractSignatureData : undefined;
const pdfBuffer = await generateContractFromTemplate(
bookingWithPriceConfig,
booking.location,
booking.photobox,
signatureData
);
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="Mietvertrag_${booking.bookingNumber}.pdf"`,
},
});
} catch (error: any) {
console.error('❌ Contract-PDF-Download Fehler:', error);
return NextResponse.json(
{ error: error.message || 'PDF-Download fehlgeschlagen' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,111 @@
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 bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
bookingEquipment: {
include: {
equipment: true,
},
},
},
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
let priceConfig = null;
if (booking.photobox?.model && booking.locationId) {
priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: booking.locationId,
model: booking.photobox.model,
},
},
});
}
const lineItems: any[] = [];
const withPrintFlat = booking.withPrintFlat !== false;
// Photobox LineItem
const photoboxArticleId = withPrintFlat
? (priceConfig?.lexofficeArticleIdWithFlat || priceConfig?.lexofficeArticleId)
: priceConfig?.lexofficeArticleId;
const boxName = booking.photobox?.model || 'Fotobox';
const flatSuffix = withPrintFlat ? ' mit Druckflatrate' : ' (nur digital)';
const photoboxItem: any = {
type: (photoboxArticleId && photoboxArticleId.trim()) ? 'material' : 'custom',
quantity: 1,
unitName: 'Stück',
name: `${boxName}${flatSuffix}`,
};
if (photoboxArticleId && photoboxArticleId.trim()) {
photoboxItem.id = photoboxArticleId;
} else {
photoboxItem.description = `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`;
photoboxItem.unitPrice = {
currency: 'EUR',
netAmount: priceConfig?.basePrice || 1,
taxRatePercentage: 19,
};
}
lineItems.push(photoboxItem);
return NextResponse.json({
booking: {
id: booking.id,
bookingNumber: booking.bookingNumber,
locationId: booking.locationId,
photoboxModel: booking.photobox?.model,
withPrintFlat: booking.withPrintFlat,
distance: booking.distance,
},
priceConfig: priceConfig ? {
id: priceConfig.id,
basePrice: priceConfig.basePrice,
kmFlatRate: priceConfig.kmFlatRate,
kmFlatRateUpTo: priceConfig.kmFlatRateUpTo,
pricePerKm: priceConfig.pricePerKm,
kmMultiplier: priceConfig.kmMultiplier,
lexofficeArticleId: priceConfig.lexofficeArticleId,
lexofficeArticleIdWithFlat: priceConfig.lexofficeArticleIdWithFlat,
lexofficeKmFlatArticleId: priceConfig.lexofficeKmFlatArticleId,
lexofficeKmExtraArticleId: priceConfig.lexofficeKmExtraArticleId,
} : null,
lineItems,
photoboxArticleId,
});
} catch (error: any) {
console.error('❌ Debug Fehler:', error);
return NextResponse.json(
{ error: error.message, stack: error.stack },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
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 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 bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
bookingNumber: true,
lexofficeOfferId: true,
},
});
if (!booking) {
return NextResponse.json({ error: 'Buchung nicht gefunden' }, { status: 404 });
}
if (!booking.lexofficeOfferId) {
return NextResponse.json({ error: 'Kein LexOffice Angebot vorhanden' }, { status: 404 });
}
const pdfBuffer = await lexofficeService.getQuotationPDF(booking.lexofficeOfferId);
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="Angebot_${booking.bookingNumber}.pdf"`,
},
});
} catch (error: any) {
console.error('❌ PDF-Download Fehler:', error);
return NextResponse.json(
{ error: error.message || 'PDF-Download fehlgeschlagen' },
{ status: 500 }
);
}
}

View File

@@ -4,6 +4,39 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { nextcloudCalendar } from '@/lib/nextcloud-calendar';
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 booking = await prisma.booking.findUnique({
where: { id: params.id },
include: {
location: true,
photobox: true,
bookingEquipment: {
include: { equipment: true },
},
},
});
if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
}
return NextResponse.json({ booking });
} catch (error) {
console.error('Booking GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
@@ -65,23 +98,68 @@ export async function PATCH(
const body = await request.json();
const { id } = params;
const { status } = body;
if (!status) {
return NextResponse.json({ error: 'Status is required' }, { status: 400 });
const updateData: any = {};
if (body.status) updateData.status = body.status;
if (body.customerName !== undefined) updateData.customerName = body.customerName;
if (body.customerEmail !== undefined) updateData.customerEmail = body.customerEmail;
if (body.customerPhone !== undefined) updateData.customerPhone = body.customerPhone;
if (body.customerAddress !== undefined) updateData.customerAddress = body.customerAddress;
if (body.customerCity !== undefined) updateData.customerCity = body.customerCity;
if (body.customerZip !== undefined) updateData.customerZip = body.customerZip;
if (body.companyName !== undefined) updateData.companyName = body.companyName;
if (body.invoiceType !== undefined) updateData.invoiceType = body.invoiceType;
if (body.eventDate !== undefined) updateData.eventDate = new Date(body.eventDate);
if (body.eventAddress !== undefined) updateData.eventAddress = body.eventAddress;
if (body.eventCity !== undefined) updateData.eventCity = body.eventCity;
if (body.eventZip !== undefined) updateData.eventZip = body.eventZip;
if (body.eventLocation !== undefined) updateData.eventLocation = body.eventLocation;
if (body.setupTimeStart !== undefined) updateData.setupTimeStart = body.setupTimeStart ? new Date(body.setupTimeStart) : null;
if (body.setupTimeLatest !== undefined) updateData.setupTimeLatest = body.setupTimeLatest ? new Date(body.setupTimeLatest) : null;
if (body.dismantleTimeEarliest !== undefined) updateData.dismantleTimeEarliest = body.dismantleTimeEarliest ? new Date(body.dismantleTimeEarliest) : null;
if (body.dismantleTimeLatest !== undefined) updateData.dismantleTimeLatest = body.dismantleTimeLatest ? new Date(body.dismantleTimeLatest) : null;
if (body.calculatedPrice !== undefined) updateData.calculatedPrice = body.calculatedPrice;
if (body.notes !== undefined) updateData.notes = body.notes;
if (body.withPrintFlat !== undefined) updateData.withPrintFlat = body.withPrintFlat;
const hasEquipmentUpdate = Array.isArray(body.equipmentIds);
const hasModelUpdate = body.model !== undefined;
if (Object.keys(updateData).length === 0 && !hasEquipmentUpdate && !hasModelUpdate) {
return NextResponse.json({ error: 'Keine Änderungen angegeben' }, { status: 400 });
}
const booking = await prisma.booking.update({
where: { id },
data: { status },
data: updateData,
include: {
location: true,
photobox: true,
},
});
if (hasModelUpdate && booking.photoboxId) {
await prisma.photobox.update({
where: { id: booking.photoboxId },
data: { model: body.model },
});
}
if (hasEquipmentUpdate) {
await prisma.bookingEquipment.deleteMany({ where: { bookingId: id } });
if (body.equipmentIds.length > 0) {
await prisma.bookingEquipment.createMany({
data: body.equipmentIds.map((eqId: string) => ({
bookingId: id,
equipmentId: eqId,
})),
});
}
}
try {
if (status === 'CANCELLED') {
if (updateData.status === 'CANCELLED') {
await nextcloudCalendar.removeBookingFromCalendar(booking.id);
} else {
await nextcloudCalendar.syncBookingToCalendar(booking);

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { generateContractFromTemplate } from '@/lib/pdf-template-service';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const bookingId = params.id;
const body = await request.json();
const { signatureData } = body;
if (!signatureData) {
return NextResponse.json(
{ error: 'Signatur-Daten fehlen' },
{ status: 400 }
);
}
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
},
});
if (!booking) {
return NextResponse.json(
{ error: 'Buchung nicht gefunden' },
{ status: 404 }
);
}
if (booking.contractSigned) {
return NextResponse.json(
{ error: 'Vertrag wurde bereits unterschrieben' },
{ status: 400 }
);
}
const clientIp = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'unknown';
let priceConfig = null;
if (booking.photobox?.model && booking.locationId) {
priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: booking.locationId,
model: booking.photobox.model,
},
},
});
}
const bookingWithPriceConfig = {
...booking,
priceConfig,
};
const contractPdf = await generateContractFromTemplate(
bookingWithPriceConfig,
booking.location,
booking.photobox,
signatureData
);
const updatedBooking = await prisma.booking.update({
where: { id: bookingId },
data: {
contractSigned: true,
contractSignedAt: new Date(),
contractSignedOnline: true,
contractSignatureData: signatureData,
contractSignedBy: booking.customerName,
contractSignedIp: clientIp,
},
});
await prisma.notification.create({
data: {
type: 'CONTRACT_SIGNED',
title: 'Vertrag unterschrieben',
message: `${booking.customerName} hat den Vertrag für Buchung ${booking.bookingNumber} online unterschrieben.`,
metadata: {
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
signedOnline: true,
},
},
});
console.log(`✅ Vertrag online unterschrieben: ${booking.bookingNumber}`);
return NextResponse.json({
success: true,
booking: {
id: updatedBooking.id,
bookingNumber: updatedBooking.bookingNumber,
contractSigned: updatedBooking.contractSigned,
contractSignedAt: updatedBooking.contractSignedAt,
},
});
} catch (error: any) {
console.error('❌ Signatur-Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Signatur fehlgeschlagen' },
{ status: 500 }
);
}
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const bookingId = params.id;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
bookingNumber: true,
customerName: true,
eventDate: true,
eventLocation: true,
contractSigned: true,
contractSignedAt: true,
contractSignedOnline: true,
calculatedPrice: true,
photobox: {
select: {
model: true,
},
},
location: {
select: {
name: true,
},
},
},
});
if (!booking) {
return NextResponse.json(
{ error: 'Buchung nicht gefunden' },
{ status: 404 }
);
}
return NextResponse.json({ booking });
} catch (error: any) {
console.error('❌ Buchungs-Abruf Fehler:', error);
return NextResponse.json(
{ error: error.message || 'Fehler beim Abrufen der Buchung' },
{ status: 500 }
);
}
}

View File

@@ -105,6 +105,15 @@ export async function POST(request: NextRequest) {
},
});
if (Array.isArray(body.equipmentIds) && body.equipmentIds.length > 0) {
await prisma.bookingEquipment.createMany({
data: body.equipmentIds.map((eqId: string) => ({
bookingId: booking.id,
equipmentId: eqId,
})),
});
}
try {
await nextcloudCalendar.syncBookingToCalendar(booking);
} catch (calError) {

View File

@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
import { DistanceCalculator } from '@/lib/distance-calculator';
import { PriceCalculator } from '@/lib/price-calculator';
import { bookingAutomationService } from '@/lib/booking-automation';
const bookingSchema = z.object({
locationSlug: z.string(),
@@ -92,7 +95,62 @@ export async function POST(request: NextRequest) {
},
});
const calculatedPrice = priceConfig ? priceConfig.basePrice : 0;
if (!priceConfig) {
return NextResponse.json(
{ error: 'Preiskonfiguration nicht gefunden' },
{ status: 404 }
);
}
let distance: number | null = null;
let calculatedPrice = priceConfig.basePrice;
if (location.warehouseAddress && location.warehouseZip && location.warehouseCity) {
const warehouseAddress = DistanceCalculator.formatAddress(
location.warehouseAddress,
location.warehouseZip,
location.warehouseCity
);
const eventAddress = DistanceCalculator.formatAddress(
data.eventAddress,
data.eventZip,
data.eventCity
);
const distanceResult = await DistanceCalculator.calculateDistance(
warehouseAddress,
eventAddress
);
if (distanceResult) {
distance = distanceResult.distance;
const priceBreakdown = PriceCalculator.calculateTotalPrice(
priceConfig.basePrice,
distance,
{
basePrice: priceConfig.basePrice,
kmFlatRate: priceConfig.kmFlatRate,
kmFlatRateUpTo: priceConfig.kmFlatRateUpTo,
pricePerKm: priceConfig.pricePerKm,
kmMultiplier: priceConfig.kmMultiplier,
}
);
calculatedPrice = priceBreakdown.totalPrice;
console.log('📍 Distanzberechnung:', {
from: warehouseAddress,
to: eventAddress,
distance: `${distance}km`,
breakdown: PriceCalculator.formatPriceBreakdown(priceBreakdown),
});
} else {
console.warn('⚠️ Distanzberechnung fehlgeschlagen, verwende nur Grundpreis');
}
} else {
console.warn('⚠️ Keine Lager-Adresse konfiguriert, verwende nur Grundpreis');
}
const booking = await prisma.booking.create({
data: {
@@ -117,6 +175,7 @@ export async function POST(request: NextRequest) {
setupTimeLatest: new Date(data.setupTimeLatest),
dismantleTimeEarliest: data.dismantleTimeEarliest ? new Date(data.dismantleTimeEarliest) : null,
dismantleTimeLatest: data.dismantleTimeLatest ? new Date(data.dismantleTimeLatest) : null,
distance,
calculatedPrice,
notes: data.notes,
},
@@ -138,6 +197,12 @@ export async function POST(request: NextRequest) {
},
});
// 🤖 Automatische Post-Booking Aktionen (E-Mail + Kalender)
console.log('📢 Starte automatische Aktionen...');
bookingAutomationService.runPostBookingActions(booking.id).catch(err => {
console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err);
});
return NextResponse.json({
success: true,
booking: {
@@ -174,7 +239,13 @@ export async function GET(request: NextRequest) {
const where: any = {};
if (status) {
where.status = status;
// Support multiple statuses separated by comma
const statuses = status.split(',').map(s => s.trim());
if (statuses.length > 1) {
where.status = { in: statuses };
} else {
where.status = status;
}
}
if (locationSlug) {

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'DRIVER') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { status } = body;
if (!status) {
return NextResponse.json({ error: 'Status is required' }, { status: 400 });
}
const tourStop = await prisma.tourStop.findUnique({
where: { id: params.id },
include: {
tour: {
select: {
driverId: true,
},
},
},
});
if (!tourStop) {
return NextResponse.json({ error: 'Tour stop not found' }, { status: 404 });
}
if (tourStop.tour.driverId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const updateData: any = { status };
switch (status) {
case 'ARRIVED':
updateData.arrivedAt = new Date();
break;
case 'SETUP_IN_PROGRESS':
updateData.setupStartedAt = new Date();
break;
case 'SETUP_COMPLETE':
updateData.setupCompleteAt = new Date();
break;
case 'PICKUP_IN_PROGRESS':
updateData.pickupStartedAt = new Date();
break;
case 'PICKUP_COMPLETE':
updateData.pickupCompleteAt = new Date();
break;
}
const updatedStop = await prisma.tourStop.update({
where: { id: params.id },
data: updateData,
});
return NextResponse.json({ tourStop: updatedStop });
} catch (error: any) {
console.error('Status update error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update status' },
{ status: 500 }
);
}
}

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(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'DRIVER') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tour = await prisma.tour.findUnique({
where: {
id: params.id,
driverId: session.user.id,
},
include: {
tourStops: {
include: {
booking: {
include: {
photobox: {
select: {
model: true,
serialNumber: true,
},
},
},
},
photos: {
select: {
id: true,
photoType: true,
fileName: true,
},
},
},
orderBy: {
stopOrder: 'asc',
},
},
},
});
if (!tour) {
return NextResponse.json({ error: 'Tour not found' }, { status: 404 });
}
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 }
);
}
}

View File

@@ -0,0 +1,30 @@
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 || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const equipment = await prisma.equipment.findMany({
where: { status: 'AVAILABLE' },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
type: true,
price: true,
},
});
return NextResponse.json({ equipment });
} catch (error) {
console.error('Equipment GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -106,6 +106,7 @@ export async function POST(request: NextRequest) {
},
data: {
tourId: tour.id,
status: 'ASSIGNED',
},
});
@@ -145,6 +146,13 @@ export async function POST(request: NextRequest) {
},
});
// Create TourStops for each booking
const fullBookings = await prisma.booking.findMany({
where: { id: { in: bookingIds } },
include: { setupWindows: true },
orderBy: { setupTimeStart: 'asc' },
});
try {
// For route optimization, use the selected setup window time if available
const stopsWithSetupTimes = bookings.map((booking: any) => {
@@ -182,8 +190,38 @@ export async function POST(request: NextRequest) {
estimatedDuration: routeData.totalDuration,
},
});
// Create TourStops based on optimized order
const optimizedOrder = routeData.optimizedOrder || fullBookings.map((_, i) => i);
for (let i = 0; i < optimizedOrder.length; i++) {
const orderIndex = optimizedOrder[i];
const booking = fullBookings[orderIndex];
await prisma.tourStop.create({
data: {
tourId: tour.id,
bookingId: booking.id,
stopOrder: i + 1,
stopType: 'DELIVERY',
status: 'PENDING',
},
});
}
} catch (routeError) {
console.error('Route optimization error:', routeError);
// If route optimization fails, create TourStops in simple order
for (let i = 0; i < fullBookings.length; i++) {
await prisma.tourStop.create({
data: {
tourId: tour.id,
bookingId: fullBookings[i].id,
stopOrder: i + 1,
stopType: 'DELIVERY',
status: 'PENDING',
},
});
}
}
}