diff --git a/app/Enums/DataChangeDecision.php b/app/Enums/DataChangeDecision.php new file mode 100644 index 0000000..b61cbec --- /dev/null +++ b/app/Enums/DataChangeDecision.php @@ -0,0 +1,39 @@ + '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(); + } +} diff --git a/app/Enums/DataChangeStatus.php b/app/Enums/DataChangeStatus.php new file mode 100644 index 0000000..5b9e4d0 --- /dev/null +++ b/app/Enums/DataChangeStatus.php @@ -0,0 +1,42 @@ + '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(); + } +} diff --git a/app/Filament/Pages/AdminVerification.php b/app/Filament/Pages/AdminVerification.php index 0eddcc3..e2dbc14 100644 --- a/app/Filament/Pages/AdminVerification.php +++ b/app/Filament/Pages/AdminVerification.php @@ -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(); diff --git a/app/Filament/Pages/Company.php b/app/Filament/Pages/Company.php index fdfaacf..4b739c8 100644 --- a/app/Filament/Pages/Company.php +++ b/app/Filament/Pages/Company.php @@ -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(); } } diff --git a/app/Filament/Pages/Media.php b/app/Filament/Pages/Media.php index 938c90e..d184083 100644 --- a/app/Filament/Pages/Media.php +++ b/app/Filament/Pages/Media.php @@ -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(); + } } } diff --git a/app/Filament/Resources/Manage/DataChanges/Actions/DataChangeRequest/AcceptAction.php b/app/Filament/Resources/Manage/DataChanges/Actions/DataChangeRequest/AcceptAction.php new file mode 100644 index 0000000..2223512 --- /dev/null +++ b/app/Filament/Resources/Manage/DataChanges/Actions/DataChangeRequest/AcceptAction.php @@ -0,0 +1,162 @@ +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)); + } +} diff --git a/app/Filament/Resources/Manage/DataChanges/Actions/DataChangeRequest/RejectAction.php b/app/Filament/Resources/Manage/DataChanges/Actions/DataChangeRequest/RejectAction.php new file mode 100644 index 0000000..3ba5ba9 --- /dev/null +++ b/app/Filament/Resources/Manage/DataChanges/Actions/DataChangeRequest/RejectAction.php @@ -0,0 +1,65 @@ +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); + } +} diff --git a/app/Filament/Resources/Manage/DataChanges/DataChangesResource.php b/app/Filament/Resources/Manage/DataChanges/DataChangesResource.php new file mode 100644 index 0000000..ccf7191 --- /dev/null +++ b/app/Filament/Resources/Manage/DataChanges/DataChangesResource.php @@ -0,0 +1,282 @@ +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 'Format data tidak valid.'; + } + + $renderValue = function ($value) { + if (empty($value)) { + return 'Kosong'; + } + + $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 ""; + } + + if ($extension === 'pdf') { + return " +
+ + ↗️ Buka PDF di Tab Baru +
+ "; + } + + return "📎 Buka Dokumen (".strtoupper($extension).')'; + }) + ->implode('
'); + } + + return e(is_scalar($value) ? $value : json_encode($value)); + }; + + // 1. Deletion Request + if (isset($new['__delete_request__']) && $new['__delete_request__'] === true) { + return " +
+
🚮 Permohonan Penghapusan Data
+

User mengajukan penghapusan permanen untuk data ini. Semua informasi terkait akan dihapus setelah disetujui.

+
+ "; + } + + // 2. New Addition Request + if (empty($old)) { + $rows = collect($new) + ->map(function ($value, $key) use ($renderValue) { + return sprintf( + "
+
%s
+
[BARU]
+
%s
+
", + e($key), + $renderValue($value) + ); + }) + ->implode(''); + + return " +
+
✨ Permohonan Penambahan Data
+

Berikut adalah data baru yang akan dibuat.

+
+ {$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( + "
+
%s
+
+
+ LAMA +
%s
+
+
+ BARU +
%s
+
+
+
", + 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}'), + ]; + } +} diff --git a/app/Filament/Resources/Manage/DataChanges/Pages/ManageDataChanges.php b/app/Filament/Resources/Manage/DataChanges/Pages/ManageDataChanges.php new file mode 100644 index 0000000..affc40d --- /dev/null +++ b/app/Filament/Resources/Manage/DataChanges/Pages/ManageDataChanges.php @@ -0,0 +1,13 @@ +label('Kembali') + ->url(DataChangesResource::getUrl()) + ->outlined() + ->color('secondary'), + ]; + } +} diff --git a/app/Filament/Resources/Manage/Journalists/Actions/CreateJournalistAction.php b/app/Filament/Resources/Manage/Journalists/Actions/CreateJournalistAction.php new file mode 100644 index 0000000..171c22b --- /dev/null +++ b/app/Filament/Resources/Manage/Journalists/Actions/CreateJournalistAction.php @@ -0,0 +1,119 @@ +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; + }); + } +} diff --git a/app/Filament/Resources/Manage/Journalists/Actions/DeleteJournalistAction.php b/app/Filament/Resources/Manage/Journalists/Actions/DeleteJournalistAction.php index 8715571..a7b7e2c 100644 --- a/app/Filament/Resources/Manage/Journalists/Actions/DeleteJournalistAction.php +++ b/app/Filament/Resources/Manage/Journalists/Actions/DeleteJournalistAction.php @@ -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; }); } diff --git a/app/Filament/Resources/Manage/Journalists/Actions/EditJournalistAction.php b/app/Filament/Resources/Manage/Journalists/Actions/EditJournalistAction.php index cb5d930..3a3c7c4 100644 --- a/app/Filament/Resources/Manage/Journalists/Actions/EditJournalistAction.php +++ b/app/Filament/Resources/Manage/Journalists/Actions/EditJournalistAction.php @@ -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'); + } + } + } } diff --git a/app/Filament/Resources/Manage/Journalists/JournalistResource.php b/app/Filament/Resources/Manage/Journalists/JournalistResource.php index 33c42a0..cd3918b 100644 --- a/app/Filament/Resources/Manage/Journalists/JournalistResource.php +++ b/app/Filament/Resources/Manage/Journalists/JournalistResource.php @@ -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); } diff --git a/app/Filament/Resources/Manage/Journalists/Pages/ManageJournalists.php b/app/Filament/Resources/Manage/Journalists/Pages/ManageJournalists.php index 4a564d5..a1eb103 100644 --- a/app/Filament/Resources/Manage/Journalists/Pages/ManageJournalists.php +++ b/app/Filament/Resources/Manage/Journalists/Pages/ManageJournalists.php @@ -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(), ]; } } diff --git a/app/Models/Company.php b/app/Models/Company.php index 5c202b2..4aa4882 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -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', + ]; + } } diff --git a/app/Models/DataChangeRequest.php b/app/Models/DataChangeRequest.php new file mode 100644 index 0000000..a82728c --- /dev/null +++ b/app/Models/DataChangeRequest.php @@ -0,0 +1,62 @@ + 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(); + } +} diff --git a/app/Models/DataChangeReview.php b/app/Models/DataChangeReview.php new file mode 100644 index 0000000..6dbb6d6 --- /dev/null +++ b/app/Models/DataChangeReview.php @@ -0,0 +1,32 @@ + 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'); + } +} diff --git a/app/Models/Journalist.php b/app/Models/Journalist.php index a6afd14..0635777 100644 --- a/app/Models/Journalist.php +++ b/app/Models/Journalist.php @@ -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', + ]; + } } diff --git a/app/Models/PartnerMedia.php b/app/Models/PartnerMedia.php index 7a530a1..7e7ccbe 100644 --- a/app/Models/PartnerMedia.php +++ b/app/Models/PartnerMedia.php @@ -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', + ]; + } } diff --git a/app/Providers/Filament/DashboardPanelProvider.php b/app/Providers/Filament/DashboardPanelProvider.php index df892bf..df3bd91 100644 --- a/app/Providers/Filament/DashboardPanelProvider.php +++ b/app/Providers/Filament/DashboardPanelProvider.php @@ -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)); } } diff --git a/database/migrations/2026_01_28_110008_create_data_change_requests_table.php b/database/migrations/2026_01_28_110008_create_data_change_requests_table.php new file mode 100644 index 0000000..e4f4227 --- /dev/null +++ b/database/migrations/2026_01_28_110008_create_data_change_requests_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_01_28_110032_create_data_change_reviews_table.php b/database/migrations/2026_01_28_110032_create_data_change_reviews_table.php new file mode 100644 index 0000000..b900f2b --- /dev/null +++ b/database/migrations/2026_01_28_110032_create_data_change_reviews_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/resources/views/filament/pages/company.blade.php b/resources/views/filament/pages/company.blade.php index fed56ed..01e2f4e 100644 --- a/resources/views/filament/pages/company.blade.php +++ b/resources/views/filament/pages/company.blade.php @@ -1,13 +1,9 @@ -
+ {{ $this->form }} - @if ( - !$verificationRequest || - ($verificationRequest && $verificationRequest->status === App\Enums\VerificationStatus::NEED_REVISION)) -
- Simpan -
- @endif +
+ {{ $this->saveAction() }} +
diff --git a/resources/views/filament/pages/media.blade.php b/resources/views/filament/pages/media.blade.php index 64521ba..e4dac6b 100644 --- a/resources/views/filament/pages/media.blade.php +++ b/resources/views/filament/pages/media.blade.php @@ -29,16 +29,12 @@ class="dark:text-gray-400"> @else -
+ {{ $this->form }} - @if ( - !$verificationRequest || - ($verificationRequest && $verificationRequest->status === App\Enums\VerificationStatus::NEED_REVISION)) -
- Simpan -
- @endif +
+ {{ $this->saveAction() }} +
@endif