diff --git a/app/Enums/OutletStatus.php b/app/Enums/OutletStatus.php
new file mode 100644
index 0000000..93c3635
--- /dev/null
+++ b/app/Enums/OutletStatus.php
@@ -0,0 +1,47 @@
+ 'Operasional',
+ self::UNDER_CONSTRUCTION => 'Dalam Konstruksi',
+ self::TERMINATED => 'Dihentikan',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::OPERATIONAL => 'emerald',
+ self::UNDER_CONSTRUCTION => 'yellow',
+ self::TERMINATED => 'red',
+ };
+ }
+
+ public function icon(): string
+ {
+ return match ($this) {
+ self::OPERATIONAL => 'check-circle',
+ self::UNDER_CONSTRUCTION => 'wrench-screwdriver',
+ self::TERMINATED => 'x-circle',
+ };
+ }
+
+ public static function values(): array
+ {
+ return array_map(fn($case) => $case->value, self::cases());
+ }
+
+ public static function comment(): string
+ {
+ return implode(', ', array_map(fn($case) => "{$case->value}: {$case->label()}", self::cases()));
+ }
+}
diff --git a/app/Helpers/DateTimeHelpers.php b/app/Helpers/DateTimeHelpers.php
index 5bac846..b4ef86c 100644
--- a/app/Helpers/DateTimeHelpers.php
+++ b/app/Helpers/DateTimeHelpers.php
@@ -29,3 +29,16 @@ function timeAgo(?string $date = null): string
return ! $date ? '-' : Carbon::parse($date)->diffForHumans();
}
}
+
+if (! function_exists('normalizeOpeningHours')) {
+ function normalizeOpeningHours(array $openingHours)
+ {
+ $days = ['senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu', 'minggu'];
+
+ return collect($days)
+ ->mapWithKeys(fn($day) => [
+ $day => $openingHours[$day] ?? ['open' => null, 'close' => null],
+ ])
+ ->toArray();
+ }
+}
diff --git a/app/Livewire/Datatable/OutletsTable.php b/app/Livewire/Datatable/OutletsTable.php
new file mode 100644
index 0000000..3919113
--- /dev/null
+++ b/app/Livewire/Datatable/OutletsTable.php
@@ -0,0 +1,120 @@
+searchable(),
+
+ Column::make('Nomor Telepon', 'phone_number')->searchable(),
+
+ Column::make('Alamat')
+ ->label(function ($row) {
+ return <<
+
{$row->landmark}
+ {$row->address}
+
+ HTML;
+ })
+ ->searchable(function ($query, $searchTerm) {
+ $query->orWhere('landmark', 'like', "%{$searchTerm}%")
+ ->orWhere('address', 'like', "%{$searchTerm}%");
+ })
+ ->html(),
+
+ ArrayColumn::make('Jam Buka', 'opening_hours')
+ ->data(fn($value, $row) => normalizeOpeningHours($row->opening_hours))
+ ->outputFormat(
+ fn($index, $value) => "
+ " . ucfirst($index) . ":
+ "
+ . (($value['open'] === null && $value['close'] === null)
+ ? 'Tutup'
+ : "{$value['open']} - {$value['close']}")
+ . "
+ "
+ )
+ ->flexCol(['class' => 'gap-2 flex-wrap']),
+
+ ArrayColumn::make('Fasilitas', 'facilities')
+ ->data(fn($value, $row) => $row->facilities)
+ ->outputFormat(fn($index, $value) => Blade::render('' . $value . ''))
+ ->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 <<
+
+ Tgl Buka:
+ {$openedDate}
+ ({$openedAgo})
+
+
+ Tgl Tutup:
+ {$closedDate}
+ ({$closedAgo})
+
+
+ HTML;
+ })
+ ->searchable(function ($query, $searchTerm) {
+ $query->orWhere('landmark', 'like', "%{$searchTerm}%")
+ ->orWhere('address', 'like', "%{$searchTerm}%");
+ })
+ ->html(),
+
+ Column::make('Status', 'status')
+ ->format(fn($value) => Blade::render('' . $value->label() . ''))
+ ->html(),
+
+ Column::make('Aksi')
+ ->label(function ($row) {
+ $actions = '';
+
+ $actions .= view('components.datatables.edit', [
+ 'id' => $row->id,
+ 'editRoute' => route('studio.master.outlet.edit', $row->id),
+ ])->render();
+
+ $actions .= view('components.datatables.delete', [
+ 'id' => $row->id,
+ 'deleteRoute' => route('studio.master.outlet.delete', $row->id),
+ ])->render();
+
+ return $actions;
+ })
+ ->html()
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return Outlet::select('id', 'name', 'phone_number', 'landmark', 'address', 'opening_hours', 'facilities', 'opened_date', 'closed_date', 'status');
+ }
+}
diff --git a/app/Livewire/Forms/OutletForm.php b/app/Livewire/Forms/OutletForm.php
new file mode 100644
index 0000000..5155e20
--- /dev/null
+++ b/app/Livewire/Forms/OutletForm.php
@@ -0,0 +1,129 @@
+ ['open' => null, 'close' => null],
+ 'selasa' => ['open' => null, 'close' => null],
+ 'rabu' => ['open' => null, 'close' => null],
+ 'kamis' => ['open' => null, 'close' => null],
+ 'jumat' => ['open' => null, 'close' => null],
+ 'sabtu' => ['open' => null, 'close' => null],
+ 'minggu' => ['open' => null, 'close' => null],
+ ];
+
+ public array $facilities = [''];
+
+ public string $opened_date = '';
+
+ public ?string $status = null;
+
+ public function rules(): array
+ {
+ $days = ['senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu', 'minggu'];
+
+ $rules = [
+ 'name' => ['required', 'string', 'max:100'],
+ 'phone_number' => ['required', 'string', new PhoneNumber],
+ 'address' => ['required', 'string'],
+ 'landmark' => ['required', 'string', 'max:40'],
+ 'maps_url' => [
+ 'nullable',
+ 'string',
+ 'regex:/^(https?:\/\/)?(www\.)?(google\.[a-z.]+\/maps\/.+|goo\.gl\/maps\/.+|maps\.app\.goo\.gl\/.+)$/i'
+ ],
+ 'opening_hours' => ['required', 'array'],
+ 'facilities' => ['required', 'array', 'min:1'],
+ 'facilities.*' => ['required', 'string', 'max:20'],
+ 'opened_date' => ['required', 'date'],
+ 'status' => ['required', Rule::in(OutletStatus::cases())],
+ ];
+
+ foreach ($days as $day) {
+ $rules["opening_hours.$day"] = ['required', 'array'];
+ $rules["opening_hours.$day.open"] = ['nullable', 'date_format:H:i'];
+ $rules["opening_hours.$day.close"] = ['nullable', 'date_format:H:i'];
+ }
+
+ return $rules;
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'name' => 'nama',
+ 'phone_number' => 'nomor telepon',
+ 'address' => 'alamat',
+ 'landmark' => 'patokan',
+ 'maps_url' => 'tautan google maps',
+ 'opening_hours.*' => 'jam beroperasi',
+ 'facilities.*' => 'fasilitas',
+ 'opened_date' => 'tanggal buka',
+ ];
+ }
+
+ public function setOutlet(Outlet $outlet)
+ {
+ $this->outlet = $outlet;
+
+ $this->name = $outlet->name;
+ $this->phone_number = $outlet->phone_number;
+ $this->address = $outlet->address;
+ $this->landmark = $outlet->landmark;
+ $this->maps_url = $outlet->maps_url;
+ $this->opening_hours = $outlet->opening_hours;
+ $this->facilities = $outlet->facilities;
+ $this->opened_date = $outlet->opened_date;
+ $this->status = $outlet->status->value;
+ }
+
+ public function store()
+ {
+ $this->validate();
+
+ $data = $this->all();
+
+ if ($data['status'] == OutletStatus::TERMINATED) {
+ $data['closed_date'] = now();
+ } else {
+ $data['closed_date'] = null;
+ }
+
+ Outlet::create($data);
+ }
+
+ public function update()
+ {
+ $this->validate();
+
+ $data = $this->all();
+
+ if ($data['status'] == OutletStatus::TERMINATED->value) {
+ $data['closed_date'] = now()->toDateString();
+ } else {
+ $data['closed_date'] = null;
+ }
+
+ $this->outlet->update($data);
+ }
+}
diff --git a/app/Livewire/Studio/Master/Outlet/Create.php b/app/Livewire/Studio/Master/Outlet/Create.php
new file mode 100644
index 0000000..df6dd87
--- /dev/null
+++ b/app/Livewire/Studio/Master/Outlet/Create.php
@@ -0,0 +1,39 @@
+form->store();
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Outlet berhasil ditambahkan.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ $this->redirectRoute('studio.master.outlet.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.master.outlet.form', [
+ 'pageTitle' => 'Tambah Outlet',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Master/Outlet/Edit.php b/app/Livewire/Studio/Master/Outlet/Edit.php
new file mode 100644
index 0000000..f976838
--- /dev/null
+++ b/app/Livewire/Studio/Master/Outlet/Edit.php
@@ -0,0 +1,47 @@
+form->setOutlet($outlet);
+ }
+
+ public function save()
+ {
+ $this->form->update();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Outlet berhasil diperbarui.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ $this->redirectRoute('studio.master.outlet.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.master.outlet.form', [
+ 'pageTitle' => 'Ubah Outlet',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Master/Outlet/Index.php b/app/Livewire/Studio/Master/Outlet/Index.php
new file mode 100644
index 0000000..f913e50
--- /dev/null
+++ b/app/Livewire/Studio/Master/Outlet/Index.php
@@ -0,0 +1,38 @@
+delete();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Outlet berhasil dihapus.',
+ variant: 'success',
+ );
+
+ Flux::modals()->close();
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.master.outlet.index', [
+ 'pageTitle' => 'Outlet',
+ ]);
+ }
+}
diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php
new file mode 100644
index 0000000..67c97df
--- /dev/null
+++ b/app/Models/Outlet.php
@@ -0,0 +1,39 @@
+ 'array',
+ 'facilities' => 'array',
+ 'status' => OutletStatus::class,
+ ];
+ }
+
+ public function getSlugOptions(): SlugOptions
+ {
+ return SlugOptions::create()
+ ->generateSlugsFrom('name')
+ ->saveSlugsTo('slug');
+ }
+
+ public function users(): BelongsToMany
+ {
+ return $this->belongsToMany(User::class, 'user_outlet')->withPivot('position_id');
+ }
+}
diff --git a/app/Traits/Outlet/WithFacilityHandler.php b/app/Traits/Outlet/WithFacilityHandler.php
new file mode 100644
index 0000000..ac9c43b
--- /dev/null
+++ b/app/Traits/Outlet/WithFacilityHandler.php
@@ -0,0 +1,25 @@
+form->facilities[] = '';
+ }
+
+ public function removeFacility(int $index): void
+ {
+ unset($this->form->facilities[$index]);
+ $this->form->facilities = array_values($this->form->facilities);
+
+ if (empty($this->form->facilities)) {
+ $this->form->facilities = [''];
+ }
+ }
+}
diff --git a/database/migrations/2025_09_13_045752_create_outlets_table.php b/database/migrations/2025_09_13_045752_create_outlets_table.php
new file mode 100644
index 0000000..d573dea
--- /dev/null
+++ b/database/migrations/2025_09_13_045752_create_outlets_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->string('name', 100);
+ $table->string('slug', 120);
+ $table->string('phone_number', 20);
+ $table->text('address');
+ $table->string('landmark', 20);
+ $table->text('maps_url')->nullable();
+ $table->json('opening_hours');
+ $table->json('facilities');
+ $table->enum('status', [OutletStatus::values()])->default(OutletStatus::OPERATIONAL)->comment(OutletStatus::comment());
+ $table->date('opened_date');
+ $table->date('closed_date')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('outlets');
+ }
+};
diff --git a/resources/views/components/confirmation/delete.blade.php b/resources/views/components/confirmation/delete.blade.php
new file mode 100644
index 0000000..04c1aa8
--- /dev/null
+++ b/resources/views/components/confirmation/delete.blade.php
@@ -0,0 +1,18 @@
+
+
+
+ {{ $confirmingTitle }}
+ {{ $confirmingMessage }}
+
+
+
+
+ Batal
+
+
+ {{ $confirmingButtonText }}
+
+
+
+
diff --git a/resources/views/components/partials/studio/_sidebar.blade.php b/resources/views/components/partials/studio/_sidebar.blade.php
index 1e4f15c..b9a216e 100644
--- a/resources/views/components/partials/studio/_sidebar.blade.php
+++ b/resources/views/components/partials/studio/_sidebar.blade.php
@@ -9,6 +9,17 @@ class="px-2 hidden dark:flex" />
Ringkasan
+
+
+
Master
+
+
+ Outlet
+
+
+
+
Inbox
Marketing site
diff --git a/resources/views/livewire/studio/master/outlet/form.blade.php b/resources/views/livewire/studio/master/outlet/form.blade.php
new file mode 100644
index 0000000..69f6b49
--- /dev/null
+++ b/resources/views/livewire/studio/master/outlet/form.blade.php
@@ -0,0 +1,100 @@
+
+
+
+ {{ $pageTitle }}
+
+
+
+ Kembali
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @foreach (\App\Enums\OutletStatus::cases() as $status)
+
+ {{ $status->label() }}
+
+ @endforeach
+
+
+
+
+
+
+
+
+
Jam Operasional
+ @foreach (['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu'] as $day)
+
+ {{ $day }}
+
+
+
+ @endforeach
+
+
+
+
+
+
Fasilitas
+ @foreach ($form->facilities as $index => $facility)
+
+
+ @if ($index != 0)
+
+ @endif
+
+ @endforeach
+
+ + Tambah Fasilitas
+
+
+
+
+
+
+
+
+ Simpan
+
+
+
+
+
+
diff --git a/resources/views/livewire/studio/master/outlet/index.blade.php b/resources/views/livewire/studio/master/outlet/index.blade.php
new file mode 100644
index 0000000..c8a43df
--- /dev/null
+++ b/resources/views/livewire/studio/master/outlet/index.blade.php
@@ -0,0 +1,19 @@
+
+
+
+ {{ $pageTitle }}
+
+
+
+ Tambah
+
+
+
+
+
+
+
+
+ @include('components.confirmation.delete')
+
diff --git a/routes/web.php b/routes/web.php
index 49c5cbe..1c03435 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,6 +2,9 @@
use App\Livewire\Auth\Login;
use App\Livewire\Studio\Dashboard\Overview;
+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;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
@@ -22,4 +25,13 @@
->group(function () {
Route::get('overview', Overview::class)->name('overview');
});
+
+ Route::prefix('master')
+ ->as('studio.master.outlet.')
+ ->group(function () {
+ Route::get('outlets', OutletIndex::class)->name('index');
+ Route::get('outlets/create', OutletCreate::class)->name('create');
+ Route::get('outlets/{outlet}/edit', OutletEdit::class)->name('edit');
+ Route::get('outlets/{outlet}/delete', OutletCreate::class)->name('delete');
+ });
});