feat(expense): membuat crud menggunakan modal, belum jalan karena outletnya belum diisi

This commit is contained in:
Yoga Pangestu 2025-10-02 19:02:25 +07:00
parent 739f7e761e
commit e1b96b2e2f
11 changed files with 461 additions and 0 deletions

33
app/Enums/ExpenseType.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
use App\Traits\WithCommentEnum;
use App\Traits\WithValueEnum;
enum ExpenseType: int
{
use WithCommentEnum, WithValueEnum;
case OPERATIONAL = 1;
case PURCHASE = 2;
case SALARY = 3;
public function label()
{
return match ($this) {
self::OPERATIONAL => 'Operasional',
self::PURCHASE => 'Belanja',
self::SALARY => 'Gaji',
};
}
public function color()
{
return match ($this) {
self::OPERATIONAL => 'emerald',
self::PURCHASE => 'rose',
self::SALARY => 'cyan',
};
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Datatable;
use App\Models\Expense;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use App\Traits\WithMediaHandler;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class ExpensesTable extends DataTableComponent
{
use WithConfiguration, WithMediaHandler, WithPrependColumn;
protected $model = Expense::class;
public function columns(): array
{
return [
Column::make('Keterangan', 'description')->searchable(),
Column::make('Jumlah', 'amount')
->format(fn ($value) => currency($value, 'Rp'))
->searchable(),
Column::make('Aksi')
->label(function ($row) {
$actions = '';
$actions .= view('components.datatables.edit-modal', [
'id' => $row->id,
'method' => 'update',
'modalTitle' => 'Ubah Kategori',
])->render();
$actions .= view('components.datatables.delete', [
'id' => $row->id,
'deleteRoute' => route('studio.finance.expense.delete', $row->id),
])->render();
return $actions;
})
->html(),
];
}
public function builder(): Builder
{
return Expense::select('id', 'description', 'amount');
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Forms;
use App\Models\Expense;
use App\Rules\UnsignedInteger;
use App\Traits\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Livewire\Form;
class ExpenseForm extends Form
{
use WithMediaHandler;
public ?Expense $expense = null;
public string $description = '';
public string $amount = '';
public array $image = [];
public function rules(): array
{
return [
'description' => ['required', 'string', 'max:100'],
'amount' => ['required', 'numeric', new UnsignedInteger],
'image' => ['nullable', 'array'],
];
}
public function validationAttributes(): array
{
return [
'description' => 'keterangan',
'amount' => 'jumlah',
'image' => 'gambar',
];
}
public function setExpense(Expense $expense)
{
$this->expense = $expense;
$this->description = $expense->description;
$this->amount = $expense->amount;
$this->image = $this->mapMediaCollection($expense->getMedia('image'));
}
public function store()
{
$this->validate();
DB::transaction(function () {
$expense = Expense::create([
'description' => $this->description,
'amount' => $this->amount,
]);
$this->uploadMedia($this->image, $expense, 'image');
});
}
public function update()
{
$this->validate();
DB::transaction(function () {
$this->expense->update([
'description' => $this->description,
'amount' => $this->amount,
]);
$this->syncMedia($this->image, $this->expense, 'image');
$this->uploadMedia($this->image, $this->expense, 'image');
});
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Studio\Finance;
use App\Livewire\Forms\ExpenseForm;
use App\Models\Expense as ExpenseModel;
use App\Traits\WithCloseModal;
use App\Traits\WithConfirmation;
use App\Traits\WithUpdatedData;
use Flux\Flux;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Pengeluaran')]
class Expense extends Component
{
use WithCloseModal, WithConfirmation, WithUpdatedData;
public ExpenseForm $form;
public string $method = 'create';
public string $modalTitle = '';
#[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->setExpense(ExpenseModel::findOrFail($id));
}
}
public function create()
{
$this->form->store();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Pengeluaran berhasil ditambahkan.',
variant: 'success',
duration: 3000
);
Flux::modals()->close();
}
public function update()
{
$this->form->update();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Pengeluaran berhasil diperbarui.',
variant: 'success',
duration: 3000
);
Flux::modals()->close();
}
public function delete(ExpenseModel $expense)
{
$expense->delete();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Pengeluaran berhasil dihapus.',
variant: 'success',
);
Flux::modals()->close();
}
public function render()
{
return view('livewire.studio.finance.expenses', [
'pageTitle' => 'Pengeluaran',
]);
}
}

29
app/Models/Expense.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Enums\ExpenseType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Expense extends Model
{
use HasFactory, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'type' => ExpenseType::class,
'amount' => 'int',
];
}
public function outlet(): BelongsTo
{
return $this->belongsTo(Outlet::class);
}
}

View File

@ -67,4 +67,9 @@ public function bottles(): BelongsToMany
{
return $this->belongsToMany(Bottle::class);
}
public function expenses(): HasMany
{
return $this->hasMany(Expense::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
use App\Enums\ExpenseType;
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('expenses', function (Blueprint $table) {
$table->id();
$table->foreignId('outlet_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('amount');
$table->string('description', 100);
$table->enum('type', ExpenseType::values())->comment(ExpenseType::comment());
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('expenses');
}
};

View File

@ -62,5 +62,14 @@ class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-z
</flux:navlist.item>
</div>
</div>
<div class="mt-3 mb-1">
<div class="text-zinc-500 dark:text-gray-300 text-sm/6">Keuangan</div>
<div class="grid gap-2">
<flux:navlist.item icon="banknote-arrow-down" href="{{ route('studio.finance.expense.index') }}"
:current="request()->routeIs('studio.finance.expense.*')" wire:navigate.hover>Pengeluaran
</flux:navlist.item>
</div>
</div>
</flux:navlist>
</flux:sidebar>

View File

@ -0,0 +1,46 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')
->add(match($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
});
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<path d="M12 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5" />
<path d="m16 19 3 3 3-3" />
<path d="M18 12h.01" />
<path d="M19 16v6" />
<path d="M6 12h.01" />
<circle cx="12" cy="12" r="2" />
</svg>

View File

@ -0,0 +1,71 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:modal.trigger name="form-modal">
<flux:button variant="primary" class="text-sm"
wire:click="$dispatch('modal:open', {method: 'create', 'modalTitle': 'Tambah Pengeluaran'})">Tambah
</flux:button>
</flux:modal.trigger>
</div>
</div>
<div class="mt-6">
<livewire:datatable.expenses-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>Keterangan <span class="text-red-500 ms-1">*</span></flux:label>
<flux:input placeholder="Masukkan keterangan" wire:model.live.debounce.500ms="form.description"
autofocus autocomplete="off" />
<flux:error name="form.description" />
</flux:field>
<flux:field>
<flux:label>Jumlah <span class="text-red-500 ms-1">*</span>
</flux:label>
<flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="Masukkan jumlah" x-mask:dynamic="$money($input, ',')"
wire:model.live.debounce.500ms="form.amount" autocomplete="off" />
</flux:input.group>
<flux:error name="form.amount" />
</flux:field>
<div class="space-y-3">
<h3 class="text-sm font-medium">Gambar</h3>
<div class="dropzone-wrapper">
<livewire:dropzone wire:model="form.image" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1" :key="'image'"
:files="$form->image ?? []" />
@error('form.image')
<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>
<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

@ -12,6 +12,7 @@
use App\Livewire\Studio\Catalog\Product\Edit as ProductEdit;
use App\Livewire\Studio\Catalog\Product\Index as ProductIndex;
use App\Livewire\Studio\Dashboard\Overview as OverviewComponent;
use App\Livewire\Studio\Finance\Expense as ExpenseComponent;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
@ -119,4 +120,13 @@
Route::get('/{bottle}/delete', BottleCreate::class)->name('delete');
});
});
Route::prefix('finance')
->name('studio.finance.')
->group(function () {
Route::prefix('expenses')->name('expense.')->group(function () {
Route::get('/', ExpenseComponent::class)->name('index');
Route::delete('/{expense}/delete', ExpenseComponent::class)->name('delete');
});
});
});