diff --git a/app/Http/Controllers/AdvanceController.php b/app/Http/Controllers/AdvanceController.php new file mode 100644 index 0000000..558b5e9 --- /dev/null +++ b/app/Http/Controllers/AdvanceController.php @@ -0,0 +1,105 @@ +user()->hasRole('pracownik')) { + $query->where('user_id', $request->user()->id); + } + + $search = trim((string) $request->query('search', '')); + if ($search !== '') { + $query->where(function ($query) use ($search) { + $query->whereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'like', "%{$search}%") + ->orWhere('surname', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + })->orWhereHas('type', function ($typeQuery) use ($search) { + $typeQuery->where('name', 'like', "%{$search}%"); + })->orWhere('amount', 'like', "%{$search}%"); + }); + } + + if ($request->filled('type')) { + $query->where('advance_type_id', $request->integer('type')); + } + + if ($request->filled('date_from')) { + $query->whereDate('date', '>=', $request->query('date_from')); + } + + if ($request->filled('date_to')) { + $query->whereDate('date', '<=', $request->query('date_to')); + } + + $advances = $query->orderByDesc('date')->orderByDesc('id')->paginate(10)->withQueryString(); + $types = AdvanceType::orderBy('name')->get(); + + return view('zaliczki', compact('advances', 'types')); + } + + public function create() + { + $types = AdvanceType::orderBy('name')->get(); + $users = request()->user()->hasRole('pracownik') + ? collect() + : User::orderBy('name')->orderBy('surname')->get(); + + return view('zaliczki.create', compact('types', 'users')); + } + + public function store(StoreAdvanceRequest $request) + { + Advance::create([ + 'date' => $request->validated('date'), + 'amount' => $request->validated('amount'), + 'advance_type_id' => $request->validated('advance_type_id'), + 'user_id' => $request->validated('user_id'), + ]); + + return redirect()->route('zaliczki')->with('status', 'Zaliczka została dodana.'); + } + + public function edit(Request $request, Advance $advance) + { + $this->authorizeAccess($request, $advance); + $types = AdvanceType::orderBy('name')->get(); + $users = $request->user()->hasRole('pracownik') + ? collect() + : User::orderBy('name')->orderBy('surname')->get(); + + return view('zaliczki.edit', compact('advance', 'types', 'users')); + } + + public function update(StoreAdvanceRequest $request, Advance $advance) + { + $this->authorizeAccess($request, $advance); + $advance->update($request->validated()); + + return redirect()->route('zaliczki')->with('status', 'Zaliczka została zaktualizowana.'); + } + + public function destroy(Request $request, Advance $advance) + { + $this->authorizeAccess($request, $advance); + $advance->delete(); + + return redirect()->route('zaliczki')->with('status', 'Zaliczka została usunięta.'); + } + + private function authorizeAccess(Request $request, Advance $advance): void + { + abort_if($request->user()->hasRole('pracownik') && $advance->user_id !== $request->user()->id, 403); + } +} diff --git a/app/Http/Requests/StoreAdvanceRequest.php b/app/Http/Requests/StoreAdvanceRequest.php new file mode 100644 index 0000000..22d6644 --- /dev/null +++ b/app/Http/Requests/StoreAdvanceRequest.php @@ -0,0 +1,40 @@ +user()?->can('zaliczki_access') ?? false; + } + + public function rules(): array + { + return [ + 'date' => ['required', 'date'], + 'user_id' => ['required', 'integer', 'exists:users,id'], + 'amount' => ['required', 'numeric', 'min:0.01'], + 'advance_type_id' => ['required', 'integer', 'exists:advance_types,id'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->user()?->hasRole('pracownik')) { + $this->merge(['user_id' => $this->user()->id]); + } + } + + public function attributes(): array + { + return [ + 'date' => 'data', + 'user_id' => 'użytkownik', + 'amount' => 'kwota', + 'advance_type_id' => 'rodzaj', + ]; + } +} diff --git a/app/Models/Advance.php b/app/Models/Advance.php new file mode 100644 index 0000000..d6f2cbe --- /dev/null +++ b/app/Models/Advance.php @@ -0,0 +1,47 @@ + 'date', + 'amount' => 'decimal:2', + ]; + } + + protected static function booted(): void + { + static::saving(function (Advance $advance) { + $typeName = AdvanceType::whereKey($advance->advance_type_id)->value('name'); + $amount = abs((float) $advance->amount); + + $advance->amount = $typeName === 'Wpłata' ? $amount : -$amount; + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function type(): BelongsTo + { + return $this->belongsTo(AdvanceType::class, 'advance_type_id'); + } +} diff --git a/app/Models/AdvanceType.php b/app/Models/AdvanceType.php new file mode 100644 index 0000000..9a4c6f1 --- /dev/null +++ b/app/Models/AdvanceType.php @@ -0,0 +1,19 @@ +hasMany(Advance::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 18b8a07..6544e7f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Database\Eloquent\Relations\HasMany; use Spatie\Permission\Traits\HasRoles; // 1. Import traitu Spatie class User extends Authenticatable @@ -53,4 +54,9 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + public function advances(): HasMany + { + return $this->hasMany(Advance::class); + } } \ No newline at end of file diff --git a/database/migrations/2026_08_28_000001_create_advance_types_table.php b/database/migrations/2026_08_28_000001_create_advance_types_table.php new file mode 100644 index 0000000..cd09720 --- /dev/null +++ b/database/migrations/2026_08_28_000001_create_advance_types_table.php @@ -0,0 +1,22 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('advance_types'); + } +}; diff --git a/database/migrations/2026_08_28_000002_create_advances_table.php b/database/migrations/2026_08_28_000002_create_advances_table.php new file mode 100644 index 0000000..439be2d --- /dev/null +++ b/database/migrations/2026_08_28_000002_create_advances_table.php @@ -0,0 +1,27 @@ +id(); + $table->date('date'); + $table->foreignId('user_id')->constrained()->restrictOnDelete(); + $table->decimal('amount', 12, 2); + $table->foreignId('advance_type_id')->constrained('advance_types')->restrictOnDelete(); + $table->timestamps(); + + $table->index(['date', 'advance_type_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('advances'); + } +}; diff --git a/database/migrations/2026_08_28_000003_normalize_advance_amount_signs.php b/database/migrations/2026_08_28_000003_normalize_advance_amount_signs.php new file mode 100644 index 0000000..9f7a84c --- /dev/null +++ b/database/migrations/2026_08_28_000003_normalize_advance_amount_signs.php @@ -0,0 +1,28 @@ +where('name', 'Wpłata') + ->pluck('id'); + + if ($depositTypeIds->isNotEmpty()) { + DB::table('advances') + ->whereIn('advance_type_id', $depositTypeIds) + ->update(['amount' => DB::raw('ABS(amount)')]); + } + + DB::table('advances') + ->whereNotIn('advance_type_id', $depositTypeIds) + ->update(['amount' => DB::raw('-ABS(amount)')]); + } + + public function down(): void + { + } +}; diff --git a/database/seeders/AdvanceTypeSeeder.php b/database/seeders/AdvanceTypeSeeder.php new file mode 100644 index 0000000..5efb77c --- /dev/null +++ b/database/seeders/AdvanceTypeSeeder.php @@ -0,0 +1,16 @@ + $name]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6ce2359..938673c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,10 @@ class DatabaseSeeder extends Seeder Permission::firstOrCreate(['name' => $permission]); } + $this->call([ + AdvanceTypeSeeder::class, + ]); + // 2. Tworzenie ról (Roles) $adminRole = Role::firstOrCreate(['name' => 'admin']); $kierownikRole = Role::firstOrCreate(['name' => 'kierownik']); @@ -52,8 +56,6 @@ class DatabaseSeeder extends Seeder ]); // 4. Wywołanie seedera użytkowników - $this->call([ - UserSeeder::class, - ]); + $this->call([UserSeeder::class]); } } \ No newline at end of file diff --git a/resources/views/zaliczki.blade.php b/resources/views/zaliczki.blade.php index 7b6462d..33f1ee9 100644 --- a/resources/views/zaliczki.blade.php +++ b/resources/views/zaliczki.blade.php @@ -1,14 +1,98 @@ -

- {{ __('Zaliczki') }} -

+
+

{{ __('Zaliczki') }}

+ Dodaj zaliczkę +
+ +
-
- Tu będą Zaliczki +
+ @if(session('status')) +
{{ session('status') }}
+ @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Szukaj + @if(request()->hasAny(['search', 'type', 'date_from', 'date_to'])) + Wyczyść + @endif +
+
+ +
+ + + + + + + + + + + + @forelse($advances as $advance) + + + + + + + + @empty + + @endforelse + +
DataUżytkownikKwotaRodzajAkcje
{{ $advance->date->format('d.m.Y') }}{{ $advance->user->name }} {{ $advance->user->surname }}{{ number_format((float) $advance->amount, 2, ',', ' ') }} zł{{ $advance->type->name }} +
+ Edytuj +
+ @csrf + @method('DELETE') + +
+
+
Brak zaliczek spełniających podane kryteria.
+
+ + @if($advances->hasPages()) +
{{ $advances->links() }}
+ @endif
diff --git a/resources/views/zaliczki/create.blade.php b/resources/views/zaliczki/create.blade.php new file mode 100644 index 0000000..1caae73 --- /dev/null +++ b/resources/views/zaliczki/create.blade.php @@ -0,0 +1,63 @@ + + +
+

+ {{ __('Dodaj zaliczkę') }} +

+ + Powrót do listy + +
+
+ +
+
+
+
+ @csrf + +
+ + + +
+ +
+ @if($users->isNotEmpty()) + + + + @endif +
+ +
+ + + +
+ +
+ + + +
+ +
+ Anuluj + Zapisz +
+
+
+
+
+
diff --git a/resources/views/zaliczki/edit.blade.php b/resources/views/zaliczki/edit.blade.php new file mode 100644 index 0000000..17612df --- /dev/null +++ b/resources/views/zaliczki/edit.blade.php @@ -0,0 +1,58 @@ + + +
+

{{ __('Edytuj zaliczkę') }}

+ Powrót do listy +
+
+ +
+
+
+
+ @csrf + @method('PUT') + +
+ + + +
+ +
+ @if($users->isNotEmpty()) + + + + @endif +
+ +
+ + + +
+ +
+ + + +
+ +
+ Anuluj + Zapisz +
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 38a821c..be067b2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { // Moduł Zaliczki Route::middleware(['can:zaliczki_access'])->group(function () { - Route::view('/zaliczki', 'zaliczki')->name('zaliczki'); + Route::get('/zaliczki', [AdvanceController::class, 'index'])->name('zaliczki'); + Route::get('/zaliczki/create', [AdvanceController::class, 'create'])->name('zaliczki.create'); + Route::post('/zaliczki', [AdvanceController::class, 'store'])->name('zaliczki.store'); + Route::get('/zaliczki/{advance}/edit', [AdvanceController::class, 'edit'])->name('zaliczki.edit'); + Route::put('/zaliczki/{advance}', [AdvanceController::class, 'update'])->name('zaliczki.update'); + Route::delete('/zaliczki/{advance}', [AdvanceController::class, 'destroy'])->name('zaliczki.destroy'); }); }); diff --git a/tests/Feature/AdvanceTest.php b/tests/Feature/AdvanceTest.php new file mode 100644 index 0000000..2dc4543 --- /dev/null +++ b/tests/Feature/AdvanceTest.php @@ -0,0 +1,140 @@ + 'zaliczki_access']); +}); + +function userWithAdvanceAccess(string $roleName = 'pracownik'): User +{ + $user = User::factory()->create(); + $role = Role::firstOrCreate(['name' => $roleName]); + $role->givePermissionTo('zaliczki_access'); + $user->assignRole($role); + + return $user; +} + +test('employee sees only own advances', function () { + $employee = userWithAdvanceAccess(); + $otherUser = User::factory()->create(); + $type = AdvanceType::create(['name' => 'Paliwo']); + + Advance::create(['date' => '2026-08-01', 'user_id' => $employee->id, 'amount' => 100, 'advance_type_id' => $type->id]); + Advance::create(['date' => '2026-08-02', 'user_id' => $otherUser->id, 'amount' => 200, 'advance_type_id' => $type->id]); + + $response = $this->actingAs($employee)->get(route('zaliczki')); + + $response->assertOk(); + $response->assertSee('100,00'); + $response->assertDontSee('200,00'); +}); + +test('advance is stored for authenticated user', function () { + $employee = userWithAdvanceAccess(); + $otherUser = User::factory()->create(); + $type = AdvanceType::create(['name' => 'Hotel']); + + $response = $this->actingAs($employee)->post(route('zaliczki.store'), [ + 'date' => '2026-08-28', + 'user_id' => $otherUser->id, + 'amount' => '250.50', + 'advance_type_id' => $type->id, + ]); + + $response->assertRedirect(route('zaliczki')); + $this->assertDatabaseHas('advances', [ + 'user_id' => $employee->id, + 'advance_type_id' => $type->id, + 'amount' => -250.50, + ]); +}); + +test('manager can create an advance for another user', function () { + $manager = userWithAdvanceAccess('kierownik'); + $employee = User::factory()->create(); + $type = AdvanceType::create(['name' => 'Wpłata']); + + $response = $this->actingAs($manager)->post(route('zaliczki.store'), [ + 'date' => '2026-08-28', + 'user_id' => $employee->id, + 'amount' => '300.00', + 'advance_type_id' => $type->id, + ]); + + $response->assertRedirect(route('zaliczki')); + $this->assertDatabaseHas('advances', [ + 'user_id' => $employee->id, + 'advance_type_id' => $type->id, + 'amount' => 300.00, + ]); +}); + +test('employee can edit own advance but cannot access another users advance', function () { + $employee = userWithAdvanceAccess(); + $otherUser = User::factory()->create(); + $type = AdvanceType::create(['name' => 'Hotel']); + $advance = Advance::create(['date' => '2026-08-28', 'user_id' => $employee->id, 'amount' => 100, 'advance_type_id' => $type->id]); + $otherAdvance = Advance::create(['date' => '2026-08-28', 'user_id' => $otherUser->id, 'amount' => 200, 'advance_type_id' => $type->id]); + + $this->actingAs($employee)->get(route('zaliczki.edit', $otherAdvance))->assertForbidden(); + + $response = $this->actingAs($employee)->put(route('zaliczki.update', $advance), [ + 'date' => '2026-08-27', + 'amount' => '150.00', + 'advance_type_id' => $type->id, + ]); + + $response->assertRedirect(route('zaliczki')); + $this->assertDatabaseHas('advances', ['id' => $advance->id, 'amount' => -150, 'date' => '2026-08-27 00:00:00']); +}); + +test('manager can delete an advance', function () { + $manager = userWithAdvanceAccess('kierownik'); + $employee = User::factory()->create(); + $type = AdvanceType::create(['name' => 'Paliwo']); + $advance = Advance::create(['date' => '2026-08-28', 'user_id' => $employee->id, 'amount' => 100, 'advance_type_id' => $type->id]); + + $response = $this->actingAs($manager)->delete(route('zaliczki.destroy', $advance)); + + $response->assertRedirect(route('zaliczki')); + $this->assertDatabaseMissing('advances', ['id' => $advance->id]); +}); + +test('expenses are negative and deposits are positive regardless of input sign', function () { + $employee = userWithAdvanceAccess(); + $fuel = AdvanceType::create(['name' => 'Paliwo']); + $deposit = AdvanceType::create(['name' => 'Wpłata']); + + Advance::create(['date' => '2026-08-28', 'user_id' => $employee->id, 'amount' => 100, 'advance_type_id' => $fuel->id]); + Advance::create(['date' => '2026-08-28', 'user_id' => $employee->id, 'amount' => -200, 'advance_type_id' => $deposit->id]); + + $this->assertDatabaseHas('advances', ['advance_type_id' => $fuel->id, 'amount' => -100]); + $this->assertDatabaseHas('advances', ['advance_type_id' => $deposit->id, 'amount' => 200]); +}); + +test('manager can filter advances by text type and date range', function () { + $manager = userWithAdvanceAccess('kierownik'); + $employee = User::factory()->create(['name' => 'Anna', 'surname' => 'Nowak']); + $fuel = AdvanceType::create(['name' => 'Paliwo']); + $hotel = AdvanceType::create(['name' => 'Hotel']); + + Advance::create(['date' => '2026-08-10', 'user_id' => $employee->id, 'amount' => 100, 'advance_type_id' => $fuel->id]); + Advance::create(['date' => '2026-08-20', 'user_id' => $employee->id, 'amount' => 200, 'advance_type_id' => $hotel->id]); + + $response = $this->actingAs($manager)->get(route('zaliczki', [ + 'search' => 'Anna', + 'type' => $fuel->id, + 'date_from' => '2026-08-01', + 'date_to' => '2026-08-15', + ])); + + $response->assertOk(); + $response->assertSee('100,00'); + $response->assertDontSee('200,00'); +});