refactor: modularize data change comparison views and update media disk configuration

This commit is contained in:
Yoga Pangestu 2026-04-09 11:06:17 +07:00
parent 480796e8f2
commit 5e5a7caeb5
6 changed files with 340 additions and 117 deletions

View File

@ -1,6 +1,6 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest;
namespace App\Filament\Resources\Manage\DataChanges\Actions;
use App\Enums\DataChangeDecision;
use App\Enums\DataChangeStatus;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest;
namespace App\Filament\Resources\Manage\DataChanges\Actions;
use App\Enums\DataChangeDecision;
use App\Enums\DataChangeStatus;

View File

@ -25,123 +25,208 @@ public static function configure(Schema $schema): Schema
$new = $record->new_data ?? [];
if (! is_array($old) || ! is_array($new)) {
return '<em>Format data tidak valid.</em>';
return self::alert('Format data tidak valid.', 'warning');
}
$renderValue = function ($value) {
if (empty($value)) {
return '<em>Kosong</em>';
}
$paths = is_array($value) ? $value : [$value];
$isProbablyPath = false;
foreach ($paths as $path) {
if (is_string($path) && (str_contains($path, '/') || str_contains($path, '\\'))) {
$isProbablyPath = true;
break;
}
}
if ($isProbablyPath) {
return collect($paths)
->map(function ($path) {
if (! is_string($path)) {
return e(json_encode($path));
}
$url = Storage::disk(config('filesystems.default'))->url($path);
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'])) {
return "<a href='{$url}' target='_blank'><img src='{$url}' style='max-height:100px; border-radius:8px; margin-top:4px; border:1px solid #e5e7eb;'></a>";
}
if ($extension === 'pdf') {
return "
<div style='margin-top:4px;'>
<iframe src='{$url}' style='width:100%; height:300px; border:1px solid #e5e7eb; border-radius:8px;'></iframe>
<a href='{$url}' target='_blank' style='display:block; margin-top:4px; color:#3b82f6; text-decoration:underline; font-size:0.75rem;'>↗️ Buka PDF di Tab Baru</a>
</div>
";
}
return "<a href='{$url}' target='_blank' style='color:#3b82f6; text-decoration:underline; font-size:0.875rem;'>📎 Buka Dokumen (".strtoupper($extension).')</a>';
})
->implode('<br>');
}
return e(is_scalar($value) ? $value : json_encode($value));
};
// 1. Deletion Request
if (isset($new['__delete_request__']) && $new['__delete_request__'] === true) {
return "
<div style='background-color:#fee2e2; border:1px solid #f87171; color:#991b1b; padding:16px; border-radius:8px; margin-bottom:16px;'>
<div style='font-weight:700; font-size:1.125rem; margin-bottom:4px;'>".CheerfulNotification::getByKey('data_change.deletion_request_title')."</div>
<p style='font-size:0.875rem;'>".CheerfulNotification::getByKey('data_change.deletion_request_desc').'</p>
</div>
';
return self::deletionView($old);
}
// 2. New Addition Request
if (empty($old)) {
$rows = collect($new)
->map(function ($value, $key) use ($renderValue) {
return sprintf(
"<div style='margin-bottom:12px; border-bottom:1px solid #f3f4f6; padding-bottom:8px'>
<div style='font-weight:600; color:#374151; margin-bottom:4px'>%s</div>
<div style='color:#16a34a; font-weight:500;'>[BARU]</div>
<div style='margin-top:4px'>%s</div>
</div>",
e($key),
$renderValue($value)
);
})
->implode('');
return "
<div style='background-color:#f0fdf4; border:1px solid #bbf7d0; color:#166534; padding:16px; border-radius:8px; margin-bottom:16px;'>
<div style='font-weight:700; font-size:1.125rem; margin-bottom:4px;'>".CheerfulNotification::getByKey('data_change.addition_request_title')."</div>
<p style='font-size:0.875rem;'>".CheerfulNotification::getByKey('data_change.addition_request_desc')."</p>
</div>
{$rows}
";
return self::additionView($new);
}
// 3. Standard Comparison (Update)
return collect($new)
->map(function ($newValue, $key) use ($old, $renderValue) {
$oldValue = $old[$key] ?? null;
if ($oldValue === $newValue) {
return null;
}
return sprintf(
"<div style='margin-bottom:16px; border-bottom:1px solid #f3f4f6; padding-bottom:12px'>
<div style='font-weight:600; color:#374151; margin-bottom:4px'>%s</div>
<div style='display:grid; grid-template-columns: 1fr 1fr; gap:16px'>
<div>
<span style='font-size:0.75rem; font-weight:700; color:#dc2626; text-transform:uppercase'>LAMA</span>
<div style='margin-top:2px'>%s</div>
</div>
<div>
<span style='font-size:0.75rem; font-weight:700; color:#16a34a; text-transform:uppercase'>BARU</span>
<div style='margin-top:2px'>%s</div>
</div>
</div>
</div>",
e($key),
$renderValue($oldValue),
$renderValue($newValue)
);
})
->filter()
->implode('');
return self::updateView($old, $new);
}),
]),
])
->columns(1);
}
private static function deletionView(array $old): string
{
$title = CheerfulNotification::getByKey('data_change.deletion_request_title');
$desc = CheerfulNotification::getByKey('data_change.deletion_request_desc');
$rows = collect($old)
->map(fn ($v, $k) => self::row($k, self::renderValue($v)))
->implode('');
return self::card(
header: self::header('del', $title, $desc, self::badge('Penghapusan', 'del')),
body: $rows ?: self::emptyRow(),
);
}
private static function additionView(array $new): string
{
$title = CheerfulNotification::getByKey('data_change.addition_request_title');
$desc = CheerfulNotification::getByKey('data_change.addition_request_desc');
$rows = collect($new)
->map(fn ($v, $k) => self::row($k, self::renderValue($v)))
->implode('');
return self::card(
header: self::header('add', $title, $desc, self::badge('Penambahan Baru', 'add')),
body: $rows ?: self::emptyRow(),
);
}
private static function updateView(array $old, array $new): string
{
$changed = collect($new)
->map(function ($newVal, $key) use ($old) {
$oldVal = $old[$key] ?? null;
if ($oldVal === $newVal) {
return null;
}
return "
<div style='display:grid; grid-template-columns:180px 1fr; border-bottom:1px solid var(--dcr-row-border); align-items:stretch;'>
<div style='padding:11px 14px; font-size:0.75rem; font-weight:500; color:var(--dcr-key-color); letter-spacing:0.025em; background:var(--dcr-key-bg); border-right:1px solid var(--dcr-key-border); display:flex; align-items:center; word-break:break-word;'>".e($key)."</div>
<div style='display:grid; grid-template-columns:1fr 1fr;'>
<div style='padding:11px 14px; background:var(--dcr-old-bg); border-right:1px solid var(--dcr-old-border); color:var(--dcr-old-color); word-break:break-word; font-size:0.8125rem;'>".self::renderValue($oldVal)."</div>
<div style='padding:11px 14px; background:var(--dcr-new-bg); color:var(--dcr-new-color); word-break:break-word; font-size:0.8125rem;'>".self::renderValue($newVal).'</div>
</div>
</div>';
})
->filter();
if ($changed->isEmpty()) {
return self::alert('Tidak ada perubahan data yang terdeteksi.', 'info');
}
$count = $changed->count();
$label = $count === 1 ? '1 field diubah' : "{$count} field diubah";
$diffHeader = "
<div style='display:grid; grid-template-columns:180px 1fr;'>
<div style='padding:8px 14px; background:var(--dcr-key-bg); border-right:1px solid var(--dcr-key-border); border-bottom:1px solid var(--dcr-header-border);'></div>
<div style='display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--dcr-header-border);'>
<div style='padding:8px 14px; font-size:0.7rem; font-weight:600; color:var(--dcr-key-color); text-transform:uppercase; letter-spacing:0.06em; background:var(--dcr-old-bg); border-right:1px solid var(--dcr-old-border);'>Sebelum</div>
<div style='padding:8px 14px; font-size:0.7rem; font-weight:600; color:var(--dcr-key-color); text-transform:uppercase; letter-spacing:0.06em; background:var(--dcr-new-bg);'>Sesudah</div>
</div>
</div>";
return self::card(
header: self::header('edit', CheerfulNotification::getByKey('data_change.comparison_title'), CheerfulNotification::getByKey('data_change.comparison_desc'), self::badge($label, 'default')),
body: $diffHeader.$changed->implode(''),
);
}
private static function card(string $header, string $body): string
{
return "
<div style='border:1px solid var(--dcr-border); border-radius:var(--c-border-radius); overflow:hidden; background:var(--dcr-bg); font-family:var(--font-family,Inter,ui-sans-serif,sans-serif); font-size:0.8125rem;'>
{$header}
<div>{$body}</div>
</div>";
}
private static function header(string $type, string $title, string $desc, string $badge): string
{
$icon = self::icon($type);
return "
<div style='padding:14px 18px; background:var(--dcr-header-bg); border-bottom:1px solid var(--dcr-header-border); display:flex; align-items:center; gap:10px;'>
{$icon}
<div style='flex:1; min-width:0;'>
<div style='font-size:0.875rem; font-weight:600; color:var(--dcr-title); line-height:1.4;'>{$title}</div>
<div style='font-size:0.75rem; color:var(--dcr-desc); margin-top:1px;'>{$desc}</div>
</div>
{$badge}
</div>";
}
private static function row(string $key, string $value): string
{
return "
<div style='display:grid; grid-template-columns:180px 1fr; border-bottom:1px solid var(--dcr-row-border);'>
<div style='padding:11px 14px; font-size:0.75rem; font-weight:500; color:var(--dcr-key-color); letter-spacing:0.025em; background:var(--dcr-key-bg); border-right:1px solid var(--dcr-key-border); display:flex; align-items:flex-start; word-break:break-word;'>".e($key)."</div>
<div style='padding:11px 14px; color:var(--dcr-val-color); word-break:break-word;'>{$value}</div>
</div>";
}
private static function emptyRow(): string
{
return "<div style='padding:18px; font-size:0.8125rem; color:var(--dcr-muted); font-style:italic;'>Tidak ada data tersedia.</div>";
}
private static function badge(string $label, string $variant): string
{
[$bg, $color, $border] = match ($variant) {
'del' => ['var(--dcr-badge-del-bg)', 'var(--dcr-badge-del-color)', 'var(--dcr-badge-del-border)'],
'add' => ['var(--dcr-badge-add-bg)', 'var(--dcr-badge-add-color)', 'var(--dcr-badge-add-border)'],
default => ['var(--dcr-badge-bg)', 'var(--dcr-badge-color)', 'var(--dcr-badge-border)'],
};
return "<span style='display:inline-flex; align-items:center; border-radius:var(--c-border-radius); padding:2px 8px; font-size:0.7rem; font-weight:600; letter-spacing:0.025em; white-space:nowrap; background:{$bg}; color:{$color}; border:1px solid {$border};'>{$label}</span>";
}
private static function alert(string $message, string $type = 'info'): string
{
$icon = $type === 'warning' ? '⚠' : '';
return "
<div style='background:var(--dcr-alert-bg); border:1px solid var(--dcr-alert-border); border-radius:var(--c-border-radius); padding:14px 16px; display:flex; align-items:flex-start; gap:10px; font-family:var(--font-family,Inter,ui-sans-serif,sans-serif);'>
<span style='color:var(--dcr-alert-color); font-size:0.875rem; margin-top:1px; flex-shrink:0;'>{$icon}</span>
<span style='color:var(--dcr-alert-color); font-size:0.8125rem; font-weight:500;'>{$message}</span>
</div>";
}
private static function icon(string $type): string
{
return match ($type) {
'del' => "<svg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='var(--dcr-icon-del)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='flex-shrink:0'><polyline points='3 6 5 6 21 6'/><path d='M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6'/><path d='M10 11v6M14 11v6'/><path d='M9 6V4h6v2'/></svg>",
'add' => "<svg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='var(--dcr-icon-add)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='flex-shrink:0'><line x1='12' y1='5' x2='12' y2='19'/><line x1='5' y1='12' x2='19' y2='12'/></svg>",
'edit' => "<svg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='var(--dcr-icon-edit)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='flex-shrink:0'><path d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'/><path d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'/></svg>",
default => '',
};
}
private static function renderValue(mixed $value): string
{
if ($value === null || $value === '' || (is_array($value) && empty($value))) {
return "<span style='color:var(--dcr-muted); font-size:0.8rem;'>—</span>";
}
$paths = is_array($value) ? $value : [$value];
return collect($paths)->map(function ($item) {
if (! is_string($item)) {
return "<span style='color:var(--dcr-val-color); font-size:0.8125rem;'>".e(json_encode($item)).'</span>';
}
if (preg_match('/^https?:\/\//i', $item)) {
return "<a href='{$item}' target='_blank' style='color:var(--dcr-link); text-decoration:underline; font-size:0.8125rem; word-break:break-all;'>".e($item).'</a>';
}
if ((str_contains($item, '/') || str_contains($item, '\\')) && ! str_contains($item, ' ')) {
$url = Storage::disk(config('filesystems.default'))->url($item);
$ext = strtolower(pathinfo($item, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'])) {
return "<img src='{$url}' style='max-height:96px; border-radius:var(--c-border-radius); border:1px solid var(--dcr-img-border); margin-top:4px; display:block;'>";
}
if ($ext === 'pdf') {
return "
<div style='margin-top:6px;'>
<iframe src='{$url}' style='width:100%; height:280px; border:1px solid var(--dcr-img-border); border-radius:var(--c-border-radius);'></iframe>
</div>";
}
$extUp = $ext ? strtoupper($ext) : 'FILE';
return "<a href='{$url}' style='display:inline-flex; align-items:center; gap:4px; color:var(--dcr-link); font-size:0.8rem; font-weight:500; text-decoration:none;'>
<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48'/></svg>
Dokumen {$extUp}
</a>";
}
return "<span style='color:var(--dcr-val-color); font-size:0.8125rem;'>".e($item).'</span>';
})->implode('<br>');
}
}

View File

@ -6,8 +6,8 @@
use App\Enums\RoleEnum;
use App\Filament\Actions\DefaultBulkActions;
use App\Filament\Columns\RowIndexColumn;
use App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest\AcceptAction;
use App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest\RejectAction;
use App\Filament\Resources\Manage\DataChanges\Actions\AcceptAction;
use App\Filament\Resources\Manage\DataChanges\Actions\RejectAction;
use App\Filament\Support\CheerfulNotification;
use App\Models\Company;
use App\Models\DataChangeRequest;
@ -29,22 +29,16 @@ public static function configure(Table $table): Table
->columns([
...RowIndexColumn::make(),
TextColumn::make('user.name')
TextColumn::make('user.company.name')
->label('Pemohon')
->searchable()
->sortable()
->description(fn (DataChangeRequest $record): ?string => $record?->entity?->name)
->visible(! auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value)),
TextColumn::make('entity_type')
->label('Jenis Data')
->searchable()
->sortable()
->description(fn (DataChangeRequest $record): ?string => match (true) {
isset($record->new_data['__delete_request__']) => CheerfulNotification::getByKey('data_change.deletion'),
empty($record->old_data) => CheerfulNotification::getByKey('data_change.addition'),
default => CheerfulNotification::getByKey('data_change.change'),
})
->formatStateUsing(fn (DataChangeRequest $record): ?string => match ($record->entity_type) {
Company::class => 'Perusahaan',
PartnerMedia::class => 'Media',

View File

@ -74,6 +74,94 @@ public function handle(Request $request, Closure $next): Response
:root {
--font-family: '{$font}', sans-serif;
--c-border-radius: {$radius};
/* ── DataChangeInfolist tokens (light) ── */
--dcr-bg: #ffffff;
--dcr-border: #e4e4e7;
--dcr-header-bg: #fafafa;
--dcr-header-border: #e4e4e7;
--dcr-title: #18181b;
--dcr-desc: #71717a;
--dcr-key-bg: #fafafa;
--dcr-key-border: #f4f4f5;
--dcr-key-color: #71717a;
--dcr-val-color: #18181b;
--dcr-row-border: #f4f4f5;
--dcr-muted: #a1a1aa;
/* diff */
--dcr-old-bg: #fff5f5;
--dcr-old-border: #fee2e2;
--dcr-old-color: #18181b;
--dcr-new-bg: #f0fdf4;
--dcr-new-color: #18181b;
/* badge: neutral */
--dcr-badge-bg: #f4f4f5;
--dcr-badge-color: #52525b;
--dcr-badge-border: #e4e4e7;
/* badge: destructive */
--dcr-badge-del-bg: #fef2f2;
--dcr-badge-del-color: #b91c1c;
--dcr-badge-del-border: #fecaca;
/* badge: success */
--dcr-badge-add-bg: #f0fdf4;
--dcr-badge-add-color: #15803d;
--dcr-badge-add-border: #bbf7d0;
/* alert */
--dcr-alert-bg: #f8fafc;
--dcr-alert-border: #e2e8f0;
--dcr-alert-color: #475569;
/* icon */
--dcr-icon-del: #b91c1c;
--dcr-icon-add: #15803d;
--dcr-icon-edit: #52525b;
/* file */
--dcr-link: #3b82f6;
--dcr-img-border: #e4e4e7;
}
.dark {
/* ── DataChangeInfolist tokens (dark) ── */
--dcr-bg: #09090b;
--dcr-border: #27272a;
--dcr-header-bg: #18181b;
--dcr-header-border: #27272a;
--dcr-title: #fafafa;
--dcr-desc: #71717a;
--dcr-key-bg: #18181b;
--dcr-key-border: #27272a;
--dcr-key-color: #71717a;
--dcr-val-color: #e4e4e7;
--dcr-row-border: #27272a;
--dcr-muted: #52525b;
/* diff */
--dcr-old-bg: #1c0c0c;
--dcr-old-border: #3f1c22;
--dcr-old-color: #fca5a5;
--dcr-new-bg: #0c1c0e;
--dcr-new-color: #86efac;
/* badge: neutral */
--dcr-badge-bg: #27272a;
--dcr-badge-color: #a1a1aa;
--dcr-badge-border: #3f3f46;
/* badge: destructive */
--dcr-badge-del-bg: #1c0c0c;
--dcr-badge-del-color: #f87171;
--dcr-badge-del-border: #7f1d1d;
/* badge: success */
--dcr-badge-add-bg: #0c1c0e;
--dcr-badge-add-color: #4ade80;
--dcr-badge-add-border: #14532d;
/* alert */
--dcr-alert-bg: #18181b;
--dcr-alert-border: #27272a;
--dcr-alert-color: #a1a1aa;
/* icon */
--dcr-icon-del: #f87171;
--dcr-icon-add: #4ade80;
--dcr-icon-edit: #a1a1aa;
/* file */
--dcr-link: #60a5fa;
--dcr-img-border: #3f3f46;
}
/* Override Filament maximum width if centered */

View File

@ -652,6 +652,62 @@
'cheerful' => 'Belum Ada Pengajuan Perubahan! 📂✨',
'formal' => 'Tidak Ada Pengajuan Perubahan',
],
// Action modal headings
'reject_title' => [
'cheerful' => 'Yakin Mau Ditolak? 🛑🤔',
'formal' => 'Konfirmasi Penolakan Perubahan Data',
],
'approve_title' => [
'cheerful' => 'Udah Dicek Semua? Kita Setujui! ✅🚀',
'formal' => 'Konfirmasi Persetujuan Perubahan Data',
],
'approve_desc' => [
'cheerful' => 'Yakin nih udah dicek semua? Kalau udah, yuk langsung disetujui biar datanya segera update! ✨🚀',
'formal' => 'Apakah Anda yakin ingin menyetujui perubahan data ini? Data pada sistem akan diperbarui secara otomatis.',
],
// Infolist card headers — addition request
'addition_request_title' => [
'cheerful' => 'Ada Data Baru yang Mau Masuk! 🆕🎉',
'formal' => 'Permohonan Penambahan Data',
],
'addition_request_desc' => [
'cheerful' => 'Cek dulu yuk, ada data baru yang menunggu persetujuan buat masuk ke sistem! 👀✨',
'formal' => 'Berikut adalah data baru yang diajukan untuk ditambahkan ke dalam sistem.',
],
// Infolist card headers — deletion request
'deletion_request_title' => [
'cheerful' => 'Ada yang Mau Dihapus Nih! 🗑️⚠️',
'formal' => 'Permohonan Penghapusan Data',
],
'deletion_request_desc' => [
'cheerful' => 'Hati-hati ya! Data di bawah ini diajukan untuk dihapus. Pastikan sudah diperiksa dengan teliti sebelum disetujui! 👀',
'formal' => 'Berikut adalah data yang diajukan untuk dihapus secara permanen dari sistem. Tinjau dengan seksama.',
],
// Infolist card headers — update/comparison view
'comparison_title' => [
'cheerful' => 'Nih, Ada yang Berubah! 📝🔄',
'formal' => 'Perbandingan Perubahan Data',
],
'comparison_desc' => [
'cheerful' => 'Yuk bandingkan data lamanya dengan yang baru. Pastikan perubahannya sudah sesuai sebelum disetujui! 🧐',
'formal' => 'Berikut perbandingan antara data lama dan data baru yang diajukan untuk ditinjau.',
],
'change_approved' => [
'cheerful' => 'Perubahan Disetujui! ✅🚀',
'formal' => 'Perubahan Disetujui',
],
'change_success_desc' => [
'cheerful' => 'Yeay! Perubahan data telah disetujui dan berhasil diterapkan ke sistem. Mantap! ✨',
'formal' => 'Perubahan data telah disetujui dan berhasil diterapkan.',
],
'request_rejected' => [
'cheerful' => 'Yah, Perubahan Ditolak 😔',
'formal' => 'Perubahan Ditolak',
],
'request_rejected_desc' => [
'cheerful' => 'Maaf ya, pengajuan perubahan data kamu terpaksa ditolak untuk saat ini. Tetap semangat! 💪',
'formal' => 'Pengajuan perubahan data Anda tidak dapat disetujui.',
],
],
'visitor' => [
'empty_state' => [