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;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 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[] }) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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::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