feat(broadcast): implement broadcast management

This commit is contained in:
Yoga Pangestu 2025-11-21 21:58:24 +07:00
parent fb424c4192
commit 8e1238a2dd
12 changed files with 425 additions and 7 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Datatable\Studio\Information;
use App\Models\Broadcast;
use App\Traits\Datatable\WithAppendColumn;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
class BroadcastsTable extends DataTableComponent
{
use WithAppendColumn, WithConfiguration, WithPrependColumn;
protected $model = Broadcast::class;
public function columns(): array
{
return [
Column::make('Judul', 'title')->searchable(),
Column::make('Body', 'body')->searchable(),
ArrayColumn::make('Audiensi')
->data(
fn ($value, $row) => $row->audiences->map(fn ($audience) => [
'name' => $audience->name,
])->toArray()
)
->outputFormat(fn ($index, $value) => Blade::render('
<div class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
</div>', ['value' => $value]))
->flexRow(['class' => 'gap-2 flex-wrap']),
Column::make('Tanggal')
->label(fn ($row, $column) => formatDateTime($row->created_at)),
];
}
public function builder(): Builder
{
return Broadcast::select('id', 'title', 'body')
->with('audiences');
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Forms\Studio\Information;
use App\Models\Broadcast;
use App\Traits\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Form;
class BroadcastForm extends Form
{
use WithMediaHandler;
public ?Broadcast $broadcast = null;
public string $title = '';
public string $body = '';
public array $role_ids = [];
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:30'],
'body' => ['required', 'string', 'max:50'],
'role_ids' => ['required', 'array', 'min:1'],
'role_ids.*' => Rule::exists('roles', 'id'),
];
}
public function validationAttributes(): array
{
return [
'title' => 'judul',
'role_ids' => 'audiensi',
'role_ids.*' => 'audiensi',
];
}
public function setBroadcast(Broadcast $broadcast)
{
$this->title = $broadcast->title;
$this->body = $broadcast->body;
$this->role_ids = $broadcast->roles->pluck('id')->toArray();
}
public function store()
{
$this->validate();
DB::transaction(function () use (&$broadcast) {
$broadcast = Broadcast::create([
'user_id' => auth()->id(),
'title' => $this->title,
'body' => $this->body,
]);
$broadcast->audiences()->attach($this->role_ids);
});
return [
'status' => true,
'data' => [
'title' => $broadcast->title,
'body' => $broadcast->body,
'audience_ids' => $this->role_ids,
],
];
}
public function update()
{
$this->validate();
DB::transaction(function () {
$this->broadcast->update([
'title' => $this->title,
]);
$this->broadcast->audiences()->sync($this->role_ids);
});
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Livewire\Studio\Information;
use App\Jobs\StorePushNotification;
use App\Livewire\Forms\Studio\Information\BroadcastForm;
use App\Models\Broadcast as BroadcastModel;
use App\Models\PushNotification;
use App\Models\User;
use App\Traits\WithAuthorization;
use App\Traits\WithCloseModal;
use App\Traits\WithConfirmation;
use App\Traits\WithRoleSelector;
use App\Traits\WithToast;
use App\Traits\WithUpdatedData;
use Flux\Flux;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Permission\Models\Role;
#[Title('Broadcast')]
class Broadcast extends Component
{
use WithAuthorization, WithCloseModal, WithConfirmation, WithRoleSelector, WithToast, WithUpdatedData;
public BroadcastForm $form;
public string $method = 'create';
public string $modalTitle = '';
public array $roles = [];
public function mount()
{
$this->roles = Role::where('name', '!=', 'Developer')->orderBy('id')->pluck('name', 'id')->toArray();
}
#[On('modal:open')]
public function openModal(string $method, string $modalTitle, ?string $id = null)
{
$this->resetValidation();
$this->resetErrorBag();
$this->method = $method;
$this->modalTitle = $modalTitle;
if ($id) {
$this->form->setBroadcast(BroadcastModel::byHashOrFail($id));
}
}
public function create()
{
$this->canOrAbort('create broadcast');
$result = $this->form->store();
$userIds = User::role(array_map('intval', $result['data']['audience_ids']))->pluck('id')->toArray();
StorePushNotification::dispatch(
PushNotification::whereIn('user_id', $userIds)->get(),
[
'title' => $result['data']['title'],
'body' => $result['data']['body'],
],
);
$this->dispatch('refreshDatatable');
$this->toast('Broadcast berhasil ditambahkan.');
Flux::modals()->close();
}
public function update()
{
$this->canOrAbort('update broadcast');
$this->form->update();
$this->dispatch('refreshDatatable');
$this->toast('Broadcast berhasil diperbarui.');
Flux::modals()->close();
}
public function delete(BroadcastModel $broadcast)
{
$broadcast->delete();
$this->dispatch('refreshDatatable');
$this->toast('Broadcast berhasil dihapus.');
Flux::modals()->close();
}
public function render()
{
return view('livewire.studio.information.broadcasts', [
'pageTitle' => 'Broadcast',
]);
}
}

23
app/Models/Broadcast.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Spatie\Permission\Models\Role;
class Broadcast extends Model
{
protected $guarded = ['id'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function audiences(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'broadcast_audiences', 'broadcast_id', 'audience_id');
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BroadcastAudience extends Model
{
protected $guarded = ['id'];
}

View File

@ -137,8 +137,8 @@ public function pushNotifications(): HasMany
return $this->hasMany(PushNotification::class); return $this->hasMany(PushNotification::class);
} }
public function users(): HasMany public function broadcasts(): HasMany
{ {
return $this->hasMany(User::class); return $this->hasMany(Broadcast::class);
} }
} }

View File

@ -186,6 +186,18 @@ public function boot(): void
], ],
], ],
], ],
[
'heading' => 'Informasi',
'items' => [
[
'label' => 'Broadcast',
'icon' => 'megaphone',
'route' => 'studio.information.broadcast.index',
'match' => 'studio.information.broadcast.*',
'can' => 'view broadcast',
],
],
],
[ [
'heading' => 'Keunggulan', 'heading' => 'Keunggulan',
'items' => [ 'items' => [

View File

@ -0,0 +1,31 @@
<?php
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('broadcasts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title', 30);
$table->string('body', 50);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('broadcasts');
}
};

View File

@ -0,0 +1,29 @@
<?php
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('broadcast_audiences', function (Blueprint $table) {
$table->id();
$table->foreignId('broadcast_id')->constrained()->cascadeOnDelete();
$table->foreignId('audience_id')->constrained('roles')->cascadeOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('broadcast_audiences');
}
};

View File

@ -127,6 +127,9 @@ public function run(): void
'update faq', 'update faq',
'delete faq', 'delete faq',
'view broadcast',
'create broadcast',
'view perfume feature', 'view perfume feature',
'create perfume feature', 'create perfume feature',
'update perfume feature', 'update perfume feature',

View File

@ -0,0 +1,64 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
@can('create broadcast')
<div>
<flux:modal.trigger name="form-modal">
<flux:button variant="primary" class="text-sm"
wire:click="$dispatch('modal:open', {method: 'create', 'modalTitle': 'Tambah Broadcast'})">Tambah
</flux:button>
</flux:modal.trigger>
</div>
@endcan
</div>
<div class="mt-6">
<livewire:datatable.studio.information.broadcasts-table />
</div>
@include('components.confirmation.delete')
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
<div class="p-4 space-y-6">
<flux:heading size="lg">{{ $modalTitle }}</flux:heading>
<flux:field>
<flux:label>Judul <span class="text-red-500 ms-1">*</span></flux:label>
<flux:input placeholder="Yakin nggak mau coba aroma ini? 🌸✨"
wire:model.live.debounce.500ms="form.title" autofocus autocomplete="off" />
<flux:error name="form.title" />
</flux:field>
<flux:field>
<flux:label>Body <span class="text-red-500 ms-1">*</span></flux:label>
<flux:textarea placeholder="Coba cek sekarang… siapa tahu cocok banget 😌🍃"
wire:model.live.debounce.500ms="form.body" autocomplete="off" rows="2" />
<flux:error name="form.body" />
</flux:field>
<flux:field>
<flux:label>Audiensi <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" multiple searchable placeholder="Pilih Audiensi"
wire:model.live.debounce.500ms="form.role_ids">
<flux:select.option wire:click="selectAllRoles" wire:ignore>Pilih Semua
</flux:select.option>
<flux:select.option wire:click="deselectAllRoles" wire:ignore>Hapus Semua
</flux:select.option>
@foreach ($roles as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.role_ids" />
</flux:field>
<div class="flex">
<flux:spacer />
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="{{ $method }}">
Simpan
</flux:button>
</div>
</div>
</flux:modal>
</flux:main>

View File

@ -21,6 +21,7 @@
use App\Livewire\Studio\Feature\PerfumeFeature as PerfumeFeatureComponent; use App\Livewire\Studio\Feature\PerfumeFeature as PerfumeFeatureComponent;
use App\Livewire\Studio\Finance\Expense as ExpenseComponent; use App\Livewire\Studio\Finance\Expense as ExpenseComponent;
use App\Livewire\Studio\Finance\Payroll as PayrollComponent; use App\Livewire\Studio\Finance\Payroll as PayrollComponent;
use App\Livewire\Studio\Information\Broadcast as BroadcastComponent;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent; use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Tier as TierComponent; use App\Livewire\Studio\Loyalty\Tier as TierComponent;
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate; use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
@ -173,16 +174,20 @@
Route::delete('/{article}/delete', ArticleCreate::class)->name('delete')->middleware('can:delete article'); Route::delete('/{article}/delete', ArticleCreate::class)->name('delete')->middleware('can:delete article');
}); });
Route::prefix('faqs')->name('faq.')->group(function () {
Route::get('/', FaqComponent::class)->name('index')->middleware('can:view faq');
Route::delete('/{faq}/delete', FaqComponent::class)->name('delete')->middleware('can:delete faq');
});
Route::prefix('price-requests')->name('price_request.')->group(function () { Route::prefix('price-requests')->name('price_request.')->group(function () {
Route::get('/', PriceRequestComponent::class)->name('index')->middleware('can:view price request'); Route::get('/', PriceRequestComponent::class)->name('index')->middleware('can:view price request');
Route::patch('/{priceRequest}/approve', PriceRequestComponent::class)->name('approve')->middleware('can:approve price request'); Route::patch('/{priceRequest}/approve', PriceRequestComponent::class)->name('approve')->middleware('can:approve price request');
Route::patch('/{priceRequest}/reject', PriceRequestComponent::class)->name('reject')->middleware('can:reject price request'); Route::patch('/{priceRequest}/reject', PriceRequestComponent::class)->name('reject')->middleware('can:reject price request');
}); });
Route::prefix('faqs')->name('faq.')->group(function () {
Route::get('/', FaqComponent::class)->name('index')->middleware('can:view faq');
Route::delete('/{faq}/delete', FaqComponent::class)->name('delete')->middleware('can:delete faq');
});
});
Route::prefix('information')->name('studio.information.')->group(function () {
Route::get('/broadcasts', BroadcastComponent::class)->name('broadcast.index')->middleware('can:view broadcast');
}); });
Route::prefix('feature')->name('studio.feature.')->group(function () { Route::prefix('feature')->name('studio.feature.')->group(function () {