Skip to content

Commit 45dbe16

Browse files
committed
Adding shipment audits
1 parent 102ab09 commit 45dbe16

7 files changed

Lines changed: 206 additions & 24 deletions

File tree

_ide_helper_actions.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,20 @@ class DispatchShipment
860860
class GetShipmentAccounting
861861
{
862862
}
863+
/**
864+
* @method static \Lorisleiva\Actions\Decorators\JobDecorator|\Lorisleiva\Actions\Decorators\UniqueJobDecorator makeJob(\App\Models\Shipments\Shipment $shipment)
865+
* @method static \Lorisleiva\Actions\Decorators\UniqueJobDecorator makeUniqueJob(\App\Models\Shipments\Shipment $shipment)
866+
* @method static \Illuminate\Foundation\Bus\PendingDispatch dispatch(\App\Models\Shipments\Shipment $shipment)
867+
* @method static \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent dispatchIf(bool $boolean, \App\Models\Shipments\Shipment $shipment)
868+
* @method static \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent dispatchUnless(bool $boolean, \App\Models\Shipments\Shipment $shipment)
869+
* @method static dispatchSync(\App\Models\Shipments\Shipment $shipment)
870+
* @method static dispatchNow(\App\Models\Shipments\Shipment $shipment)
871+
* @method static dispatchAfterResponse(\App\Models\Shipments\Shipment $shipment)
872+
* @method static mixed run(\App\Models\Shipments\Shipment $shipment)
873+
*/
874+
class GetShipmentAuditHistory
875+
{
876+
}
863877
/**
864878
* @method static \Lorisleiva\Actions\Decorators\JobDecorator|\Lorisleiva\Actions\Decorators\UniqueJobDecorator makeJob(\App\Models\Shipments\Shipment $shipment, ?\App\Services\Shipments\ShipmentStateService $stateService = null)
865879
* @method static \Lorisleiva\Actions\Decorators\UniqueJobDecorator makeUniqueJob(\App\Models\Shipments\Shipment $shipment, ?\App\Services\Shipments\ShipmentStateService $stateService = null)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace App\Actions\Shipments;
4+
5+
use App\Models\Shipments\Shipment;
6+
use App\Traits\HandlesAuditHistory;
7+
use Lorisleiva\Actions\ActionRequest;
8+
use Lorisleiva\Actions\Concerns\AsAction;
9+
10+
class GetShipmentAuditHistory
11+
{
12+
use AsAction;
13+
use HandlesAuditHistory;
14+
15+
public function handle(Shipment $shipment)
16+
{
17+
$audits = $this->getAuditHistory($shipment);
18+
return $this->formatAuditData($audits);
19+
}
20+
21+
public function asController(ActionRequest $request, Shipment $shipment)
22+
{
23+
$audits = $this->handle($shipment);
24+
return $this->jsonResponse($audits);
25+
}
26+
27+
public function jsonResponse($audits)
28+
{
29+
return response()->json([
30+
'audits' => $audits,
31+
]);
32+
}
33+
34+
public function authorize(ActionRequest $request, Shipment $shipment): bool
35+
{
36+
return $request->user()->can('view', $shipment);
37+
}
38+
39+
protected function getModelSpecificFieldMappings(string $auditableType): array
40+
{
41+
if ($auditableType === Shipment::class) {
42+
return [
43+
'organization_id' => 'Organization',
44+
'carrier_id' => 'Carrier',
45+
'driver_id' => 'Driver',
46+
'weight' => 'Weight',
47+
'trip_distance' => 'Trip Distance',
48+
'trailer_type_id' => 'Trailer Type',
49+
'trailer_size_id' => 'Trailer Size',
50+
'trailer_temperature_range' => 'Temperature Controlled',
51+
'trailer_temperature' => 'Temperature',
52+
'trailer_temperature_maximum' => 'Maximum Temperature',
53+
'shipment_number' => 'Shipment Number',
54+
'state' => 'Status',
55+
];
56+
}
57+
58+
return [];
59+
}
60+
}

app/Models/Shipments/Shipment.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
2424
use Illuminate\Database\Eloquent\Relations\HasMany;
2525
use Laravel\Scout\Searchable;
26+
use OwenIt\Auditing\Contracts\Auditable;
27+
use OwenIt\Auditing\Auditable as AuditableTrait;
2628
use Spatie\ModelStates\HasStates;
2729
use Spatie\ModelStates\HasStatesContract;
2830

29-
class Shipment extends Model implements HasStatesContract
31+
class Shipment extends Model implements HasStatesContract, Auditable
3032
{
31-
use HasOrganization, Searchable, HasFactory, HasNotes, HasStates, HasDocuments, HasAliases;
33+
use HasOrganization, Searchable, HasFactory, HasNotes, HasStates, HasDocuments, HasAliases, AuditableTrait;
3234

3335
protected $fillable = [
3436
'organization_id',

app/Traits/HandlesAuditHistory.php

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,40 @@ private function getRelatedModelAudits(
4848
string $typeField,
4949
string $idField
5050
): \Illuminate\Support\Collection {
51-
return Audit::where('auditable_type', $auditableType)
52-
->where(function ($query) use ($parentClass, $parentId, $typeField, $idField) {
53-
// Include audits where the model still exists
54-
$query->whereHas('auditable', function ($subQuery) use ($parentClass, $parentId, $typeField, $idField) {
55-
$subQuery->where($typeField, $parentClass)
56-
->where($idField, $parentId);
57-
})
58-
// OR include audits where model was deleted but belonged to this parent
59-
->orWhere(function ($subQuery) use ($parentClass, $parentId, $typeField, $idField) {
60-
$subQuery->whereDoesntHave('auditable')
61-
->where(function ($valueQuery) use ($parentClass, $parentId, $typeField, $idField) {
62-
$valueQuery->whereJsonContains("old_values->{$typeField}", $parentClass)
63-
->whereJsonContains("old_values->{$idField}", $parentId)
64-
->orWhereJsonContains("new_values->{$typeField}", $parentClass)
65-
->whereJsonContains("new_values->{$idField}", $parentId);
66-
});
67-
});
51+
$audits = collect();
52+
53+
// Get audits for models that currently exist and belong to this parent
54+
$existingModelAudits = Audit::where('auditable_type', $auditableType)
55+
->whereHas('auditable', function ($subQuery) use ($parentClass, $parentId, $typeField, $idField) {
56+
$subQuery->where($typeField, $parentClass)
57+
->where($idField, $parentId);
6858
})
6959
->with('user', 'auditable')
7060
->get();
61+
62+
$audits = $audits->merge($existingModelAudits);
63+
64+
// Get audits for deleted models - but be very precise to avoid cross-contamination
65+
$deletedModelAudits = Audit::where('auditable_type', $auditableType)
66+
->whereDoesntHave('auditable')
67+
->get()
68+
->filter(function (Audit $audit) use ($parentClass, $parentId, $typeField, $idField) {
69+
$oldValues = $audit->getAttributeValue('old_values') ?? [];
70+
$newValues = $audit->getAttributeValue('new_values') ?? [];
71+
72+
// Check if this audit belongs to our specific parent
73+
$matchesInOld = isset($oldValues[$typeField]) && isset($oldValues[$idField]) &&
74+
$oldValues[$typeField] === $parentClass &&
75+
(int)$oldValues[$idField] === (int)$parentId;
76+
77+
$matchesInNew = isset($newValues[$typeField]) && isset($newValues[$idField]) &&
78+
$newValues[$typeField] === $parentClass &&
79+
(int)$newValues[$idField] === (int)$parentId;
80+
81+
return $matchesInOld || $matchesInNew;
82+
});
83+
84+
return $audits->merge($deletedModelAudits);
7185
}
7286

7387
protected function formatAuditData(\Illuminate\Support\Collection $audits): \Illuminate\Support\Collection
@@ -181,6 +195,7 @@ private function getEntityType(string $auditableType): string
181195
\App\Models\Customers\Customer::class => 'Customer',
182196
\App\Models\Facility::class => 'Facility',
183197
\App\Models\Carriers\Carrier::class => 'Carrier',
198+
\App\Models\Shipments\Shipment::class => 'Shipment',
184199
Document::class => 'Document',
185200
Contact::class => 'Contact',
186201
default => class_basename($auditableType),
@@ -200,11 +215,13 @@ private function getEntityName(Audit $audit): string
200215
if (!$auditable) {
201216
// For deleted entities, try to get name from old_values or new_values
202217
$name = $oldValues['name'] ?? $newValues['name'] ?? null;
218+
$shipmentNumber = $oldValues['shipment_number'] ?? $newValues['shipment_number'] ?? null;
203219

204220
return match ($auditableType) {
205221
\App\Models\Customers\Customer::class => $name ? $name . ' (deleted)' : 'Deleted Customer',
206222
\App\Models\Facility::class => $name ? $name . ' (deleted)' : 'Deleted Facility',
207223
\App\Models\Carriers\Carrier::class => $name ? $name . ' (deleted)' : 'Deleted Carrier',
224+
\App\Models\Shipments\Shipment::class => $shipmentNumber ? 'Shipment ' . $shipmentNumber . ' (deleted)' : 'Deleted Shipment',
208225
Document::class => $name ? $name . ' (deleted)' : 'Deleted Document',
209226
Contact::class => $name ? $name . ' (deleted)' : 'Deleted Contact',
210227
default => 'Deleted Entity',
@@ -215,6 +232,7 @@ private function getEntityName(Audit $audit): string
215232
\App\Models\Customers\Customer::class => $auditable->getAttributeValue('name') ?? 'Unknown Customer',
216233
\App\Models\Facility::class => $auditable->getAttributeValue('name') ?? 'Unknown Facility',
217234
\App\Models\Carriers\Carrier::class => $auditable->getAttributeValue('name') ?? 'Unknown Carrier',
235+
\App\Models\Shipments\Shipment::class => $auditable->getAttributeValue('shipment_number') ? 'Shipment ' . $auditable->getAttributeValue('shipment_number') : 'Shipment #' . $auditable->getKey(),
218236
Document::class => $auditable->getAttributeValue('name') ?? 'Unknown Document',
219237
Contact::class => $auditable->getAttributeValue('name') ?? 'Unknown Contact',
220238
default => 'Unknown Entity',
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Card, CardContent } from '@/Components/ui/card';
2+
import { Shipment } from '@/types';
3+
import { useEffect, useState } from 'react';
4+
import AuditTimeline from './AuditTimeline';
5+
6+
interface AuditChange {
7+
field: string;
8+
field_name: string;
9+
old_value: unknown;
10+
new_value: unknown;
11+
}
12+
13+
interface AuditEntry {
14+
id: number;
15+
event: string;
16+
auditable_type?: string;
17+
auditable_id?: number;
18+
entity_type?: string;
19+
entity_name?: string;
20+
user: {
21+
id: number;
22+
name: string;
23+
email: string;
24+
} | null;
25+
old_values: Record<string, unknown>;
26+
new_values: Record<string, unknown>;
27+
changes: AuditChange[];
28+
created_at: string;
29+
created_at_human: string;
30+
ip_address?: string;
31+
user_agent?: string;
32+
}
33+
34+
interface ShipmentAuditHistoryProps {
35+
shipment: Shipment;
36+
}
37+
38+
export default function ShipmentAuditHistory({
39+
shipment,
40+
}: ShipmentAuditHistoryProps) {
41+
const [audits, setAudits] = useState<AuditEntry[]>([]);
42+
const [loading, setLoading] = useState(true);
43+
const [error, setError] = useState<string | null>(null);
44+
45+
useEffect(() => {
46+
const fetchAudits = async () => {
47+
try {
48+
setLoading(true);
49+
const response = await fetch(
50+
route('shipments.audit-history', shipment.id),
51+
);
52+
53+
if (!response.ok) {
54+
throw new Error('Failed to fetch audit history');
55+
}
56+
57+
const data = await response.json();
58+
setAudits(data.audits || []);
59+
} catch (err) {
60+
setError(
61+
err instanceof Error ? err.message : 'An error occurred',
62+
);
63+
} finally {
64+
setLoading(false);
65+
}
66+
};
67+
68+
if (shipment.id) {
69+
fetchAudits();
70+
}
71+
}, [shipment.id]);
72+
73+
if (error) {
74+
return (
75+
<Card>
76+
<CardContent className="p-6">
77+
<div className="py-8 text-center">
78+
<div className="mb-2 text-red-500">
79+
Error loading audit history
80+
</div>
81+
<div className="text-sm text-gray-500">{error}</div>
82+
</div>
83+
</CardContent>
84+
</Card>
85+
);
86+
}
87+
88+
return <AuditTimeline audits={audits} loading={loading} />;
89+
}

resources/js/Pages/Shipments/Show.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import ShipmentAuditHistory from '@/Components/Audit/ShipmentAuditHistory';
12
import DocumentsList from '@/Components/Documents/DocumentsList';
23
import LocationMap from '@/Components/Shipments/LocationMap';
34
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
4-
import { ComingSoon } from '@/Components/ui/coming-soon';
55
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
66
import { Shipment, ShipmentStop, TrailerSize, TrailerType } from '@/types';
77
import { Documentable } from '@/types/enums';
@@ -102,10 +102,7 @@ export default function Show({
102102
<CardTitle>Recent Activity</CardTitle>
103103
</CardHeader>
104104
<CardContent className="space-y-2">
105-
<ComingSoon
106-
variant="outline"
107-
className="mx-auto"
108-
/>
105+
<ShipmentAuditHistory shipment={shipment} />
109106
</CardContent>
110107
</Card>
111108

routes/web.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use App\Actions\Shipments\CreateShipment;
4343
use App\Actions\Shipments\DispatchShipment;
4444
use App\Actions\Shipments\GetShipmentAccounting;
45+
use App\Actions\Shipments\GetShipmentAuditHistory;
4546
use App\Actions\Shipments\UncancelShipment;
4647
use App\Actions\Shipments\UpdateShipmentCarrierDetails;
4748
use App\Actions\Shipments\UpdateShipmentGeneral;
@@ -248,6 +249,7 @@
248249
Route::get('shipments/{shipment}/accounting', GetShipmentAccounting::class)->name('shipments.accounting');
249250
Route::post('shipments/{shipment}/accounting/payables', SavePayables::class)->name('shipments.accounting.payables');
250251
Route::post('shipments/{shipment}/accounting/receivables', SaveReceivables::class)->name('shipments.accounting.receivables');
252+
Route::get('shipments/{shipment}/audit-history', GetShipmentAuditHistory::class)->name('shipments.audit-history');
251253

252254
Route::post('shipments/{shipment}/documents/generate-rate-con', GenerateRateConfirmation::class)->name('shipments.documents.generate-rate-confirmation');
253255
Route::post('shipments/{shipment}/documents/generate-customer-invoice/{customer}', GenerateCustomerInvoice::class)->name('shipments.documents.generate-customer-invoice');

0 commit comments

Comments
 (0)