This commit is contained in:
2025-10-08 19:48:48 +02:00
parent cdcd34fa0f
commit 76e3053e50
14 changed files with 587 additions and 52 deletions
@@ -6,6 +6,7 @@ use App\Models\ServiceRequest;
use App\Models\Setting;
use App\Models\Communication;
use App\Mail\ServiceRequestNotification;
use App\Mail\PickupConfirmation;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
@@ -115,11 +116,11 @@ class ServiceRequestController extends Controller
$validated = $request->validate([
'actual_repair_date' => 'nullable|date|after:today',
'actual_repair_time' => 'nullable|date_format:H:i',
'status' => 'nullable|in:pending,confirmed,completed,cancelled',
'status' => 'nullable|in:pending,confirmed,mail_sent,completed,cancelled',
], [
'actual_repair_date.after' => 'Werkelijke reparatie datum moet in de toekomst liggen',
'actual_repair_time.date_format' => 'Werkelijke reparatie tijd moet een geldig tijdformaat hebben (HH:MM)',
'status.in' => 'Status moet een van de volgende waarden hebben: in afwachting, bevestigd, voltooid, geannuleerd',
'status.in' => 'Status moet een van de volgende waarden hebben: in afwachting, bevestigd, bevestigd mail verstuurd, voltooid, geannuleerd',
]);
$serviceRequest->update($validated);
@@ -252,6 +253,74 @@ class ServiceRequestController extends Controller
}
}
/**
* Send pickup confirmation email to customer
*/
public function sendPickupConfirmation(ServiceRequest $serviceRequest): JsonResponse
{
try {
if (empty($serviceRequest->email)) {
return response()->json([
'message' => 'Geen e-mailadres beschikbaar voor deze service aanvraag'
], 400);
}
if (empty($serviceRequest->actual_repair_date) || empty($serviceRequest->actual_repair_time)) {
return response()->json([
'message' => 'Werkelijke reparatie datum en tijd zijn vereist'
], 400);
}
// Get the custom message from settings
$customMessage = Setting::getValue('pickup_confirmation_email_text', 'Uw fiets is klaar en kan worden opgehaald.');
// Format the pickup date and time
$pickupDate = \Carbon\Carbon::parse($serviceRequest->actual_repair_date)->format('d-m-Y');
$pickupTime = $serviceRequest->actual_repair_time;
// Send email
Mail::to($serviceRequest->email)->send(
new PickupConfirmation(
$serviceRequest,
$customMessage,
$pickupDate,
$pickupTime
)
);
// Update status to 'mail_sent'
$serviceRequest->update(['status' => 'mail_sent']);
// Store communication record
$communication = Communication::create([
'service_request_id' => $serviceRequest->id,
'type' => 'email',
'message' => 'Ophaalbevestiging verzonden - Datum: ' . $pickupDate . ', Tijd: ' . $pickupTime,
'direction' => 'admin_to_customer',
'recipient' => $serviceRequest->email,
'sent_at' => now(),
]);
Log::info('Ophaalbevestiging e-mail verzonden naar klant', [
'service_request_id' => $serviceRequest->id,
'email' => $serviceRequest->email,
'pickup_date' => $pickupDate,
'pickup_time' => $pickupTime
]);
return response()->json([
'message' => 'Ophaalbevestiging succesvol verzonden',
'communication' => $communication,
'service_request' => $serviceRequest->fresh()
]);
} catch (\Exception $e) {
return response()->json([
'message' => 'Ophaalbevestiging verzenden mislukt',
'error' => $e->getMessage()
], 500);
}
}
/**
* Get communication history
*/
@@ -261,7 +330,7 @@ class ServiceRequestController extends Controller
$communications = $serviceRequest->communications()
->orderBy('created_at', 'desc')
->get();
return response()->json($communications);
} catch (\Exception $e) {
return response()->json([
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Mail;
use App\Models\ServiceRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PickupConfirmation extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public ServiceRequest $serviceRequest,
public string $customMessage,
public string $pickupDate,
public string $pickupTime
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Uw fiets is klaar voor ophalen - ' . $this->serviceRequest->name,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.pickup-confirmation',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}
+2 -4
View File
@@ -24,10 +24,8 @@ class ServiceRequest extends Model
];
protected $casts = [
'requested_date' => 'date',
'requested_time' => 'datetime:H:i',
'actual_repair_date' => 'date',
'actual_repair_time' => 'datetime:H:i',
'requested_date' => 'date:Y-m-d',
'actual_repair_date' => 'date:Y-m-d',
];
public function communications()
@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// For SQLite, we need to recreate the table to modify the enum
// First, create a temporary table with the new structure
Schema::create('service_requests_temp', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('address');
$table->string('place');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->text('repair_description');
$table->date('requested_date');
$table->time('requested_time');
$table->date('actual_repair_date')->nullable();
$table->time('actual_repair_time')->nullable();
$table->enum('status', ['pending', 'confirmed', 'mail_sent', 'completed', 'cancelled'])->default('pending');
$table->timestamps();
});
// Copy data from old table to temp table
DB::statement('INSERT INTO service_requests_temp SELECT * FROM service_requests');
// Drop the old table
Schema::dropIfExists('service_requests');
// Rename temp table to original name
Schema::rename('service_requests_temp', 'service_requests');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Recreate the table with original enum values
Schema::create('service_requests_temp', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('address');
$table->string('place');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->text('repair_description');
$table->date('requested_date');
$table->time('requested_time');
$table->date('actual_repair_date')->nullable();
$table->time('actual_repair_time')->nullable();
$table->enum('status', ['pending', 'confirmed', 'completed', 'cancelled'])->default('pending');
$table->timestamps();
});
// Copy data, converting 'mail_sent' status to 'confirmed'
DB::statement("INSERT INTO service_requests_temp SELECT id, name, address, place, email, phone, repair_description, requested_date, requested_time, actual_repair_date, actual_repair_time, CASE WHEN status = 'mail_sent' THEN 'confirmed' ELSE status END, created_at, updated_at FROM service_requests");
// Drop the current table
Schema::dropIfExists('service_requests');
// Rename temp table to original name
Schema::rename('service_requests_temp', 'service_requests');
}
};
@@ -38,6 +38,18 @@ class SettingsSeeder extends Seeder
'key' => 'working_hours',
'value' => 'Maandag-Vrijdag: 08:00 - 18:00, Zaterdag: 09:00 - 16:00',
'description' => 'Bedrijfsopeningstijden'
],
[
'key' => 'pickup_confirmation_email_text',
'value' => 'Beste klant,
Uw fiets is klaar en kan worden opgehaald op het onderstaande tijdstip.
Wij zien u graag tegemoet!
Met vriendelijke groet,
Fiets Service Centrum',
'description' => 'Standaard tekst voor ophaalbevestiging e-mail'
]
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Professionele fietsreparatie en onderhoudsdiensten" />
<title>Bike Service - Jouw fiets, onze zorg</title>
<script type="module" crossorigin src="/assets/index-CTOGI7pY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jfsEyp3I.css">
<script type="module" crossorigin src="/assets/index-aYPxxDKl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAYhzhBJ.css">
</head>
<body>
<div id="app"></div>
@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Uw fiets is klaar voor ophalen</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.header {
background: linear-gradient(135deg, #2c5f5d 0%, #3d7a77 100%);
color: white;
padding: 30px 20px;
text-align: center;
}
.logo {
max-width: 200px;
height: auto;
margin-bottom: 15px;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: bold;
}
.content {
padding: 30px;
}
.message {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #2c5f5d;
margin-bottom: 30px;
white-space: pre-line;
}
.pickup-info {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
padding: 25px;
border-radius: 10px;
border: 3px solid #2c5f5d;
margin: 20px 0;
}
.pickup-info h2 {
color: #2c5f5d;
margin-top: 0;
margin-bottom: 20px;
font-size: 20px;
text-align: center;
}
.info-row {
display: flex;
align-items: center;
padding: 15px;
background: white;
border-radius: 8px;
margin-bottom: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.info-row:last-child {
margin-bottom: 0;
}
.info-icon {
font-size: 24px;
margin-right: 15px;
min-width: 30px;
}
.info-label {
font-weight: bold;
color: #2c5f5d;
min-width: 140px;
}
.info-value {
color: #333;
font-size: 18px;
font-weight: bold;
}
.customer-info {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.customer-info h3 {
color: #2c5f5d;
margin-top: 0;
margin-bottom: 15px;
font-size: 16px;
}
.customer-row {
margin-bottom: 10px;
padding: 8px;
background: white;
border-radius: 5px;
}
.customer-label {
font-weight: bold;
color: #666;
margin-bottom: 3px;
}
.customer-value {
color: #333;
}
.footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
border-top: 1px solid #e1e8ed;
}
.footer p {
margin: 5px 0;
}
.highlight {
background: #8b6f47;
color: white;
padding: 15px;
border-radius: 8px;
text-align: center;
margin: 20px 0;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{ config('app.url') }}/images/bikeservice_logo_navbar.svg" alt="Fiets Service Logo" class="logo" />
<h1>🚲 Uw fiets is klaar!</h1>
</div>
<div class="content">
<div class="message">
{{ $customMessage }}
</div>
<div class="pickup-info">
<h2>📅 Ophaalgegevens</h2>
<div class="info-row">
<div class="info-icon">📅</div>
<div>
<div class="info-label">Datum van ophalen:</div>
<div class="info-value">{{ $pickupDate }}</div>
</div>
</div>
<div class="info-row">
<div class="info-icon">🕐</div>
<div>
<div class="info-label">Tijd van ophalen:</div>
<div class="info-value">{{ $pickupTime }}</div>
</div>
</div>
</div>
<div class="highlight">
Vergeet niet uw afhaalbewijs mee te nemen!
</div>
<div class="customer-info">
<h3>Uw gegevens</h3>
<div class="customer-row">
<div class="customer-label">Naam:</div>
<div class="customer-value">{{ $serviceRequest->name }}</div>
</div>
<div class="customer-row">
<div class="customer-label">Adres:</div>
<div class="customer-value">{{ $serviceRequest->address }}, {{ $serviceRequest->place }}</div>
</div>
@if($serviceRequest->phone)
<div class="customer-row">
<div class="customer-label">Telefoon:</div>
<div class="customer-value">{{ $serviceRequest->phone }}</div>
</div>
@endif
</div>
</div>
<div class="footer">
<p><strong>Fiets Service Centrum</strong></p>
<p>Dit is een automatisch gegenereerde e-mail.</p>
<p>Heeft u vragen? Neem dan contact met ons op.</p>
</div>
</div>
</body>
</html>
+1
View File
@@ -48,6 +48,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('service-requests', ServiceRequestController::class);
Route::post('service-requests/{serviceRequest}/send-email', [ServiceRequestController::class, 'sendEmail']);
Route::post('service-requests/{serviceRequest}/send-sms', [ServiceRequestController::class, 'sendSms']);
Route::post('service-requests/{serviceRequest}/send-pickup-confirmation', [ServiceRequestController::class, 'sendPickupConfirmation']);
Route::get('service-requests/{serviceRequest}/communications', [ServiceRequestController::class, 'communications']);
Route::apiResource('blocked-dates', BlockedDateController::class);
+131 -9
View File
@@ -283,6 +283,19 @@
💾
</button>
</div>
<div class="setting-group">
<label for="pickup_confirmation_email_text">Ophaalbevestiging E-mail Tekst</label>
<textarea
id="pickup_confirmation_email_text"
v-model="settings.pickup_confirmation_email_text"
rows="8"
placeholder="Standaard tekst voor ophaalbevestiging e-mail"
></textarea>
<button @click="updateSetting('pickup_confirmation_email_text', settings.pickup_confirmation_email_text)" class="save-btn" title="Opslaan">
💾
</button>
</div>
</div>
</div>
@@ -321,25 +334,34 @@
<strong>Gewenste Datum/Tijd:</strong> {{ formatDateTime(selectedRequest.requested_date, selectedRequest.requested_time) }}
</div>
<div class="detail-row">
<strong>Status:</strong>
<strong>Status:</strong>
<select v-model="selectedRequest.status" @change="updateRequestStatus">
<option value="pending">In afwachting</option>
<option value="confirmed">Bevestigd</option>
<option value="mail_sent">Bevestigd mail verstuurd</option>
<option value="completed">Voltooid</option>
<option value="cancelled">Geannuleerd</option>
</select>
</div>
<div class="detail-row">
<strong>Werkelijke Reparatie Datum:</strong>
<strong>Werkelijke ophaalDatum:</strong>
<input type="date" v-model="selectedRequest.actual_repair_date" @change="updateRequestDate" />
</div>
<div class="detail-row">
<strong>Werkelijke Reparatie Tijd:</strong>
<strong>Werkelijke ophaaltijd:</strong>
<input type="time" v-model="selectedRequest.actual_repair_time" @change="updateRequestTime" />
</div>
</div>
</div>
<div class="modal-footer">
<button
@click="sendPickupConfirmation"
class="btn-pickup-confirmation"
:disabled="!selectedRequest?.email || !selectedRequest?.actual_repair_date || !selectedRequest?.actual_repair_time || pickupConfirmationLoading"
title="Verstuur ophaalbevestiging e-mail naar klant"
>
{{ pickupConfirmationLoading ? '📧 Versturen...' : '📧 Ophaalbevestiging Versturen' }}
</button>
<button @click="closeRequestModal" class="btn-primary">Sluiten</button>
</div>
</div>
@@ -522,6 +544,7 @@ export default {
// Loading states
const emailLoading = ref(false)
const smsLoading = ref(false)
const pickupConfirmationLoading = ref(false)
const tabs = [
{ id: 'requests', label: 'Service Aanvragen' },
@@ -542,6 +565,7 @@ export default {
const statusMap = {
'pending': 'In afwachting',
'confirmed': 'Bevestigd',
'mail_sent': 'Bevestigd mail verstuurd',
'completed': 'Voltooid',
'cancelled': 'Geannuleerd'
}
@@ -658,7 +682,7 @@ export default {
const viewRequest = async (request) => {
requestModalLoading.value = true
showRequestModal.value = true
// Get the latest data from the database to ensure we have the most current information
try {
const response = await fetch(`/api/service-requests/${request.id}`, {
@@ -666,16 +690,29 @@ export default {
})
if (response.ok) {
const latestRequest = await response.json()
// Format dates for HTML date inputs (YYYY-MM-DD)
if (latestRequest.actual_repair_date) {
// Extract just the date part if it's a datetime string
latestRequest.actual_repair_date = latestRequest.actual_repair_date.split('T')[0]
}
selectedRequest.value = { ...latestRequest }
} else {
// Fallback to local data if API call fails
const latestRequest = requests.value.find(r => r.id === request.id)
if (latestRequest && latestRequest.actual_repair_date) {
latestRequest.actual_repair_date = latestRequest.actual_repair_date.split('T')[0]
}
selectedRequest.value = latestRequest ? { ...latestRequest } : { ...request }
}
} catch (error) {
console.error('Failed to fetch latest request data:', error)
// Fallback to local data if API call fails
const latestRequest = requests.value.find(r => r.id === request.id)
if (latestRequest && latestRequest.actual_repair_date) {
latestRequest.actual_repair_date = latestRequest.actual_repair_date.split('T')[0]
}
selectedRequest.value = latestRequest ? { ...latestRequest } : { ...request }
} finally {
requestModalLoading.value = false
@@ -764,7 +801,7 @@ export default {
const submitSms = async () => {
if (!smsForm.value.message.trim()) return
smsLoading.value = true
try {
const response = await fetch(`/api/service-requests/${selectedRequest.value.id}/send-sms`, {
@@ -772,7 +809,7 @@ export default {
headers: authStore.getAuthHeaders(),
body: JSON.stringify({ message: smsForm.value.message })
})
if (response.ok) {
closeSmsModal()
await fetchRequests() // Refresh to show new communication
@@ -783,6 +820,51 @@ export default {
smsLoading.value = false
}
}
const sendPickupConfirmation = async () => {
if (!selectedRequest.value?.email) {
alert('Geen e-mailadres beschikbaar voor deze klant')
return
}
if (!selectedRequest.value?.actual_repair_date || !selectedRequest.value?.actual_repair_time) {
alert('Vul eerst de werkelijke ophaaldatum en tijd in')
return
}
if (!confirm('Weet u zeker dat u de ophaalbevestiging wilt versturen naar ' + selectedRequest.value.email + '?')) {
return
}
pickupConfirmationLoading.value = true
try {
const response = await fetch(`/api/service-requests/${selectedRequest.value.id}/send-pickup-confirmation`, {
method: 'POST',
headers: authStore.getAuthHeaders()
})
if (response.ok) {
const data = await response.json()
alert('Ophaalbevestiging succesvol verzonden!')
// Update the selected request with the new status
if (data.service_request) {
selectedRequest.value = data.service_request
}
// Refresh the requests list
await fetchRequests()
} else {
const error = await response.json()
alert('Fout bij versturen: ' + (error.message || 'Onbekende fout'))
}
} catch (error) {
console.error('Failed to send pickup confirmation:', error)
alert('Fout bij versturen van ophaalbevestiging')
} finally {
pickupConfirmationLoading.value = false
}
}
const editBlockedDate = (date) => {
editingBlockedDate.value = date
@@ -1045,6 +1127,7 @@ export default {
communications,
emailLoading,
smsLoading,
pickupConfirmationLoading,
minDate,
getStatusText,
getCommTypeText,
@@ -1063,6 +1146,7 @@ export default {
viewCommunications,
submitEmail,
submitSms,
sendPickupConfirmation,
editBlockedDate,
saveBlockedDate,
deleteBlockedDate,
@@ -1311,6 +1395,12 @@ export default {
color: #0c5460;
}
.status-badge.mail_sent {
background: #e7d4c5;
color: #6d5637;
font-weight: bold;
}
.status-badge.completed {
background: #d4edda;
color: #155724;
@@ -1604,16 +1694,24 @@ export default {
color: #2c3e50;
}
.setting-group input {
.setting-group input,
.setting-group textarea {
padding: 0.75rem;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
}
.setting-group input:focus {
.setting-group input:focus,
.setting-group textarea:focus {
outline: none;
border-color: #3498db;
border-color: var(--primary-color);
}
.setting-group textarea {
resize: vertical;
min-height: 120px;
}
.save-btn {
@@ -1801,6 +1899,30 @@ export default {
background: #5a6268;
}
.btn-pickup-confirmation {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s ease;
background: var(--secondary-color);
color: white;
font-weight: bold;
}
.btn-pickup-confirmation:hover:not(:disabled) {
background: var(--secondary-dark);
box-shadow: 0 4px 10px rgba(139, 111, 71, 0.3);
transform: translateY(-2px);
}
.btn-pickup-confirmation:disabled {
background: #bdc3c7;
cursor: not-allowed;
opacity: 0.6;
}
/* Form Styles */
.form-group {
display: flex;
+8 -8
View File
@@ -93,7 +93,7 @@ export default {
.back-btn {
display: inline-block;
background: #6c757d;
background: var(--secondary-color);
color: white;
padding: 0.75rem 1.5rem;
text-decoration: none;
@@ -103,7 +103,7 @@ export default {
}
.back-btn:hover {
background: #5a6268;
background: var(--secondary-dark);
}
.loading-state, .empty-state {
@@ -114,7 +114,7 @@ export default {
.loading-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-top: 4px solid var(--primary-color);
border-radius: 50%;
width: 50px;
height: 50px;
@@ -146,7 +146,7 @@ export default {
.price-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
border-color: #3498db;
border-color: var(--primary-color);
}
.price-header {
@@ -165,7 +165,7 @@ export default {
}
.price {
background: #3498db;
background: var(--primary-color);
color: white;
padding: 0.5rem 1rem;
border-radius: 25px;
@@ -184,7 +184,7 @@ export default {
background: #f8f9fa;
padding: 3rem 2rem;
border-radius: 15px;
border: 2px dashed #3498db;
border: 2px dashed var(--primary-color);
}
.cta-section h2 {
@@ -200,7 +200,7 @@ export default {
.cta-button {
display: inline-block;
background: #3498db;
background: var(--primary-color);
color: white;
padding: 1rem 2rem;
text-decoration: none;
@@ -211,7 +211,7 @@ export default {
}
.cta-button:hover {
background: #2980b9;
background: var(--primary-dark);
}
@media (max-width: 768px) {