feat: introduce a comprehensive data change request and review system with new Filament resources, models, migrations, and actions for managing data changes.

This commit is contained in:
Yoga Pangestu 2026-01-29 09:44:52 +07:00
parent 25e6713a65
commit 2626d890a4
25 changed files with 1702 additions and 266 deletions

View File

@ -0,0 +1,39 @@
<?php
namespace App\Enums;
use App\Traits\Enum\WithComment;
use App\Traits\Enum\WithValue;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum DataChangeDecision: int implements HasColor, HasLabel
{
use WithComment, WithValue;
case APPROVED = 1;
case REJECTED = 2;
public function getLabel(): ?string
{
return match ($this) {
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::APPROVED => 'success',
self::REJECTED => 'danger',
};
}
public static function options(): array
{
return collect(self::cases())
->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()])
->toArray();
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Enums;
use App\Traits\Enum\WithComment;
use App\Traits\Enum\WithValue;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum DataChangeStatus: int implements HasColor, HasLabel
{
use WithComment, WithValue;
case PENDING = 1;
case APPROVED = 2;
case REJECTED = 3;
public function getLabel(): ?string
{
return match ($this) {
self::PENDING => 'Menunggu Verifikasi',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::PENDING => 'warning',
self::APPROVED => 'success',
self::REJECTED => 'danger',
};
}
public static function options(): array
{
return collect(self::cases())
->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()])
->toArray();
}
}

View File

@ -250,7 +250,7 @@ protected function processReview(int $requestId, DecisionAdmin $decision, ?strin
CheerfulNotification::success(
'Berhasil Diproses! ✨',
"Mantap! Verifikasi telah {$actionLabel} dan notifikasi sudah dikirim ke user. Kerja bagus, Admin! 💪"
"Mantap! Verifikasi telah {$actionLabel} dan notifikasi sudah dikirim ke pengguna. Kerja bagus, Admin! 💪"
)
->send();

View File

@ -2,23 +2,33 @@
namespace App\Filament\Pages;
use App\Enums\DataChangeStatus;
use App\Enums\VerificationStatus;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use App\Filament\Support\CheerfulNotification;
use App\Models\Company as CompanyModel;
use App\Models\DataChangeRequest;
use App\Models\User;
use App\Models\VerificationRequest;
use App\Notifications\BroadcastNotification;
use Asmit\FilamentUpload\Forms\Components\AdvancedFileUpload;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class Company extends Page
class Company extends Page implements HasForms
{
use HasPageShield;
@ -26,6 +36,8 @@ class Company extends Page
public array $data = [];
public array $originalDocuments = [];
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static string|BackedEnum|null $navigationIcon = Heroicon::BuildingOffice2;
@ -80,7 +92,9 @@ public function mount(): void
->first();
if ($media) {
$this->data["{$docType}_docs"] = [$media->getPathRelativeToRoot()];
$path = $media->getPathRelativeToRoot();
$this->data["{$docType}_docs"] = [$path];
$this->originalDocuments["{$docType}_docs"] = [$path];
}
}
}
@ -250,67 +264,218 @@ public static function form(Schema $schema): Schema
->statePath('data');
}
public function save()
public function save(): void
{
$data = $this->form->getState();
$this->mountAction('save');
}
$company = CompanyModel::updateOrCreate([
'id' => $this->company?->id,
], [
'user_id' => auth()->id(),
'name' => $data['name'],
'email' => $data['email'],
'address' => $data['address'],
'director_name' => $data['director_name'],
'director_nik' => $data['director_nik'],
'deed_incorporation' => $data['deed_incorporation'],
'trade_license' => $data['trade_license'],
'tax_id_number' => $data['tax_id_number'],
'taxable_enterprise' => $data['taxable_enterprise'],
'annual_tax_return' => $data['annual_tax_return'],
'domicile_certificate' => $data['domicile_certificate'],
'profile' => $data['profile'],
]);
public function saveAction(): Action
{
$needsReview = $this->company?->status === VerificationStatus::APPROVED;
$this->company = $company;
return Action::make('save')
->label('Simpan')
->modalHeading('Ajukan Perubahan')
->modal($needsReview)
->schema(
$needsReview
? [
Textarea::make('change_reason')
->label('Alasan')
->placeholder('Jelaskan alasan perubahan data ini...')
->autocomplete(false)
->autofocus(),
]
: []
)
->action(function (array $data = []) use ($needsReview) {
try {
$this->performSave($data, $needsReview);
} catch (ValidationException $e) {
$this->unmountAction();
$documents = [
'director_nik_docs',
'deed_incorporation_docs',
'trade_license_docs',
'tax_id_number_docs',
'taxable_enterprise_docs',
'annual_tax_return_docs',
'domicile_certificate_docs',
'profile_docs',
];
throw $e;
}
})
->modalWidth(Width::Large);
}
foreach ($documents as $field) {
if (empty($this->data[$field])) {
continue;
public function performSave(array $data, bool $needsReview = false): void
{
if ($needsReview) {
$existingRequest = DataChangeRequest::where('entity_type', CompanyModel::class)
->where('entity_id', $this->company->id)
->where('status', DataChangeStatus::PENDING)
->exists();
if ($existingRequest) {
CheerfulNotification::warning(
'Pengajuan Sudah Ada ⏳',
'Masih ada pengajuan perubahan data yang sedang menunggu verifikasi. Silakan tunggu hingga selesai diproses.'
)
->send();
return;
}
foreach ((array) $this->data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
$state = $this->form->getState();
if (! file_exists($fullPath)) {
$data = array_merge($state, $data);
$fieldLabels = CompanyModel::dataChangeEntryLabels();
$editableFields = [
'name',
'email',
'address',
'director_name',
'director_nik',
'deed_incorporation',
'trade_license',
'tax_id_number',
'taxable_enterprise',
'annual_tax_return',
'domicile_certificate',
'profile',
];
$changedFields = [];
$oldData = [];
foreach ($editableFields as $field) {
if ($this->company->{$field} !== ($data[$field] ?? null)) {
$label = $fieldLabels[$field] ?? $field;
$changedFields[$label] = $data[$field] ?? null;
$oldData[$label] = $this->company->{$field};
}
}
$documents = [
'director_nik_docs',
'deed_incorporation_docs',
'trade_license_docs',
'tax_id_number_docs',
'taxable_enterprise_docs',
'annual_tax_return_docs',
'domicile_certificate_docs',
'profile_docs',
];
foreach ($documents as $field) {
$newValue = $this->data[$field] ?? [];
$oldValue = $this->originalDocuments[$field] ?? [];
// Compare as arrays since file uploads are often arrays
if (json_encode($newValue) !== json_encode($oldValue)) {
$label = $fieldLabels[$field] ?? $field;
$changedFields[$label] = $newValue;
$oldData[$label] = $oldValue;
}
}
if (empty($changedFields)) {
CheerfulNotification::info(
'Belum ada yang berubah ✨',
'Ubah data terlebih dulu lalu simpan kembali 💪'
)
->send();
return;
}
$dataChangeRequest = DataChangeRequest::create([
'user_id' => auth()->id(),
'entity_type' => CompanyModel::class,
'entity_id' => $this->company->id,
'old_data' => $oldData,
'new_data' => $changedFields,
'change_reason' => $data['change_reason'],
]);
CheerfulNotification::success(
'Permohonan Berhasil 🎉',
'Data sudah dikirim dan sedang menunggu verifikasi admin ⏳'
)
->send();
User::superAdmin()
->get()
->each(function ($admin) use ($dataChangeRequest): void {
$admin->notify(new BroadcastNotification([
'title' => 'Ada Pengajuan Perubahan Data ✨',
'body' => 'Halooo Admin! 👋 '.auth()->user()->name.' baru saja mengajukan perubahan data. Yuk, cek detailnya dan lakukan verifikasi ya! 🚀',
'action' => [
Action::make('view')
->label('Lihat')
->url(DataChangesResource::getUrl('view', ['record' => $dataChangeRequest->id])),
],
]));
});
} else {
$data = $this->form->getState();
$company = CompanyModel::updateOrCreate([
'id' => $this->company?->id,
], [
'user_id' => auth()->id(),
'name' => $data['name'],
'email' => $data['email'],
'address' => $data['address'],
'director_name' => $data['director_name'],
'director_nik' => $data['director_nik'],
'deed_incorporation' => $data['deed_incorporation'],
'trade_license' => $data['trade_license'],
'tax_id_number' => $data['tax_id_number'],
'taxable_enterprise' => $data['taxable_enterprise'],
'annual_tax_return' => $data['annual_tax_return'],
'domicile_certificate' => $data['domicile_certificate'],
'profile' => $data['profile'],
]);
$this->company = $company;
$documents = [
'director_nik_docs',
'deed_incorporation_docs',
'trade_license_docs',
'tax_id_number_docs',
'taxable_enterprise_docs',
'annual_tax_return_docs',
'domicile_certificate_docs',
'profile_docs',
];
foreach ($documents as $field) {
if (empty($this->data[$field])) {
continue;
}
$company
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'companies',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('companies');
foreach ((array) $this->data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
if (! file_exists($fullPath)) {
continue;
}
$company
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'companies',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('companies');
}
}
if ($this->company) {
CheerfulNotification::update()->send();
} else {
CheerfulNotification::create()->send();
}
}
CheerfulNotification::update()->send();
}
}

View File

@ -2,28 +2,38 @@
namespace App\Filament\Pages;
use App\Enums\DataChangeStatus;
use App\Enums\MediaClassification;
use App\Enums\MediaType;
use App\Enums\RoleEnum;
use App\Enums\VerificationStatus;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use App\Filament\Support\CheerfulNotification;
use App\Models\Company;
use App\Models\DataChangeRequest;
use App\Models\PartnerMedia;
use App\Models\User;
use App\Models\VerificationRequest;
use App\Notifications\BroadcastNotification;
use Asmit\FilamentUpload\Forms\Components\AdvancedFileUpload;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Contracts\HasLabel;
use Filament\Support\Enums\Width;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Storage;
use UnitEnum;
class Media extends Page
class Media extends Page implements HasForms
{
use HasPageShield;
@ -31,6 +41,8 @@ class Media extends Page
public array $data = [];
public array $originalDocuments = [];
public ?PartnerMedia $media = null;
protected static ?string $model = PartnerMedia::class;
@ -63,18 +75,28 @@ public function mount(): void
return;
}
$this->company = auth()->user()->company;
$company = auth()->user()->company;
$media = $this->company?->partnerMedia;
if (! $company) {
$this->restricted = true;
return;
}
$this->company = $company;
$verificationRequest = $this->company->verificationRequest()
->latest()
->first();
$this->verificationRequest = $verificationRequest;
$media = $company->partnerMedia;
if (! $media) {
return;
}
$this->verificationRequest = $this->company->verificationRequest()
->latest()
->first();
$this->media = $media;
$this->form->fill($media->toArray());
@ -87,13 +109,15 @@ public function mount(): void
$mediaItems = $media->getMedia('partnerMedia');
foreach ($documents as $docType) {
$media = $mediaItems
$mediaItem = $mediaItems
->where('custom_properties.doc_type', str($docType)->replace('_docs', '')->slug('-'))
->sortByDesc('created_at')
->first();
if ($media) {
$this->data["{$docType}_docs"] = [$media->getPathRelativeToRoot()];
if ($mediaItem) {
$path = $mediaItem->getPathRelativeToRoot();
$this->data["{$docType}_docs"] = [$path];
$this->originalDocuments["{$docType}_docs"] = [$path];
}
}
}
@ -185,8 +209,7 @@ public static function form(Schema $schema): Schema
->disk(config('filesystems.default'))
->acceptedFileTypes($doc['accept'])
->maxSize($doc['max_size'])
->directory($doc['folder'].now()->toDateString())
->required(),
->directory($doc['folder'].now()->toDateString()),
]);
})->toArray()
)
@ -195,56 +218,199 @@ public static function form(Schema $schema): Schema
->statePath('data');
}
public function save()
public function save(): void
{
$data = $this->form->getState();
$this->mountAction('save');
}
$media = PartnerMedia::updateOrCreate([
'id' => $this->media?->id,
], [
'company_id' => $this->company->id,
'name' => $data['name'],
'link' => $data['link'],
'address' => $data['address'],
'type' => $data['type'],
'classification' => $data['classification'],
'journalism_organization' => $data['journalism_organization'],
'press_council_certificate' => $data['press_council_certificate'],
]);
public function saveAction(): Action
{
$needsReview = $this->media?->status === VerificationStatus::APPROVED;
$this->media = $media;
return Action::make('save')
->label('Simpan')
->modalHeading('Ajukan Perubahan')
->modal($needsReview)
->schema(
$needsReview
? [
Textarea::make('change_reason')
->label('Alasan')
->required()
->placeholder('Jelaskan alasan perubahan data ini...')
->autocomplete(false)
->autofocus(),
]
: []
)
->action(function (array $data = []) use ($needsReview) {
$this->performSave($data, $needsReview);
})
->modalWidth(Width::Large);
}
$documents = [
'journalism_organization_docs',
'press_council_certificate_docs',
];
public function performSave(array $data = [], bool $needsReview = false): void
{
if ($needsReview && $this->media !== null) {
$existingRequest = DataChangeRequest::where('entity_type', PartnerMedia::class)
->where('entity_id', $this->media->id)
->where('status', DataChangeStatus::PENDING)
->exists();
foreach ($documents as $field) {
if (empty($this->data[$field])) {
continue;
if ($existingRequest) {
CheerfulNotification::warning(
'Pengajuan Sudah Ada ⏳',
'Masih ada pengajuan perubahan data yang sedang menunggu verifikasi. Silakan tunggu hingga selesai diproses.'
)
->send();
return;
}
foreach ((array) $this->data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
$state = $this->form->getState();
$data = array_merge($state, $data);
if (! file_exists($fullPath)) {
$fieldLabels = PartnerMedia::dataChangeEntryLabels();
$editableFields = [
'name',
'link',
'address',
'type',
'classification',
'journalism_organization',
'press_council_certificate',
];
$changedFields = [];
$oldDataArr = [];
$casts = $this->media->getCasts();
foreach ($editableFields as $field) {
$val = $data[$field];
$oldVal = $this->media->{$field};
// If value is a raw scalar but should be an enum, resolve it for display
if (isset($casts[$field]) && ! ($val instanceof BackedEnum) && enum_exists($casts[$field])) {
$enumClass = $casts[$field];
$val = $enumClass::tryFrom($val) ?? $val;
}
// Use labels for Enums if they implement HasLabel
$displayVal = ($val instanceof HasLabel) ? $val->getLabel() : ($val instanceof BackedEnum ? $val->value : $val);
$displayOldVal = ($oldVal instanceof HasLabel) ? $oldVal->getLabel() : ($oldVal instanceof BackedEnum ? $oldVal->value : $oldVal);
if ($displayOldVal !== $displayVal) {
$label = $fieldLabels[$field] ?? $field;
$changedFields[$label] = $displayVal;
$oldDataArr[$label] = $displayOldVal;
}
}
$documents = [
'journalism_organization_docs',
'press_council_certificate_docs',
];
foreach ($documents as $field) {
$newValue = $this->data[$field] ?? [];
$oldValue = $this->originalDocuments[$field] ?? [];
if (json_encode($newValue) !== json_encode($oldValue)) {
$label = $fieldLabels[$field] ?? $field;
$changedFields[$label] = $newValue;
$oldDataArr[$label] = $oldValue;
}
}
if (empty($changedFields)) {
CheerfulNotification::info(
'Belum ada yang berubah ✨',
'Ubah data terlebih dulu lalu simpan kembali 💪'
)
->send();
return;
}
$dataChangeRequest = DataChangeRequest::create([
'user_id' => auth()->id(),
'entity_type' => PartnerMedia::class,
'entity_id' => $this->media->id,
'old_data' => $oldDataArr,
'new_data' => $changedFields,
'change_reason' => $data['change_reason'],
]);
CheerfulNotification::success(
'Permohonan Berhasil 🎉',
'Data sudah dikirim dan sedang menunggu verifikasi admin ⏳'
)
->send();
User::superAdmin()
->get()
->each(function ($admin) use ($dataChangeRequest): void {
$admin->notify(new BroadcastNotification([
'title' => 'Ada Pengajuan Perubahan Data Media ✨',
'body' => 'Halooo Admin! 👋 '.auth()->user()->name.' baru saja mengajukan perubahan data media. Yuk, cek detailnya! 🚀',
'action' => [
Action::make('view')
->label('Lihat')
->url(DataChangesResource::getUrl('view', ['record' => $dataChangeRequest->id])),
],
]));
});
} else {
$state = $this->form->getState();
$media = PartnerMedia::updateOrCreate([
'id' => $this->media?->id,
], [
'company_id' => $this->company->id,
'name' => $state['name'],
'link' => $state['link'],
'address' => $state['address'],
'type' => $state['type'],
'classification' => $state['classification'],
'journalism_organization' => $state['journalism_organization'],
'press_council_certificate' => $state['press_council_certificate'],
]);
$this->media = $media;
$documents = [
'journalism_organization_docs',
'press_council_certificate_docs',
];
foreach ($documents as $field) {
if (empty($this->data[$field])) {
continue;
}
$media
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'media',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('partnerMedia');
}
}
foreach ((array) $this->data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
CheerfulNotification::update()->send();
if (! file_exists($fullPath)) {
continue;
}
$media
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'media',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('partnerMedia');
}
}
CheerfulNotification::create()->send();
}
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest;
use App\Enums\DataChangeDecision;
use App\Enums\DataChangeStatus;
use App\Enums\RoleEnum;
use App\Filament\Support\CheerfulNotification;
use App\Models\DataChangeRequest;
use App\Models\DataChangeReview;
use App\Notifications\BroadcastNotification;
use Filament\Actions\Action;
use Filament\Support\Contracts\HasLabel;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Storage;
class AcceptAction extends Action
{
protected function setUp(): void
{
parent::setUp();
$this->label('Setujui')
->icon(Heroicon::Check)
->color('success')
->requiresConfirmation()
->modalHeading('Setujui Perubahan Data')
->modalDescription('Apakah Anda yakin ingin menyetujui dan menerapkan perubahan data ini?')
->action(function (DataChangeRequest $record): void {
$entity = $record->entity;
$newData = $record->new_data;
if (isset($newData['__delete_request__']) && $newData['__delete_request__'] === true) {
$entity->delete();
$record->update(['status' => DataChangeStatus::APPROVED]);
DataChangeReview::create([
'data_change_request_id' => $record->id,
'reviewer_id' => auth()->id(),
'decision' => DataChangeDecision::APPROVED,
]);
CheerfulNotification::success(
'Penghapusan Disetujui! ✅',
'Data telah berhasil dihapus sesuai dengan permohonan.'
)->send();
// Notify User
$record->user?->notify(new BroadcastNotification([
'title' => 'Permohonan Penghapusan Disetujui ✨',
'body' => "Halo! Permohonan penghapusan data Anda untuk {$record->change_reason} telah disetujui oleh admin. 🚀",
]));
return;
}
if (! $entity) {
CheerfulNotification::danger(
'Entitas Tidak Ditemukan ❌',
'Data asli untuk permohonan ini tidak dapat ditemukan.'
)->send();
return;
}
$fields = [];
$docs = [];
$reverseLabels = [];
if (method_exists($record->entity_type, 'dataChangeEntryLabels')) {
$reverseLabels = array_flip(($record->entity_type)::dataChangeEntryLabels());
}
$casts = $entity->getCasts();
foreach ($newData as $key => $value) {
$actualKey = $reverseLabels[$key] ?? $key;
if (str_ends_with($actualKey, '_docs')) {
$docs[$actualKey] = $value;
} else {
// Handle Enum labels reverse mapping
if (isset($casts[$actualKey])) {
$castType = $casts[$actualKey];
if (is_string($castType) && enum_exists($castType)) {
if (is_subclass_of($castType, HasLabel::class)) {
foreach ($castType::cases() as $case) {
if ($case->getLabel() === $value) {
$value = $case->value;
break;
}
}
}
// If it's an int-backed enum but we have a string (e.g. from older data or failed mapping)
if (is_string($value) && is_numeric($value)) {
$reflection = new \ReflectionEnum($castType);
if ($reflection->isBacked() && $reflection->getBackingType()?->getName() === 'int') {
$value = (int) $value;
}
}
}
}
$fields[$actualKey] = $value;
}
}
// Update model fields
$entity->update($fields);
// Update media/documents
foreach ($docs as $field => $filePaths) {
if (empty($filePaths)) {
continue;
}
$docType = str($field)->replace('_docs', '')->slug('-');
$collection = match ($record->entity_type) {
'App\Models\PartnerMedia' => 'partnerMedia',
default => str($record->entity_type)->afterLast('\\')->plural()->lower()->toString(),
};
foreach ((array) $filePaths as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
if (! file_exists($fullPath)) {
continue;
}
$entity->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => $collection,
'date' => now()->toDateString(),
'doc_type' => $docType,
])
->toMediaCollection($collection);
}
}
$record->update(['status' => DataChangeStatus::APPROVED]);
DataChangeReview::create([
'data_change_request_id' => $record->id,
'reviewer_id' => auth()->id(),
'decision' => DataChangeDecision::APPROVED,
]);
CheerfulNotification::success(
'Perubahan Disetujui! ✅',
'Data telah berhasil diperbarui sesuai dengan permohonan.'
)->send();
// Notify User
$record->user?->notify(new BroadcastNotification([
'title' => 'Permohonan Perubahan Disetujui ✨',
'body' => "Halo! Permohonan perubahan data Anda untuk {$record->change_reason} telah disetujui oleh admin. Cek sekarang ya! 🚀",
]));
})
->visible(fn (DataChangeRequest $record): bool => $record->status === DataChangeStatus::PENDING && ! auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value));
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest;
use App\Enums\DataChangeDecision;
use App\Enums\DataChangeStatus;
use App\Enums\RoleEnum;
use App\Filament\Support\CheerfulNotification;
use App\Models\DataChangeRequest;
use App\Models\DataChangeReview;
use App\Notifications\BroadcastNotification;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Support\Enums\Width;
use Filament\Support\Icons\Heroicon;
class RejectAction extends Action
{
protected function setUp(): void
{
parent::setUp();
$this->label('Tolak')
->icon(Heroicon::XMark)
->color('danger')
->schema([
Textarea::make('rejection_reason')
->label('Alasan Penolakan')
->placeholder('Berikan alasan mengapa permohonan ini ditolak...')
->required(),
])
->modalHeading('Tolak Perubahan Data')
->action(function (DataChangeRequest $record, array $data): void {
$record->update([
'status' => DataChangeStatus::REJECTED,
]);
DataChangeReview::create([
'data_change_request_id' => $record->id,
'reviewer_id' => auth()->id(),
'decision' => DataChangeDecision::REJECTED,
'note' => $data['rejection_reason'],
]);
// If it was a new addition (empty old_data), delete the entity
$entity = $record->entity;
if (empty($record->old_data) && $entity) {
$entity->forceDelete();
}
CheerfulNotification::info(
'Permohonan Ditolak 🛑',
'Permohonan perubahan data telah ditolak. 😔'
)->send();
// Notify User
$record->user?->notify(new BroadcastNotification([
'title' => 'Permohonan Perubahan Ditolak 🛑',
'body' => "Halo! Mohon maaf, permohonan perubahan data Anda ({$record->change_reason}) ditolak oleh admin. Alasan: {$data['rejection_reason']} 😔",
]));
})
->visible(fn (DataChangeRequest $record): bool => $record->status === DataChangeStatus::PENDING && ! auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value))
->modalWidth(Width::Large);
}
}

View File

@ -0,0 +1,282 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges;
use App\Enums\DataChangeStatus;
use App\Enums\RoleEnum;
use App\Filament\Actions\DefaultBulkActions;
use App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest\AcceptAction;
use App\Filament\Resources\Manage\DataChanges\Actions\DataChangeRequest\RejectAction;
use App\Filament\Resources\Manage\DataChanges\Pages\ManageDataChanges;
use App\Filament\Resources\Manage\DataChanges\Pages\ViewDataChange;
use App\Models\Company;
use App\Models\DataChangeRequest;
use App\Models\Journalist;
use App\Models\PartnerMedia;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Storage;
use UnitEnum;
class DataChangesResource extends Resource
{
protected static ?string $model = DataChangeRequest::class;
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static string|BackedEnum|null $navigationIcon = Heroicon::DocumentText;
protected static ?string $navigationLabel = 'Permohonan Perubahan Data';
protected static ?int $navigationSort = 3;
protected static ?string $recordTitleAttribute = 'change_reason';
protected static ?string $slug = 'manage/data-changes';
public static function getNavigationBadge(): string
{
return static::getModel()::pending()
->when(auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value), function (Builder $query): void {
$query->where('user_id', auth()->id());
})
->count();
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Perbandingan Data')
->schema([
TextEntry::make('comparison')
->hiddenLabel()
->html()
->state(function (DataChangeRequest $record): string {
$old = $record->old_data ?? [];
$new = $record->new_data ?? [];
if (! is_array($old) || ! is_array($new)) {
return '<em>Format data tidak valid.</em>';
}
$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;'>🚮 Permohonan Penghapusan Data</div>
<p style='font-size:0.875rem;'>User mengajukan penghapusan permanen untuk data ini. Semua informasi terkait akan dihapus setelah disetujui.</p>
</div>
";
}
// 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;'> Permohonan Penambahan Data</div>
<p style='font-size:0.875rem;'>Berikut adalah data baru yang akan dibuat.</p>
</div>
{$rows}
";
}
// 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('');
})
->columnSpanFull(),
]),
])
->columns(1);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.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__']) => '🗑️ Penghapusan',
empty($record->old_data) => '✨ Penambahan',
default => '📝 Perubahan',
})
->formatStateUsing(fn (DataChangeRequest $record): ?string => match ($record->entity_type) {
Company::class => 'Perusahaan',
PartnerMedia::class => 'Media',
Journalist::class => 'Jurnalis',
default => 'Lainnya',
})
->color(fn (DataChangeRequest $record): ?string => match ($record->entity_type) {
Company::class => 'success',
PartnerMedia::class => 'info',
Journalist::class => 'warning',
default => 'secondary',
})
->badge(),
TextColumn::make('status')
->label('Status')
->searchable()
->sortable()
->badge()
->formatStateUsing(fn (DataChangeStatus $state): string => $state->getLabel())
->color(fn (DataChangeStatus $state): string => $state->getColor())
->description(fn (DataChangeRequest $record): ?string => $record?->review ? 'Oleh: '.$record->review?->reviewer?->name : ''),
TextColumn::make('change_reason')
->label('Alasan Perubahan')
->limit(50),
TextColumn::make('review.note')
->label('Alasan Penolakan')
->limit(50),
TextColumn::make('created_at')
->label('Tgl Permohonan')
->date()
->sortable()
->dateTime('l, d F Y H:i:s')
->wrap(),
])
->filters([
TrashedFilter::make()
->native(false)
->visible(fn () => auth()->user()?->hasRole('Developer')),
])
->recordActions([
ViewAction::make(),
AcceptAction::make('accept'),
RejectAction::make('reject'),
])
->toolbarActions([
BulkActionGroup::make([
...DefaultBulkActions::make('permohonan perubahan data'),
]),
])
->emptyStateIcon(Heroicon::DocumentText)
->emptyStateDescription('Belum ada permohonan perubahan data.')
->defaultSort('created_at', 'desc')
->deferFilters(false)
->modifyQueryUsing(function (Builder $query): void {
$query->when(auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value), function (Builder $q): Builder {
return $q->where('user_id', auth()->id());
});
});
}
public static function getPages(): array
{
return [
'index' => ManageDataChanges::route('/'),
'view' => ViewDataChange::route('/{record}'),
];
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges\Pages;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use Filament\Resources\Pages\ListRecords;
class ManageDataChanges extends ListRecords
{
protected static string $resource = DataChangesResource::class;
protected static ?string $title = 'Permohonan Perubahan Data';
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\Manage\DataChanges\Pages;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ViewRecord;
class ViewDataChange extends ViewRecord
{
protected static string $resource = DataChangesResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('back')
->label('Kembali')
->url(DataChangesResource::getUrl())
->outlined()
->color('secondary'),
];
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace App\Filament\Resources\Manage\Journalists\Actions;
use App\Enums\VerificationStatus;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use App\Filament\Support\CheerfulNotification;
use App\Models\DataChangeRequest;
use App\Models\Journalist;
use App\Models\User;
use App\Notifications\BroadcastNotification;
use Filament\Actions\Action;
use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CreateJournalistAction extends CreateAction
{
protected function setUp(): void
{
parent::setUp();
$this->label('Tambah')
->modalHeading('Tambah Jurnalis')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->successNotification(null)
->extraModalFooterActions(fn (CreateAction $action): array => [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::Large)
->using(function (array $data, string $model): Model {
$data['partner_media_id'] = auth()->user()->company?->partnerMedia?->id;
$journalist = $model::create($data);
$documents = [
'press_card_docs',
'ukw_certificate_docs',
];
foreach ($documents as $field) {
if (empty($data[$field])) {
continue;
}
foreach ((array) $data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
if (! file_exists($fullPath)) {
continue;
}
$journalist
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'journalists',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('journalists');
}
}
// If company is already approved, addition of new journalist needs permohonan/review
if (auth()->user()->company?->status === VerificationStatus::APPROVED) {
$fieldLabels = Journalist::dataChangeEntryLabels();
$newFields = [];
foreach (['name', 'email', 'phone_number', 'press_card', 'ukw_certificate'] as $f) {
$label = $fieldLabels[$f] ?? $f;
$newFields[$label] = $data[$f] ?? '-';
}
foreach (['press_card_docs', 'ukw_certificate_docs'] as $df) {
$label = $fieldLabels[$df] ?? $df;
$newFields[$label] = $data[$df] ?? [];
}
$dataChangeRequest = DataChangeRequest::create([
'user_id' => auth()->id(),
'entity_type' => Journalist::class,
'entity_id' => $journalist->id,
'old_data' => [],
'new_data' => $newFields,
'change_reason' => 'Penambahan jurnalis baru',
]);
CheerfulNotification::success(
'Permohonan Berhasil 🎉',
'Data jurnalis baru telah dikirim dan sedang menunggu verifikasi admin ⏳'
)->send();
User::superAdmin()
->get()
->each(function ($admin) use ($dataChangeRequest): void {
$admin->notify(new BroadcastNotification([
'title' => 'Ada Penambahan Jurnalis Baru ✨',
'body' => 'Halooo Admin! 👋 '.auth()->user()->name.' baru saja menambahkan jurnalis baru. Yuk, cek detailnya! 🚀',
'action' => [
Action::make('view')
->label('Lihat')
->url(DataChangesResource::getUrl('view', ['record' => $dataChangeRequest->id])),
],
]));
});
} else {
CheerfulNotification::create()->send();
}
return $journalist;
});
}
}

View File

@ -2,10 +2,18 @@
namespace App\Filament\Resources\Manage\Journalists\Actions;
use App\Enums\DataChangeStatus;
use App\Enums\RoleEnum;
use App\Enums\VerificationStatus;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use App\Filament\Support\CheerfulNotification;
use App\Models\DataChangeRequest;
use App\Models\Journalist;
use App\Models\User;
use App\Notifications\BroadcastNotification;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Textarea;
class DeleteJournalistAction extends DeleteAction
{
@ -13,12 +21,74 @@ protected function setUp(): void
{
parent::setUp();
$this->successNotification(
CheerfulNotification::delete()
);
$this->successNotification(null);
$this->modalHeading(fn () => auth()->user()->company?->status === VerificationStatus::APPROVED && auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value) ? 'Ajukan Penghapusan Jurnalis' : 'Hapus Jurnalis');
$this->schema(fn () => auth()->user()->company?->status === VerificationStatus::APPROVED && auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value) ? [
Textarea::make('deletion_reason')
->label('Alasan Penghapusan')
->placeholder('Jelaskan alasan mengapa jurnalis ini dihapus...')
->required(),
] : []);
$this->action(function (Journalist $record, array $data): void {
$user = auth()->user();
$needsReview = $user->company?->status === VerificationStatus::APPROVED &&
$user->hasRole(RoleEnum::PERUSAHAAN->value);
if ($needsReview) {
$existingRequest = DataChangeRequest::where('entity_type', Journalist::class)
->where('entity_id', $record->id)
->where('status', DataChangeStatus::PENDING)
->exists();
if ($existingRequest) {
CheerfulNotification::warning(
'Pengajuan Sudah Ada ⏳',
'Masih ada pengajuan penghapusan jurnalis yang sedang menunggu verifikasi.'
)->send();
return;
}
$dataChangeRequest = DataChangeRequest::create([
'user_id' => $user->id,
'entity_type' => Journalist::class,
'entity_id' => $record->id,
'old_data' => $record->toArray(),
'new_data' => ['__delete_request__' => true],
'change_reason' => $data['deletion_reason'] ?? 'Penghapusan jurnalis',
]);
CheerfulNotification::success(
'Permohonan Berhasil 🎉',
'Permohonan penghapusan jurnalis telah dikirim dan sedang menunggu verifikasi admin ⏳'
)->send();
User::superAdmin()
->get()
->each(function ($admin) use ($user, $record, $dataChangeRequest): void {
$admin->notify(new BroadcastNotification([
'title' => 'Ada Pengajuan Penghapusan Jurnalis ✨',
'body' => 'Halooo Admin! 👋 '.$user->name.' baru saja mengajukan penghapusan jurnalis '.$record->name.'. Yuk, cek detailnya! 🚀',
'action' => [
Action::make('view')
->label('Lihat')
->url(DataChangesResource::getUrl('view', ['record' => $dataChangeRequest->id])),
],
]));
});
return;
}
$record->delete();
CheerfulNotification::delete()->send();
});
$this->visible(function (): bool {
$user = auth()->user();
if (! $user->hasRole(RoleEnum::PERUSAHAAN->value)) {
@ -29,15 +99,6 @@ protected function setUp(): void
return false;
}
$verificationRequest = $user->company
->verificationRequest()
->latest()
->first();
if ($verificationRequest && $verificationRequest->status !== VerificationStatus::NEED_REVISION) {
return false;
}
return true;
});
}

View File

@ -2,13 +2,19 @@
namespace App\Filament\Resources\Manage\Journalists\Actions;
use App\Enums\DataChangeStatus;
use App\Enums\RoleEnum;
use App\Enums\VerificationStatus;
use App\Filament\Resources\Manage\DataChanges\DataChangesResource;
use App\Filament\Support\CheerfulNotification;
use App\Models\DataChangeRequest;
use App\Models\Journalist;
use App\Models\User;
use App\Notifications\BroadcastNotification;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Support\Contracts\HasLabel;
use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class EditJournalistAction extends EditAction
@ -17,83 +23,151 @@ protected function setUp(): void
{
parent::setUp();
$this->successNotification(
CheerfulNotification::update()
);
$this->modalWidth(Width::Large)
->successNotification(null)
->fillForm(function (Journalist $journalist): array {
$data = [
'name' => $journalist->name,
'email' => $journalist->email,
'phone_number' => $journalist->phone_number,
'press_card' => $journalist->press_card,
'ukw_certificate' => $journalist->ukw_certificate,
];
$documents = [
'press_card',
'ukw_certificate',
];
$mediaItems = $journalist->getMedia('journalists');
foreach ($documents as $docType) {
$media = $mediaItems
->where('custom_properties.doc_type', str($docType)->replace('_docs', '')->slug('-'))
->sortByDesc('created_at')
->first();
if ($media) {
$data["{$docType}_docs"] = [$media->getPathRelativeToRoot()];
}
}
return $data;
})
->using(function (Model $record, array $data): Model {
$record->update([
'name' => $data['name'],
'email' => $data['email'],
'phone_number' => $data['phone_number'],
'press_card' => $data['press_card'],
'ukw_certificate' => $data['ukw_certificate'],
]);
$data = $journalist->attributesToArray();
$documents = [
'press_card_docs',
'ukw_certificate_docs',
];
foreach ($documents as $field) {
if (empty($data[$field])) {
continue;
}
$mediaItems = $journalist->getMedia('journalists');
foreach ((array) $data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
foreach ($documents as $docField) {
$docType = str($docField)->replace('_docs', '')->slug('-');
$media = $mediaItems
->where('custom_properties.doc_type', $docType)
->sortByDesc('created_at')
->first();
if (! file_exists($fullPath)) {
continue;
}
$record
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'journalists',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('journalists');
if ($media) {
$data[$docField] = [$media->getPathRelativeToRoot()];
}
}
return $data;
})
->using(function (Journalist $record, array $data): Journalist {
$needsReview = auth()->user()->company?->status === VerificationStatus::APPROVED &&
auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value);
if ($needsReview) {
$existingRequest = DataChangeRequest::where('entity_type', Journalist::class)
->where('entity_id', $record->id)
->where('status', DataChangeStatus::PENDING)
->exists();
if ($existingRequest) {
CheerfulNotification::warning(
'Pengajuan Sudah Ada ⏳',
'Masih ada pengajuan perubahan data yang sedang menunggu verifikasi.'
)->send();
return $record;
}
}
$fieldLabels = Journalist::dataChangeEntryLabels();
$editableFields = ['name', 'email', 'phone_number', 'press_card', 'ukw_certificate'];
$changedFields = [];
$oldDataArr = [];
$casts = $record->getCasts();
foreach ($editableFields as $field) {
$val = $data[$field] ?? null;
$oldVal = $record->{$field};
// If value is a raw scalar but should be an enum, resolve it for display
if (isset($casts[$field]) && ! ($val instanceof \BackedEnum) && enum_exists($casts[$field])) {
$enumClass = $casts[$field];
$val = $enumClass::tryFrom($val) ?? $val;
}
$displayVal = ($val instanceof HasLabel) ? $val->getLabel() : ($val instanceof \BackedEnum ? $val->value : $val);
$displayOldVal = ($oldVal instanceof HasLabel) ? $oldVal->getLabel() : ($oldVal instanceof \BackedEnum ? $oldVal->value : $oldVal);
if ($displayOldVal !== $displayVal) {
$label = $fieldLabels[$field] ?? $field;
$changedFields[$label] = $displayVal;
$oldDataArr[$label] = $displayOldVal;
}
}
$documentFields = ['press_card_docs', 'ukw_certificate_docs'];
$mediaItems = $record->getMedia('journalists');
foreach ($documentFields as $field) {
$newDocs = array_values((array) ($data[$field] ?? []));
sort($newDocs);
$docType = str($field)->replace('_docs', '')->slug('-');
$currentMedia = $mediaItems
->where('custom_properties.doc_type', $docType)
->sortByDesc('created_at')
->first();
$oldDocs = $currentMedia ? [$currentMedia->getPathRelativeToRoot()] : [];
$oldDocs = array_values($oldDocs);
sort($oldDocs);
if (json_encode($newDocs) !== json_encode($oldDocs)) {
$label = $fieldLabels[$field] ?? $field;
$changedFields[$label] = $newDocs;
$oldDataArr[$label] = $oldDocs;
}
}
if (empty($changedFields)) {
CheerfulNotification::info(
'Belum ada yang berubah ✨',
'Ubah data terlebih dulu lalu simpan kembali 💪'
)->send();
return $record;
}
if ($needsReview) {
$dataChangeRequest = DataChangeRequest::create([
'user_id' => auth()->id(),
'entity_type' => Journalist::class,
'entity_id' => $record->id,
'old_data' => $oldDataArr,
'new_data' => $changedFields,
'change_reason' => $data['change_reason'] ?? '-',
]);
CheerfulNotification::success(
'Permohonan Berhasil 🎉',
'Data sudah dikirim dan sedang menunggu verifikasi admin ⏳'
)->send();
User::superAdmin()
->get()
->each(function ($admin) use ($dataChangeRequest): void {
$admin->notify(new BroadcastNotification([
'title' => 'Ada Pengajuan Perubahan Jurnalis ✨',
'body' => 'Halooo Admin! 👋 '.auth()->user()->name.' baru saja mengajukan perubahan data jurnalis. Yuk, cek detailnya! 🚀',
'action' => [
Action::make('view')
->label('Lihat')
->url(DataChangesResource::getUrl('view', ['record' => $dataChangeRequest->id])),
],
]));
});
return $record;
}
$this->performSave($record, $data);
CheerfulNotification::update()->send(); // Manual success notification
return $record;
})
->visible(function (): bool {
->visible(function (?Journalist $record): bool {
$user = auth()->user();
if (! $user->hasRole(RoleEnum::PERUSAHAAN->value)) {
@ -104,16 +178,57 @@ protected function setUp(): void
return false;
}
$verificationRequest = $user->company
->verificationRequest()
->latest()
->first();
if ($verificationRequest && $verificationRequest->status !== VerificationStatus::NEED_REVISION) {
return false;
}
return true;
});
}
private function performSave(Journalist $record, array $data): void
{
$record->update([
'name' => $data['name'],
'email' => $data['email'],
'phone_number' => $data['phone_number'],
'press_card' => $data['press_card'],
'ukw_certificate' => $data['ukw_certificate'],
]);
$documents = [
'press_card_docs',
'ukw_certificate_docs',
];
$existingMedia = $record->getMedia('journalists');
$existingPaths = $existingMedia->map(fn ($m) => $m->getPathRelativeToRoot())->toArray();
foreach ($documents as $field) {
if (empty($data[$field])) {
continue;
}
foreach ((array) $data[$field] as $filePath) {
// If this path is already from one of our media items, skip re-adding it
if (in_array($filePath, $existingPaths)) {
continue;
}
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
if (! file_exists($fullPath)) {
continue;
}
$record
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'journalists',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('journalists');
}
}
}
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\Manage\Journalists;
use App\Enums\RoleEnum;
use App\Enums\VerificationStatus;
use App\Filament\Actions\Cheerful\ForceDeleteAction;
use App\Filament\Actions\Cheerful\RestoreAction;
use App\Filament\Columns\TimestampColumns;
@ -18,6 +19,7 @@
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Group;
@ -47,6 +49,17 @@ class JournalistResource extends Resource
protected static ?string $slug = 'manage/journalists';
public static function getNavigationBadge(): ?string
{
return static::getModel()::pending()
->when(auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value), function (Builder $query): void {
$query->whereHas('partnerMedia.company', function (Builder $q): void {
$q->where('user_id', auth()->id());
});
})
->count() ?: null;
}
public static function form(Schema $schema): Schema
{
$documents = [
@ -123,6 +136,16 @@ public static function form(Schema $schema): Schema
})
->toArray()
),
Section::make('Alasan Perubahan')
->schema([
Textarea::make('change_reason')
->label('Alasan')
->placeholder('Jelaskan alasan perubahan data ini...')
->rows(3)
->required(),
])
->visible(fn () => auth()->user()->company?->status === VerificationStatus::APPROVED && auth()->user()->hasRole(RoleEnum::PERUSAHAAN->value)),
])
->columns(1);
}

View File

@ -3,13 +3,9 @@
namespace App\Filament\Resources\Manage\Journalists\Pages;
use App\Enums\RoleEnum;
use App\Enums\VerificationStatus;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Filament\Resources\Manage\Journalists\Actions\CreateJournalistAction;
use App\Filament\Resources\Manage\Journalists\JournalistResource;
use Filament\Resources\Pages\ManageRecords;
use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ManageJournalists extends ManageRecords
{
@ -23,63 +19,8 @@ protected function getHeaderActions(): array
return [];
}
$verificationRequest = auth()->user()->company?->verificationRequest()
->latest()
->first();
if ($verificationRequest && $verificationRequest->status !== VerificationStatus::NEED_REVISION) {
return [];
}
return [
CreateAction::make()
->label('Tambah')
->modalHeading('Tambah Jurnalis')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->extraModalFooterActions(fn (CreateAction $action): array => [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::Large)
->using(function (array $data, string $model): Model {
$data['partner_media_id'] = auth()->user()->company?->partnerMedia?->id;
$journalist = $model::create($data);
$documents = [
'press_card_docs',
'ukw_certificate_docs',
];
foreach ($documents as $field) {
if (empty($data[$field])) {
continue;
}
foreach ((array) $data[$field] as $filePath) {
$fullPath = Storage::disk(config('filesystems.default'))->path($filePath);
if (! file_exists($fullPath)) {
continue;
}
$journalist
->addMediaFromDisk($filePath, config('filesystems.default'))
->preservingOriginal()
->withCustomProperties([
'feature' => 'journalists',
'date' => now()->toDateString(),
'doc_type' => str($field)
->replace('_docs', '')
->slug('-'),
])
->toMediaCollection('journalists');
}
}
return $journalist;
}),
CreateJournalistAction::make(),
];
}
}

View File

@ -45,4 +45,30 @@ public function verificationRequest(): HasOne
{
return $this->hasOne(VerificationRequest::class);
}
public static function dataChangeEntryLabels(): array
{
return [
'name' => 'Nama',
'email' => 'Alamat Surel',
'address' => 'Alamat',
'director_name' => 'Nama Direktur',
'director_nik' => 'NIK Direktur',
'director_nik_docs' => 'Dokumen NIK Direktur',
'deed_incorporation' => 'No. Akta Pendirian',
'deed_incorporation_docs' => 'Dokumen Akta Pendirian',
'trade_license' => 'No. SIUP / NIB',
'trade_license_docs' => 'Dokumen SIUP / NIB',
'tax_id_number' => 'No. NPWP',
'tax_id_number_docs' => 'Dokumen NPWP',
'taxable_enterprise' => 'No. PKP',
'taxable_enterprise_docs' => 'Dokumen PKP',
'annual_tax_return' => 'No. SPT Tahunan',
'annual_tax_return_docs' => 'Dokumen SPT Tahunan',
'domicile_certificate' => 'No. Suket Domisili',
'domicile_certificate_docs' => 'Dokumen Suket Domisili',
'profile' => 'No. Profil Perusahaan',
'profile_docs' => 'Dokumen Profil Perusahaan',
];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Enums\DataChangeStatus;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Swindon\FilamentHashids\Traits\HasHashid;
class DataChangeRequest extends Model
{
use HasHashid, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'status' => DataChangeStatus::class,
'old_data' => 'array',
'new_data' => 'array',
];
}
#[Scope]
protected function pending(Builder $query): void
{
$query->where('status', DataChangeStatus::PENDING);
}
#[Scope]
protected function approved(Builder $query): void
{
$query->where('status', DataChangeStatus::APPROVED);
}
#[Scope]
protected function rejected(Builder $query): void
{
$query->where('status', DataChangeStatus::REJECTED);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function review(): HasOne
{
return $this->hasOne(DataChangeReview::class);
}
public function entity(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Enums\DataChangeDecision;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class DataChangeReview extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'decision' => DataChangeDecision::class,
];
}
public function request(): BelongsTo
{
return $this->belongsTo(DataChangeRequest::class, 'data_change_request_id');
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
}

View File

@ -28,4 +28,22 @@ public function partnerMedia(): BelongsTo
{
return $this->belongsTo(PartnerMedia::class);
}
public function scopePending($query)
{
return $query->where('status', VerificationStatus::PENDING);
}
public static function dataChangeEntryLabels(): array
{
return [
'name' => 'Nama',
'email' => 'Alamat Surel',
'phone_number' => 'Nomor Telepon',
'press_card' => 'No. Kartu Pers',
'press_card_docs' => 'Dokumen Kartu Pers',
'ukw_certificate' => 'No. Sertifikat UKW',
'ukw_certificate_docs' => 'Dokumen Sertifikat UKW',
];
}
}

View File

@ -131,4 +131,19 @@ public function taskAssignments(): BelongsToMany
->withPivot(['status'])
->withTimestamps();
}
public static function dataChangeEntryLabels(): array
{
return [
'name' => 'Nama',
'link' => 'Tautan',
'address' => 'Alamat',
'type' => 'Jenis',
'classification' => 'Klasifikasi',
'journalism_organization' => 'No. Organisasi Kewartawanan',
'journalism_organization_docs' => 'Dokumen Organisasi Kewartawanan',
'press_council_certificate' => 'No. Sertifikat Dewan Pers',
'press_council_certificate_docs' => 'Dokumen Sertifikat Dewan Pers',
];
}
}

View File

@ -110,6 +110,8 @@ public function panel(Panel $panel): Panel
'Pelindung',
])
->databaseNotifications()
->databaseNotificationsPolling('30s');
->databaseNotificationsPolling('30s')
->brandLogo(fn () => Storage::url($generalSettings->site_logo))
->favicon(fn () => Storage::url($generalSettings->site_icon));
}
}

View File

@ -0,0 +1,37 @@
<?php
use App\Enums\DataChangeStatus;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('data_change_requests', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class);
$table->morphs('entity');
$table->enum('status', DataChangeStatus::cases())->default(DataChangeStatus::PENDING)->comment(DataChangeStatus::comment());
$table->json('old_data')->nullable();
$table->json('new_data');
$table->text('change_reason')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('data_change_requests');
}
};

View File

@ -0,0 +1,36 @@
<?php
use App\Enums\DataChangeDecision;
use App\Models\DataChangeRequest;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('data_change_reviews', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(DataChangeRequest::class);
$table->foreignIdFor(User::class, 'reviewer_id');
$table->enum('decision', DataChangeDecision::cases())->comment(DataChangeDecision::comment());
$table->text('note')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('data_change_reviews');
}
};

View File

@ -1,13 +1,9 @@
<x-filament-panels::page>
<form wire:submit="save">
<form wire:submit.prevent="save">
{{ $this->form }}
@if (
!$verificationRequest ||
($verificationRequest && $verificationRequest->status === App\Enums\VerificationStatus::NEED_REVISION))
<div class="mt-6">
<x-filament::button type="submit">Simpan</x-filament::button>
</div>
@endif
<div class="mt-6">
{{ $this->saveAction() }}
</div>
</form>
</x-filament-panels::page>

View File

@ -29,16 +29,12 @@ class="dark:text-gray-400">
</div>
</div>
@else
<form wire:submit="save">
<form wire:submit.prevent="save">
{{ $this->form }}
@if (
!$verificationRequest ||
($verificationRequest && $verificationRequest->status === App\Enums\VerificationStatus::NEED_REVISION))
<div class="mt-6">
<x-filament::button type="submit">Simpan</x-filament::button>
</div>
@endif
<div class="mt-6">
{{ $this->saveAction() }}
</div>
</form>
@endif
</x-filament-panels::page>