feat: implement form history logging for CRUD operations and add activity log management interface

This commit is contained in:
Yoga Pangestu 2026-08-16 20:41:48 +07:00
parent 581761398f
commit 919e8228d2
17 changed files with 807 additions and 7 deletions

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Admin\System;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Services\Admin\System\FormHistoryService;
use Inertia\Inertia;
use Inertia\Response;
class FormHistoryController extends Controller
{
public function __construct(
private FormHistoryService $service
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/system/form-histories/index', [
'formHistories' => $this->service->paginated(...$request->validatedWithDefaults()),
'modules' => $this->service->getModules(),
'events' => $this->service->getEvents(),
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_created_at'])]
#[Guarded(['id'])]
class FormHistory extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'attribute_changes' => 'array',
];
}
protected function formattedCreatedAt(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function causer(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -3,11 +3,14 @@
namespace App\Services\Admin\Master; namespace App\Services\Admin\Master;
use App\Models\Category; use App\Models\Category;
use App\Services\Concerns\LogsFormHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class CategoryService class CategoryService
{ {
use LogsFormHistory;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return Category::query() return Category::query()
@ -24,18 +27,41 @@ public function getAll(): Collection
public function store(array $data): Category public function store(array $data): Category
{ {
return Category::create($data); $category = Category::create($data);
$this->logCreated(
model: $category,
module: 'Kategori',
newValues: ['Nama' => $category->name],
);
return $category;
} }
public function update(Category $category, array $data): Category public function update(Category $category, array $data): Category
{ {
$oldValues = ['Nama' => $category->name];
$category->update($data); $category->update($data);
$this->logUpdated(
model: $category,
module: 'Kategori',
oldValues: $oldValues,
newValues: ['Nama' => $category->name],
);
return $category; return $category;
} }
public function destroy(Category $category): bool public function destroy(Category $category): bool
{ {
$this->logDeleted(
model: $category,
module: 'Kategori',
oldValues: ['Nama' => $category->name],
);
return $category->delete(); return $category->delete();
} }
} }

View File

@ -7,6 +7,7 @@
use App\Enums\Role; use App\Enums\Role;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\Concerns\LogsFormHistory;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use App\Services\StockMutationService; use App\Services\StockMutationService;
@ -17,7 +18,7 @@
class ProductService class ProductService
{ {
use HasRoleChecks; use HasRoleChecks, LogsFormHistory;
public function __construct( public function __construct(
private ProductVariantService $variantService, private ProductVariantService $variantService,
@ -165,6 +166,12 @@ public function store(array $data): Product
url: route('admin.master.products.index', ['highlight' => $product->id]), url: route('admin.master.products.index', ['highlight' => $product->id]),
); );
$this->logCreated(
model: $product,
module: 'Produk',
newValues: $this->getProductLogValues($product),
);
return $product; return $product;
} }
@ -210,6 +217,14 @@ public function update(Product $product, array $data): Product
{ {
$this->assertNotPending($product); $this->assertNotPending($product);
$oldValues = $this->getProductLogValues($product);
$oldVariants = $product->productVariants->map(fn ($v) => [
'Nama Varian' => $v->name,
'Stok' => $v->stock,
'Stok Reject' => $v->reject_stock,
'Stok Retail' => $v->retail_stock,
])->toArray();
$product = DB::transaction(function () use ($product, $data) { $product = DB::transaction(function () use ($product, $data) {
// Auto-resubmit: non-verifier editing rejected product → status becomes pending // Auto-resubmit: non-verifier editing rejected product → status becomes pending
$newStatus = $data['status'] ?? $product->status; $newStatus = $data['status'] ?? $product->status;
@ -407,6 +422,13 @@ public function update(Product $product, array $data): Product
url: route('admin.master.products.index', ['highlight' => $product->id]), url: route('admin.master.products.index', ['highlight' => $product->id]),
); );
$this->logUpdated(
model: $product,
module: 'Produk',
oldValues: $oldValues,
newValues: $this->getProductLogValues($product),
);
return $product; return $product;
} }
@ -414,6 +436,8 @@ public function destroy(Product $product): bool
{ {
$this->assertNotPending($product); $this->assertNotPending($product);
$oldValues = $this->getProductLogValues($product);
$result = DB::transaction(function () use ($product) { $result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) { $product->productVariants->each(function (ProductVariant $variant) {
$variant->delete(); $variant->delete();
@ -431,6 +455,12 @@ public function destroy(Product $product): bool
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
$this->logDeleted(
model: $product,
module: 'Produk',
oldValues: $oldValues,
);
return $result; return $result;
} }
@ -524,4 +554,47 @@ private function assertNotPending(Product $product): void
]); ]);
} }
} }
private function getProductLogValues(Product $product): array
{
$product->load(['categories:id,name', 'productVariants.productPrices']);
$priceTypeLabels = [
'distributor' => 'Harga Distributor',
'agent' => 'Harga Agen',
'sub_agent' => 'Harga Sub Agen',
'wholesale' => 'Harga Grosir',
'retail' => 'Harga Ecer',
'tiktok' => 'Harga TikTok',
'shopee' => 'Harga Shopee',
'capital' => 'Harga Modal',
'reject_capital' => 'Harga Reject Modal',
'reject_selling' => 'Harga Reject Jual',
];
$values = [
'Nama Produk' => $product->name,
'Status' => $product->status?->label(),
'Unggulan' => $this->formatBoolean($product->is_featured),
'Kategori' => $product->categories->pluck('name')->toArray(),
'Deskripsi' => $product->description,
'Varian' => $product->productVariants->map(function ($variant) use ($priceTypeLabels) {
$variantData = [
'Nama Varian' => $variant->name,
'Stok Bagus' => $variant->stock,
'Stok Reject' => $variant->reject_stock,
'Stok Retail' => $variant->retail_stock,
];
foreach ($variant->productPrices as $price) {
$label = $priceTypeLabels[$price->type->value] ?? $price->type->label();
$variantData[$label] = $this->formatCurrency($price->price);
}
return $variantData;
})->toArray(),
];
return $values;
}
} }

View File

@ -25,6 +25,40 @@ public function __construct(
private StockMutationService $stockMutationService, private StockMutationService $stockMutationService,
) {} ) {}
public function getForStokOpname(): array
{
$products = Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
])
->active()
->orderBy('name')
->get();
$allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all();
if ($allVariantIds !== []) {
$mediaByVariant = Media::query()
->whereIn('model_id', $allVariantIds)
->where('model_type', ProductVariant::class)
->where('collection_name', 'images')
->get()
->groupBy('model_id');
} else {
$mediaByVariant = collect();
}
return $products->each(function (Product $product) use ($mediaByVariant) {
$product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) {
$media = $mediaByVariant->get($variant->id, collect())->first();
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
});
})->toArray();
}
public function getForRestock(): array public function getForRestock(): array
{ {
$products = Product::query() $products = Product::query()

View File

@ -5,6 +5,7 @@
use App\Enums\Role; use App\Enums\Role;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
@ -15,7 +16,7 @@
class RawMaterialService class RawMaterialService
{ {
use RegistersMedia; use LogsFormHistory, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
@ -117,6 +118,8 @@ public function store(array $data): RawMaterial
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
); );
$this->logCreated($rawMaterial, 'Bahan Baku', $this->getRawMaterialLogValues($rawMaterial));
return $rawMaterial; return $rawMaterial;
} }
@ -152,6 +155,8 @@ public function getForEdit(RawMaterial $rawMaterial): array
public function update(RawMaterial $rawMaterial, array $data): RawMaterial public function update(RawMaterial $rawMaterial, array $data): RawMaterial
{ {
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
$rawMaterial = DB::transaction(function () use ($rawMaterial, $data) { $rawMaterial = DB::transaction(function () use ($rawMaterial, $data) {
$rawMaterial->update([ $rawMaterial->update([
'name' => $data['name'], 'name' => $data['name'],
@ -254,11 +259,15 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
); );
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
return $rawMaterial; return $rawMaterial;
} }
public function destroy(RawMaterial $rawMaterial): bool public function destroy(RawMaterial $rawMaterial): bool
{ {
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
foreach ($rawMaterial->rawMaterialPrices as $price) { foreach ($rawMaterial->rawMaterialPrices as $price) {
if ($price->stock > 0) { if ($price->stock > 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@ -292,11 +301,15 @@ public function destroy(RawMaterial $rawMaterial): bool
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),
); );
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
return $result; return $result;
} }
public function toggleStatus(RawMaterial $rawMaterial): void public function toggleStatus(RawMaterial $rawMaterial): void
{ {
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
$rawMaterial->update([ $rawMaterial->update([
'is_active' => ! $rawMaterial->is_active, 'is_active' => ! $rawMaterial->is_active,
]); ]);
@ -309,5 +322,23 @@ public function toggleStatus(RawMaterial $rawMaterial): void
body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.', body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
); );
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
}
private function getRawMaterialLogValues(RawMaterial $rawMaterial): array
{
$rawMaterial->load('rawMaterialPrices');
return [
'Nama Bahan Baku' => $rawMaterial->name,
'Satuan' => $rawMaterial->unit?->label(),
'Status' => $this->formatBoolean($rawMaterial->is_active),
'Varian' => $rawMaterial->rawMaterialPrices->map(fn ($price) => [
'Nama Varian' => $price->variant,
'Harga' => $this->formatCurrency($price->price),
'Stok' => number_format((float) $price->stock, 2, ',', '.'),
])->toArray(),
];
} }
} }

View File

@ -5,6 +5,7 @@
use App\Enums\Role; use App\Enums\Role;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
@ -13,7 +14,7 @@
class RawMaterialVariantService class RawMaterialVariantService
{ {
use RegistersMedia; use LogsFormHistory, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
@ -67,6 +68,9 @@ public function getForEdit(RawMaterialPrice $variant): array
public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
{ {
$rawMaterial = $variant->rawMaterial;
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
DB::transaction(function () use ($variant, $data) { DB::transaction(function () use ($variant, $data) {
$variant->update([ $variant->update([
'variant' => $data['variant'], 'variant' => $data['variant'],
@ -94,11 +98,16 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
url: route('admin.master.raw-materials.index', ['highlight' => $variant->raw_material_id]), url: route('admin.master.raw-materials.index', ['highlight' => $variant->raw_material_id]),
); );
$rawMaterial->refresh();
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
return $variant->fresh(); return $variant->fresh();
} }
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{ {
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
if ($variant->stock > 0) { if ($variant->stock > 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'variant' => 'Varian tidak dapat dihapus karena masih memiliki stok.', 'variant' => 'Varian tidak dapat dihapus karena masih memiliki stok.',
@ -126,6 +135,24 @@ public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bo
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),
); );
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
return $result; return $result;
} }
private function getRawMaterialLogValues(RawMaterial $rawMaterial): array
{
$rawMaterial->load('rawMaterialPrices');
return [
'Nama Bahan Baku' => $rawMaterial->name,
'Satuan' => $rawMaterial->unit?->label(),
'Status' => $this->formatBoolean($rawMaterial->is_active),
'Varian' => $rawMaterial->rawMaterialPrices->map(fn ($price) => [
'Nama Varian' => $price->variant,
'Harga' => $this->formatCurrency($price->price),
'Stok' => number_format((float) $price->stock, 2, ',', '.'),
])->toArray(),
];
}
} }

View File

@ -0,0 +1,45 @@
<?php
namespace App\Services\Admin\System;
use App\Models\FormHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class FormHistoryService
{
public function paginated(
int $perPage = 25,
string $search = '',
string $sort = 'created_at',
string $direction = 'desc',
array $filters = [],
): LengthAwarePaginator {
return FormHistory::query()
->select(['id', 'causer_id', 'module', 'event', 'description', 'attribute_changes', 'created_at'])
->with(['causer.userProfile'])
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->when($filters['module'] ?? null, fn ($q, $module) => $q->where('module', $module))
->when($filters['event'] ?? null, fn ($q, $event) => $q->where('event', $event))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function getModules(): array
{
return FormHistory::query()
->distinct()
->pluck('module')
->sort()
->values()
->toArray();
}
public function getEvents(): array
{
return [
'created' => 'Ditambahkan',
'updated' => 'Diperbarui',
'deleted' => 'Dihapus',
];
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Services\Concerns;
use App\Models\FormHistory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
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,
]);
FormHistory::create([
'causer_id' => Auth::id(),
'module' => $module,
'event' => $event,
'description' => $description,
'attribute_changes' => $attributeChanges ?: null,
]);
}
private function formatCurrency(int $value): string
{
return 'Rp ' . number_format($value, 0, ',', '.');
}
private function formatDate(?string $date, string $format = 'l, d F Y'): ?string
{
return $date ? \Carbon\Carbon::parse($date)->translatedFormat($format) : null;
}
private function formatDateTime(?string $datetime): ?string
{
return $datetime ? \Carbon\Carbon::parse($datetime)->translatedFormat('l, d F Y H:i') : null;
}
private function formatBoolean(?bool $value): ?string
{
return $value === null ? null : ($value ? 'Ya' : 'Tidak');
}
private function resolveRelation(Model $model, string $relation, string $attribute): ?string
{
$related = $model->{$relation};
return $related?->{$attribute} ?? null;
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('causer_id')->constrained('users');
$table->string('module');
$table->string('event');
$table->string('description');
$table->json('attribute_changes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('form_histories');
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('form_histories', function (Blueprint $table) {
$table->text('attribute_changes')->nullable()->change();
});
}
public function down(): void
{
Schema::table('form_histories', function (Blueprint $table) {
$table->json('attribute_changes')->nullable()->change();
});
}
};

View File

@ -50,6 +50,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings'; import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases'; import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks'; import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as stokOpnamesIndex } from '@/routes/admin/manage/stok-opnames';
import { index as transactionsIndex } from '@/routes/admin/manage/transactions'; import { index as transactionsIndex } from '@/routes/admin/manage/transactions';
import { index as categoriesIndex } from '@/routes/admin/master/categories'; import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as customersIndex } from '@/routes/admin/master/customers';
@ -86,7 +87,7 @@ const kelolaItems: NavMenuItem[] = [
{ title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors, permission: 'cuttings.view' }, { title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors, permission: 'cuttings.view' },
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.view' }, { title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.view' },
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.view' }, { title: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.view' },
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck, permission: 'stok_opnames.view' }, { title: 'Stok Opname', href: stokOpnamesIndex.url(), icon: ClipboardCheck, permission: 'stok_opnames.view' },
]; ];
const keuanganItems: NavMenuItem[] = [ const keuanganItems: NavMenuItem[] = [
@ -105,7 +106,7 @@ const hrItems: NavMenuItem[] = [
const sistemItems: NavMenuItem[] = [ const sistemItems: NavMenuItem[] = [
{ title: 'Pengaturan', href: '/admin/settings', icon: Settings, permission: ['settings.view_system', 'settings.view_homepage', 'settings.view_social_media', 'settings.view_hr'] }, { title: 'Pengaturan', href: '/admin/settings', icon: Settings, permission: ['settings.view_system', 'settings.view_homepage', 'settings.view_social_media', 'settings.view_hr'] },
{ title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' }, { title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' },
// { title: 'Log Aktivitas', href: '#', icon: Activity, permission: 'activity_logs.view' }, { title: 'Log Aktivitas', href: '/admin/form-histories', icon: Activity, permission: 'activity_logs.view' },
]; ];
function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) { function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {

View File

@ -96,6 +96,16 @@ export default function RestockEdit({ restock, products }: Props) {
[products], [products],
); );
const productByVariantId = useMemo(
() =>
new Map(
products.flatMap((p) =>
p.product_variants.map((v) => [v.id, p.name]),
),
),
[products],
);
const getUnitPrice = useCallback( const getUnitPrice = useCallback(
(variantId: number) => { (variantId: number) => {
const variant = variantById.get(variantId); const variant = variantById.get(variantId);
@ -148,10 +158,11 @@ return 0;
if (variant) { if (variant) {
const unitPrice = stockType === 'reject' ? variant.reject_price : variant.capital_price; const unitPrice = stockType === 'reject' ? variant.reject_price : variant.capital_price;
const productName = productByVariantId.get(id) ?? '';
lines.push({ lines.push({
key: `variant-${id}`, key: `variant-${id}`,
photoUrl: variant.photo_url, photoUrl: variant.photo_url,
title: variant.name, title: `${productName}${variant.name}`,
subtitle: `${formatCurrency(unitPrice)} / pcs`, subtitle: `${formatCurrency(unitPrice)} / pcs`,
price: unitPrice, price: unitPrice,
quantity, quantity,

View File

@ -0,0 +1,169 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { FormHistory } from './columns';
type AttributeChangesDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
item: FormHistory | null;
};
const eventBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
created: 'default',
updated: 'secondary',
deleted: 'destructive',
};
const eventLabel: Record<string, string> = {
created: 'Ditambahkan',
updated: 'Diperbarui',
deleted: 'Dihapus',
};
function renderValue(key: string, value: unknown): React.ReactNode {
if (value === null || value === undefined) {
return <span className="text-muted-foreground">-</span>;
}
if (typeof value === 'boolean') {
return value ? 'Ya' : 'Tidak';
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
if (typeof value[0] === 'object' && value[0] !== null) {
return <RenderObjectArray items={value} />;
}
return value.join(', ');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
function RenderObjectArray({ items }: { items: Record<string, unknown>[] }) {
if (!items.length) {
return <span className="text-muted-foreground">-</span>;
}
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>
);
}
export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeChangesDialogProps) {
if (!item) {
return null;
}
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;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span>{item.description}</span>
<Badge variant={eventBadgeVariant[item.event] ?? 'outline'}>
{eventLabel[item.event] ?? item.event}
</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 overflow-y-auto max-h-[calc(85vh-8rem)]">
<div className="text-sm text-muted-foreground space-y-1">
<div>Oleh: <span className="font-medium text-foreground">{item.causer?.full_name ?? item.causer?.username ?? '-'}</span></div>
<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>
</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 && (
<p className="text-sm text-muted-foreground text-center py-4">
Tidak ada perubahan data.
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,106 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Eye } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
export type FormHistory = {
id: number;
causer_id: number;
module: string;
event: string;
description: string;
attribute_changes: {
new?: Record<string, unknown>;
old?: Record<string, unknown>;
} | null;
created_at: string;
formatted_created_at: string;
causer: {
id: number;
username: string;
full_name: string;
};
};
const eventBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
created: 'default',
updated: 'secondary',
deleted: 'destructive',
};
const eventLabel: Record<string, string> = {
created: 'Ditambahkan',
updated: 'Diperbarui',
deleted: 'Dihapus',
};
type CreateColumnsParams = {
handleDetail: (item: FormHistory) => void;
};
export function createFormHistoryColumns({ handleDetail }: CreateColumnsParams): ColumnDef<FormHistory>[] {
return [
{
accessorKey: 'formatted_created_at',
header: () => <span>Waktu</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.formatted_created_at}
</span>
),
},
{
accessorKey: 'causer',
header: () => <span>User</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.original.causer?.full_name ?? row.original.causer?.username ?? '-'}
</span>
),
},
{
accessorKey: 'module',
header: () => <span>Module</span>,
cell: ({ row }) => (
<span>{row.getValue('module') as string}</span>
),
},
{
accessorKey: 'event',
header: () => <span>Aksi</span>,
cell: ({ row }) => {
const event = row.getValue('event') as string;
return (
<Badge variant={eventBadgeVariant[event] ?? 'outline'}>
{eventLabel[event] ?? event}
</Badge>
);
},
},
{
accessorKey: 'description',
header: () => <span>Deskripsi</span>,
cell: ({ row }) => (
<span>{row.getValue('description') as string}</span>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<Button
variant="ghost"
size="icon"
onClick={() => handleDetail(row.original)}
>
<Eye className="h-4 w-4" />
</Button>
),
},
];
}

View File

@ -0,0 +1,78 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-display';
import { DataTable } from '@/components/data-display';
import { PageHeader } from '@/components/layout';
import { useServerTable } from '@/hooks/use-server-table';
import type { FormHistory } from './columns';
import { createFormHistoryColumns } from './columns';
import { AttributeChangesDialog } from './attribute-changes-dialog';
type Props = {
formHistories: {
data: FormHistory[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
modules: string[];
events: Record<string, string>;
};
export default function FormHistoryIndex({ formHistories, modules, events }: Props) {
const [detailItem, setDetailItem] = useState<FormHistory | null>(null);
const pagination: PaginationState = {
current_page: formHistories.current_page,
last_page: formHistories.last_page,
per_page: formHistories.per_page,
total: formHistories.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => route('admin.form-histories.index'),
pagination,
});
const columns = createFormHistoryColumns({
handleDetail: (item) => setDetailItem(item),
});
return (
<>
<Head title="Log Aktivitas" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader title="Log Aktivitas" />
<DataTable
columns={columns}
data={formHistories.data}
searchKey="description"
emptyText="Belum ada log aktivitas."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<AttributeChangesDialog
open={detailItem !== null}
onOpenChange={(open) => {
if (!open) {
setDetailItem(null);
}
}}
item={detailItem}
/>
</div>
</>
);
}

View File

@ -149,6 +149,7 @@
Route::delete('roles/{role}', [RoleController::class, 'destroy'])->name('roles.destroy')->middleware('permission:roles.delete'); Route::delete('roles/{role}', [RoleController::class, 'destroy'])->name('roles.destroy')->middleware('permission:roles.delete');
}); });
Route::get('form-histories', [FormHistoryController::class, 'index'])->name('admin.form-histories.index')->middleware('permission:activity_logs.view');
}); });
}); });