feat: implement form history logging for CRUD operations and add activity log management interface
This commit is contained in:
parent
581761398f
commit
919e8228d2
25
app/Http/Controllers/Admin/System/FormHistoryController.php
Normal file
25
app/Http/Controllers/Admin/System/FormHistoryController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Models/FormHistory.php
Normal file
36
app/Models/FormHistory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,14 @@
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class CategoryService
|
||||
{
|
||||
use LogsFormHistory;
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return Category::query()
|
||||
@ -24,18 +27,41 @@ public function getAll(): Collection
|
||||
|
||||
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
|
||||
{
|
||||
$oldValues = ['Nama' => $category->name];
|
||||
|
||||
$category->update($data);
|
||||
|
||||
$this->logUpdated(
|
||||
model: $category,
|
||||
module: 'Kategori',
|
||||
oldValues: $oldValues,
|
||||
newValues: ['Nama' => $category->name],
|
||||
);
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
public function destroy(Category $category): bool
|
||||
{
|
||||
$this->logDeleted(
|
||||
model: $category,
|
||||
module: 'Kategori',
|
||||
oldValues: ['Nama' => $category->name],
|
||||
);
|
||||
|
||||
return $category->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Enums\Role;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use App\Services\StockMutationService;
|
||||
@ -17,7 +18,7 @@
|
||||
|
||||
class ProductService
|
||||
{
|
||||
use HasRoleChecks;
|
||||
use HasRoleChecks, LogsFormHistory;
|
||||
|
||||
public function __construct(
|
||||
private ProductVariantService $variantService,
|
||||
@ -165,6 +166,12 @@ public function store(array $data): Product
|
||||
url: route('admin.master.products.index', ['highlight' => $product->id]),
|
||||
);
|
||||
|
||||
$this->logCreated(
|
||||
model: $product,
|
||||
module: 'Produk',
|
||||
newValues: $this->getProductLogValues($product),
|
||||
);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
@ -210,6 +217,14 @@ public function update(Product $product, array $data): 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) {
|
||||
// Auto-resubmit: non-verifier editing rejected product → status becomes pending
|
||||
$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]),
|
||||
);
|
||||
|
||||
$this->logUpdated(
|
||||
model: $product,
|
||||
module: 'Produk',
|
||||
oldValues: $oldValues,
|
||||
newValues: $this->getProductLogValues($product),
|
||||
);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
@ -414,6 +436,8 @@ public function destroy(Product $product): bool
|
||||
{
|
||||
$this->assertNotPending($product);
|
||||
|
||||
$oldValues = $this->getProductLogValues($product);
|
||||
|
||||
$result = DB::transaction(function () use ($product) {
|
||||
$product->productVariants->each(function (ProductVariant $variant) {
|
||||
$variant->delete();
|
||||
@ -431,6 +455,12 @@ public function destroy(Product $product): bool
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
$this->logDeleted(
|
||||
model: $product,
|
||||
module: 'Produk',
|
||||
oldValues: $oldValues,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,40 @@ public function __construct(
|
||||
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
|
||||
{
|
||||
$products = Product::query()
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Enums\Role;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
@ -15,7 +16,7 @@
|
||||
|
||||
class RawMaterialService
|
||||
{
|
||||
use RegistersMedia;
|
||||
use LogsFormHistory, RegistersMedia;
|
||||
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service,
|
||||
@ -117,6 +118,8 @@ public function store(array $data): RawMaterial
|
||||
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
|
||||
);
|
||||
|
||||
$this->logCreated($rawMaterial, 'Bahan Baku', $this->getRawMaterialLogValues($rawMaterial));
|
||||
|
||||
return $rawMaterial;
|
||||
}
|
||||
|
||||
@ -152,6 +155,8 @@ public function getForEdit(RawMaterial $rawMaterial): array
|
||||
|
||||
public function update(RawMaterial $rawMaterial, array $data): RawMaterial
|
||||
{
|
||||
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
|
||||
|
||||
$rawMaterial = DB::transaction(function () use ($rawMaterial, $data) {
|
||||
$rawMaterial->update([
|
||||
'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]),
|
||||
);
|
||||
|
||||
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
|
||||
|
||||
return $rawMaterial;
|
||||
}
|
||||
|
||||
public function destroy(RawMaterial $rawMaterial): bool
|
||||
{
|
||||
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
|
||||
|
||||
foreach ($rawMaterial->rawMaterialPrices as $price) {
|
||||
if ($price->stock > 0) {
|
||||
throw ValidationException::withMessages([
|
||||
@ -292,11 +301,15 @@ public function destroy(RawMaterial $rawMaterial): bool
|
||||
url: route('admin.master.raw-materials.index'),
|
||||
);
|
||||
|
||||
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function toggleStatus(RawMaterial $rawMaterial): void
|
||||
{
|
||||
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
|
||||
|
||||
$rawMaterial->update([
|
||||
'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.'.',
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Enums\Role;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
@ -13,7 +14,7 @@
|
||||
|
||||
class RawMaterialVariantService
|
||||
{
|
||||
use RegistersMedia;
|
||||
use LogsFormHistory, RegistersMedia;
|
||||
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service,
|
||||
@ -67,6 +68,9 @@ public function getForEdit(RawMaterialPrice $variant): array
|
||||
|
||||
public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
|
||||
{
|
||||
$rawMaterial = $variant->rawMaterial;
|
||||
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
|
||||
|
||||
DB::transaction(function () use ($variant, $data) {
|
||||
$variant->update([
|
||||
'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]),
|
||||
);
|
||||
|
||||
$rawMaterial->refresh();
|
||||
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
|
||||
|
||||
return $variant->fresh();
|
||||
}
|
||||
|
||||
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
|
||||
{
|
||||
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
|
||||
|
||||
if ($variant->stock > 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'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'),
|
||||
);
|
||||
|
||||
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Services/Admin/System/FormHistoryService.php
Normal file
45
app/Services/Admin/System/FormHistoryService.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
89
app/Services/Concerns/LogsFormHistory.php
Normal file
89
app/Services/Concerns/LogsFormHistory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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 purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
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 categoriesIndex } from '@/routes/admin/master/categories';
|
||||
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: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.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[] = [
|
||||
@ -105,7 +106,7 @@ const hrItems: 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: '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[] }) {
|
||||
|
||||
@ -96,6 +96,16 @@ export default function RestockEdit({ restock, products }: Props) {
|
||||
[products],
|
||||
);
|
||||
|
||||
const productByVariantId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
products.flatMap((p) =>
|
||||
p.product_variants.map((v) => [v.id, p.name]),
|
||||
),
|
||||
),
|
||||
[products],
|
||||
);
|
||||
|
||||
const getUnitPrice = useCallback(
|
||||
(variantId: number) => {
|
||||
const variant = variantById.get(variantId);
|
||||
@ -148,10 +158,11 @@ return 0;
|
||||
|
||||
if (variant) {
|
||||
const unitPrice = stockType === 'reject' ? variant.reject_price : variant.capital_price;
|
||||
const productName = productByVariantId.get(id) ?? '';
|
||||
lines.push({
|
||||
key: `variant-${id}`,
|
||||
photoUrl: variant.photo_url,
|
||||
title: variant.name,
|
||||
title: `${productName} — ${variant.name}`,
|
||||
subtitle: `${formatCurrency(unitPrice)} / pcs`,
|
||||
price: unitPrice,
|
||||
quantity,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
106
resources/js/pages/admin/system/form-histories/columns.tsx
Normal file
106
resources/js/pages/admin/system/form-histories/columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
78
resources/js/pages/admin/system/form-histories/index.tsx
Normal file
78
resources/js/pages/admin/system/form-histories/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -149,6 +149,7 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user