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