From b4d2ae52f0fc6edb6d19f313602b1315f598b638 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 22 Dec 2025 15:00:19 +0700 Subject: [PATCH] feat: Implement a comprehensive verification system with dedicated pages, models, and UI components for companies, media, and journalists. --- app/Enums/DecisionAdmin.php | 42 ++ app/Enums/VerificationStatus.php | 45 ++ app/Filament/Pages/AdminVerification.php | 258 +++++++++++ app/Filament/Pages/CustomVerification.php | 434 ++++++++++++++++++ app/Models/Company.php | 14 + app/Models/Journalist.php | 14 + app/Models/PartnerMedia.php | 10 +- app/Models/User.php | 10 + app/Models/VerificationRequest.php | 39 ++ app/Models/VerificationReview.php | 32 ++ app/View/Components/Stepper.php | 26 ++ app/View/Components/VerificationCard.php | 28 ++ ...25_11_20_041121_create_companies_table.php | 4 +- ...1_20_041125_create_partner_media_table.php | 2 + ..._11_20_041138_create_journalists_table.php | 2 + ...245_create_verification_requests_table.php | 34 ++ ...0256_create_verification_reviews_table.php | 36 ++ database/seeders/ShieldSeeder.php | 1 + resources/views/components/stepper.blade.php | 50 ++ .../components/verification-card.blade.php | 234 ++++++++++ .../pages/admin-verification.blade.php | 326 +++++++++++++ .../filament/pages/verification.blade.php | 14 + 22 files changed, 1652 insertions(+), 3 deletions(-) create mode 100644 app/Enums/DecisionAdmin.php create mode 100644 app/Enums/VerificationStatus.php create mode 100644 app/Filament/Pages/AdminVerification.php create mode 100644 app/Filament/Pages/CustomVerification.php create mode 100644 app/Models/VerificationRequest.php create mode 100644 app/Models/VerificationReview.php create mode 100644 app/View/Components/Stepper.php create mode 100644 app/View/Components/VerificationCard.php create mode 100644 database/migrations/2025_12_22_130245_create_verification_requests_table.php create mode 100644 database/migrations/2025_12_22_130256_create_verification_reviews_table.php create mode 100644 resources/views/components/stepper.blade.php create mode 100644 resources/views/components/verification-card.blade.php create mode 100644 resources/views/filament/pages/admin-verification.blade.php create mode 100644 resources/views/filament/pages/verification.blade.php diff --git a/app/Enums/DecisionAdmin.php b/app/Enums/DecisionAdmin.php new file mode 100644 index 0000000..f75c29c --- /dev/null +++ b/app/Enums/DecisionAdmin.php @@ -0,0 +1,42 @@ + 'Disetujui', + self::NEED_REVISION => 'Perlu Revisi', + self::REJECTED => 'Ditolak', + }; + } + + public function getColor(): string|array|null + { + return match ($this) { + self::APPROVED => 'success', + self::NEED_REVISION => 'info', + 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/VerificationStatus.php b/app/Enums/VerificationStatus.php new file mode 100644 index 0000000..fd44038 --- /dev/null +++ b/app/Enums/VerificationStatus.php @@ -0,0 +1,45 @@ + 'Menunggu Verifikasi', + self::APPROVED => 'Disetujui', + self::NEED_REVISION => 'Perlu Revisi', + self::REJECTED => 'Ditolak', + }; + } + + public function getColor(): string|array|null + { + return match ($this) { + self::PENDING => 'warning', + self::APPROVED => 'success', + self::NEED_REVISION => 'info', + 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 new file mode 100644 index 0000000..706a967 --- /dev/null +++ b/app/Filament/Pages/AdminVerification.php @@ -0,0 +1,258 @@ +latest('updated_at'); + + // Apply status filter + if ($this->filterStatus !== 'all') { + $query->where('status', $this->filterStatus); + } + + return $query->paginate(12); + } + + public function setFilter(string $status): void + { + $this->filterStatus = $status; + } + + public function getStatusCounts(): array + { + return [ + 'all' => VerificationRequest::count(), + 'pending' => VerificationRequest::where('status', VerificationStatus::PENDING)->count(), + 'revision' => VerificationRequest::where('status', VerificationStatus::NEED_REVISION)->count(), + 'approved' => VerificationRequest::where('status', VerificationStatus::APPROVED)->count(), + 'rejected' => VerificationRequest::where('status', VerificationStatus::REJECTED)->count(), + ]; + } + + /** + * Get the entity type label + */ + public function getEntityTypeLabel(VerificationRequest $request): string + { + $type = class_basename($request->verifiable_type); + + return match ($type) { + 'Company' => 'Perusahaan', + 'PartnerMedia' => 'Media', + 'Journalist' => 'Jurnalis', + default => $type, + }; + } + + /** + * Get the entity name + */ + public function getEntityName(VerificationRequest $request): string + { + $entity = $request->verifiable; + + if (! $entity) { + return 'Data tidak ditemukan'; + } + + return $entity->name ?? 'Tanpa Nama'; + } + + /** + * Get detail URL based on entity type + */ + public function getDetailUrl(VerificationRequest $request): ?string + { + $entity = $request->verifiable; + $type = class_basename($request->verifiable_type); + + if (! $entity) { + return null; + } + + return match ($type) { + 'Company' => route('filament.dashboard.resources.manage.partners.view', ['record' => $entity->user_id]), + 'PartnerMedia' => route('filament.dashboard.resources.manage.partners.view', ['record' => $entity->company?->user_id]), + 'Journalist' => route('filament.dashboard.resources.manage.journalists.view', ['record' => $entity->id]), + default => null, + }; + } + + /** + * Approve verification action + */ + public function approveAction(): Action + { + return Action::make('approve') + ->label('Setujui') + ->color('success') + ->icon('heroicon-o-check') + ->size('sm') + ->requiresConfirmation() + ->modalHeading('Setujui Verifikasi') + ->modalDescription('Apakah Anda yakin ingin menyetujui verifikasi ini?') + ->modalSubmitActionLabel('Ya, Setujui') + ->form([ + Textarea::make('note') + ->label('Catatan (Opsional)') + ->placeholder('Tambahkan catatan jika diperlukan...'), + ]) + ->action(function (array $arguments, array $data) { + $this->processReview($arguments['id'], DecisionAdmin::APPROVED, $data['note'] ?? null); + }); + } + + /** + * Revision verification action + */ + public function revisionAction(): Action + { + return Action::make('revision') + ->label('Minta Revisi') + ->color('warning') + ->icon('heroicon-o-pencil-square') + ->size('sm') + ->modalHeading('Minta Revisi') + ->modalDescription('User akan diminta untuk memperbaiki data sesuai catatan yang diberikan.') + ->form([ + Textarea::make('note') + ->label('Catatan Revisi') + ->required() + ->placeholder('Jelaskan bagian yang perlu diperbaiki...') + ->helperText('Catatan ini akan ditampilkan kepada user.'), + ]) + ->action(function (array $arguments, array $data) { + $this->processReview($arguments['id'], DecisionAdmin::NEED_REVISION, $data['note']); + }); + } + + /** + * Reject verification action + */ + public function rejectAction(): Action + { + return Action::make('reject') + ->label('Tolak') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->size('sm') + ->requiresConfirmation() + ->modalHeading('Tolak Verifikasi') + ->modalDescription('Pengajuan akan ditolak secara permanen. User tidak dapat mengajukan ulang untuk data ini.') + ->modalSubmitActionLabel('Ya, Tolak') + ->form([ + Textarea::make('note') + ->label('Alasan Penolakan') + ->required() + ->placeholder('Jelaskan alasan pengajuan ditolak...') + ->helperText('Alasan ini akan ditampilkan kepada user.'), + ]) + ->action(function (array $arguments, array $data) { + $this->processReview($arguments['id'], DecisionAdmin::REJECTED, $data['note']); + }); + } + + /** + * Process review decision + */ + protected function processReview(int $requestId, DecisionAdmin $decision, ?string $note): void + { + $verificationRequest = VerificationRequest::find($requestId); + + if (! $verificationRequest) { + Notification::make() + ->title('Error') + ->body('Data verifikasi tidak ditemukan.') + ->danger() + ->send(); + + return; + } + + // Create review record + VerificationReview::create([ + 'verification_request_id' => $verificationRequest->id, + 'reviewer_id' => Auth::id(), + 'decision' => $decision, + 'note' => $note, + ]); + + // Update verification request status + $newStatus = match ($decision) { + DecisionAdmin::APPROVED => VerificationStatus::APPROVED, + DecisionAdmin::NEED_REVISION => VerificationStatus::NEED_REVISION, + DecisionAdmin::REJECTED => VerificationStatus::REJECTED, + }; + + $verificationRequest->update([ + 'status' => $newStatus, + ]); + + // Update the related entity status if needed + $entity = $verificationRequest->verifiable; + if ($entity && method_exists($entity, 'update')) { + $entity->update(['status' => $newStatus]); + } + + $actionLabel = match ($decision) { + DecisionAdmin::APPROVED => 'disetujui', + DecisionAdmin::NEED_REVISION => 'diminta revisi', + DecisionAdmin::REJECTED => 'ditolak', + }; + + $color = match ($decision) { + DecisionAdmin::APPROVED => 'success', + DecisionAdmin::NEED_REVISION => 'info', + DecisionAdmin::REJECTED => 'danger', + }; + + Notification::make() + ->title('Berhasil') + ->body("Verifikasi telah {$actionLabel}.") + ->color($color) + ->send(); + } +} diff --git a/app/Filament/Pages/CustomVerification.php b/app/Filament/Pages/CustomVerification.php new file mode 100644 index 0000000..b79a361 --- /dev/null +++ b/app/Filament/Pages/CustomVerification.php @@ -0,0 +1,434 @@ +company = auth()->user()->company; + $this->partnerMedia = auth()->user()->company?->partnerMedia; + $this->journalist = auth()->user()->company?->partnerMedia?->journalists?->first(); + + // Load verification requests with reviews + if ($this->company) { + $this->companyVerification = $this->company->verificationRequest() + ->with(['reviews.reviewer']) + ->latest() + ->first(); + } + + if ($this->partnerMedia) { + $this->mediaVerification = $this->partnerMedia->verificationRequest() + ->with(['reviews.reviewer']) + ->latest() + ->first(); + } + + if ($this->journalist) { + $this->journalistVerification = $this->journalist->verificationRequest() + ->with(['reviews.reviewer']) + ->latest() + ->first(); + } + } + + /** + * Determine the current step based on status + */ + protected function resolveStep(?VerificationStatus $status, bool $hasData = false): int + { + if (! $hasData) { + return 1; // No data yet + } + + return match ($status) { + VerificationStatus::PENDING, + VerificationStatus::NEED_REVISION => 2, // In review or needs revision + + VerificationStatus::APPROVED, + VerificationStatus::REJECTED => 3, // Final decision + + default => 1, // Draft / not submitted yet + }; + } + + /** + * Check if user can submit verification for entity + */ + protected function canSubmit($entity, ?VerificationRequest $verification): bool + { + if (! $entity) { + return false; + } + + // No verification yet - can submit + if (! $verification) { + return true; + } + + // Can resubmit only if status is NEED_REVISION + return $verification->status === VerificationStatus::NEED_REVISION; + } + + /** + * Check if entity is approved + */ + protected function isApproved(?VerificationRequest $verification): bool + { + return $verification?->status === VerificationStatus::APPROVED; + } + + /** + * Check if entity is rejected + */ + protected function isRejected(?VerificationRequest $verification): bool + { + return $verification?->status === VerificationStatus::REJECTED; + } + + /** + * Check if entity is pending review + */ + protected function isPending(?VerificationRequest $verification): bool + { + return $verification?->status === VerificationStatus::PENDING; + } + + /** + * Check if entity needs revision + */ + protected function needsRevision(?VerificationRequest $verification): bool + { + return $verification?->status === VerificationStatus::NEED_REVISION; + } + + /** + * Get the latest review notes for an entity + */ + protected function getLatestReviewNotes(?VerificationRequest $verification): ?string + { + if (! $verification) { + return null; + } + + $latestReview = $verification->reviews() + ->whereIn('decision', [DecisionAdmin::NEED_REVISION, DecisionAdmin::REJECTED]) + ->latest() + ->first(); + + return $latestReview?->note; + } + + /** + * Get all reviews history for an entity + */ + protected function getReviewHistory(?VerificationRequest $verification): Collection + { + if (! $verification) { + return collect(); + } + + return $verification->reviews() + ->with('reviewer') + ->latest() + ->get(); + } + + public function companyProgress(): array + { + $hasData = $this->company !== null; + $currentStep = $this->resolveStep($this->companyVerification?->status, $hasData); + + return [ + 'title' => 'Perusahaan', + 'description' => 'Progress verifikasi perusahaan', + 'icon' => 'heroicon-o-building-office-2', + 'currentStep' => $currentStep, + 'entity' => $this->company, + 'verification' => $this->companyVerification, + 'canSubmit' => $this->canSubmit($this->company, $this->companyVerification), + 'isApproved' => $this->isApproved($this->companyVerification), + 'isRejected' => $this->isRejected($this->companyVerification), + 'isPending' => $this->isPending($this->companyVerification), + 'needsRevision' => $this->needsRevision($this->companyVerification), + 'latestNotes' => $this->getLatestReviewNotes($this->companyVerification), + 'reviewHistory' => $this->getReviewHistory($this->companyVerification), + 'editUrl' => route('filament.dashboard.pages.manage.company'), + 'steps' => [ + [ + 'title' => 'Pengajuan', + 'description' => $hasData ? 'Data perusahaan sudah diisi' : 'Lengkapi data perusahaan', + ], + [ + 'title' => 'Verifikasi', + 'description' => match ($this->companyVerification?->status) { + VerificationStatus::NEED_REVISION => 'Perlu perbaikan data', + VerificationStatus::PENDING => 'Sedang diverifikasi admin', + default => 'Menunggu pengajuan', + }, + ], + [ + 'title' => 'Selesai', + 'description' => match ($this->companyVerification?->status) { + VerificationStatus::APPROVED => 'Perusahaan disetujui', + VerificationStatus::REJECTED => 'Perusahaan ditolak', + default => 'Menunggu keputusan', + }, + ], + ], + ]; + } + + public function mediaProgress(): array + { + $hasData = $this->partnerMedia !== null; + $currentStep = $this->resolveStep($this->mediaVerification?->status, $hasData); + + return [ + 'title' => 'Media', + 'description' => 'Progress verifikasi media', + 'currentStep' => $currentStep, + 'entity' => $this->partnerMedia, + 'verification' => $this->mediaVerification, + 'canSubmit' => $this->canSubmit($this->partnerMedia, $this->mediaVerification), + 'isApproved' => $this->isApproved($this->mediaVerification), + 'isRejected' => $this->isRejected($this->mediaVerification), + 'isPending' => $this->isPending($this->mediaVerification), + 'needsRevision' => $this->needsRevision($this->mediaVerification), + 'latestNotes' => $this->getLatestReviewNotes($this->mediaVerification), + 'reviewHistory' => $this->getReviewHistory($this->mediaVerification), + 'editUrl' => route('filament.dashboard.pages.manage.media'), + 'steps' => [ + [ + 'title' => 'Pengajuan', + 'description' => $hasData ? 'Data media sudah diisi' : 'Lengkapi data media', + ], + [ + 'title' => 'Verifikasi', + 'description' => match ($this->mediaVerification?->status) { + VerificationStatus::NEED_REVISION => 'Perlu perbaikan data', + VerificationStatus::PENDING => 'Sedang diverifikasi admin', + default => 'Menunggu pengajuan', + }, + ], + [ + 'title' => 'Selesai', + 'description' => match ($this->mediaVerification?->status) { + VerificationStatus::APPROVED => 'Media disetujui', + VerificationStatus::REJECTED => 'Media ditolak', + default => 'Menunggu keputusan', + }, + ], + ], + ]; + } + + public function journalistProgress(): array + { + $hasData = $this->journalist !== null; + $currentStep = $this->resolveStep($this->journalistVerification?->status, $hasData); + + return [ + 'title' => 'Jurnalis', + 'description' => 'Progress verifikasi jurnalis', + 'currentStep' => $currentStep, + 'entity' => $this->journalist, + 'verification' => $this->journalistVerification, + 'canSubmit' => $this->canSubmit($this->journalist, $this->journalistVerification), + 'isApproved' => $this->isApproved($this->journalistVerification), + 'isRejected' => $this->isRejected($this->journalistVerification), + 'isPending' => $this->isPending($this->journalistVerification), + 'needsRevision' => $this->needsRevision($this->journalistVerification), + 'latestNotes' => $this->getLatestReviewNotes($this->journalistVerification), + 'reviewHistory' => $this->getReviewHistory($this->journalistVerification), + 'editUrl' => route('filament.dashboard.resources.manage.journalists.index'), + 'steps' => [ + [ + 'title' => 'Pengajuan', + 'description' => $hasData ? 'Data jurnalis sudah diisi' : 'Lengkapi data jurnalis', + ], + [ + 'title' => 'Verifikasi', + 'description' => match ($this->journalistVerification?->status) { + VerificationStatus::NEED_REVISION => 'Perlu perbaikan data', + VerificationStatus::PENDING => 'Sedang diverifikasi admin', + default => 'Menunggu pengajuan', + }, + ], + [ + 'title' => 'Selesai', + 'description' => match ($this->journalistVerification?->status) { + VerificationStatus::APPROVED => 'Jurnalis disetujui', + VerificationStatus::REJECTED => 'Jurnalis ditolak', + default => 'Menunggu keputusan', + }, + ], + ], + ]; + } + + /** + * Submit company verification action + */ + public function submitCompanyAction(): Action + { + return Action::make('submitCompany') + ->label(fn () => $this->needsRevision($this->companyVerification) ? 'Kirim Ulang' : 'Ajukan Verifikasi') + ->icon('heroicon-o-paper-airplane') + ->color('primary') + ->requiresConfirmation() + ->modalHeading('Ajukan Verifikasi Perusahaan') + ->modalDescription('Apakah Anda yakin ingin mengajukan data perusahaan untuk diverifikasi? Pastikan semua data sudah benar.') + ->modalSubmitActionLabel('Ya, Ajukan') + ->visible(fn () => $this->canSubmit($this->company, $this->companyVerification)) + ->action(function () { + $this->submitVerification('company', $this->company, $this->companyVerification); + }); + } + + /** + * Submit media verification action + */ + public function submitMediaAction(): Action + { + return Action::make('submitMedia') + ->label(fn () => $this->needsRevision($this->mediaVerification) ? 'Kirim Ulang' : 'Ajukan Verifikasi') + ->icon('heroicon-o-paper-airplane') + ->color('primary') + ->requiresConfirmation() + ->modalHeading('Ajukan Verifikasi Media') + ->modalDescription('Apakah Anda yakin ingin mengajukan data media untuk diverifikasi? Pastikan semua data sudah benar.') + ->modalSubmitActionLabel('Ya, Ajukan') + ->visible(fn () => $this->canSubmit($this->partnerMedia, $this->mediaVerification)) + ->action(function () { + $this->submitVerification('media', $this->partnerMedia, $this->mediaVerification); + }); + } + + /** + * Submit journalist verification action + */ + public function submitJournalistAction(): Action + { + return Action::make('submitJournalist') + ->label(fn () => $this->needsRevision($this->journalistVerification) ? 'Kirim Ulang' : 'Ajukan Verifikasi') + ->icon('heroicon-o-paper-airplane') + ->color('primary') + ->requiresConfirmation() + ->modalHeading('Ajukan Verifikasi Jurnalis') + ->modalDescription('Apakah Anda yakin ingin mengajukan data jurnalis untuk diverifikasi? Pastikan semua data sudah benar.') + ->modalSubmitActionLabel('Ya, Ajukan') + ->visible(fn () => $this->canSubmit($this->journalist, $this->journalistVerification)) + ->action(function () { + $this->submitVerification('journalist', $this->journalist, $this->journalistVerification); + }); + } + + /** + * Handle verification submission + */ + protected function submitVerification(string $type, $entity, ?VerificationRequest $existingRequest): void + { + if (! $entity) { + Notification::make() + ->title('Error') + ->body('Data tidak ditemukan. Silakan lengkapi data terlebih dahulu.') + ->danger() + ->send(); + + return; + } + + if ($existingRequest && $existingRequest->status === VerificationStatus::NEED_REVISION) { + // Resubmit - update existing request + $existingRequest->update([ + 'status' => VerificationStatus::PENDING, + ]); + + $verification = $existingRequest; + } else { + // New submission + $verification = VerificationRequest::create([ + 'verifiable_type' => get_class($entity), + 'verifiable_id' => $entity->id, + 'submitted_by' => auth()->id(), + 'status' => VerificationStatus::PENDING, + ]); + } + + // Update the local property + match ($type) { + 'company' => $this->companyVerification = $verification->fresh(['reviews.reviewer']), + 'media' => $this->mediaVerification = $verification->fresh(['reviews.reviewer']), + 'journalist' => $this->journalistVerification = $verification->fresh(['reviews.reviewer']), + }; + + $entityName = match ($type) { + 'company' => 'Perusahaan', + 'media' => 'Media', + 'journalist' => 'Jurnalis', + }; + + Notification::make() + ->title('Berhasil!') + ->body("Data {$entityName} berhasil diajukan untuk diverifikasi.") + ->success() + ->send(); + } + + public function getViewData(): array + { + return [ + 'companyProgress' => $this->companyProgress(), + 'mediaProgress' => $this->mediaProgress(), + 'journalistProgress' => $this->journalistProgress(), + ]; + } +} diff --git a/app/Models/Company.php b/app/Models/Company.php index 4d51ddf..9d87b77 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -2,11 +2,13 @@ namespace App\Models; +use App\Enums\VerificationStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; @@ -17,6 +19,13 @@ class Company extends Model implements HasMedia protected $guarded = ['id']; + protected function casts(): array + { + return [ + 'status' => VerificationStatus::class, + ]; + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -31,4 +40,9 @@ public function journalists(): HasManyThrough { return $this->hasManyThrough(Journalist::class, PartnerMedia::class); } + + public function verificationRequest(): MorphOne + { + return $this->morphOne(VerificationRequest::class, 'verifiable'); + } } diff --git a/app/Models/Journalist.php b/app/Models/Journalist.php index 20498d6..36ab847 100644 --- a/app/Models/Journalist.php +++ b/app/Models/Journalist.php @@ -2,9 +2,11 @@ namespace App\Models; +use App\Enums\VerificationStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; @@ -15,8 +17,20 @@ class Journalist extends Model implements HasMedia protected $guarded = ['id']; + protected function casts(): array + { + return [ + 'status' => VerificationStatus::class, + ]; + } + public function partnerMedia(): BelongsTo { return $this->belongsTo(PartnerMedia::class); } + + public function verificationRequest(): MorphOne + { + return $this->morphOne(VerificationRequest::class, 'verifiable'); + } } diff --git a/app/Models/PartnerMedia.php b/app/Models/PartnerMedia.php index 1549e13..4e98115 100644 --- a/app/Models/PartnerMedia.php +++ b/app/Models/PartnerMedia.php @@ -4,18 +4,20 @@ use App\Enums\MediaClassification; use App\Enums\MediaType; +use App\Enums\VerificationStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; class PartnerMedia extends Model implements HasMedia { - use HasFactory, InteractsWithMedia,SoftDeletes; + use HasFactory, InteractsWithMedia, SoftDeletes; protected $guarded = ['id']; @@ -24,6 +26,7 @@ protected function casts(): array return [ 'type' => MediaType::class, 'classification' => MediaClassification::class, + 'status' => VerificationStatus::class, ]; } @@ -65,4 +68,9 @@ public function cooperationMedias(): HasMany { return $this->hasMany(CooperationMedia::class); } + + public function verificationRequest(): MorphOne + { + return $this->morphOne(VerificationRequest::class, 'verifiable'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 36c8e56..d2a9a48 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -61,4 +61,14 @@ public function news(): HasMany { return $this->hasMany(News::class, 'author_id'); } + + public function verificationRequests(): HasMany + { + return $this->hasMany(VerificationRequest::class, 'submitted_by'); + } + + public function verificationReviews(): HasMany + { + return $this->hasMany(VerificationReview::class, 'reviewer_id'); + } } diff --git a/app/Models/VerificationRequest.php b/app/Models/VerificationRequest.php new file mode 100644 index 0000000..6f10228 --- /dev/null +++ b/app/Models/VerificationRequest.php @@ -0,0 +1,39 @@ + VerificationStatus::class, + ]; + } + + public function verifiable(): MorphTo + { + return $this->morphTo(); + } + + public function submittedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'submitted_by'); + } + + public function reviews(): HasMany + { + return $this->hasMany(VerificationReview::class); + } +} diff --git a/app/Models/VerificationReview.php b/app/Models/VerificationReview.php new file mode 100644 index 0000000..98e5932 --- /dev/null +++ b/app/Models/VerificationReview.php @@ -0,0 +1,32 @@ + DecisionAdmin::class, + ]; + } + + public function verificationRequest(): BelongsTo + { + return $this->belongsTo(VerificationRequest::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewer_id'); + } +} diff --git a/app/View/Components/Stepper.php b/app/View/Components/Stepper.php new file mode 100644 index 0000000..b837ecb --- /dev/null +++ b/app/View/Components/Stepper.php @@ -0,0 +1,26 @@ +string('annual_tax_return', 100); $table->string('domicile_certificate', 100); $table->string('profile', 100); - $table->date('validated_at')->nullable(); - $table->text('rejection_reason')->nullable(); + $table->enum('status', VerificationStatus::cases())->default(VerificationStatus::PENDING)->comment(VerificationStatus::comment())->after('validated_at'); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); $table->softDeletes(); diff --git a/database/migrations/2025_11_20_041125_create_partner_media_table.php b/database/migrations/2025_11_20_041125_create_partner_media_table.php index 8b35d19..064cbcd 100644 --- a/database/migrations/2025_11_20_041125_create_partner_media_table.php +++ b/database/migrations/2025_11_20_041125_create_partner_media_table.php @@ -2,6 +2,7 @@ use App\Enums\MediaClassification; use App\Enums\MediaType; +use App\Enums\VerificationStatus; use App\Models\Company; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; @@ -24,6 +25,7 @@ public function up(): void $table->enum('classification', [MediaClassification::values()])->comment(MediaClassification::comment()); $table->string('journalism_organization')->nullable(); $table->string('press_council_certificate')->nullable(); + $table->enum('status', VerificationStatus::cases())->default(VerificationStatus::PENDING)->comment(VerificationStatus::comment())->after('profile'); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); $table->softDeletes(); diff --git a/database/migrations/2025_11_20_041138_create_journalists_table.php b/database/migrations/2025_11_20_041138_create_journalists_table.php index f1802d3..bc3fbcc 100644 --- a/database/migrations/2025_11_20_041138_create_journalists_table.php +++ b/database/migrations/2025_11_20_041138_create_journalists_table.php @@ -1,5 +1,6 @@ string('phone_number', 20); $table->string('press_card', 100)->nullable(); $table->string('ukw_certificate', 100)->nullable(); + $table->enum('status', VerificationStatus::cases())->default(VerificationStatus::PENDING)->comment(VerificationStatus::comment())->after('profile'); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); $table->softDeletes(); diff --git a/database/migrations/2025_12_22_130245_create_verification_requests_table.php b/database/migrations/2025_12_22_130245_create_verification_requests_table.php new file mode 100644 index 0000000..2817c75 --- /dev/null +++ b/database/migrations/2025_12_22_130245_create_verification_requests_table.php @@ -0,0 +1,34 @@ +id(); + $table->morphs('verifiable'); + $table->foreignIdFor(User::class, 'submitted_by'); + $table->enum('status', VerificationStatus::cases())->default(VerificationStatus::PENDING)->comment(VerificationStatus::comment()); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('verification_requests'); + } +}; diff --git a/database/migrations/2025_12_22_130256_create_verification_reviews_table.php b/database/migrations/2025_12_22_130256_create_verification_reviews_table.php new file mode 100644 index 0000000..7388576 --- /dev/null +++ b/database/migrations/2025_12_22_130256_create_verification_reviews_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignIdFor(VerificationRequest::class); + $table->foreignIdFor(User::class, 'reviewer_id'); + $table->enum('decision', DecisionAdmin::cases())->comment(DecisionAdmin::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('verification_reviews'); + } +}; diff --git a/database/seeders/ShieldSeeder.php b/database/seeders/ShieldSeeder.php index 5f1be7d..3857955 100644 --- a/database/seeders/ShieldSeeder.php +++ b/database/seeders/ShieldSeeder.php @@ -442,6 +442,7 @@ public function run(): void "permissions": [ "View:CustomCompany", "View:CustomMedia", + "View:CustomVerification", "ViewAny:Cooperation", "View:Cooperation", "Create:Cooperation", diff --git a/resources/views/components/stepper.blade.php b/resources/views/components/stepper.blade.php new file mode 100644 index 0000000..52b5fbf --- /dev/null +++ b/resources/views/components/stepper.blade.php @@ -0,0 +1,50 @@ + diff --git a/resources/views/components/verification-card.blade.php b/resources/views/components/verification-card.blade.php new file mode 100644 index 0000000..09d762a --- /dev/null +++ b/resources/views/components/verification-card.blade.php @@ -0,0 +1,234 @@ +@php + $hasEntity = $progress['entity'] !== null; + $verification = $progress['verification']; + $status = $verification?->status; +@endphp + + + +
+
+ {{-- Icon based on type --}} + @if ($type === 'company') +
+ +
+ @elseif($type === 'media') +
+ +
+ @else +
+ +
+ @endif + +
+

+ {{ $progress['title'] }} +

+

+ {{ $progress['description'] }} +

+
+
+ + {{-- Status Badge --}} + @if ($verification) + + {{ $status->getLabel() }} + + @else + + Belum Mengajukan + + @endif +
+
+ +
+ {{-- Stepper Progress --}} +
+ +
+ + {{-- Alert for Revision Notes --}} + @if ($progress['needsRevision'] && $progress['latestNotes']) +
+
+
+ +
+
+

+ Catatan Revisi dari Admin +

+

+ {{ $progress['latestNotes'] }} +

+
+ + Perbaiki Data + +
+
+
+
+ @endif + + {{-- Alert for Rejection --}} + @if ($progress['isRejected'] && $progress['latestNotes']) +
+
+
+ +
+
+

+ Pengajuan Ditolak +

+

+ {{ $progress['latestNotes'] }} +

+
+
+
+ @endif + + {{-- Success Message for Approved --}} + @if ($progress['isApproved']) +
+
+
+ +
+
+

+ Verifikasi Diterima +

+

+ Data {{ strtolower($progress['title']) }} Anda telah diverifikasi dan disetujui oleh admin. +

+
+
+
+ @endif + + {{-- Pending Message --}} + @if ($progress['isPending']) +
+
+
+ +
+
+

+ Menunggu Verifikasi +

+

+ Data sedang dalam proses verifikasi oleh admin. Mohon tunggu. +

+
+
+
+ @endif + + {{-- No Data Message --}} + @if (!$hasEntity) +
+
+
+ +
+
+

+ Data Belum Tersedia +

+

+ Silakan lengkapi data {{ strtolower($progress['title']) }} terlebih dahulu sebelum + mengajukan verifikasi. +

+
+ + Lengkapi Data + +
+
+
+
+ @endif + + {{-- Review History Accordion --}} + @if ($progress['reviewHistory']->isNotEmpty()) +
+ + +
+
+ @foreach ($progress['reviewHistory'] as $review) +
+
+
+
+ + {{ $review->decision->getLabel() }} + + + oleh {{ $review->reviewer?->name ?? 'Admin' }} + +
+ @if ($review->note) +

+ {{ $review->note }} +

+ @endif +
+
+ {{ $review->created_at->format('d M Y, H:i') }} +
+
+
+ @endforeach +
+
+
+ @endif +
+ + {{-- Footer with Actions --}} + +
+
+ @if ($verification) + Terakhir diajukan: {{ $verification->updated_at->format('d M Y, H:i') }} + @endif +
+ +
+ @if ($hasEntity && !$progress['isPending'] && !$progress['isApproved']) + + Edit Data + + @endif + + @if ($progress['canSubmit']) + {{ $submitAction }} + @endif +
+
+
+
diff --git a/resources/views/filament/pages/admin-verification.blade.php b/resources/views/filament/pages/admin-verification.blade.php new file mode 100644 index 0000000..ddd7696 --- /dev/null +++ b/resources/views/filament/pages/admin-verification.blade.php @@ -0,0 +1,326 @@ + + {{-- Status Filter Tabs --}} + @php + $counts = $this->getStatusCounts(); + @endphp + +
+
+ + + + + + + + + +
+
+ + {{-- Verification Cards Grid --}} +
+ @forelse ($this->getVerifications() as $verification) + @php + $entityType = $this->getEntityTypeLabel($verification); + $entityName = $this->getEntityName($verification); + $detailUrl = $this->getDetailUrl($verification); + $latestReview = $verification->reviews->first(); + @endphp + + + +
+
+
+ {{-- Entity Type Badge --}} + @php + $typeColor = match ($entityType) { + 'Perusahaan' => 'primary', + 'Media' => 'info', + 'Jurnalis' => 'success', + default => 'gray', + }; + @endphp + + {{ $entityType }} + +
+

+ {{ $entityName }} +

+
+ + {{-- Status Badge --}} + + {{ $verification->status->getLabel() }} + +
+
+ +
+ {{-- Submitter Info --}} +
+
+
+ +
+
+
+

Diajukan oleh +

+

+ {{ $verification->submittedBy?->name ?? 'Unknown' }} +

+
+
+ + {{-- Timeline Info --}} +
+
+

Diajukan

+

+ {{ $verification->created_at->format('d M Y') }} +

+

+ {{ $verification->created_at->format('H:i') }} +

+
+
+

Update Terakhir +

+

+ {{ $verification->updated_at->format('d M Y') }} +

+

+ {{ $verification->updated_at->diffForHumans() }} +

+
+
+ + {{-- Latest Review Note --}} + @if ($latestReview && $latestReview->note) +
+

+ Catatan Terakhir +

+

+ {{ $latestReview->note }} +

+

+ oleh {{ $latestReview->reviewer?->name ?? 'Admin' }} • + {{ $latestReview->created_at->diffForHumans() }} +

+
+ @endif + + {{-- Review History Accordion --}} + @if ($verification->reviews->count() > 0) +
+ + +
+
+ @foreach ($verification->reviews as $review) +
+
+
+
+ + {{ $review->decision->getLabel() }} + + + oleh {{ $review->reviewer?->name ?? 'Admin' }} + +
+ @if ($review->note) +

+ {{ $review->note }} +

+ @endif +
+
+ {{ $review->created_at->format('d M Y, H:i') }} +
+
+
+ @endforeach +
+
+
+ @endif +
+ + +
+ {{-- View Detail Button --}} + @if ($detailUrl) + + Detail + + @endif + + {{-- Action Buttons based on status --}} + @if ($verification->status === \App\Enums\VerificationStatus::PENDING) + {{ ($this->approveAction)(['id' => $verification->id]) }} + {{ ($this->revisionAction)(['id' => $verification->id]) }} + {{ ($this->rejectAction)(['id' => $verification->id]) }} + @elseif($verification->status === \App\Enums\VerificationStatus::NEED_REVISION) + + + Menunggu revisi dari user... + + @elseif($verification->status === \App\Enums\VerificationStatus::APPROVED) + + + Verifikasi selesai + + @elseif($verification->status === \App\Enums\VerificationStatus::REJECTED) + + + Pengajuan ditolak + + @endif +
+
+
+ @empty +
+
+ +

Tidak ada pengajuan verifikasi

+

+ @if ($filterStatus === 'all') + Belum ada pengajuan verifikasi dari user. + @else + Tidak ada pengajuan dengan status ini. + @endif +

+
+
+ @endforelse +
+ + {{-- Pagination --}} + @if ($this->getVerifications()->hasPages()) +
+ {{ $this->getVerifications()->links() }} +
+ @endif + + +
diff --git a/resources/views/filament/pages/verification.blade.php b/resources/views/filament/pages/verification.blade.php new file mode 100644 index 0000000..4801db1 --- /dev/null +++ b/resources/views/filament/pages/verification.blade.php @@ -0,0 +1,14 @@ + +
+ {{-- Company Verification Section --}} + + + {{-- Media Verification Section --}} + + + {{-- Journalist Verification Section --}} + +
+ + +