refactor(outlet): mengubah cara menampilkan data dari menggunakan datatable menjadi foreach card

-kasih pengecekan isset() ke form WithCloseModal
-menambahkan form atribute
-menghapus dispacth datatable, karena sudah tidak perlu
-kasih error untuk input fasilitas
This commit is contained in:
Yoga Pangestu 2025-10-14 15:38:03 +07:00
parent db3d9edb18
commit 77bce6f0eb
8 changed files with 199 additions and 154 deletions

View File

@ -1,146 +0,0 @@
<?php
namespace App\Livewire\Datatable\Studio\Master;
use App\Enums\Day;
use App\Enums\OutletStatus;
use App\Models\Outlet;
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 Illuminate\Support\Str;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
use Rappasoft\LaravelLivewireTables\Views\Filters\MultiSelectFilter;
class OutletsTable extends DataTableComponent
{
use WithAppendColumn, WithConfiguration, WithPrependColumn;
protected $model = Outlet::class;
public function columns(): array
{
return [
Column::make('Nama', 'name')->searchable(),
Column::make('Nomor Telepon', 'phone_number')->searchable(),
Column::make('Alamat')
->label(function ($row) {
return <<<HTML
<div>
<div>{$row->landmark}</div>
<div>{$row->address}</div>
</div>
HTML;
})
->searchable(function ($query, $searchTerm) {
$query->orWhere('landmark', 'like', "%{$searchTerm}%")
->orWhere('address', 'like', "%{$searchTerm}%");
})
->html(),
ArrayColumn::make('Jam Operasioanl')
->data(
fn ($value, $row) => $row->openingHours
->mapWithKeys(fn ($hour) => [
Str::lower(Day::from($hour->day->value)->label()) => [
'open' => formatTime($hour->open_time),
'close' => formatTime($hour->close_time),
],
])
)
->outputFormat(
fn ($index, $value) => "<span class='flex items-center gap-2 py-1 text-xs'>
<span class='font-semibold'>".Str::ucfirst($index).': </span>
<span class="text-gray-600 dark:text-gray-400">'.(
($value['open'] === null && $value['close'] === null)
? 'Tutup'
: "{$value['open']} - {$value['close']}"
).'</span>
</span>'
)
->flexCol(['class' => 'gap-2 flex-wrap']),
ArrayColumn::make('Fasilitas')
->data(fn ($value, $row) => $row->facilities->pluck('name')->toArray())
->outputFormat(fn ($index, $value) => Blade::render('<flux:badge color="zinc" dot>'.$value.'</flux:badge>'))
->flexRow(['class' => 'gap-2 flex-wrap']),
Column::make('Tanggal Operasi')
->label(function ($row) {
$openedDate = formatDate($row->opened_date);
$openedAgo = timeAgo($row->opened_date);
$closedDate = formatDate($row->closed_date);
$closedAgo = timeAgo($row->closed_date);
return <<<HTML
<div class="flex flex-col text-xs text-gray-700 dark:text-gray-300 gap-0.5">
<div>
<span class="font-medium text-gray-900 dark:text-white">Tgl Buka:</span>
<span>{$openedDate}</span>
<span class="text-gray-500">({$openedAgo})</span>
</div>
<div>
<span class="font-medium text-gray-900 dark:text-white">Tgl Tutup:</span>
<span>{$closedDate}</span>
<span class="text-gray-500">({$closedAgo})</span>
</div>
</div>
HTML;
})
->html(),
Column::make('Status', 'status')
->format(fn ($value) => Blade::render('<flux:badge color="'.$value->color().'" dot>'.$value->label().'</flux:badge>'))
->html(),
Column::make('Aksi')
->label(function ($row) {
$actions = '';
if (auth()->user()->can('update outlet')) {
$actions .= view('components.datatables.edit', [
'id' => $row->hash,
'editRoute' => route('studio.master.outlet.edit', $row->hash),
])->render();
}
if (auth()->user()->can('delete outlet')) {
$actions .= view('components.datatables.delete', [
'id' => $row->hash,
'deleteRoute' => route('studio.master.outlet.delete', $row->hash),
])->render();
}
return $actions;
})
->html(),
];
}
public function builder(): Builder
{
return Outlet::select('id', 'name', 'phone_number', 'landmark', 'address', 'opened_date', 'closed_date', 'status')->with(['openingHours', 'facilities']);
}
public function filters(): array
{
return [
MultiSelectFilter::make('Status')
->options(
collect(OutletStatus::cases())
->mapWithKeys(fn ($case) => [$case->value => $case->label()])
->toArray()
)
->filter(function (Builder $builder, array $values) {
$builder->whereIn('status', $values);
}),
];
}
}

View File

@ -78,7 +78,9 @@ public function validationAttributes(): array
'address' => 'alamat',
'landmark' => 'patokan',
'maps_url' => 'tautan google maps',
'opening_hours' => 'jam beroperasi',
'opening_hours.*' => 'jam beroperasi',
'facilities' => 'fasilitas',
'facilities.*' => 'fasilitas',
'opened_date' => 'tanggal buka',
'featured_image' => 'gambar utama',

View File

@ -36,8 +36,6 @@ public function save()
$this->form->store();
$this->dispatch('refreshDatatable');
$this->toast('Outlet berhasil ditambahkan.');
$this->redirectRoute('studio.master.outlet.index', navigate: true);

View File

@ -34,11 +34,9 @@ public function save()
$this->form->update();
$this->dispatch('refreshDatatable');
$this->redirectRoute('studio.master.outlet.index', navigate: true);
$this->toast('Outlet berhasil diperbarui.');
$this->redirectRoute('studio.master.outlet.index', navigate: true);
}
public function render()

View File

@ -2,17 +2,67 @@
namespace App\Livewire\Studio\Master\Outlet;
use App\Enums\Day;
use App\Models\Outlet;
use App\Traits\WithCloseModal;
use App\Traits\WithConfirmation;
use App\Traits\WithMediaHandler;
use App\Traits\WithToast;
use Flux\Flux;
use Illuminate\Support\Str;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Outlet')]
class Index extends Component
{
use WithConfirmation, WithToast;
use WithCloseModal, WithConfirmation, WithMediaHandler, WithToast;
public array $outlets = [];
public ?string $imageUrl = null;
public function mount()
{
$this->outlets = Outlet::with(['openingHours', 'facilities'])
->latest()
->get()
->map(fn ($outlet) => [
'hash' => $outlet->hash,
'name' => $outlet->name,
'phone_number' => $outlet->phone_number,
'landmark' => $outlet->landmark,
'address' => $outlet->address,
'status' => [
'label' => $outlet->status->label(),
'color' => $outlet->status->color(),
],
'opening_hours' => $outlet->openingHours->mapWithKeys(fn ($hour) => [
Str::ucfirst(Str::lower(Day::from($hour->day->value)->label())) => [
'open' => formatTime($hour->open_time),
'close' => formatTime($hour->close_time),
'is_closed' => $hour->open_time === null && $hour->close_time === null,
],
])->toArray(),
'facilities' => $outlet->facilities->pluck('name')->toArray(),
'opened_date' => formatDate($outlet->opened_date),
'opened_ago' => timeAgo($outlet->opened_date),
'closed_date' => $outlet->closed_date ? formatDate($outlet->closed_date) : null,
'closed_ago' => $outlet->closed_date ? timeAgo($outlet->closed_date) : null,
'edit_route' => route('studio.master.outlet.edit', $outlet->hash),
'delete_route' => route('studio.master.outlet.delete', $outlet->hash),
'image' => $this->mapMediaCollection($outlet->getMedia('featured_image'))[0]['temporaryUrl'],
'images' => collect($this->mapMediaCollection($outlet->getMedia('images')))->map(fn ($image) => $image['temporaryUrl']),
])
->toArray();
}
public function openImage(string $imageUrl)
{
$this->imageUrl = $imageUrl;
Flux::modal('image-modal')->show();
}
public function delete(Outlet $outlet)
{

View File

@ -10,7 +10,10 @@ public function closeModal(string $modalName, ?bool $isCloseAll = false)
{
$this->resetErrorBag();
$this->resetValidation();
$this->reset('form');
if (isset($this->form)) {
$this->reset('form');
}
if ($isCloseAll) {
Flux::modals()->close();

View File

@ -170,7 +170,26 @@ class="mt-3 text-sm font-medium text-red-500 dark:text-red-400">
class="cursor-pointer" />
@endif
</div>
@error("form.facilities.$index")
<flux:error name="form.facilities.{{ $index }}" />
@enderror
@endforeach
@error('form.facilities')
<div role="alert" aria-live="polite" aria-atomic="true"
class="mt-3 text-sm font-medium text-red-500 dark:text-red-400" data-flux-error="">
<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
<flux:button variant="primary" size="sm" wire:click="addFacility"
class="sm:w-auto cursor-pointer">
+ Tambah Fasilitas

View File

@ -14,8 +14,129 @@ class="text-sm">
</div>
<div class="mt-6">
<livewire:datatable.studio.master.outlets-table />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach ($outlets as $outlet)
<flux:card
class="space-y-2 transition-transform duration-300 ease-out hover:-translate-y-1 hover:shadow-lg">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<flux:avatar size="lg" src="{{ $outlet['image'] }}"
wire:click="openImage('{{ $outlet['image'] }}')" />
<div>
<flux:heading size="lg">{{ $outlet['name'] }}</flux:heading>
<flux:badge color="{{ $outlet['status']['color'] }}" size="sm">
{{ $outlet['status']['label'] }}
</flux:badge>
</div>
</div>
</div>
<div class="space-y-3 mb-4">
<div class="flex items-center gap-2 text-sm">
<flux:icon.phone class="w-4 h-4 text-gray-500 dark:text-white" />
<span class="text-gray-700 dark:text-gray-300">{{ $outlet['phone_number'] }}</span>
</div>
<div class="flex items-start gap-2 text-sm">
<flux:icon.map-pin class="w-4 h-4 text-gray-500 dark:text-white" />
<div class="text-gray-700 dark:text-gray-300">
<div class="font-medium">{{ $outlet['landmark'] }}</div>
<div class="text-gray-600 dark:text-gray-400">{{ $outlet['address'] }}</div>
</div>
</div>
</div>
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<flux:icon.clock class="w-4 h-4" />
Jam Operasional
</h4>
<div class="space-y-1 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
@foreach ($outlet['opening_hours'] as $day => $time)
<div class="flex justify-between text-xs">
<span
class="font-medium text-gray-700 dark:text-gray-300">{{ $day }}:</span>
<span class="text-gray-600 dark:text-gray-400">
{{ $time['is_closed'] ? 'Tutup' : "{$time['open']} - {$time['close']}" }}
</span>
</div>
@endforeach
</div>
</div>
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<flux:icon.wifi class="w-4 h-4" />
Fasilitas
</h4>
<div class="flex flex-wrap gap-2">
@foreach ($outlet['facilities'] as $facility)
<flux:badge color="zinc" size="sm">{{ $facility }}</flux:badge>
@endforeach
</div>
</div>
<div class="mb-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<div class="flex flex-col gap-2 text-xs">
<div>
<span class="font-medium text-gray-900 dark:text-white">Tgl Buka:</span>
<span class="text-gray-700 dark:text-gray-300">{{ $outlet['opened_date'] }}</span>
<span class="text-gray-500">({{ $outlet['opened_ago'] }})</span>
</div>
@if ($outlet['closed_date'])
<div>
<span class="font-medium text-gray-900 dark:text-white">Tgl Tutup:</span>
<span class="text-gray-700 dark:text-gray-300">{{ $outlet['closed_date'] }}</span>
<span class="text-gray-500">({{ $outlet['closed_ago'] }})</span>
</div>
@endif
</div>
</div>
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<flux:icon.photo class="w-4 h-4" />
Gambar
</h4>
<flux:avatar.group class="mt-6">
@foreach ($outlet['images'] as $key => $value)
<flux:avatar circle size="lg" src="{{ $value }}"
wire:click="openImage('{{ $value }}')" />
@endforeach
</flux:avatar.group>
</div>
<flux:separator></flux:separator>
<div class="flex gap-2 pt-2 border-gray-200 dark:border-gray-700">
@if (auth()->user()->can('update outlet'))
<flux:tooltip content="Ubah">
<flux:button href="{{ $outlet['edit_route'] }}" wire:navigate.hover variant="primary"
color="yellow" icon="pencil-square" size="sm"></flux:button>
</flux:tooltip>
@endif
@if (auth()->user()->can('delete outlet'))
<flux:modal.trigger name="delete">
<flux:tooltip content="Hapus">
<flux:button variant="danger" icon="trash" size="sm"
wire:click="$dispatch('fn:confirmDelete', {id: '{{ $outlet['hash'] }}'})">
</flux:button>
</flux:tooltip>
</flux:modal.trigger>
@endif
</div>
</flux:card>
@endforeach
</div>
</div>
@include('components.confirmation.delete')
<flux:modal name="image-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
<div class="flex justify-center items-center p-4">
<img src="{{ $imageUrl }}" alt="Outlet Image" class="rounded-lg max-h-[80vh] object-contain" />
</div>
</flux:modal>
</flux:main>