feat: Implement a comprehensive verification system with dedicated pages, models, and UI components for companies, media, and journalists.

This commit is contained in:
Yoga Pangestu 2025-12-22 15:00:19 +07:00
parent 7637bcc8b5
commit b4d2ae52f0
22 changed files with 1652 additions and 3 deletions

View File

@ -0,0 +1,42 @@
<?php
namespace App\Enums;
use App\Traits\Enum\WithComment;
use App\Traits\Enum\WithValue;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum DecisionAdmin: int implements HasColor, HasLabel
{
use WithComment, WithValue;
case APPROVED = 1;
case NEED_REVISION = 2;
case REJECTED = 3;
public function getLabel(): ?string
{
return match ($this) {
self::APPROVED => '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();
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Enums;
use App\Traits\Enum\WithComment;
use App\Traits\Enum\WithValue;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum VerificationStatus: int implements HasColor, HasLabel
{
use WithComment, WithValue;
case PENDING = 1;
case APPROVED = 2;
case NEED_REVISION = 3;
case REJECTED = 4;
public function getLabel(): ?string
{
return match ($this) {
self::PENDING => '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();
}
}

View File

@ -0,0 +1,258 @@
<?php
namespace App\Filament\Pages;
use App\Enums\DecisionAdmin;
use App\Enums\VerificationStatus;
use App\Models\VerificationRequest;
use App\Models\VerificationReview;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class AdminVerification extends Page implements HasActions, HasForms
{
use InteractsWithActions, InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static ?string $title = 'Verifikasi Rekanan';
protected static ?string $navigationLabel = 'Verifikasi Rekanan';
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static ?int $navigationSort = 10;
protected static ?string $slug = 'manage/admin-verification';
protected string $view = 'filament.pages.admin-verification';
public string $filterStatus = 'all';
public function getVerifications(): LengthAwarePaginator
{
$query = VerificationRequest::with([
'verifiable',
'submittedBy',
'reviews.reviewer',
])
->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();
}
}

View File

@ -0,0 +1,434 @@
<?php
namespace App\Filament\Pages;
use App\Enums\DecisionAdmin;
use App\Enums\VerificationStatus;
use App\Models\Company;
use App\Models\Journalist;
use App\Models\PartnerMedia;
use App\Models\VerificationRequest;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
use UnitEnum;
class CustomVerification extends Page implements HasActions, HasForms
{
use HasPageShield, InteractsWithActions, InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-check-badge';
protected static ?string $title = 'Verifikasi';
protected static ?string $navigationLabel = 'Verifikasi';
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static ?string $slug = 'manage/verification';
protected static ?int $navigationSort = 12;
protected string $view = 'filament.pages.verification';
public ?Company $company = null;
public ?PartnerMedia $partnerMedia = null;
public ?Journalist $journalist = null;
public ?VerificationRequest $companyVerification = null;
public ?VerificationRequest $mediaVerification = null;
public ?VerificationRequest $journalistVerification = null;
public function mount(): void
{
$this->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(),
];
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Enums\VerificationStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class VerificationRequest extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'status' => 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);
}
}

View File

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

View File

@ -0,0 +1,26 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Stepper extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public int $currentStep = 1,
public array $steps = []
) {}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.stepper');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\View\Components;
use Closure;
use Filament\Actions\Action;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class VerificationCard extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public array $progress,
public Action $submitAction,
public string $type = 'company'
) {}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.verification-card');
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\VerificationStatus;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@ -27,8 +28,7 @@ public function up(): void
$table->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();

View File

@ -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();

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\VerificationStatus;
use App\Models\PartnerMedia;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@ -20,6 +21,7 @@ public function up(): void
$table->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();

View File

@ -0,0 +1,34 @@
<?php
use App\Enums\VerificationStatus;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('verification_requests', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,36 @@
<?php
use App\Enums\DecisionAdmin;
use App\Models\User;
use App\Models\VerificationRequest;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('verification_reviews', function (Blueprint $table) {
$table->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');
}
};

View File

@ -442,6 +442,7 @@ public function run(): void
"permissions": [
"View:CustomCompany",
"View:CustomMedia",
"View:CustomVerification",
"ViewAny:Cooperation",
"View:Cooperation",
"Create:Cooperation",

View File

@ -0,0 +1,50 @@
<ul class="relative flex flex-col md:flex-row gap-2">
@foreach ($steps as $index => $step)
@php
$stepNumber = $index + 1;
$isCompleted = $stepNumber < $currentStep;
$isActive = $stepNumber === $currentStep;
@endphp
<li class="md:shrink md:basis-0 flex-1 group flex gap-x-2 md:block">
<div
class="min-w-7 min-h-7 flex flex-col items-center md:w-full md:inline-flex md:flex-wrap md:flex-row text-xs align-middle">
{{-- Circle --}}
<span @class([
'size-7 flex justify-center items-center shrink-0 rounded-full font-medium',
'bg-primary-600 text-white' => $isActive || $isCompleted,
'bg-gray-100 text-gray-800 dark:bg-neutral-700 dark:text-white' =>
!$isActive && !$isCompleted,
])>
@if ($isCompleted)
@else
{{ $stepNumber }}
@endif
</span>
{{-- Line --}}
<div
class="mt-2 w-px h-full md:mt-0 md:ms-2 md:w-full md:h-px md:flex-1
{{ $loop->last ? 'hidden' : 'bg-gray-200 dark:bg-neutral-700' }}">
</div>
</div>
{{-- Content --}}
<div class="grow md:grow-0 md:mt-3 pb-5">
<span @class([
'block text-sm font-medium',
'text-primary-600' => $isActive || $isCompleted,
'text-gray-800 dark:text-white' => !$isActive && !$isCompleted,
])>
{{ $step['title'] }}
</span>
<p class="text-sm text-gray-800 dark:text-neutral-500">
{{ $step['description'] }}
</p>
</div>
</li>
@endforeach
</ul>

View File

@ -0,0 +1,234 @@
@php
$hasEntity = $progress['entity'] !== null;
$verification = $progress['verification'];
$status = $verification?->status;
@endphp
<x-filament::section>
<x-slot name="heading">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{{-- Icon based on type --}}
@if ($type === 'company')
<div class="p-2 rounded-lg bg-info-50 dark:bg-info-500/10">
<x-heroicon-o-building-office-2 class="w-6 h-6 text-info-500" />
</div>
@elseif($type === 'media')
<div class="p-2 rounded-lg bg-info-50 dark:bg-info-500/10">
<x-heroicon-o-newspaper class="w-6 h-6 text-info-500" />
</div>
@else
<div class="p-2 rounded-lg bg-info-50 dark:bg-info-500/10">
<x-heroicon-o-user-circle class="w-6 h-6 text-info-500" />
</div>
@endif
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $progress['title'] }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $progress['description'] }}
</p>
</div>
</div>
{{-- Status Badge --}}
@if ($verification)
<x-filament::badge :color="$status->getColor()" size="lg">
{{ $status->getLabel() }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="lg">
Belum Mengajukan
</x-filament::badge>
@endif
</div>
</x-slot>
<div class="space-y-6">
{{-- Stepper Progress --}}
<div class="pt-4">
<x-stepper :current-step="$progress['currentStep']" :steps="$progress['steps']" />
</div>
{{-- Alert for Revision Notes --}}
@if ($progress['needsRevision'] && $progress['latestNotes'])
<div
class="p-4 rounded-xl bg-warning-50 dark:bg-warning-500/10 border border-warning-200 dark:border-warning-500/20">
<div class="flex gap-3">
<div class="shrink-0">
<x-heroicon-s-exclamation-triangle class="w-5 h-5 text-warning-500" />
</div>
<div>
<h4 class="font-medium text-warning-800 dark:text-warning-200">
Catatan Revisi dari Admin
</h4>
<p class="mt-1 text-sm text-warning-700 dark:text-warning-300">
{{ $progress['latestNotes'] }}
</p>
<div class="mt-3">
<x-filament::button tag="a" :href="$progress['editUrl']" size="sm" color="warning"
icon="heroicon-m-pencil-square">
Perbaiki Data
</x-filament::button>
</div>
</div>
</div>
</div>
@endif
{{-- Alert for Rejection --}}
@if ($progress['isRejected'] && $progress['latestNotes'])
<div
class="p-4 rounded-xl bg-danger-50 dark:bg-danger-500/10 border border-danger-200 dark:border-danger-500/20">
<div class="flex gap-3">
<div class="shrink-0">
<x-heroicon-s-x-circle class="w-5 h-5 text-danger-500" />
</div>
<div>
<h4 class="font-medium text-danger-800 dark:text-danger-200">
Pengajuan Ditolak
</h4>
<p class="mt-1 text-sm text-danger-700 dark:text-danger-300">
{{ $progress['latestNotes'] }}
</p>
</div>
</div>
</div>
@endif
{{-- Success Message for Approved --}}
@if ($progress['isApproved'])
<div
class="p-4 rounded-xl bg-success-50 dark:bg-success-500/10 border border-success-200 dark:border-success-500/20">
<div class="flex gap-3">
<div class="shrink-0">
<x-heroicon-s-check-circle class="w-5 h-5 text-success-500" />
</div>
<div>
<h4 class="font-medium text-success-800 dark:text-success-200">
Verifikasi Diterima
</h4>
<p class="mt-1 text-sm text-success-700 dark:text-success-300">
Data {{ strtolower($progress['title']) }} Anda telah diverifikasi dan disetujui oleh admin.
</p>
</div>
</div>
</div>
@endif
{{-- Pending Message --}}
@if ($progress['isPending'])
<div class="p-4 rounded-xl bg-info-50 dark:bg-info-500/10 border border-info-200 dark:border-info-500/20">
<div class="flex gap-3">
<div class="shrink-0">
<x-heroicon-s-clock class="w-5 h-5 text-info-500 animate-pulse" />
</div>
<div>
<h4 class="font-medium text-info-800 dark:text-info-200">
Menunggu Verifikasi
</h4>
<p class="mt-1 text-sm text-info-700 dark:text-info-300">
Data sedang dalam proses verifikasi oleh admin. Mohon tunggu.
</p>
</div>
</div>
</div>
@endif
{{-- No Data Message --}}
@if (!$hasEntity)
<div class="p-4 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="flex gap-3">
<div class="shrink-0">
<x-heroicon-o-information-circle class="w-5 h-5 text-gray-400" />
</div>
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-300">
Data Belum Tersedia
</h4>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Silakan lengkapi data {{ strtolower($progress['title']) }} terlebih dahulu sebelum
mengajukan verifikasi.
</p>
<div class="mt-3">
<x-filament::button tag="a" :href="$progress['editUrl']" size="sm" color="gray"
icon="heroicon-m-plus">
Lengkapi Data
</x-filament::button>
</div>
</div>
</div>
</div>
@endif
{{-- Review History Accordion --}}
@if ($progress['reviewHistory']->isNotEmpty())
<div x-data="{ open: false }" class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<button @click="open = !open"
class="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<div class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<x-heroicon-o-clock class="w-4 h-4" />
Riwayat Review ({{ $progress['reviewHistory']->count() }})
</div>
<x-heroicon-o-chevron-down class="w-4 h-4 text-gray-500 transition-transform duration-200"
x-bind:class="{ 'rotate-180': open }" />
</button>
<div x-show="open" x-collapse class="border-t border-gray-200 dark:border-gray-700">
<div class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach ($progress['reviewHistory'] as $review)
<div class="p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<x-filament::badge :color="$review->decision->getColor()" size="sm">
{{ $review->decision->getLabel() }}
</x-filament::badge>
<span class="text-xs text-gray-500 dark:text-gray-400">
oleh {{ $review->reviewer?->name ?? 'Admin' }}
</span>
</div>
@if ($review->note)
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ $review->note }}
</p>
@endif
</div>
<div class="shrink-0 text-xs text-gray-400">
{{ $review->created_at->format('d M Y, H:i') }}
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
</div>
{{-- Footer with Actions --}}
<x-slot name="footer">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
@if ($verification)
Terakhir diajukan: {{ $verification->updated_at->format('d M Y, H:i') }}
@endif
</div>
<div class="flex items-center gap-2">
@if ($hasEntity && !$progress['isPending'] && !$progress['isApproved'])
<x-filament::button tag="a" :href="$progress['editUrl']" color="gray" size="sm"
icon="heroicon-m-pencil">
Edit Data
</x-filament::button>
@endif
@if ($progress['canSubmit'])
{{ $submitAction }}
@endif
</div>
</div>
</x-slot>
</x-filament::section>

View File

@ -0,0 +1,326 @@
<x-filament-panels::page>
{{-- Status Filter Tabs --}}
@php
$counts = $this->getStatusCounts();
@endphp
<div class="mb-6">
<div class="flex flex-wrap gap-2">
<button wire:click="setFilter('all')" @class([
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200',
'bg-primary-500 text-white shadow-md shadow-primary-500/20' =>
$filterStatus === 'all',
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700' =>
$filterStatus !== 'all',
])>
<span>Semua</span>
<span @class([
'px-2 py-0.5 text-xs rounded-full',
'bg-white/20' => $filterStatus === 'all',
'bg-gray-200 dark:bg-gray-700' => $filterStatus !== 'all',
])>{{ $counts['all'] }}</span>
</button>
<button wire:click="setFilter('{{ \App\Enums\VerificationStatus::PENDING->value }}')"
@class([
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200',
'bg-warning-500 text-white shadow-md shadow-warning-500/20' =>
$filterStatus == \App\Enums\VerificationStatus::PENDING->value,
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700' =>
$filterStatus != \App\Enums\VerificationStatus::PENDING->value,
])>
<x-heroicon-s-clock class="w-4 h-4" />
<span>Menunggu</span>
<span @class([
'px-2 py-0.5 text-xs rounded-full',
'bg-white/20' =>
$filterStatus == \App\Enums\VerificationStatus::PENDING->value,
'bg-warning-100 text-warning-700 dark:bg-warning-500/20 dark:text-warning-400' =>
$filterStatus != \App\Enums\VerificationStatus::PENDING->value,
])>{{ $counts['pending'] }}</span>
</button>
<button wire:click="setFilter('{{ \App\Enums\VerificationStatus::NEED_REVISION->value }}')"
@class([
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200',
'bg-info-500 text-white shadow-md shadow-info-500/20' =>
$filterStatus == \App\Enums\VerificationStatus::NEED_REVISION->value,
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700' =>
$filterStatus != \App\Enums\VerificationStatus::NEED_REVISION->value,
])>
<x-heroicon-s-pencil-square class="w-4 h-4" />
<span>Revisi</span>
<span @class([
'px-2 py-0.5 text-xs rounded-full',
'bg-white/20' =>
$filterStatus == \App\Enums\VerificationStatus::NEED_REVISION->value,
'bg-info-100 text-info-700 dark:bg-info-500/20 dark:text-info-400' =>
$filterStatus != \App\Enums\VerificationStatus::NEED_REVISION->value,
])>{{ $counts['revision'] }}</span>
</button>
<button wire:click="setFilter('{{ \App\Enums\VerificationStatus::APPROVED->value }}')"
@class([
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200',
'bg-success-500 text-white shadow-md shadow-success-500/20' =>
$filterStatus == \App\Enums\VerificationStatus::APPROVED->value,
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700' =>
$filterStatus != \App\Enums\VerificationStatus::APPROVED->value,
])>
<x-heroicon-s-check-circle class="w-4 h-4" />
<span>Disetujui</span>
<span @class([
'px-2 py-0.5 text-xs rounded-full',
'bg-white/20' =>
$filterStatus == \App\Enums\VerificationStatus::APPROVED->value,
'bg-success-100 text-success-700 dark:bg-success-500/20 dark:text-success-400' =>
$filterStatus != \App\Enums\VerificationStatus::APPROVED->value,
])>{{ $counts['approved'] }}</span>
</button>
<button wire:click="setFilter('{{ \App\Enums\VerificationStatus::REJECTED->value }}')"
@class([
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200',
'bg-danger-500 text-white shadow-md shadow-danger-500/20' =>
$filterStatus == \App\Enums\VerificationStatus::REJECTED->value,
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700' =>
$filterStatus != \App\Enums\VerificationStatus::REJECTED->value,
])>
<x-heroicon-s-x-circle class="w-4 h-4" />
<span>Ditolak</span>
<span @class([
'px-2 py-0.5 text-xs rounded-full',
'bg-white/20' =>
$filterStatus == \App\Enums\VerificationStatus::REJECTED->value,
'bg-danger-100 text-danger-700 dark:bg-danger-500/20 dark:text-danger-400' =>
$filterStatus != \App\Enums\VerificationStatus::REJECTED->value,
])>{{ $counts['rejected'] }}</span>
</button>
</div>
</div>
{{-- Verification Cards Grid --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@forelse ($this->getVerifications() as $verification)
@php
$entityType = $this->getEntityTypeLabel($verification);
$entityName = $this->getEntityName($verification);
$detailUrl = $this->getDetailUrl($verification);
$latestReview = $verification->reviews->first();
@endphp
<x-filament::section class="h-full">
<x-slot name="heading">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
{{-- Entity Type Badge --}}
@php
$typeColor = match ($entityType) {
'Perusahaan' => 'primary',
'Media' => 'info',
'Jurnalis' => 'success',
default => 'gray',
};
@endphp
<x-filament::badge :color="$typeColor" size="sm">
{{ $entityType }}
</x-filament::badge>
</div>
<h3 class="font-bold text-lg text-gray-900 dark:text-white truncate"
title="{{ $entityName }}">
{{ $entityName }}
</h3>
</div>
{{-- Status Badge --}}
<x-filament::badge :color="$verification->status->getColor()" size="lg">
{{ $verification->status->getLabel() }}
</x-filament::badge>
</div>
</x-slot>
<div class="space-y-4">
{{-- Submitter Info --}}
<div class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="shrink-0">
<div
class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center">
<x-heroicon-s-user class="w-5 h-5 text-primary-500" />
</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Diajukan oleh
</p>
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ $verification->submittedBy?->name ?? 'Unknown' }}
</p>
</div>
</div>
{{-- Timeline Info --}}
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Diajukan</p>
<p class="text-gray-700 dark:text-gray-300">
{{ $verification->created_at->format('d M Y') }}
</p>
<p class="text-xs text-gray-500">
{{ $verification->created_at->format('H:i') }}
</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">Update Terakhir
</p>
<p class="text-gray-700 dark:text-gray-300">
{{ $verification->updated_at->format('d M Y') }}
</p>
<p class="text-xs text-gray-500">
{{ $verification->updated_at->diffForHumans() }}
</p>
</div>
</div>
{{-- Latest Review Note --}}
@if ($latestReview && $latestReview->note)
<div
class="p-3 rounded-lg border
{{ match ($latestReview->decision) {
\App\Enums\DecisionAdmin::APPROVED
=> 'bg-success-50 dark:bg-success-500/10 border-success-200 dark:border-success-500/20',
\App\Enums\DecisionAdmin::NEED_REVISION
=> 'bg-warning-50 dark:bg-warning-500/10 border-warning-200 dark:border-warning-500/20',
\App\Enums\DecisionAdmin::REJECTED
=> 'bg-danger-50 dark:bg-danger-500/10 border-danger-200 dark:border-danger-500/20',
} }}">
<p
class="text-xs font-semibold uppercase mb-1
{{ match ($latestReview->decision) {
\App\Enums\DecisionAdmin::APPROVED => 'text-success-700 dark:text-success-400',
\App\Enums\DecisionAdmin::NEED_REVISION => 'text-warning-700 dark:text-warning-400',
\App\Enums\DecisionAdmin::REJECTED => 'text-danger-700 dark:text-danger-400',
} }}">
Catatan Terakhir
</p>
<p class="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
{{ $latestReview->note }}
</p>
<p class="text-xs text-gray-500 mt-1">
oleh {{ $latestReview->reviewer?->name ?? 'Admin' }}
{{ $latestReview->created_at->diffForHumans() }}
</p>
</div>
@endif
{{-- Review History Accordion --}}
@if ($verification->reviews->count() > 0)
<div x-data="{ open: false }"
class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<button @click="open = !open"
class="w-full flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<div
class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<x-heroicon-o-clock class="w-4 h-4" />
Riwayat Review ({{ $verification->reviews->count() }})
</div>
<x-heroicon-o-chevron-down
class="w-4 h-4 text-gray-500 transition-transform duration-200"
x-bind:class="{ 'rotate-180': open }" />
</button>
<div x-show="open" x-collapse class="border-t border-gray-200 dark:border-gray-700">
<div class="divide-y divide-gray-100 dark:divide-gray-700 max-h-60 overflow-y-auto">
@foreach ($verification->reviews as $review)
<div class="p-3">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<x-filament::badge :color="$review->decision->getColor()" size="sm">
{{ $review->decision->getLabel() }}
</x-filament::badge>
<span class="text-xs text-gray-500 dark:text-gray-400">
oleh {{ $review->reviewer?->name ?? 'Admin' }}
</span>
</div>
@if ($review->note)
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ $review->note }}
</p>
@endif
</div>
<div class="shrink-0 text-xs text-gray-400">
{{ $review->created_at->format('d M Y, H:i') }}
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
</div>
<x-slot name="footer">
<div class="flex flex-wrap items-center gap-2">
{{-- View Detail Button --}}
@if ($detailUrl)
<x-filament::button color="gray" size="sm" icon="heroicon-m-eye" tag="a"
:href="$detailUrl" target="_blank">
Detail
</x-filament::button>
@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)
<span
class="flex items-center gap-1 text-xs text-warning-600 dark:text-warning-400 font-medium italic">
<x-heroicon-s-clock class="w-3 h-3 animate-pulse" />
Menunggu revisi dari user...
</span>
@elseif($verification->status === \App\Enums\VerificationStatus::APPROVED)
<span
class="flex items-center gap-1 text-xs text-success-600 dark:text-success-400 font-medium">
<x-heroicon-s-check-circle class="w-3 h-3" />
Verifikasi selesai
</span>
@elseif($verification->status === \App\Enums\VerificationStatus::REJECTED)
<span
class="flex items-center gap-1 text-xs text-danger-600 dark:text-danger-400 font-medium">
<x-heroicon-s-x-circle class="w-3 h-3" />
Pengajuan ditolak
</span>
@endif
</div>
</x-slot>
</x-filament::section>
@empty
<div class="col-span-full">
<div
class="flex flex-col items-center justify-center py-16 text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700">
<x-heroicon-o-inbox class="w-16 h-16 mb-4 opacity-50" />
<p class="text-lg font-medium">Tidak ada pengajuan verifikasi</p>
<p class="text-sm text-gray-500 mt-1">
@if ($filterStatus === 'all')
Belum ada pengajuan verifikasi dari user.
@else
Tidak ada pengajuan dengan status ini.
@endif
</p>
</div>
</div>
@endforelse
</div>
{{-- Pagination --}}
@if ($this->getVerifications()->hasPages())
<div class="mt-6">
{{ $this->getVerifications()->links() }}
</div>
@endif
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@ -0,0 +1,14 @@
<x-filament-panels::page>
<div class="space-y-6">
{{-- Company Verification Section --}}
<x-verification-card :progress="$companyProgress" :submit-action="$this->submitCompanyAction" type="company" />
{{-- Media Verification Section --}}
<x-verification-card :progress="$mediaProgress" :submit-action="$this->submitMediaAction" type="media" />
{{-- Journalist Verification Section --}}
<x-verification-card :progress="$journalistProgress" :submit-action="$this->submitJournalistAction" type="journalist" />
</div>
<x-filament-actions::modals />
</x-filament-panels::page>