feat(article): implementasi crud

-membuat permission di seeder
-membuat enum untuk status
This commit is contained in:
Yoga Pangestu 2025-10-03 13:48:40 +07:00
parent 7dca0c9231
commit 8b8ce3077b
14 changed files with 537 additions and 0 deletions

View File

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
use App\Traits\WithCommentEnum;
use App\Traits\WithValueEnum;
enum ArticleStatus: int
{
use WithCommentEnum, WithValueEnum;
case PUBLISHED = 1;
case DRAFT = 2;
case ARCHIVED = 3;
public function label()
{
return match ($this) {
self::PUBLISHED => 'Publish',
self::DRAFT => 'Draft',
self::ARCHIVED => 'Arsip',
};
}
public function color()
{
return match ($this) {
self::PUBLISHED => 'emerald',
self::DRAFT => 'yellow',
self::ARCHIVED => 'rose',
};
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Livewire\Datatable\Studio\Manage;
use App\Models\Article;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use App\Traits\WithMediaHandler;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class ArticlesTable extends DataTableComponent
{
use WithConfiguration, WithMediaHandler, WithPrependColumn;
protected $model = Article::class;
public function columns(): array
{
return [
Column::make('Penulis', 'author.employee.full_name')->searchable(),
Column::make('Judul', 'title')->searchable(),
Column::make('Status', 'status')
->format(
fn($value) => Blade::render('<flux:badge color="' . $value->color() . '">' . $value->label() . '</flux:badge>')
)
->html(),
Column::make('Aksi')
->label(function ($row) {
$actions = '';
if (auth()->user()->can('update article')) {
$actions .= view('components.datatables.edit', [
'id' => $row->hash,
'editRoute' => route('studio.manage.article.edit', $row->hash),
])->render();
}
if (auth()->user()->can('delete article')) {
$actions .= view('components.datatables.delete', [
'id' => $row->hash,
'deleteRoute' => route('studio.manage.article.delete', $row->hash),
])->render();
}
return $actions;
})
->html(),
];
}
public function builder(): Builder
{
return Article::select('articles.id', 'title', 'articles.status')->with(['author', 'author.employee']);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Livewire\Forms\Studio\Manage;
use App\Enums\ArticleStatus;
use App\Models\Article;
use App\Traits\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Form;
class ArticleForm extends Form
{
use WithMediaHandler;
public ?Article $article = null;
public string $title = '';
public string $excerpt = '';
public string $content = '';
public string $status = '';
public array $thumbnail = [];
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'excerpt' => ['required', 'string'],
'content' => ['required', 'string'],
'status' => ['required', Rule::in(ArticleStatus::cases())],
'thumbnail' => ['required', 'array'],
];
}
public function validationAttributes(): array
{
return [
'title' => 'judul',
'excerpt' => 'cuplikan',
'content' => 'konten',
];
}
public function setArticle(Article $article)
{
$this->article = $article;
$this->title = $article->title;
$this->excerpt = $article->excerpt;
$this->content = $article->content;
$this->status = $article->status->value;
$this->thumbnail = $this->mapMediaCollection($article->getMedia('thumbnail'));
}
public function store()
{
$this->validate();
DB::transaction(function () {
$article = Article::create([
'author_id' => auth()->id(),
'title' => $this->title,
'excerpt' => $this->excerpt,
'content' => $this->content,
'status' => $this->status,
'published_at' => $this->status == ArticleStatus::PUBLISHED->value ? now() : null,
]);
$this->uploadMedia($this->thumbnail, $article, 'thumbnail');
});
}
public function update()
{
$this->validate();
DB::transaction(function () {
$this->article->update(array_merge(
[
'title' => $this->title,
'excerpt' => $this->excerpt,
'content' => $this->content,
'status' => $this->status,
],
($this->status == ArticleStatus::PUBLISHED->value && ! $this->article->published_at)
? ['published_at' => now()]
: []
));
$this->syncMedia($this->thumbnail, $this->article, 'thumbnail');
$this->uploadMedia($this->thumbnail, $this->article, 'thumbnail');
});
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Livewire\Studio\Manage\Article;
use App\Livewire\Forms\Studio\Manage\ArticleForm;
use App\Traits\WithAuthorization;
use App\Traits\WithToast;
use App\Traits\WithUpdatedData;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Tambah Artikel')]
class Create extends Component
{
use WithAuthorization, WithToast, WithUpdatedData;
public ArticleForm $form;
public function save()
{
$this->canOrAbort('create article');
$this->form->store();
$this->dispatch('refreshDatatable');
$this->toast('Artikel berhasil ditambahkan.');
$this->redirectRoute('studio.manage.article.index', navigate: true);
}
public function render()
{
return view('livewire.studio.manage.article.form', [
'pageTitle' => 'Tambah Artikel',
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Studio\Manage\Article;
use App\Livewire\Forms\Studio\Manage\ArticleForm;
use App\Models\Article;
use App\Traits\WithAuthorization;
use App\Traits\WithToast;
use App\Traits\WithUpdatedData;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Ubah Artikel')]
class Edit extends Component
{
use WithToast, WithUpdatedData, WithAuthorization;
public ArticleForm $form;
public function mount(Article $article)
{
$this->form->setArticle($article);
}
public function save()
{
$this->canOrAbort('update article');
$this->form->update();
$this->dispatch('refreshDatatable');
$this->toast('Artikel berhasil diperbarui.');
$this->redirectRoute('studio.manage.article.index', navigate: true);
}
public function render()
{
return view('livewire.studio.manage.article.form', [
'pageTitle' => 'Ubah Artikel',
]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Studio\Manage\Article;
use App\Models\Article;
use App\Traits\WithConfirmation;
use App\Traits\WithToast;
use Flux\Flux;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Artikel')]
class Index extends Component
{
use WithConfirmation, WithToast;
public function delete(Article $article)
{
$article->delete();
$this->dispatch('refreshDatatable');
$this->toast('Artikel berhasil dihapus.');
Flux::modals()->close();
}
public function render()
{
return view('livewire.studio.manage.article.index', [
'pageTitle' => 'Artikel',
]);
}
}

42
app/Models/Article.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Enums\ArticleStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class Article extends Model implements HasMedia
{
use HasFactory, HasSlug, InteractsWithMedia, SoftDeletes, HashableId;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'status' => ArticleStatus::class,
'published_at' => 'datetime',
'viwes' => 'int',
];
}
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->generateSlugsFrom('name')
->saveSlugsTo('slug');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}

View File

@ -9,6 +9,7 @@
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -62,4 +63,9 @@ public function outlets(): BelongsToMany
{
return $this->belongsToMany(Outlet::class);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
}

View File

@ -120,6 +120,18 @@ public function boot(): void
],
],
],
[
'heading' => 'Kelola',
'items' => [
[
'label' => 'Artikel',
'icon' => 'newspaper',
'route' => 'studio.manage.article.index',
'match' => 'studio.manage.article.*',
'can' => 'view article',
],
],
],
];
$view->with('sidebar', $sidebar);

View File

@ -0,0 +1,38 @@
<?php
use App\Enums\ArticleStatus;
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('articles', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
$table->string('title', 200);
$table->string('slug')->unique();
$table->text('excerpt');
$table->text('content');
$table->enum('status', [ArticleStatus::values()])->default(ArticleStatus::PUBLISH)->comment(ArticleStatus::comment());
$table->timestamp('published_at')->nullable();
$table->unsignedInteger('views')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('articles');
}
};

View File

@ -83,6 +83,11 @@ public function run(): void
'create expense',
'update expense',
'delete expense',
'view article',
'create article',
'update article',
'delete article',
];
foreach ($permissions as $permission) {
@ -163,6 +168,11 @@ public function run(): void
'create bottle',
'update bottle',
'delete bottle',
'view article',
'create article',
'update article',
'delete article',
]));
$partner->syncPermissions([

View File

@ -0,0 +1,88 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:button href="{{ route('studio.manage.article.index') }}" wire:navigate.hover class="text-sm">
Kembali
</flux:button>
</div>
</div>
<div class="mt-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="w-full lg:w-3/4 space-y-4">
<flux:card class="space-y-6 p-6">
<flux:field>
<flux:label>Judul <span class="text-red-500 ms-1">*</span></flux:label>
<flux:input placeholder="Masukkan judul" wire:model.live.debounce.500ms="form.title"
autofocus autocomplete="off" />
<flux:error name="form.title" />
</flux:field>
<flux:field>
<flux:label>Cuplikasn <span class="text-red-500 ms-1">*</span></flux:label>
<flux:editor wire:model="form.excerpt" placeholder="Masukkan cuplikan" auotocomplete="off"
class="**:data-[slot=content]:min-h-[100px]!" />
<flux:error name="form.excerpt" />
</flux:field>
<flux:field>
<flux:label>Konten <span class="text-red-500 ms-1">*</span></flux:label>
<flux:editor wire:model="form.content" placeholder="Masukkan konten" auotocomplete="off"
class="**:data-[slot=content]:min-h-[100px]!" />
<flux:error name="form.content" />
</flux:field>
</flux:card>
</div>
<div class="w-full lg:w-1/3 space-y-4">
<flux:card class="space-y-6 p-6">
<flux:field>
<flux:label>Status <span class="text-red-500 ms-1">*</span></flux:label>
<flux:radio.group wire:model.live="form.status" variant="buttons" class="w-full *:flex-1">
@foreach (\App\Enums\ArticleStatus::cases() as $status)
<flux:radio value="{{ $status->value }}">
{{ $status->label() }}
</flux:radio>
@endforeach
</flux:radio.group>
<flux:error name="form.status" />
</flux:field>
</flux:card>
<flux:card class="space-y-6 p-6">
<div class="space-y-3">
<h3 class="text-sm font-medium">Thumbnail <span class="text-red-500 ms-1">*</span></h3>
<div class="dropzone-wrapper">
<livewire:dropzone wire:model="form.thumbnail" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1"
:key="'thumbnail'" :files="$form->thumbnail" />
@error('form.thumbnail')
<div role="alert" aria-live="polite" aria-atomic="true"
class="mt-3 text-sm font-medium text-red-500 dark:text-red-400">
<svg class="shrink-0 [:where(&amp;)]:size-5 inline" data-flux-icon=""
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"></path>
</svg>
{{ $message }}
</div>
@enderror
</div>
</div>
</flux:card>
</div>
</div>
<div class="flex justify-start">
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="save">
Simpan
</flux:button>
</div>
</div>
</div>
</flux:main>

View File

@ -0,0 +1,21 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
@if (auth()->user()->can('create article'))
<div>
<flux:button href="{{ route('studio.manage.article.create') }}" variant="primary" wire:navigate.hover
class="text-sm">
Tambah
</flux:button>
</div>
@endif
</div>
<div class="mt-6">
<livewire:datatable.studio.manage.articles-table />
</div>
@include('components.confirmation.delete')
</flux:main>

View File

@ -18,6 +18,9 @@
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
use App\Livewire\Studio\Loyalty\Voucher\Edit as VoucherEdit;
use App\Livewire\Studio\Loyalty\Voucher\Index as VoucherIndex;
use App\Livewire\Studio\Manage\Article\Create as ArticleCreate;
use App\Livewire\Studio\Manage\Article\Edit as ArticleEdit;
use App\Livewire\Studio\Manage\Article\Index as ArticleIndex;
use App\Livewire\Studio\Master\Outlet\Create as OutletCreate;
use App\Livewire\Studio\Master\Outlet\Edit as OutletEdit;
use App\Livewire\Studio\Master\Outlet\Index as OutletIndex;
@ -114,4 +117,13 @@
Route::delete('/{expense}/delete', ExpenseComponent::class)->name('delete')->middleware();
});
});
Route::prefix('manage')->name('studio.manage.')->group(function () {
Route::prefix('articles')->name('article.')->group(function () {
Route::get('/', ArticleIndex::class)->name('index')->middleware('can:view article');
Route::get('/create', ArticleCreate::class)->name('create')->middleware('can:create article');
Route::get('/{article}/edit', ArticleEdit::class)->name('edit')->middleware('can:update article');
Route::delete('/{article}/delete', ArticleCreate::class)->name('delete')->middleware('can:delete article');
});
});
});