Add cash management features including CashAccount and CashTransaction models, CashController for handling transactions, and corresponding requests for validation. Implement permissions in the Permission and Role enums, and update routes for cash operations. Enhance UI components for cash transaction management, including a data table and form modal. Update sidebar for cash navigation.
This commit is contained in:
parent
01997dccc4
commit
0e8166a7ff
@ -44,6 +44,11 @@ enum Permission: string
|
||||
case RAW_MATERIALS_DELETE = 'raw-materials.delete';
|
||||
case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status';
|
||||
|
||||
case CASH_VIEW = 'cash.view';
|
||||
case CASH_DEPOSIT = 'cash.deposit';
|
||||
case CASH_UPDATE = 'cash.update';
|
||||
case CASH_DELETE = 'cash.delete';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
@ -82,6 +87,11 @@ public function label(): string
|
||||
self::RAW_MATERIALS_UPDATE => 'Ubah Bahan Baku',
|
||||
self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku',
|
||||
self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku',
|
||||
|
||||
self::CASH_VIEW => 'Lihat Kas',
|
||||
self::CASH_DEPOSIT => 'Setor Kas',
|
||||
self::CASH_UPDATE => 'Ubah Setor Kas',
|
||||
self::CASH_DELETE => 'Hapus Setor Kas',
|
||||
};
|
||||
}
|
||||
|
||||
@ -101,6 +111,7 @@ public function group(): string
|
||||
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
|
||||
self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE,
|
||||
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
|
||||
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -66,6 +66,10 @@ public function permissions(): array
|
||||
Permission::RAW_MATERIALS_UPDATE,
|
||||
Permission::RAW_MATERIALS_DELETE,
|
||||
Permission::RAW_MATERIALS_TOGGLE_STATUS,
|
||||
Permission::CASH_VIEW,
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_UPDATE,
|
||||
Permission::CASH_DELETE,
|
||||
],
|
||||
self::ADMIN_TOKO => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
@ -91,6 +95,10 @@ public function permissions(): array
|
||||
Permission::PRODUCTS_UPDATE,
|
||||
Permission::PRODUCTS_DELETE,
|
||||
Permission::PRODUCTS_TOGGLE_STATUS,
|
||||
Permission::CASH_VIEW,
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_UPDATE,
|
||||
Permission::CASH_DELETE,
|
||||
],
|
||||
self::ADMIN_BAHAN_BAKU => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
|
||||
64
app/Http/Controllers/Admin/Finance/CashController.php
Normal file
64
app/Http/Controllers/Admin/Finance/CashController.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Finance\DepositCashRequest;
|
||||
use App\Http\Requests\Admin\Finance\UpdateCashTransactionRequest;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Services\Finance\CashService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CashController extends Controller
|
||||
{
|
||||
use ParsesDataTableQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly CashService $cashService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
$cashAccount = $this->cashService->getDefaultAccount();
|
||||
|
||||
return Inertia::render('admin/finance/cash/Index', [
|
||||
'cashAccount' => $cashAccount,
|
||||
'transactions' => $this->cashService->paginateForIndex($cashAccount, $tableQuery),
|
||||
'filters' => $this->dataTableFilters($tableQuery),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deposit(DepositCashRequest $request): RedirectResponse
|
||||
{
|
||||
$cashAccount = $this->cashService->getDefaultAccount();
|
||||
|
||||
$this->cashService->deposit($cashAccount, $request->validated(), $request->user());
|
||||
|
||||
Inertia::flash('success', 'Setor kas berhasil dicatat.');
|
||||
|
||||
return redirect()->route('admin.finance.cash.index');
|
||||
}
|
||||
|
||||
public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
|
||||
{
|
||||
$this->cashService->updateDeposit($cashTransaction, $request->validated());
|
||||
|
||||
Inertia::flash('success', 'Setor kas berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('admin.finance.cash.index');
|
||||
}
|
||||
|
||||
public function destroy(CashTransaction $cashTransaction): RedirectResponse
|
||||
{
|
||||
$this->cashService->deleteTransaction($cashTransaction);
|
||||
|
||||
Inertia::flash('success', 'Setor kas berhasil dihapus.');
|
||||
|
||||
return redirect()->route('admin.finance.cash.index');
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/Admin/Finance/DepositCashRequest.php
Normal file
22
app/Http/Requests/Admin/Finance/DepositCashRequest.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DepositCashRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::CASH_DEPOSIT->value) ?? false;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'description' => ['required', 'string', 'max:200'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateCashTransactionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$transaction = $this->route('cashTransaction');
|
||||
|
||||
return $this->user()?->can(Permission::CASH_UPDATE->value)
|
||||
&& $transaction?->reference_type === null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'description' => ['required', 'string', 'max:200'],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Models/CashAccount.php
Normal file
33
app/Models/CashAccount.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['balance_formatted'])]
|
||||
class CashAccount extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'balance' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function balanceFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->balance, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CashTransaction::class);
|
||||
}
|
||||
}
|
||||
112
app/Models/CashTransaction.php
Normal file
112
app/Models/CashTransaction.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
'amount_formatted',
|
||||
'balance_after_formatted',
|
||||
'reference_label',
|
||||
'is_incoming',
|
||||
'created_at_formatted',
|
||||
'created_by_name',
|
||||
])]
|
||||
class CashTransaction extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function amountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function balanceAfterFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->balance_after, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function referenceLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => self::labelForReferenceType($this->reference_type),
|
||||
);
|
||||
}
|
||||
|
||||
public function isIncoming(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => self::isIncomingReference($this->reference_type),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdByName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username,
|
||||
);
|
||||
}
|
||||
|
||||
public static function labelForReferenceType(?string $referenceType): string
|
||||
{
|
||||
if ($referenceType === null) {
|
||||
return 'Setor Kas';
|
||||
}
|
||||
|
||||
return match ($referenceType) {
|
||||
default => class_basename($referenceType),
|
||||
};
|
||||
}
|
||||
|
||||
public static function isIncomingReference(?string $referenceType): bool
|
||||
{
|
||||
if ($referenceType === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($referenceType) {
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function cashAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CashAccount::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_id');
|
||||
}
|
||||
|
||||
public function reference(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
@ -64,4 +65,9 @@ public function employee(): HasOne
|
||||
{
|
||||
return $this->hasOne(Employee::class);
|
||||
}
|
||||
|
||||
public function cashTransactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CashTransaction::class, 'created_by_id');
|
||||
}
|
||||
}
|
||||
|
||||
212
app/Services/Finance/CashService.php
Normal file
212
app/Services/Finance/CashService.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CashService
|
||||
{
|
||||
public function getDefaultAccount(): CashAccount
|
||||
{
|
||||
return CashAccount::query()->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
||||
*/
|
||||
public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): LengthAwarePaginator
|
||||
{
|
||||
$query = CashTransaction::query()
|
||||
->with(['createdBy.profile', 'reference'])
|
||||
->where('cash_account_id', $cashAccount->id)
|
||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||
$search = $tableQuery['search'];
|
||||
$query->where(function (Builder $query) use ($search): void {
|
||||
$query->where('description', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
|
||||
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
||||
|
||||
return $query
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{amount: int, description: string} $validated
|
||||
*/
|
||||
public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
|
||||
$amount = (int) $validated['amount'];
|
||||
$newBalance = $account->balance + $amount;
|
||||
|
||||
$account->balance = $newBalance;
|
||||
$account->save();
|
||||
|
||||
return CashTransaction::create([
|
||||
'cash_account_id' => $account->id,
|
||||
'amount' => $amount,
|
||||
'balance_after' => $newBalance,
|
||||
'description' => $validated['description'],
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function recordOutgoing(
|
||||
Model $reference,
|
||||
int $amount,
|
||||
string $description,
|
||||
User $user,
|
||||
?CashAccount $cashAccount = null,
|
||||
): CashTransaction {
|
||||
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail(
|
||||
($cashAccount ?? $this->getDefaultAccount())->id,
|
||||
);
|
||||
|
||||
if ($account->balance < $amount) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => 'Saldo kas tidak mencukupi.',
|
||||
]);
|
||||
}
|
||||
|
||||
$newBalance = $account->balance - $amount;
|
||||
|
||||
$account->balance = $newBalance;
|
||||
$account->save();
|
||||
|
||||
$transaction = new CashTransaction([
|
||||
'cash_account_id' => $account->id,
|
||||
'amount' => $amount,
|
||||
'balance_after' => $newBalance,
|
||||
'description' => $description,
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$transaction->reference()->associate($reference);
|
||||
$transaction->save();
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
}
|
||||
|
||||
public function recordIncoming(
|
||||
Model $reference,
|
||||
int $amount,
|
||||
string $description,
|
||||
User $user,
|
||||
?CashAccount $cashAccount = null,
|
||||
): CashTransaction {
|
||||
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail(
|
||||
($cashAccount ?? $this->getDefaultAccount())->id,
|
||||
);
|
||||
|
||||
$newBalance = $account->balance + $amount;
|
||||
|
||||
$account->balance = $newBalance;
|
||||
$account->save();
|
||||
|
||||
$transaction = new CashTransaction([
|
||||
'cash_account_id' => $account->id,
|
||||
'amount' => $amount,
|
||||
'balance_after' => $newBalance,
|
||||
'description' => $description,
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$transaction->reference()->associate($reference);
|
||||
$transaction->save();
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{amount: int, description: string} $validated
|
||||
*/
|
||||
public function updateDeposit(CashTransaction $transaction, array $validated): void
|
||||
{
|
||||
$this->ensureEditable($transaction);
|
||||
|
||||
DB::transaction(function () use ($transaction, $validated): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
|
||||
$transaction->amount = (int) $validated['amount'];
|
||||
$transaction->description = $validated['description'];
|
||||
$transaction->save();
|
||||
|
||||
$this->recalculateBalances($transaction->cashAccount);
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteTransaction(CashTransaction $transaction): void
|
||||
{
|
||||
$this->ensureEditable($transaction);
|
||||
|
||||
DB::transaction(function () use ($transaction): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
|
||||
$transaction->delete();
|
||||
|
||||
$this->recalculateBalances($transaction->cashAccount);
|
||||
});
|
||||
}
|
||||
|
||||
private function ensureEditable(CashTransaction $transaction): void
|
||||
{
|
||||
if ($transaction->reference_type !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function recalculateBalances(CashAccount $cashAccount): void
|
||||
{
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
|
||||
$runningBalance = 0;
|
||||
|
||||
$transactions = CashTransaction::query()
|
||||
->where('cash_account_id', $account->id)
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (CashTransaction::isIncomingReference($transaction->reference_type)) {
|
||||
$runningBalance += $transaction->amount;
|
||||
} else {
|
||||
$runningBalance -= $transaction->amount;
|
||||
}
|
||||
|
||||
$transaction->balance_after = $runningBalance;
|
||||
$transaction->saveQuietly();
|
||||
}
|
||||
|
||||
$account->balance = $runningBalance;
|
||||
$account->save();
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['created_at', 'amount', 'reference_type'], true)) {
|
||||
$query->orderBy($sort, $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->latest();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cash_accounts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
||||
|
||||
$table->string('name', 200);
|
||||
$table->unsignedBigInteger('balance')->default(0);
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cash_accounts');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cash_transactions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('cash_account_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
||||
|
||||
$table->nullableMorphs('reference');
|
||||
$table->unsignedBigInteger('amount');
|
||||
$table->unsignedBigInteger('balance_after');
|
||||
$table->string('description', 200);
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cash_transactions');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cash_accounts', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('balance')->default(0)->change();
|
||||
});
|
||||
|
||||
Schema::table('cash_transactions', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('amount')->change();
|
||||
$table->unsignedBigInteger('balance_after')->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cash_accounts', function (Blueprint $table) {
|
||||
$table->decimal('balance', 18, 2)->default(0)->change();
|
||||
});
|
||||
|
||||
Schema::table('cash_transactions', function (Blueprint $table) {
|
||||
$table->decimal('amount', 18, 2)->change();
|
||||
$table->decimal('balance_after', 18, 2)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
19
database/seeders/CashAccountSeeder.php
Normal file
19
database/seeders/CashAccountSeeder.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CashAccountSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
CashAccount::firstOrCreate(
|
||||
['created_by_id' => User::first()->id],
|
||||
['name' => 'Kas Toko'],
|
||||
['balance' => 0],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ public function run(): void
|
||||
RawMaterialSeeder::class,
|
||||
SupplierSeeder::class,
|
||||
CustomerSeeder::class,
|
||||
CashAccountSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { FolderTree, Layers, LayoutDashboard, Package, User, UserCheck, Users } from '@lucide/vue';
|
||||
import { FolderTree, Layers, LayoutDashboard, Package, User, UserCheck, Users, Wallet } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Sidebar,
|
||||
@ -26,6 +26,7 @@ const isProductsActive = computed(() => page.url.startsWith('/admin/master/produ
|
||||
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
|
||||
const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/suppliers'));
|
||||
const isCustomersActive = computed(() => page.url.startsWith('/admin/master/customers'));
|
||||
const isCashActive = computed(() => page.url.startsWith('/admin/finance/cash'));
|
||||
const showMasterMenu = computed(() => (
|
||||
can('categories.view') || can('products.view') || can('raw-materials.view')
|
||||
|| can('suppliers.view') || can('customers.view')
|
||||
@ -110,6 +111,21 @@ const showMasterMenu = computed(() => (
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Keuangan</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-if="can('cash.view')">
|
||||
<SidebarMenuButton as-child tooltip="Kas Toko" :is-active="isCashActive">
|
||||
<Link href="/admin/finance/cash">
|
||||
<Wallet />
|
||||
<span>Kas Toko</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup v-if="can('employees.view')">
|
||||
<SidebarGroupLabel>HR</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { CashTransactionFormData, CashTransactionListItem } from '@/types/cash';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
transaction?: CashTransactionListItem | null;
|
||||
}>();
|
||||
|
||||
const isEditing = computed(() => props.transaction != null);
|
||||
|
||||
const form = useForm<CashTransactionFormData>({
|
||||
amount: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
function populateForm(transaction: CashTransactionListItem | null | undefined) {
|
||||
resetForm();
|
||||
|
||||
if (!transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.amount = String(transaction.amount);
|
||||
form.description = transaction.description;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.transaction,
|
||||
(transaction) => {
|
||||
populateForm(transaction);
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
populateForm(props.transaction);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const options = {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(isEditing.value
|
||||
? 'Gagal memperbarui setor kas. Periksa kembali formulir.'
|
||||
: 'Gagal mencatat setor kas. Periksa kembali formulir.');
|
||||
},
|
||||
};
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
amount: parseRupiah(data.amount),
|
||||
}));
|
||||
|
||||
if (isEditing.value && props.transaction) {
|
||||
form.put(`/admin/finance/cash/transactions/${props.transaction.id}`, options);
|
||||
} else {
|
||||
form.post('/admin/finance/cash/deposit', options);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEditing ? 'Ubah Setor Kas' : 'Setor Kas' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="cash-amount" required>Jumlah</FieldLabel>
|
||||
<RupiahInput id="cash-amount" v-model="form.amount" autofocus />
|
||||
<FieldError :errors="form.errors.amount ? [form.errors.amount] : []" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="cash-description" required>Keterangan</FieldLabel>
|
||||
<Textarea id="cash-description" v-model="form.description"
|
||||
placeholder="Contoh: Setoran kas harian" rows="3" />
|
||||
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
75
resources/js/components/admin/finance/cash/columns.ts
Normal file
75
resources/js/components/admin/finance/cash/columns.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { h } from 'vue';
|
||||
import DataTableActions from '@/components/admin/finance/cash/data-table-actions.vue';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { CashTransactionListItem } from '@/types/cash';
|
||||
|
||||
export function createColumns(onEdit: (transaction: CashTransactionListItem) => void): ColumnDef<CashTransactionListItem>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'created_at_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'created_at' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'reference_label',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Sumber', column: 'reference_type' }),
|
||||
cell: ({ row }) => {
|
||||
const isIncoming = row.original.is_incoming;
|
||||
|
||||
return h(
|
||||
Badge,
|
||||
{ variant: isIncoming ? 'default' : 'secondary' },
|
||||
() => row.original.reference_label,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Jumlah', column: 'amount' }),
|
||||
cell: ({ row }) => {
|
||||
const isIncoming = row.original.is_incoming;
|
||||
const prefix = isIncoming ? '+' : '-';
|
||||
|
||||
return h(
|
||||
'span',
|
||||
{ class: isIncoming ? 'font-medium text-green-600' : 'font-medium text-red-600' },
|
||||
`${prefix} ${row.original.amount_formatted}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'balance_after_formatted',
|
||||
enableSorting: false,
|
||||
header: () => 'Saldo Setelah',
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
enableSorting: false,
|
||||
header: () => 'Keterangan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_by_name',
|
||||
enableSorting: false,
|
||||
header: () => 'Oleh',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.reference_type !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h(DataTableActions, {
|
||||
transaction: row.original,
|
||||
onEdit: () => onEdit(row.original),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { Pencil, Trash2 } from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { CashTransactionListItem } from '@/types/cash';
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: CashTransactionListItem;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [transaction: CashTransactionListItem];
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const showActions = computed(() => props.transaction.reference_type === null);
|
||||
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const deleteProcessing = ref(false);
|
||||
|
||||
function destroyTransaction() {
|
||||
deleteProcessing.value = true;
|
||||
|
||||
router.delete(`/admin/finance/cash/transactions/${props.transaction.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
deleteConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menghapus setor kas.');
|
||||
},
|
||||
onFinish: () => {
|
||||
deleteProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="showActions">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Tooltip v-if="can('cash.update')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', transaction)">
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">Ubah</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ubah</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="can('cash.delete')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
|
||||
@click="deleteConfirmOpen = true">
|
||||
<Trash2 class="size-4" />
|
||||
<span class="sr-only">Hapus</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Hapus</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog v-if="can('cash.delete')" v-model:open="deleteConfirmOpen" title="Hapus setor kas?"
|
||||
:description="`Setor kas sebesar ${transaction.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`"
|
||||
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing"
|
||||
@confirm="destroyTransaction" />
|
||||
</template>
|
||||
</template>
|
||||
127
resources/js/pages/admin/finance/cash/Index.vue
Normal file
127
resources/js/pages/admin/finance/cash/Index.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { ArrowDownCircle, Wallet } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CashTransactionFormModal from '@/components/admin/finance/cash/CashTransactionFormModal.vue';
|
||||
import { createColumns } from '@/components/admin/finance/cash/columns';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
|
||||
const props = defineProps<{
|
||||
cashAccount: CashAccount;
|
||||
transactions: PaginatedCashTransactions;
|
||||
filters: {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
};
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
const search = ref(props.filters.search ?? '');
|
||||
const formModalOpen = ref(false);
|
||||
const editingTransaction = ref<CashTransactionListItem | null>(null);
|
||||
|
||||
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: '/admin/finance/cash',
|
||||
initial: { ...props.filters },
|
||||
});
|
||||
|
||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||
|
||||
const columns = computed(() => createColumns(openEditModal));
|
||||
|
||||
const currentSort = computed<DataTableSort | null>(() => {
|
||||
if (!query.value.sort || !query.value.direction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
column: query.value.sort,
|
||||
direction: query.value.direction,
|
||||
};
|
||||
});
|
||||
|
||||
const pagination = computed(() => ({
|
||||
currentPage: props.transactions.current_page,
|
||||
perPage: props.transactions.per_page,
|
||||
lastPage: props.transactions.last_page,
|
||||
total: props.transactions.total,
|
||||
}));
|
||||
|
||||
function openCreateModal() {
|
||||
editingTransaction.value = null;
|
||||
formModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(transaction: CashTransactionListItem) {
|
||||
editingTransaction.value = transaction;
|
||||
formModalOpen.value = true;
|
||||
}
|
||||
|
||||
watch(search, (value) => {
|
||||
setSearch(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters.search,
|
||||
(value) => {
|
||||
search.value = value ?? '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Kas Toko" />
|
||||
|
||||
<AdminLayout>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
{{ cashAccount.name }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button v-if="can('cash.deposit')" class="shrink-0 self-start sm:self-center" variant="outline"
|
||||
@click="openCreateModal">
|
||||
<ArrowDownCircle class="size-4" />
|
||||
Setor Kas
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
||||
Saldo Saat Ini
|
||||
</CardTitle>
|
||||
<Wallet class="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold tracking-tight">
|
||||
{{ cashAccount.balance_formatted }}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0 pt-6">
|
||||
<DataTable v-model:search="search" :columns="columns" :data="transactions.data"
|
||||
:pagination="pagination" :pagination-links="transactions.links" :sort="currentSort"
|
||||
@sort-change="setSort" @filters-reset="resetFilters" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CashTransactionFormModal
|
||||
v-if="can('cash.deposit') || can('cash.update')"
|
||||
v-model:open="formModalOpen"
|
||||
:transaction="editingTransaction"
|
||||
/>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
38
resources/js/types/cash.ts
Normal file
38
resources/js/types/cash.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export type CashAccount = {
|
||||
id: number;
|
||||
name: string;
|
||||
balance: number;
|
||||
balance_formatted: string;
|
||||
};
|
||||
|
||||
export type CashTransactionListItem = {
|
||||
id: number;
|
||||
reference_type: string | null;
|
||||
reference_label: string;
|
||||
is_incoming: boolean;
|
||||
amount: number;
|
||||
amount_formatted: string;
|
||||
balance_after: number;
|
||||
balance_after_formatted: string;
|
||||
description: string;
|
||||
created_at_formatted: string;
|
||||
created_by_name: string;
|
||||
};
|
||||
|
||||
export type CashTransactionFormData = {
|
||||
amount: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type PaginatedCashTransactions = {
|
||||
data: CashTransactionListItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
};
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\Finance\CashController;
|
||||
use App\Http\Controllers\Admin\Hr\EmployeeController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
@ -157,6 +158,26 @@
|
||||
Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update');
|
||||
});
|
||||
|
||||
Route::prefix('finance')->name('finance.')->group(function () {
|
||||
Route::prefix('cash')->name('cash.')
|
||||
->middleware('permission:'.Permission::CASH_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [CashController::class, 'index'])->name('index');
|
||||
|
||||
Route::post('deposit', [CashController::class, 'deposit'])
|
||||
->middleware('permission:'.Permission::CASH_DEPOSIT->value)
|
||||
->name('deposit');
|
||||
|
||||
Route::put('transactions/{cashTransaction}', [CashController::class, 'update'])
|
||||
->middleware('permission:'.Permission::CASH_UPDATE->value)
|
||||
->name('transactions.update');
|
||||
|
||||
Route::delete('transactions/{cashTransaction}', [CashController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::CASH_DELETE->value)
|
||||
->name('transactions.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
|
||||
Route::prefix('employees')->name('employees.')
|
||||
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user