Initial commit - SaveTheMoment Atlas Basis-Setup
This commit is contained in:
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
81
app/api/availability/route.ts
Normal file
81
app/api/availability/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
app/api/bookings/[id]/ai-analyze/route.ts
Normal file
78
app/api/bookings/[id]/ai-analyze/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
app/api/bookings/[id]/assign-driver/route.ts
Normal file
82
app/api/bookings/[id]/assign-driver/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
99
app/api/bookings/[id]/availability/route.ts
Normal file
99
app/api/bookings/[id]/availability/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
114
app/api/bookings/[id]/contract/route.ts
Normal file
114
app/api/bookings/[id]/contract/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/api/bookings/[id]/contract/send/route.ts
Normal file
75
app/api/bookings/[id]/contract/send/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
app/api/bookings/[id]/contract/upload/route.ts
Normal file
73
app/api/bookings/[id]/contract/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
app/api/bookings/[id]/create-quotation/route.ts
Normal file
72
app/api/bookings/[id]/create-quotation/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/bookings/[id]/release-to-drivers/route.ts
Normal file
44
app/api/bookings/[id]/release-to-drivers/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
101
app/api/bookings/[id]/route.ts
Normal file
101
app/api/bookings/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
158
app/api/bookings/[id]/setup-windows/route.ts
Normal file
158
app/api/bookings/[id]/setup-windows/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/api/bookings/[id]/status/route.ts
Normal file
46
app/api/bookings/[id]/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
125
app/api/bookings/create/route.ts
Normal file
125
app/api/bookings/create/route.ts
Normal 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
212
app/api/bookings/route.ts
Normal 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
62
app/api/calendar/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
114
app/api/calendar/sync/route.ts
Normal file
114
app/api/calendar/sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
96
app/api/contract/sign/route.ts
Normal file
96
app/api/contract/sign/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/api/contract/upload/route.ts
Normal file
74
app/api/contract/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
83
app/api/cron/check-contracts/route.ts
Normal file
83
app/api/cron/check-contracts/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
app/api/cron/email-sync/route.ts
Normal file
60
app/api/cron/email-sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
173
app/api/cron/process-pending-bookings/route.ts
Normal file
173
app/api/cron/process-pending-bookings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
145
app/api/drivers/[id]/route.ts
Normal file
145
app/api/drivers/[id]/route.ts
Normal 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
124
app/api/drivers/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/api/email-sync/route.ts
Normal file
30
app/api/email-sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
102
app/api/inventory/[id]/route.ts
Normal file
102
app/api/inventory/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
67
app/api/inventory/route.ts
Normal file
67
app/api/inventory/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
app/api/locations/[id]/email-settings/route.ts
Normal file
45
app/api/locations/[id]/email-settings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
app/api/locations/route.ts
Normal file
23
app/api/locations/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
119
app/api/photoboxes/[id]/route.ts
Normal file
119
app/api/photoboxes/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
app/api/photoboxes/route.ts
Normal file
98
app/api/photoboxes/route.ts
Normal 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
41
app/api/prices/route.ts
Normal 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
27
app/api/projects/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
app/api/tours/[id]/optimize-route/route.ts
Normal file
89
app/api/tours/[id]/optimize-route/route.ts
Normal 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
175
app/api/tours/[id]/route.ts
Normal 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
206
app/api/tours/route.ts
Normal 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
582
app/booking-page-backup.txt
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/contract/sign/[token]/page.tsx
Normal file
44
app/contract/sign/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/contract/success/page.tsx
Normal file
31
app/contract/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
app/dashboard/bookings/[id]/page.tsx
Normal file
52
app/dashboard/bookings/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/dashboard/bookings/new/page.tsx
Normal file
30
app/dashboard/bookings/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
app/dashboard/bookings/page.tsx
Normal file
39
app/dashboard/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
app/dashboard/drivers/[id]/page.tsx
Normal file
336
app/dashboard/drivers/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
app/dashboard/drivers/page.tsx
Normal file
260
app/dashboard/drivers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
546
app/dashboard/inventory/[id]/page.tsx
Normal file
546
app/dashboard/inventory/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
332
app/dashboard/inventory/new/page.tsx
Normal file
332
app/dashboard/inventory/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
app/dashboard/inventory/page.tsx
Normal file
230
app/dashboard/inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
410
app/dashboard/kalender/page.tsx
Normal file
410
app/dashboard/kalender/page.tsx
Normal 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
21
app/dashboard/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
34
app/dashboard/locations/page.tsx
Normal file
34
app/dashboard/locations/page.tsx
Normal 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
53
app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
app/dashboard/photoboxes/[id]/page.tsx
Normal file
288
app/dashboard/photoboxes/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
app/dashboard/photoboxes/page.tsx
Normal file
260
app/dashboard/photoboxes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
app/dashboard/photoboxes/page.tsx.backup
Normal file
260
app/dashboard/photoboxes/page.tsx.backup
Normal 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>
|
||||
);
|
||||
}
|
||||
197
app/dashboard/settings/page.tsx
Normal file
197
app/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/dashboard/tours/[id]/page.tsx
Normal file
279
app/dashboard/tours/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/dashboard/tours/[id]/page.tsx.bak
Normal file
279
app/dashboard/tours/[id]/page.tsx.bak
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/dashboard/tours/[id]/page.tsx.bak2
Normal file
279
app/dashboard/tours/[id]/page.tsx.bak2
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/dashboard/tours/[id]/page.tsx.bak3
Normal file
279
app/dashboard/tours/[id]/page.tsx.bak3
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/dashboard/tours/[id]/page.tsx.bak4
Normal file
279
app/dashboard/tours/[id]/page.tsx.bak4
Normal 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>
|
||||
);
|
||||
}
|
||||
430
app/dashboard/tours/page.tsx
Normal file
430
app/dashboard/tours/page.tsx
Normal 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
106
app/driver-login/page.tsx
Normal 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
21
app/driver/layout.tsx
Normal 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
60
app/driver/page.tsx
Normal 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
27
app/globals.css
Normal 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
24
app/layout.tsx
Normal 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
106
app/login/page.tsx
Normal 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
51
app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user