feat: enhance form history logging by restructuring attribute changes and improving data handling in LogsFormHistory and related components

This commit is contained in:
Yoga Pangestu 2026-08-17 23:27:24 +07:00
parent 3e715518fb
commit 4d94fbe7c6
6 changed files with 333 additions and 119 deletions

View File

@ -16,6 +16,7 @@
use App\Models\User;
use App\Services\Concerns\HasStockAdjustment;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
@ -25,7 +26,7 @@
class TransactionService
{
use HasStockAdjustment, HandlesCashTransactions, RegistersMedia;
use HasStockAdjustment, HandlesCashTransactions, LogsFormHistory, RegistersMedia;
private const SELLING_PRICE_MAP = [
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
@ -253,12 +254,16 @@ public function store(array $data): Order
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
);
$this->logCreated($order, 'Transaksi', $this->getOrderLogValues($order));
return $order;
});
}
public function update(Order $order, array $data): Order
{
$oldValues = $this->getOrderLogValues($order);
$order = DB::transaction(function () use ($order, $data) {
$order->load('orderItems');
@ -360,11 +365,15 @@ public function update(Order $order, array $data): Order
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
);
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
return $order;
}
public function destroy(Order $order): bool
{
$oldValues = $this->getOrderLogValues($order);
$result = DB::transaction(function () use ($order) {
$order->load('orderItems');
@ -397,11 +406,14 @@ public function destroy(Order $order): bool
url: route('admin.manage.transactions.index'),
);
$this->logDeleted($order, 'Transaksi', $oldValues);
return $result;
}
public function updateStatus(Order $order, string $status): Order
{
$oldValues = $this->getOrderLogValues($order);
$oldStatus = $order->status->value;
$order->update(['status' => $status]);
@ -423,6 +435,8 @@ public function updateStatus(Order $order, string $status): Order
}
}
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
return $order;
}
@ -524,4 +538,30 @@ private function isMarketingUser(User $user): bool
Role::MARKETING_ONLINE->value,
]);
}
private function getOrderLogValues(Order $order): array
{
$order->load(['customer:id,name', 'marketing:id', 'marketing.userProfile:id,user_id,full_name', 'orderItems.productVariant.product:id,name']);
return [
'No. Transaksi' => $order->order_number,
'Customer' => $order->customer?->name ?? '-',
'Marketing' => $order->marketing?->userProfile?->full_name ?? '-',
'Channel' => $order->channel?->label(),
'Tipe Harga' => $order->price_type?->label(),
'Status' => $order->status?->label(),
'Tipe Pembayaran' => $order->payment_type?->label(),
'Subtotal' => $this->formatCurrency($order->subtotal),
'Diskon' => $this->formatCurrency($order->discount),
'Harga Nego' => $order->nego_price ? $this->formatCurrency($order->nego_price) : null,
'Total' => $this->formatCurrency($order->total_amount),
'Catatan' => $order->notes,
'Item' => $order->orderItems->map(fn ($item) => [
'Nama' => $item->productVariant?->product?->name.' - '.$item->productVariant?->name ?? '-',
'Qty' => $item->quantity,
'Harga' => $this->formatCurrency($item->unit_price),
'Subtotal' => $this->formatCurrency($item->subtotal),
])->toArray(),
];
}
}

View File

@ -3,11 +3,14 @@
namespace App\Services\Admin\Master;
use App\Models\Customer;
use App\Services\Concerns\LogsFormHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class CustomerService
{
use LogsFormHistory;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Customer::query()
@ -24,18 +27,44 @@ public function getAll(): Collection
public function store(array $data): Customer
{
return Customer::create($data);
$customer = Customer::create($data);
$this->logCreated($customer, 'Customer', [
'Nama' => $customer->name,
'No. Telepon' => $customer->phone_number,
'Alamat' => $customer->address,
]);
return $customer;
}
public function update(Customer $customer, array $data): Customer
{
$oldValues = [
'Nama' => $customer->name,
'No. Telepon' => $customer->phone_number,
'Alamat' => $customer->address,
];
$customer->update($data);
$this->logUpdated($customer, 'Customer', $oldValues, [
'Nama' => $customer->name,
'No. Telepon' => $customer->phone_number,
'Alamat' => $customer->address,
]);
return $customer;
}
public function destroy(Customer $customer): bool
{
$this->logDeleted($customer, 'Customer', [
'Nama' => $customer->name,
'No. Telepon' => $customer->phone_number,
'Alamat' => $customer->address,
]);
return $customer->delete();
}
}

View File

@ -3,11 +3,14 @@
namespace App\Services\Admin\Master;
use App\Models\Supplier;
use App\Services\Concerns\LogsFormHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class SupplierService
{
use LogsFormHistory;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Supplier::query()
@ -24,18 +27,44 @@ public function getAll(): Collection
public function store(array $data): Supplier
{
return Supplier::create($data);
$supplier = Supplier::create($data);
$this->logCreated($supplier, 'Supplier', [
'Nama' => $supplier->name,
'No. Telepon' => $supplier->phone_number,
'Alamat' => $supplier->address,
]);
return $supplier;
}
public function update(Supplier $supplier, array $data): Supplier
{
$oldValues = [
'Nama' => $supplier->name,
'No. Telepon' => $supplier->phone_number,
'Alamat' => $supplier->address,
];
$supplier->update($data);
$this->logUpdated($supplier, 'Supplier', $oldValues, [
'Nama' => $supplier->name,
'No. Telepon' => $supplier->phone_number,
'Alamat' => $supplier->address,
]);
return $supplier;
}
public function destroy(Supplier $supplier): bool
{
$this->logDeleted($supplier, 'Supplier', [
'Nama' => $supplier->name,
'No. Telepon' => $supplier->phone_number,
'Alamat' => $supplier->address,
]);
return $supplier->delete();
}
}

View File

@ -10,56 +10,191 @@ trait LogsFormHistory
{
private function logCreated(Model $model, string $module, array $newValues): void
{
$this->createFormHistory(
module: $module,
event: 'created',
description: "{$module} ditambahkan",
newValues: $newValues,
);
}
private function logUpdated(Model $model, string $module, array $oldValues, array $newValues): void
{
$this->createFormHistory(
module: $module,
event: 'updated',
description: "{$module} diperbarui",
newValues: $newValues,
oldValues: $oldValues,
);
}
private function logDeleted(Model $model, string $module, array $oldValues): void
{
$this->createFormHistory(
module: $module,
event: 'deleted',
description: "{$module} dihapus",
oldValues: $oldValues,
);
}
private function createFormHistory(
string $module,
string $event,
string $description,
array $newValues = [],
array $oldValues = [],
): void {
$attributeChanges = array_filter([
'new' => $newValues ?: null,
'old' => $oldValues ?: null,
]);
$changes = $this->buildCreatedChanges($newValues);
FormHistory::create([
'causer_id' => Auth::id(),
'module' => $module,
'event' => $event,
'description' => $description,
'attribute_changes' => $attributeChanges ?: null,
'event' => 'created',
'description' => "{$module} ditambahkan",
'attribute_changes' => $changes ?: null,
]);
}
private function logUpdated(Model $model, string $module, array $oldValues, array $newValues): void
{
$changes = $this->buildUpdatedChanges($oldValues, $newValues);
FormHistory::create([
'causer_id' => Auth::id(),
'module' => $module,
'event' => 'updated',
'description' => "{$module} diperbarui",
'attribute_changes' => $changes ?: null,
]);
}
private function logDeleted(Model $model, string $module, array $oldValues): void
{
$changes = $this->buildDeletedChanges($oldValues);
FormHistory::create([
'causer_id' => Auth::id(),
'module' => $module,
'event' => 'deleted',
'description' => "{$module} dihapus",
'attribute_changes' => $changes ?: null,
]);
}
private function resolveItemName(array $item): string
{
$nameKeys = ['Nama Varian', 'Nama', 'name', 'variant', 'Variant', 'variant_name'];
foreach ($nameKeys as $nameKey) {
if (! empty($item[$nameKey])) {
return (string) $item[$nameKey];
}
}
return '?';
}
private function buildCreatedChanges(array $newValues): array
{
$changes = [];
foreach ($newValues as $key => $value) {
if (is_array($value)) {
foreach ($value as $item) {
if (is_array($item)) {
$itemName = $this->resolveItemName($item);
foreach ($item as $subKey => $subValue) {
$changes["{$key}: {$itemName}{$subKey}"] = ['new' => $subValue];
}
} else {
$changes["{$key}: {$item}"] = ['new' => null];
}
}
} else {
$changes[$key] = ['new' => $value];
}
}
return $changes;
}
private function buildUpdatedChanges(array $oldValues, array $newValues): array
{
$changes = [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
foreach ($allKeys as $key) {
$old = $oldValues[$key] ?? null;
$new = $newValues[$key] ?? null;
if (is_array($old) && is_array($new)) {
$nestedChanges = $this->diffArray($old, $new, $key);
$changes = array_merge($changes, $nestedChanges);
} elseif ($old !== $new) {
$changes[$key] = array_filter([
'old' => $old ?? null,
'new' => $new ?? null,
]);
}
}
return $changes;
}
private function buildDeletedChanges(array $oldValues): array
{
$changes = [];
foreach ($oldValues as $key => $value) {
if (is_array($value)) {
foreach ($value as $item) {
if (is_array($item)) {
$itemName = $this->resolveItemName($item);
foreach ($item as $subKey => $subValue) {
$changes["{$key}: {$itemName}{$subKey}"] = ['old' => $subValue];
}
} else {
$changes["{$key}: {$item}"] = ['old' => null];
}
}
} else {
$changes[$key] = ['old' => $value];
}
}
return $changes;
}
private function diffArray(array $old, array $new, string $prefix = ''): array
{
$changes = [];
$isAssociative = ! empty($old) && array_is_list($old) === false;
if ($isAssociative) {
$allKeys = array_unique(array_merge(array_keys($old), array_keys($new)));
foreach ($allKeys as $key) {
$fullKey = "{$prefix}.{$key}";
if (isset($old[$key]) && is_array($old[$key]) && isset($new[$key]) && is_array($new[$key])) {
$nested = $this->diffArray($old[$key], $new[$key], $fullKey);
$changes = array_merge($changes, $nested);
} else {
$oldVal = $old[$key] ?? null;
$newVal = $new[$key] ?? null;
if ($oldVal !== $newVal) {
$changes[$fullKey] = array_filter([
'old' => $oldVal ?? null,
'new' => $newVal ?? null,
]);
}
}
}
} else {
$max = max(count($old), count($new));
for ($i = 0; $i < $max; $i++) {
$oldItem = $old[$i] ?? null;
$newItem = $new[$i] ?? null;
if (is_array($oldItem) && is_array($newItem)) {
$itemName = $this->resolveItemName($newItem);
$itemPrefix = "{$prefix}: {$itemName}";
$itemKeys = array_unique(array_merge(array_keys($oldItem), array_keys($newItem)));
foreach ($itemKeys as $subKey) {
$fullKey = "{$itemPrefix}{$subKey}";
$oldVal = $oldItem[$subKey] ?? null;
$newVal = $newItem[$subKey] ?? null;
if ($oldVal !== $newVal) {
$changes[$fullKey] = array_filter([
'old' => $oldVal ?? null,
'new' => $newVal ?? null,
]);
}
}
} elseif ($oldItem !== $newItem) {
$itemName = $this->resolveItemName(is_array($oldItem) ? $oldItem : (is_array($newItem) ? $newItem : []));
$changes["{$prefix}: {$itemName}"] = array_filter([
'old' => $oldItem,
'new' => $newItem,
]);
}
}
}
return $changes;
}
private function formatCurrency(int $value): string
{
return 'Rp ' . number_format($value, 0, ',', '.');

View File

@ -33,7 +33,7 @@ const eventLabel: Record<string, string> = {
deleted: 'Dihapus',
};
function renderValue(key: string, value: unknown): React.ReactNode {
function renderValue(value: unknown): React.ReactNode {
if (value === null || value === undefined) {
return <span className="text-muted-foreground">-</span>;
}
@ -47,10 +47,6 @@ function renderValue(key: string, value: unknown): React.ReactNode {
return <span className="text-muted-foreground">-</span>;
}
if (typeof value[0] === 'object' && value[0] !== null) {
return <RenderObjectArray items={value} />;
}
return value.join(', ');
}
@ -61,41 +57,18 @@ function renderValue(key: string, value: unknown): React.ReactNode {
return String(value);
}
function RenderObjectArray({ items }: { items: Record<string, unknown>[] }) {
if (!items.length) {
return <span className="text-muted-foreground">-</span>;
function flattenChanges(attributeChanges: Record<string, { old?: unknown; new?: unknown }>): { field: string; old?: unknown; new?: unknown }[] {
const entries: { field: string; old?: unknown; new?: unknown }[] = [];
for (const [key, change] of Object.entries(attributeChanges)) {
entries.push({
field: key,
old: change.old,
new: change.new,
});
}
const keys = Object.keys(items[0]);
return (
<div className="rounded-md border">
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
{keys.map((key) => (
<TableHead key={key} className="h-8 text-xs">
{key}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
{keys.map((key) => (
<TableCell key={key} className="py-1.5 text-xs">
{renderValue(key, item[key])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
return entries;
}
export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeChangesDialogProps) {
@ -104,8 +77,8 @@ export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeCh
}
const changes = item.attribute_changes;
const hasNew = changes?.new && Object.keys(changes.new).length > 0;
const hasOld = changes?.old && Object.keys(changes.old).length > 0;
const hasChanges = changes && Object.keys(changes).length > 0;
const flattened = hasChanges ? flattenChanges(changes) : [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -125,39 +98,50 @@ export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeCh
<div>{item.formatted_created_at}</div>
</div>
{hasNew && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Nilai Baru</h4>
<div className="rounded-md border p-3 bg-muted/50">
<dl className="space-y-2">
{Object.entries(changes!.new!).map(([key, value]) => (
<div key={key} className="flex flex-col">
<dt className="text-xs text-muted-foreground">{key}</dt>
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
</div>
))}
</dl>
{hasChanges && (
<div className="rounded-md border">
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-8 text-xs w-[30%]">Field</TableHead>
<TableHead className="h-8 text-xs w-[35%]">Lama</TableHead>
<TableHead className="h-8 text-xs w-[35%]">Baru</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{flattened.map((row) => (
<TableRow key={row.field}>
<TableCell className="py-1.5 text-xs font-medium">
{row.field}
</TableCell>
<TableCell className="py-1.5 text-xs">
{row.old !== undefined ? (
<span className="text-red-600 dark:text-red-400">
{renderValue(row.old)}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="py-1.5 text-xs">
{row.new !== undefined ? (
<span className="text-green-600 dark:text-green-400">
{renderValue(row.new)}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{hasOld && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Nilai Lama</h4>
<div className="rounded-md border p-3 bg-muted/50">
<dl className="space-y-2">
{Object.entries(changes!.old!).map(([key, value]) => (
<div key={key} className="flex flex-col">
<dt className="text-xs text-muted-foreground">{key}</dt>
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
</div>
))}
</dl>
</div>
</div>
)}
{!hasNew && !hasOld && (
{!hasChanges && (
<p className="text-sm text-muted-foreground text-center py-4">
Tidak ada perubahan data.
</p>

View File

@ -9,10 +9,7 @@ export type FormHistory = {
module: string;
event: string;
description: string;
attribute_changes: {
new?: Record<string, unknown>;
old?: Record<string, unknown>;
} | null;
attribute_changes: Record<string, { old?: unknown; new?: unknown }> | null;
created_at: string;
formatted_created_at: string;
causer: {