feat: implement payroll management module with CRUD operations, including payroll generation, adjustments, and user salary history tracking
This commit is contained in:
parent
7089188d4d
commit
69131d8f8f
74
app/Console/Commands/GeneratePayrollCommand.php
Normal file
74
app/Console/Commands/GeneratePayrollCommand.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Payroll;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class GeneratePayrollCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'payroll:generate {period?}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate payroll for all users for a given period (YYYY-MM)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$periodMonth = $this->argument('period') ?? Carbon::now()->format('Y-m');
|
||||
$periodMonthFormatted = Carbon::parse($periodMonth)->translatedFormat('F Y');
|
||||
$users = User::with('profile')->get();
|
||||
$count = 0;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($users, $periodMonth, &$count) {
|
||||
foreach ($users as $user) {
|
||||
$exists = Payroll::where('user_id', $user->id)
|
||||
->where('period_month', $periodMonth)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
$baseSalary = $user->profile?->base_salary ?? 0;
|
||||
|
||||
Payroll::create([
|
||||
'user_id' => $user->id,
|
||||
'period_month' => $periodMonth,
|
||||
'base_salary' => $baseSalary,
|
||||
'bonus' => 0,
|
||||
'deduction' => 0,
|
||||
'total_salary' => $baseSalary,
|
||||
'is_paid' => false,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Payroll generate failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->info('Gagal generate data penggajian, silakan hubungi pengembang.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("$count data penggajian berhasil digenerate untuk periode $periodMonthFormatted.");
|
||||
}
|
||||
}
|
||||
17
app/Enums/SalaryAdjustmentType.php
Normal file
17
app/Enums/SalaryAdjustmentType.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SalaryAdjustmentType: string
|
||||
{
|
||||
case BONUS = 'bonus';
|
||||
case DEDUCTION = 'deduction';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BONUS => 'Bonus',
|
||||
self::DEDUCTION => 'Potongan',
|
||||
};
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Admin/Finance/PayrollController.php
Normal file
114
app/Http/Controllers/Admin/Finance/PayrollController.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Finance\SalaryAdjustmentRequest;
|
||||
use App\Models\Payroll;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Throwable;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$currentMonth = Carbon::now()->format('Y-m');
|
||||
|
||||
return Inertia::render('admin/finance/payroll/index', [
|
||||
'payrolls' => Payroll::with(['user.profile', 'adjustments'])
|
||||
->where('period_month', '!=', $currentMonth)
|
||||
->latest()
|
||||
->get(),
|
||||
'currentMonthPayrolls' => Payroll::with(['user.profile', 'adjustments'])
|
||||
->where('period_month', $currentMonth)
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(): RedirectResponse
|
||||
{
|
||||
Artisan::call('payroll:generate');
|
||||
|
||||
$output = Artisan::output();
|
||||
|
||||
return redirect()->back()->with('success', $output ?: 'Proses generate selesai.');
|
||||
}
|
||||
|
||||
public function update(SalaryAdjustmentRequest $request, Payroll $payroll): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($payroll, $validated) {
|
||||
$payroll->adjustments()->delete();
|
||||
|
||||
$totalBonus = 0;
|
||||
$totalDeduction = 0;
|
||||
|
||||
if (isset($validated['adjustments'])) {
|
||||
foreach ($validated['adjustments'] as $adj) {
|
||||
$payroll->adjustments()->create([
|
||||
'type' => $adj['type'],
|
||||
'amount' => $adj['amount'],
|
||||
'description' => $adj['description'],
|
||||
]);
|
||||
|
||||
if ($adj['type'] === 'bonus') {
|
||||
$totalBonus += $adj['amount'];
|
||||
} else {
|
||||
$totalDeduction += $adj['amount'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$payroll->update([
|
||||
'bonus' => $totalBonus,
|
||||
'deduction' => $totalDeduction,
|
||||
'total_salary' => $payroll->base_salary + $totalBonus - $totalDeduction,
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Payroll update failed', [
|
||||
'payroll_id' => $payroll->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return back()->withInput()->with('error', 'Gagal memperbarui data. Silakan coba lagi.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Data berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function togglePaid(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
$payroll->update([
|
||||
'is_paid' => ! $payroll->is_paid,
|
||||
'paid_at' => ! $payroll->is_paid ? Carbon::now() : null,
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Status pembayaran berhasil diubah');
|
||||
}
|
||||
|
||||
public function destroy(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
$payroll->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Data berhasil dihapus');
|
||||
}
|
||||
|
||||
public function bulkDestroy(Request $request): RedirectResponse
|
||||
{
|
||||
$ids = $request->input('ids');
|
||||
|
||||
Payroll::whereIn('id', $ids)->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Admin/Finance/SalaryAdjustmentRequest.php
Normal file
34
app/Http/Requests/Admin/Finance/SalaryAdjustmentRequest.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SalaryAdjustmentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'adjustments' => ['nullable', 'array'],
|
||||
'adjustments.*.amount' => ['required', 'integer', 'min:0'],
|
||||
'adjustments.*.type' => ['required', 'string', Rule::in(SalaryAdjustmentType::cases())],
|
||||
'adjustments.*.description' => ['required', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Models/Payroll.php
Normal file
82
app/Models/Payroll.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
'base_salary_formatted',
|
||||
'bonus_formatted',
|
||||
'deduction_formatted',
|
||||
'total_salary_formatted',
|
||||
'period_month_formatted',
|
||||
])]
|
||||
class Payroll extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'base_salary' => 'integer',
|
||||
'bonus' => 'integer',
|
||||
'deduction' => 'integer',
|
||||
'total_salary' => 'integer',
|
||||
'is_paid' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected function baseSalaryFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->base_salary ? 'Rp '.number_format($this->base_salary, 0, ',', '.') : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function bonusFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->bonus ? 'Rp '.number_format($this->bonus, 0, ',', '.') : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function deductionFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->deduction ? 'Rp '.number_format($this->deduction, 0, ',', '.') : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function totalSalaryFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->total_salary ? 'Rp '.number_format($this->total_salary, 0, ',', '.') : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function periodMonthFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->period_month ? Carbon::parse($this->period_month)->translatedFormat('F Y') : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function adjustments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PayrollAdjustment::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
39
app/Models/PayrollAdjustment.php
Normal file
39
app/Models/PayrollAdjustment.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['amount_formatted'])]
|
||||
class PayrollAdjustment extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => SalaryAdjustmentType::class,
|
||||
'amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected function amountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->amount ? 'Rp '.number_format($this->amount, 0, ',', '.') : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function payroll(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Payroll::class);
|
||||
}
|
||||
}
|
||||
28
app/Models/SalaryHistory.php
Normal file
28
app/Models/SalaryHistory.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class SalaryHistory extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'old_salary' => 'integer',
|
||||
'new_salary' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -42,8 +42,18 @@ public function expenses(): HasMany
|
||||
return $this->hasMany(Expense::class);
|
||||
}
|
||||
|
||||
public function payrolls(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payroll::class);
|
||||
}
|
||||
|
||||
public function profile(): HasOne
|
||||
{
|
||||
return $this->hasOne(UserProfile::class);
|
||||
}
|
||||
|
||||
public function salaryHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryHistory::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@ -22,6 +23,9 @@
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
})
|
||||
->withSchedule(function (Schedule $schedule) {
|
||||
$schedule->command('payroll:generate')->monthlyOn(1, '00:00');
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
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('payrolls', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('period_month', 7);
|
||||
$table->unsignedInteger('base_salary');
|
||||
$table->unsignedInteger('bonus');
|
||||
$table->unsignedInteger('deduction');
|
||||
$table->unsignedInteger('total_salary');
|
||||
$table->boolean('is_paid')->default(false);
|
||||
$table->dateTime('paid_at')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payrolls');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
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('payroll_adjustments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('payroll_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('type', SalaryAdjustmentType::cases());
|
||||
$table->string('description', 100);
|
||||
$table->unsignedInteger('amount');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payroll_adjustments');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
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('salary_histories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('old_salary');
|
||||
$table->unsignedInteger('new_salary');
|
||||
$table->date('effective_date');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_histories');
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Boxes, LayoutGrid, List, User, Wallet } from 'lucide-react';
|
||||
import { Boxes, Currency, DollarSign, LayoutGrid, List, User, Wallet, WalletCardsIcon } from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import {
|
||||
@ -16,6 +16,7 @@ import category from '@/routes/category';
|
||||
import type { NavItem } from '@/types';
|
||||
import product from '@/routes/product';
|
||||
import expense from '@/routes/expense';
|
||||
import payroll from '@/routes/payroll';
|
||||
import user from '@/routes/user';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
@ -50,6 +51,11 @@ const financeNavItems: NavItem[] = [
|
||||
href: expense.index().url,
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: 'Penggajian',
|
||||
href: payroll.index().url,
|
||||
icon: DollarSign,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { Payroll } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import payrollRoutes from '@/routes/payroll';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function usePayrollIndex() {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [selectedPayroll, setSelectedPayroll] = useState<Payroll | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [payrollToDelete, setPayrollToDelete] = useState<Payroll | null>(null);
|
||||
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const onGenerate = () => {
|
||||
setIsGenerating(true);
|
||||
router.post(payrollRoutes.generate().url, {}, {
|
||||
onSuccess: (response: any) => {
|
||||
console.log(response)
|
||||
toast.success(response.props.flash.success);
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (response: any) => {
|
||||
console.log(response)
|
||||
toast.error(response.props.flash.error);
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onFinish: () => setIsGenerating(false),
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (payroll: Payroll) => {
|
||||
setSelectedPayroll(payroll);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const onDelete = (payroll: Payroll) => {
|
||||
setPayrollToDelete(payroll);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (payrollToDelete) {
|
||||
router.delete(payrollRoutes.destroy(payrollToDelete.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setPayrollToDelete(null);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
router.post(payrollRoutes.bulkDestroy().url, {
|
||||
ids: rowsToDelete.map((row: any) => row.id),
|
||||
_method: 'DELETE'
|
||||
}, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setRowsToDelete([]);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onTogglePaid = (id: number) => {
|
||||
router.patch(payrollRoutes.togglePaid(id).url, {}, {
|
||||
onSuccess: (response: any) => toast.success(response.props.flash.success),
|
||||
});
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setTimeout(() => setSelectedPayroll(null), 200);
|
||||
};
|
||||
|
||||
return {
|
||||
isFormOpen,
|
||||
selectedPayroll,
|
||||
isDeleteDialogOpen,
|
||||
isBulkDeleteDialogOpen,
|
||||
payrollToDelete,
|
||||
rowsToDelete,
|
||||
rowSelection,
|
||||
isGenerating,
|
||||
setRowSelection,
|
||||
setRowsToDelete,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsBulkDeleteDialogOpen,
|
||||
onGenerate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
confirmDelete,
|
||||
confirmBulkDelete,
|
||||
onTogglePaid,
|
||||
closeForm,
|
||||
};
|
||||
}
|
||||
200
resources/js/pages/admin/finance/payroll/index.tsx
Normal file
200
resources/js/pages/admin/finance/payroll/index.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import type { Payroll } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import { usePayrollIndex } from './hooks/use-payroll-index';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { PayrollFormModal } from './partials/payroll-form-modal';
|
||||
|
||||
export default function PayrollIndex({
|
||||
payrolls,
|
||||
currentMonthPayrolls,
|
||||
}: {
|
||||
payrolls: Payroll[],
|
||||
currentMonthPayrolls: Payroll[],
|
||||
}) {
|
||||
const {
|
||||
isFormOpen,
|
||||
selectedPayroll,
|
||||
isDeleteDialogOpen,
|
||||
isBulkDeleteDialogOpen,
|
||||
payrollToDelete,
|
||||
rowsToDelete,
|
||||
rowSelection,
|
||||
isGenerating,
|
||||
setRowSelection,
|
||||
setRowsToDelete,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsBulkDeleteDialogOpen,
|
||||
onGenerate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
confirmDelete,
|
||||
confirmBulkDelete,
|
||||
onTogglePaid,
|
||||
closeForm,
|
||||
} = usePayrollIndex();
|
||||
|
||||
const columns = getColumns({ onEdit, onDelete, onTogglePaid });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Penggajian" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Penggajian</h1>
|
||||
</div>
|
||||
<Button onClick={onGenerate} disabled={isGenerating}>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{currentMonthPayrolls.map((payroll) => (
|
||||
<Card
|
||||
key={payroll.id}
|
||||
className="border-none shadow-lg bg-card/50 backdrop-blur-sm hover:scale-[1.02] transition-transform cursor-pointer"
|
||||
onClick={() => onEdit(payroll)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm line-clamp-1">{payroll.user?.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{payroll.period_month_formatted}</span>
|
||||
</div>
|
||||
<Badge variant={payroll.is_paid ? 'default' : 'outline'} className={payroll.is_paid ? 'bg-green-500 hover:bg-green-600 text-[10px]' : 'text-[10px]'}>
|
||||
{payroll.is_paid ? 'Lunas' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>Gaji Pokok</span>
|
||||
<span>{payroll.base_salary_formatted}</span>
|
||||
</div>
|
||||
{payroll.adjustments?.map((adj, idx) => (
|
||||
<div key={idx} className="flex justify-between text-[10px] text-muted-foreground italic">
|
||||
<span>{adj.description}</span>
|
||||
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-500'}>
|
||||
{adj.type === 'bonus' ? '+' : '-'}{adj.amount_formatted}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground pt-1 border-t">
|
||||
<span>Gaji Bersih</span>
|
||||
<span className="font-medium text-foreground">{payroll.total_salary_formatted}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PayrollFormModal
|
||||
isOpen={isFormOpen}
|
||||
onClose={closeForm}
|
||||
payroll={selectedPayroll}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||
<CardContent className="p-0">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={payrolls}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'is_paid',
|
||||
title: 'Status',
|
||||
options: [
|
||||
{ label: 'Dibayar', value: 'true' },
|
||||
{ label: 'Pending', value: 'false' },
|
||||
]
|
||||
}
|
||||
]}
|
||||
bulkActions={[
|
||||
{
|
||||
label: 'Hapus Terpilih',
|
||||
onClick: (rows) => {
|
||||
setRowsToDelete(rows);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
},
|
||||
icon: Trash2,
|
||||
variant: 'destructive'
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Single Delete Confirmation */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Hapus data penggajian?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. Data penggajian untuk <strong>{payrollToDelete?.user?.name}</strong> periode <strong>{payrollToDelete?.period_month}</strong> akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Hapus {rowsToDelete.length} data penggajian?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmBulkDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
Hapus
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PayrollIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Keuangan',
|
||||
},
|
||||
],
|
||||
};
|
||||
133
resources/js/pages/admin/finance/payroll/partials/columns.tsx
Normal file
133
resources/js/pages/admin/finance/payroll/partials/columns.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Payroll } from '@/types';
|
||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PencilRuler, Trash2 } from 'lucide-react';
|
||||
|
||||
interface ColumnProps {
|
||||
onEdit: (payroll: Payroll) => void;
|
||||
onDelete: (payroll: Payroll) => void;
|
||||
onTogglePaid: (id: number) => void;
|
||||
}
|
||||
|
||||
export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): ColumnDef<Payroll>[] => [
|
||||
{
|
||||
accessorKey: "user.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Pegawai" />
|
||||
),
|
||||
cell: ({ row }) => row.original.user?.name || '-',
|
||||
meta: { title: "Pegawai" },
|
||||
},
|
||||
{
|
||||
accessorKey: "period_month",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Periode" />
|
||||
),
|
||||
cell: ({ row }) => row.original.period_month_formatted,
|
||||
meta: { title: "Periode" },
|
||||
},
|
||||
{
|
||||
accessorKey: "total_salary",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Rincian Gaji" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-1 min-w-[200px]">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-tighter">
|
||||
<span>Gaji Pokok</span>
|
||||
<span className="font-medium text-foreground">{payroll.base_salary_formatted}</span>
|
||||
</div>
|
||||
|
||||
{payroll.adjustments && payroll.adjustments.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{payroll.adjustments.map((adj, idx) => (
|
||||
<div key={idx} className="flex justify-between text-[9px] text-muted-foreground italic">
|
||||
<span className="line-clamp-1 max-w-[120px]">{adj.description}</span>
|
||||
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-500'}>
|
||||
{adj.type === 'bonus' ? '+' : '-'}{adj.amount_formatted}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-xs font-bold pt-1 border-t border-dashed mt-1">
|
||||
<span className="uppercase text-[10px]">Gaji Bersih</span>
|
||||
<span className="text-primary">{payroll.total_salary_formatted}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: "Total Gaji" },
|
||||
},
|
||||
{
|
||||
accessorKey: "is_paid",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={payroll.is_paid}
|
||||
onCheckedChange={() => onTogglePaid(payroll.id)}
|
||||
/>
|
||||
<Badge
|
||||
variant={payroll.is_paid ? 'default' : 'outline'}
|
||||
className={payroll.is_paid ? 'bg-green-500/10 text-green-600 border-green-200 dark:bg-green-500/20 dark:text-green-400 dark:border-green-900' : ''}
|
||||
>
|
||||
{payroll.is_paid ? 'Dibayar' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: "Status" },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className='text-yellow-600'
|
||||
onClick={() => onEdit(payroll)}
|
||||
>
|
||||
<PencilRuler className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Ubah / Sesuaikan</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className='text-red-600'
|
||||
onClick={() => onDelete(payroll)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Hapus</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: "Aksi" },
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,191 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldGroup } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Payroll } from '@/types';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { useEffect } from 'react';
|
||||
import payrollRoutes from '@/routes/payroll';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
|
||||
interface PayrollFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
payroll: Payroll | null;
|
||||
}
|
||||
|
||||
export function PayrollFormModal({ isOpen, onClose, payroll }: PayrollFormModalProps) {
|
||||
const { data, setData, patch, processing, errors, reset, clearErrors } = useForm({
|
||||
adjustments: [] as { type: 'bonus' | 'deduction', amount: number, description: string }[],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (payroll) {
|
||||
setData({
|
||||
adjustments: payroll.adjustments?.map(adj => ({
|
||||
type: adj.type,
|
||||
amount: adj.amount,
|
||||
description: adj.description
|
||||
})) || [],
|
||||
});
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [payroll]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
clearErrors();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const addAdjustment = () => {
|
||||
setData('adjustments', [
|
||||
...data.adjustments,
|
||||
{ type: 'bonus', amount: 0, description: '' }
|
||||
]);
|
||||
};
|
||||
|
||||
const removeAdjustment = (index: number) => {
|
||||
const newAdjustments = [...data.adjustments];
|
||||
newAdjustments.splice(index, 1);
|
||||
setData('adjustments', newAdjustments);
|
||||
};
|
||||
|
||||
const updateAdjustment = (index: number, key: string, value: any) => {
|
||||
const newAdjustments = [...data.adjustments];
|
||||
newAdjustments[index] = { ...newAdjustments[index], [key]: value };
|
||||
setData('adjustments', newAdjustments);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (payroll) {
|
||||
patch(payrollRoutes.update(payroll.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Penyesuaian Penggajian - {payroll?.user?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="max-h-[60vh] overflow-y-auto px-1 py-2">
|
||||
<FieldGroup>
|
||||
<div className="flex items-center justify-between border-b pb-2 mb-4">
|
||||
<h3 className="text-sm font-medium">Bonus & Potongan</h3>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addAdjustment}>
|
||||
Tambah
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.adjustments.length === 0 && (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Ooops...</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Tidak ada data yang ditemukan.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{data.adjustments.map((adj, index) => (
|
||||
<div key={index} className="flex gap-3 items-start border-l-2 border-primary/20 pl-4 py-2 mb-4 group relative">
|
||||
<div className="flex flex-col gap-3 flex-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase mb-1">Jenis</Label>
|
||||
<RadioGroup
|
||||
value={adj.type}
|
||||
onValueChange={(val) => updateAdjustment(index, 'type', val)}
|
||||
className="flex items-center gap-6 w-fit"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="bonus" id={`bonus-${index}`} />
|
||||
<Label htmlFor={`bonus-${index}`}>Bonus</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="deduction" id={`deduction-${index}`} />
|
||||
<Label htmlFor={`deduction-${index}`}>Potongan</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase mb-1">Jumlah</Label>
|
||||
<NumericFormat
|
||||
id="amount"
|
||||
customInput={Input}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
prefix="Rp "
|
||||
value={adj.amount}
|
||||
onValueChange={(values) => {
|
||||
updateAdjustment(index, 'amount', values.value)
|
||||
}}
|
||||
placeholder="Rp 0"
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase mb-1">Keterangan</Label>
|
||||
<Input
|
||||
value={adj.description}
|
||||
onChange={e =>
|
||||
updateAdjustment(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Contoh: Lembur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 mt-6"
|
||||
onClick={() => removeAdjustment(index)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>Batal</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -4,3 +4,4 @@ export type * from './ui';
|
||||
export type * from './category';
|
||||
export type * from './product';
|
||||
export type * from './expense';
|
||||
export type * from './payroll';
|
||||
|
||||
33
resources/js/types/payroll.ts
Normal file
33
resources/js/types/payroll.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { User } from './auth';
|
||||
|
||||
export interface Adjustment {
|
||||
id: number;
|
||||
payroll_id: number;
|
||||
type: 'bonus' | 'deduction';
|
||||
amount: number;
|
||||
amount_formatted: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Payroll {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user?: User;
|
||||
period_month: string;
|
||||
period_month_formatted: string;
|
||||
base_salary: number;
|
||||
base_salary_formatted: string;
|
||||
bonus: number;
|
||||
bonus_formatted: string;
|
||||
deduction: number;
|
||||
deduction_formatted: string;
|
||||
total_salary: number;
|
||||
total_salary_formatted: string;
|
||||
is_paid: boolean;
|
||||
paid_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
adjustments?: Adjustment[];
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||
use App\Http\Controllers\Admin\Finance\PayrollController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
@ -10,5 +11,12 @@
|
||||
Route::patch('expense/update/{expense}', [ExpenseController::class, 'update'])->name('expense.update');
|
||||
Route::delete('expense/destroy/{expense}', [ExpenseController::class, 'destroy'])->name('expense.destroy');
|
||||
Route::delete('expense/bulk-destroy', [ExpenseController::class, 'bulkDestroy'])->name('expense.bulkDestroy');
|
||||
|
||||
Route::get('payrolls', [PayrollController::class, 'index'])->name('payroll.index');
|
||||
Route::post('payroll/generate', [PayrollController::class, 'generate'])->name('payroll.generate');
|
||||
Route::patch('payroll/update/{payroll}', [PayrollController::class, 'update'])->name('payroll.update');
|
||||
Route::patch('payroll/toggle-paid/{payroll}', [PayrollController::class, 'togglePaid'])->name('payroll.togglePaid');
|
||||
Route::delete('payroll/destroy/{payroll}', [PayrollController::class, 'destroy'])->name('payroll.destroy');
|
||||
Route::delete('payroll/bulk-destroy', [PayrollController::class, 'bulkDestroy'])->name('payroll.bulkDestroy');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user