From 919e8228d2b971d183d5d373564bb17995d1565c Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 16 Aug 2026 20:41:48 +0700 Subject: [PATCH] feat: implement form history logging for CRUD operations and add activity log management interface --- .../Admin/System/FormHistoryController.php | 25 +++ app/Models/FormHistory.php | 36 ++++ app/Services/Admin/Master/CategoryService.php | 28 ++- .../Admin/Master/Product/ProductService.php | 75 +++++++- .../Master/Product/ProductVariantService.php | 34 ++++ .../Master/RawMaterial/RawMaterialService.php | 33 +++- .../RawMaterial/RawMaterialVariantService.php | 29 ++- .../Admin/System/FormHistoryService.php | 45 +++++ app/Services/Concerns/LogsFormHistory.php | 89 +++++++++ ..._16_191332_create_form_histories_table.php | 26 +++ ...hanges_to_text_in_form_histories_table.php | 22 +++ .../js/components/layout/app-sidebar.tsx | 5 +- .../js/pages/admin/manage/restock/edit.tsx | 13 +- .../attribute-changes-dialog.tsx | 169 ++++++++++++++++++ .../admin/system/form-histories/columns.tsx | 106 +++++++++++ .../admin/system/form-histories/index.tsx | 78 ++++++++ routes/web.php | 1 + 17 files changed, 807 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Admin/System/FormHistoryController.php create mode 100644 app/Models/FormHistory.php create mode 100644 app/Services/Admin/System/FormHistoryService.php create mode 100644 app/Services/Concerns/LogsFormHistory.php create mode 100644 database/migrations/2026_08_16_191332_create_form_histories_table.php create mode 100644 database/migrations/2026_08_16_195528_change_attribute_changes_to_text_in_form_histories_table.php create mode 100644 resources/js/pages/admin/system/form-histories/attribute-changes-dialog.tsx create mode 100644 resources/js/pages/admin/system/form-histories/columns.tsx create mode 100644 resources/js/pages/admin/system/form-histories/index.tsx diff --git a/app/Http/Controllers/Admin/System/FormHistoryController.php b/app/Http/Controllers/Admin/System/FormHistoryController.php new file mode 100644 index 0000000..863b7b4 --- /dev/null +++ b/app/Http/Controllers/Admin/System/FormHistoryController.php @@ -0,0 +1,25 @@ + $this->service->paginated(...$request->validatedWithDefaults()), + 'modules' => $this->service->getModules(), + 'events' => $this->service->getEvents(), + ]); + } +} diff --git a/app/Models/FormHistory.php b/app/Models/FormHistory.php new file mode 100644 index 0000000..1441565 --- /dev/null +++ b/app/Models/FormHistory.php @@ -0,0 +1,36 @@ + '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); + } +} diff --git a/app/Services/Admin/Master/CategoryService.php b/app/Services/Admin/Master/CategoryService.php index dec3be3..a1a9b84 100644 --- a/app/Services/Admin/Master/CategoryService.php +++ b/app/Services/Admin/Master/CategoryService.php @@ -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(); } } diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index 795c6cb..33bf3da 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -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; + } } diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 45b97d9..d073da4 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -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() diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php index 040c8ec..beaf5c6 100644 --- a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php @@ -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(), + ]; } } diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php index 0edb7ec..1405979 100644 --- a/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php @@ -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(), + ]; + } } diff --git a/app/Services/Admin/System/FormHistoryService.php b/app/Services/Admin/System/FormHistoryService.php new file mode 100644 index 0000000..aeb0e3e --- /dev/null +++ b/app/Services/Admin/System/FormHistoryService.php @@ -0,0 +1,45 @@ +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', + ]; + } +} diff --git a/app/Services/Concerns/LogsFormHistory.php b/app/Services/Concerns/LogsFormHistory.php new file mode 100644 index 0000000..5c5bee4 --- /dev/null +++ b/app/Services/Concerns/LogsFormHistory.php @@ -0,0 +1,89 @@ +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; + } +} diff --git a/database/migrations/2026_08_16_191332_create_form_histories_table.php b/database/migrations/2026_08_16_191332_create_form_histories_table.php new file mode 100644 index 0000000..64d5e54 --- /dev/null +++ b/database/migrations/2026_08_16_191332_create_form_histories_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_16_195528_change_attribute_changes_to_text_in_form_histories_table.php b/database/migrations/2026_08_16_195528_change_attribute_changes_to_text_in_form_histories_table.php new file mode 100644 index 0000000..f882a39 --- /dev/null +++ b/database/migrations/2026_08_16_195528_change_attribute_changes_to_text_in_form_histories_table.php @@ -0,0 +1,22 @@ +text('attribute_changes')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('form_histories', function (Blueprint $table) { + $table->json('attribute_changes')->nullable()->change(); + }); + } +}; diff --git a/resources/js/components/layout/app-sidebar.tsx b/resources/js/components/layout/app-sidebar.tsx index 81bae73..1f9fcad 100644 --- a/resources/js/components/layout/app-sidebar.tsx +++ b/resources/js/components/layout/app-sidebar.tsx @@ -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[] }) { diff --git a/resources/js/pages/admin/manage/restock/edit.tsx b/resources/js/pages/admin/manage/restock/edit.tsx index b8aa0b0..c2b2046 100644 --- a/resources/js/pages/admin/manage/restock/edit.tsx +++ b/resources/js/pages/admin/manage/restock/edit.tsx @@ -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, diff --git a/resources/js/pages/admin/system/form-histories/attribute-changes-dialog.tsx b/resources/js/pages/admin/system/form-histories/attribute-changes-dialog.tsx new file mode 100644 index 0000000..9807725 --- /dev/null +++ b/resources/js/pages/admin/system/form-histories/attribute-changes-dialog.tsx @@ -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 = { + created: 'default', + updated: 'secondary', + deleted: 'destructive', +}; + +const eventLabel: Record = { + created: 'Ditambahkan', + updated: 'Diperbarui', + deleted: 'Dihapus', +}; + +function renderValue(key: string, value: unknown): React.ReactNode { + if (value === null || value === undefined) { + return -; + } + + if (typeof value === 'boolean') { + return value ? 'Ya' : 'Tidak'; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return -; + } + + if (typeof value[0] === 'object' && value[0] !== null) { + return ; + } + + return value.join(', '); + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); +} + +function RenderObjectArray({ items }: { items: Record[] }) { + if (!items.length) { + return -; + } + + const keys = Object.keys(items[0]); + + return ( +
+
+ + + + {keys.map((key) => ( + + {key} + + ))} + + + + {items.map((item, index) => ( + + {keys.map((key) => ( + + {renderValue(key, item[key])} + + ))} + + ))} + +
+
+
+ ); +} + +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 ( + + + + + {item.description} + + {eventLabel[item.event] ?? item.event} + + + + +
+
+
Oleh: {item.causer?.full_name ?? item.causer?.username ?? '-'}
+
{item.formatted_created_at}
+
+ + {hasNew && ( +
+

Nilai Baru

+
+
+ {Object.entries(changes!.new!).map(([key, value]) => ( +
+
{key}
+
{renderValue(key, value)}
+
+ ))} +
+
+
+ )} + + {hasOld && ( +
+

Nilai Lama

+
+
+ {Object.entries(changes!.old!).map(([key, value]) => ( +
+
{key}
+
{renderValue(key, value)}
+
+ ))} +
+
+
+ )} + + {!hasNew && !hasOld && ( +

+ Tidak ada perubahan data. +

+ )} +
+
+
+ ); +} diff --git a/resources/js/pages/admin/system/form-histories/columns.tsx b/resources/js/pages/admin/system/form-histories/columns.tsx new file mode 100644 index 0000000..6b9587a --- /dev/null +++ b/resources/js/pages/admin/system/form-histories/columns.tsx @@ -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; + old?: Record; + } | null; + created_at: string; + formatted_created_at: string; + causer: { + id: number; + username: string; + full_name: string; + }; +}; + +const eventBadgeVariant: Record = { + created: 'default', + updated: 'secondary', + deleted: 'destructive', +}; + +const eventLabel: Record = { + created: 'Ditambahkan', + updated: 'Diperbarui', + deleted: 'Dihapus', +}; + +type CreateColumnsParams = { + handleDetail: (item: FormHistory) => void; +}; + +export function createFormHistoryColumns({ handleDetail }: CreateColumnsParams): ColumnDef[] { + return [ + { + accessorKey: 'formatted_created_at', + header: () => Waktu, + cell: ({ row }) => ( + + {row.original.formatted_created_at} + + ), + }, + { + accessorKey: 'causer', + header: () => User, + cell: ({ row }) => ( + + {row.original.causer?.full_name ?? row.original.causer?.username ?? '-'} + + ), + }, + { + accessorKey: 'module', + header: () => Module, + cell: ({ row }) => ( + {row.getValue('module') as string} + ), + }, + { + accessorKey: 'event', + header: () => Aksi, + cell: ({ row }) => { + const event = row.getValue('event') as string; + + return ( + + {eventLabel[event] ?? event} + + ); + }, + }, + { + accessorKey: 'description', + header: () => Deskripsi, + cell: ({ row }) => ( + {row.getValue('description') as string} + ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[80px] text-center', + headerClassName: 'w-[80px] text-center', + }, + cell: ({ row }) => ( + + ), + }, + ]; +} diff --git a/resources/js/pages/admin/system/form-histories/index.tsx b/resources/js/pages/admin/system/form-histories/index.tsx new file mode 100644 index 0000000..d761943 --- /dev/null +++ b/resources/js/pages/admin/system/form-histories/index.tsx @@ -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; +}; + +export default function FormHistoryIndex({ formHistories, modules, events }: Props) { + const [detailItem, setDetailItem] = useState(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 ( + <> + + +
+ + + + + { + if (!open) { + setDetailItem(null); + } + }} + item={detailItem} + /> +
+ + ); +} diff --git a/routes/web.php b/routes/web.php index 7986db9..4f039a1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); });