Merge branch 'develop' into feature/docker

This commit is contained in:
2025-12-04 20:05:28 +01:00
102 changed files with 9008 additions and 773 deletions

116
.github/workflows/cypress.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Cypress E2E Tests
on:
push:
branches:
- develop
- feature/ci-cypress-tests-08-11-2025
jobs:
cypress-tests:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:latest
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: isop_test
ports:
- 3306:3306
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, ctype, json, mysql, pdo, pdo_mysql
coverage: none
- name: Install Composer dependencies
working-directory: ./backend
run: composer install --prefer-dist --no-progress --no-suggest
- name: Copy .env file
working-directory: ./backend
run: |
cp .env.example .env
php artisan key:generate
- name: Configure database
working-directory: ./backend
run: |
DB_CONNECTION=mariadb >> .env
echo "DB_HOST=127.0.0.1" >> .env
echo "DB_PORT=3306" >> .env
echo "DB_DATABASE=isop_test" >> .env
echo "DB_USERNAME=root" >> .env
echo "DB_PASSWORD=password" >> .env
- name: Print backend .env file
working-directory: ./backend
run: cat .env
- name: Run migrations
working-directory: ./backend
run: php artisan migrate --force
- name: Run seeders
working-directory: ./backend
run: php artisan db:seed
- name: Start Laravel server
working-directory: ./backend
run: php artisan serve --host=0.0.0.0 --port=8000 &
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Build frontend
working-directory: ./frontend
run: npm run build
- name: Start frontend server
working-directory: ./frontend
run: npm run preview &
env:
PORT: 3000
- name: Wait for servers to be ready
run: |
timeout 15 bash -c 'until curl -f http://localhost:8000/sanctum/csrf-cookie; do sleep 2; done'
timeout 15 bash -c 'until curl -f http://localhost:3000; do sleep 2; done'
- name: Run Cypress tests
working-directory: ./frontend
run: npx cypress run
env:
CYPRESS_BASE_URL: http://localhost:3000
- name: Upload Cypress screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
path: frontend/cypress/screenshots
if-no-files-found: ignore
- name: Upload Cypress videos
if: always()
uses: actions/upload-artifact@v4
with:
name: cypress-videos
path: frontend/cypress/videos
if-no-files-found: ignore

8
README.md Normal file
View File

@@ -0,0 +1,8 @@
# Informačný systém odbornej praxe - ISOP
## CI Status
| Badge | Poznámka |
|-------|-------|
| [![Cypress E2E Tests](https://github.com/isop-ukf/isop-app/actions/workflows/cypress.yml/badge.svg)](https://github.com/isop-ukf/isop-app/actions/workflows/cypress.yml) | Kompletný test aplikácie |

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\User;
use Hash;
class CreateAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:create-admin';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new admin user';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('=== Create Admin ===');
// Načítanie údajov interaktívne
$firstName = $this->ask('First name');
$lastName = $this->ask('Last name');
$email = $this->ask('E-mail');
// Kontrola duplicity emailu
if (User::where('email', $email)->exists()) {
$this->error('A user with the same email already exists.');
return 1;
}
$password = $this->secret('Enter password');
$phone = $this->ask('Enter phone number');
// Vytvorenie používateľa
$user = User::create([
'name' => $firstName . ' ' . $lastName,
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'password' => Hash::make($password),
'active' => true,
'role' => 'ADMIN',
'phone' => $phone,
]);
$this->info("\n Admin {$user->first_name} {$user->last_name} created.");
return 0;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enums;
enum InternshipStatus: string
{
case SUBMITTED = 'SUBMITTED';
case CONFIRMED_BY_COMPANY = 'CONFIRMED_BY_COMPANY';
case CONFIRMED_BY_ADMIN = 'CONFIRMED_BY_ADMIN';
case DENIED_BY_COMPANY = 'DENIED_BY_COMPANY';
case DENIED_BY_ADMIN = 'DENIED_BY_ADMIN';
case DEFENDED = 'DEFENDED';
case NOT_DEFENDED = 'NOT_DEFENDED';
public static function all(): array
{
return array_map(fn($case) => $case->value, self::cases());
}
}

View File

@@ -3,11 +3,13 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\UserAccountActivated;
use App\Mail\UserPasswordReset;
use App\Mail\UserRegistrationCompleted;
use App\Models\Company;
use App\Models\StudentData;
use App\Models\User;
use DB;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -24,9 +26,10 @@ class RegisteredUserController extends Controller
public function store(Request $request): Response
{
$password = bin2hex(random_bytes(16));
$activation_token = bin2hex(random_bytes(16));
$request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
'first_name' => ['required', 'string', 'max:64'],
'last_name' => ['required', 'string', 'max:64'],
'phone' => ['required', 'string', 'max:13'],
@@ -46,6 +49,9 @@ class RegisteredUserController extends Controller
'company_data.hiring' => ['required_if:role,EMPLOYER', 'boolean'],
]);
DB::beginTransaction();
try {
$user = User::create([
'email' => $request->email,
'first_name' => $request->first_name,
@@ -54,16 +60,17 @@ class RegisteredUserController extends Controller
'phone' => $request->phone,
'role' => $request->role,
'password' => Hash::make($password),
'activation_token' => $activation_token
]);
if($user->role === "STUDENT") {
if ($user->role === "STUDENT") {
StudentData::create([
'user_id' => $user->id,
'address' => $request->student_data['address'],
'personal_email' => $request->student_data['personal_email'],
'study_field' => $request->student_data['study_field'],
]);
} else if($user->role === "EMPLOYER") {
} else if ($user->role === "EMPLOYER") {
Company::create([
'name' => $request->company_data['name'],
'address' => $request->company_data['address'],
@@ -73,20 +80,51 @@ class RegisteredUserController extends Controller
]);
}
Mail::to($user)->sendNow(new UserRegistrationCompleted($user->name, $password));
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
Mail::to($user)->sendNow(new UserRegistrationCompleted($user->name, $activation_token));
event(new Registered($user));
return response()->noContent();
}
public function reset_password(Request $request): Response {
public function activate(Request $request)
{
$request->validate([
'token' => ['required', 'string', 'exists:users,activation_token'],
'password' => ['required', 'string', 'min:8'],
]);
$user = User::where('activation_token', '=', $request->token)->first();
if (!$user) {
return response()->json(['message' => 'Invalid activation token'], 400);
}
$user->active = true;
$user->activation_token = null;
$user->password = Hash::make($request->password);
$user->save();
Mail::to($user)->sendNow(new UserAccountActivated($user->name));
return response()->noContent();
}
public function reset_password(Request $request)
{
$request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
]);
$user = User::whereEmail($request->email)->first();
if (!$user) {
return response(status: 400);
return response()->json([
'message' => 'No such user exists.'
], 400);
}
$newPassword = bin2hex(random_bytes(16));
@@ -97,4 +135,38 @@ class RegisteredUserController extends Controller
return response()->noContent();
}
public function reset_password_2(Request $request)
{
$request->validate([
'id' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'password' => ['required', 'string', 'lowercase', 'email', 'max:255'],
]);
$user = User::whereEmail($request->email)->first();
if (!$user) {
return response()->json([
'message' => 'No such user exists.'
], 400);
}
$user->password = Hash::make($request->password);
$user->save();
return response()->noContent();
}
public function change_password(Request $request)
{
$user = auth()->user();
$request->validate([
'password' => ['required', 'string', 'min:8'],
]);
$user->password = Hash::make($request->password);
$user->save();
return response()->noContent();
}
}

View File

@@ -3,10 +3,106 @@
namespace App\Http\Controllers;
use App\Models\Company;
use App\Models\User;
use App\Models\Internship;
use App\Models\InternshipStatusData;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CompanyController extends Controller
{
public function all_simple()
{
$companies = Company::all();
$companies->each(function ($company) {
$company->contact = User::find($company->contact);
});
return response()->json($companies);
}
/**
* Get a specific company with contact details.
*/
public function get(int $id)
{
$user = auth()->user();
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
$company = Company::find($id);
if (!$company) {
return response()->json([
'message' => 'No such company exists.'
], 400);
}
$company->contact = User::find($company->contact);
return response()->json($company);
}
/**
* Update company information and contact person.
*/
public function update_all(int $id, Request $request)
{
$user = auth()->user();
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
$company = Company::find($id);
if (!$company) {
return response()->json([
'message' => 'No such company exists.'
], 400);
}
// Validácia dát
$request->validate([
'name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string', 'max:500'],
'ico' => ['required', 'integer'],
'hiring' => ['required', 'boolean'],
'contact.first_name' => ['required', 'string', 'max:255'],
'contact.last_name' => ['required', 'string', 'max:255'],
'contact.email' => ['required', 'email', 'max:255', 'unique:users,email,' . $company->contact],
'contact.phone' => ['nullable', 'string', 'max:20'],
]);
// Aktualizácia Company údajov
$company->update([
'name' => $request->name,
'address' => $request->address,
'ico' => $request->ico,
'hiring' => $request->hiring,
]);
// Aktualizácia kontaktnej osoby
if ($request->has('contact')) {
$contactPerson = User::find($company->contact);
if ($contactPerson) {
$contactPerson->update([
'first_name' => $request->contact['first_name'],
'last_name' => $request->contact['last_name'],
'name' => $request->contact['first_name'] . ' ' . $request->contact['last_name'],
'email' => $request->contact['email'],
'phone' => $request->contact['phone'] ?? null,
]);
}
}
return response()->noContent();
}
/**
* Display a listing of the resource.
*/
@@ -62,4 +158,48 @@ class CompanyController extends Controller
{
//
}
/**
* Delete a company, its contact person and all related data.
*/
public function delete(int $id)
{
$user = auth()->user();
// Admin kontrola
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
$company = Company::find($id);
$company_contact = User::find($company->contact);
if (!$company) {
return response()->json([
'message' => 'No such company exists.'
], 400);
}
DB::beginTransaction();
$internships = Internship::whereCompanyId($company->id);
// mazanie statusov
$internships->each(function ($internship) {
InternshipStatusData::whereInternshipId($internship->id)->delete();
});
// mazanie praxov
$internships->delete();
// mazanie firmy
Company::whereContact($company_contact->id);
// mazanie účtu firmy
$company_contact->delete();
DB::commit();
return response()->noContent();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Enums\InternshipStatus;
use App\Mail\InternshipStatusUpdated;
use App\Models\Internship;
use App\Models\InternshipStatusData;
use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Sanctum\Sanctum;
use Mail;
class ExternalApiController extends Controller
{
public function all_keys(Request $request)
{
$tokens = Sanctum::$personalAccessTokenModel::with('tokenable')->get();
$tokens = $tokens->map(fn($token) => [
"id" => $token->id,
"name" => $token->name,
"created_at" => $token->created_at,
"last_used_at" => $token->last_used_at,
"owner" => User::find($token->tokenable_id)->name,
]);
return response()->json($tokens);
}
public function create_key(Request $request)
{
$request->validate([
'name' => 'required|string|min:3|max:64',
]);
if (Sanctum::$personalAccessTokenModel::where('name', $request->name)->exists()) {
return response()->json([
'message' => 'A token with this name already exists.'
], 422);
}
$token = $request->user()->createToken($request->name)->plainTextToken;
return response()->json([
"key" => $token,
]);
}
public function destroy_key(Request $request, int $id)
{
$request->user()->tokens()->where('id', $id)->delete();
return response()->noContent();
}
public function update_internship_status(Request $request, $id)
{
$user = $request->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
$currentStatus = $internship->status->status;
$request->validate([
'status' => ['required', 'string', 'uppercase', 'in:DEFENDED,NOT_DEFENDED'],
'note' => ['required', 'string', 'min:1']
]);
if ($currentStatus !== InternshipStatus::CONFIRMED_BY_ADMIN) {
return response()->json([
"error" => "Expected current status to be 'CONFIRMED_BY_ADMIN', but it was '$currentStatus->value' instead",
], 422);
}
$newStatus = InternshipStatusData::make([
'internship_id' => $id,
'status' => $request->status,
'note' => $request->note,
'changed' => now(),
'modified_by' => $user->id,
]);
Mail::to($internship->student)
->sendNow(new InternshipStatusUpdated(
$internship,
$user->name,
$internship->student->name,
$internship->company->name,
$currentStatus,
$request->enum('status', InternshipStatus::class),
$request->note
));
$newStatus->save();
return response()->noContent();
}
}

View File

@@ -3,10 +3,163 @@
namespace App\Http\Controllers;
use App\Models\Internship;
use App\Models\InternshipStatusData;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Mpdf\Mpdf;
class InternshipController extends Controller
{
public function all(Request $request)
{
$internships = $this->filterSearch($request);
return response()->json($internships);
}
public function get(int $id)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
return response()->json($internship);
}
public function get_default_proof(Request $request, int $id)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
$contact = User::find($internship->company->contact);
$html = view('proof.default', [
'company' => $internship->company,
'companyContact' => $contact,
'internship' => $internship,
'student' => $internship->student,
'student_address' => $internship->student->studentData->address,
])->render();
$pdf = new Mpdf([
'orientation' => 'P'
]);
$pdf->WriteHTML($html);
return response($pdf->Output('', 'S'), 200)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="proof_' . $id . '.pdf"');
}
public function get_proof(int $id)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if (!$internship->proof) {
return response()->json([
'message' => 'No proof file exists for this internship.'
], 404);
}
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
return response($internship->proof, 200)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="proof_' . $id . '.pdf"');
}
public function get_report(int $id)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if (!$internship->report) {
return response()->json([
'message' => 'No report file exists for this internship.'
], 404);
}
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
return response($internship->report, 200)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="report_' . $id . '.pdf"');
}
public function export(Request $request)
{
$internships = $this->filterSearch($request, true);
$csv_header = [
'ID',
'Student',
'Company',
'Start',
'End',
'YearOfStudy',
'Semester',
'StudyField',
'Status'
];
$csv_content = implode(',', $csv_header) . "\n";
foreach ($internships as $internship) {
$data = [
$internship->id,
'"' . $internship->student->name . '"',
'"' . $internship->company->name . '"',
Carbon::parse($internship->start)->format('d.m.Y'),
Carbon::parse($internship->end)->format('d.m.Y'),
$internship->year_of_study,
$internship->semester,
$internship->student->studentData->study_field,
$internship->status->status->value
];
$csv_content .= implode(',', $data) . "\n";
}
return response($csv_content, 200)
->header('Content-Type', 'application/csv')
->header('Content-Disposition', 'attachment; filename="internships.csv"');
}
/**
* Display a listing of the resource.
*/
@@ -28,7 +181,31 @@ class InternshipController extends Controller
*/
public function store(Request $request)
{
//
$user = auth()->user();
$this->validateNewInternship($request);
$this->checkOverlap($user->id, $request->start, $request->end);
$Internship = Internship::create([
'user_id' => $user->id,
'company_id' => $request->company_id,
'start' => $request->start,
'end' => $request->end,
'year_of_study' => $request->year_of_study,
'semester' => $request->semester,
'position_description' => $request->position_description,
'proof' => null
]);
InternshipStatusData::create([
'internship_id' => $Internship->id,
'status' => 'SUBMITTED',
'changed' => now(),
'note' => null,
'modified_by' => $user->id
]);
return response()->noContent();
}
/**
@@ -47,6 +224,71 @@ class InternshipController extends Controller
//
}
public function update_basic(int $id, Request $request)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
$this->validateNewInternship($request);
$this->checkOverlap($internship->user_id, $request->start, $request->end, $internship->id);
$internship->update($request->except(['user_id']));
return response()->noContent();
}
public function update_documents(int $id, Request $request)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if ($internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
$request->validate([
'proof' => ['nullable', 'file', 'mimes:pdf', 'max:10240'],
'report' => ['nullable', 'file', 'mimes:pdf', 'max:10240'],
'report_confirmed' => ['required', 'boolean'],
]);
if ($request->hasFile('proof')) {
$internship->proof = file_get_contents($request->file('proof')->getRealPath());
}
if ($request->hasFile('report')) {
$internship->report = file_get_contents($request->file('report')->getRealPath());
}
if ($user->role === 'EMPLOYER') {
if ($request->report_confirmed && (!$internship->proof || !$internship->report)) {
return response()->json([
'message' => 'Report cannot be confirmed without an proof and report.'
], 400);
}
$internship->report_confirmed = $request->report_confirmed;
}
$internship->save();
return response()->noContent();
}
/**
* Update the specified resource in storage.
*/
@@ -58,8 +300,116 @@ class InternshipController extends Controller
/**
* Remove the specified resource from storage.
*/
public function destroy(Internship $internship)
public function destroy(int $id)
{
//
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
$internship->delete();
return response()->noContent();
}
private function validateNewInternship(Request $request)
{
$request->validate([
'company_id' => ['required', 'exists:companies,id'],
'start' => ['required', 'date'],
'end' => ['required', 'date', 'after:start'],
'year_of_study' => ['required', 'integer', 'between:1,5'],
'semester' => ['required', 'in:WINTER,SUMMER'],
'position_description' => ['required', 'string', 'min:1']
]);
$request->merge([
'start' => date('Y-m-d 00:00:00', strtotime($request->start)),
'end' => date('Y-m-d 00:00:00', strtotime($request->end))
]);
}
private function checkOverlap(int $user_id, string $start_date, string $end_date, ?int $current_id = null)
{
$existingInternship = Internship::where('user_id', $user_id)
// check if the two internships do not have the same ID
->when($current_id, function ($query) use ($current_id) {
$query->where('id', '!=', $current_id);
})
// check if the start/end period collides with another internship
->where(function ($query) use ($start_date, $end_date) {
$query->whereBetween('start', [$start_date, $end_date])
->orWhereBetween('end', [$start_date, $end_date])
->orWhere(function ($q) use ($end_date) {
$q->where('start', '<=', $end_date)
->where('end', '>=', $end_date);
});
})
->exists();
if ($existingInternship) {
abort(response()->json([
'message' => 'You already have an internship during this period.'
], 400));
}
}
private function filterSearch(Request $request, bool $ignorePage = false)
{
$user = $request->user();
$request->validate([
'year' => 'nullable|integer',
'company' => 'nullable|string|min:3|max:32',
'study_programe' => 'nullable|string|min:3|max:32',
'student' => 'nullable|string|min:3|max:32',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:-1|max:100',
]);
if ($ignorePage) {
$request->merge(['per_page' => -1]);
}
$perPage = $request->input('per_page', 15);
// Handle "All" items (-1)
if ($perPage == -1) {
$perPage = Internship::count();
}
$internships = Internship::query()
->with(['student.studentData'])
->when($request->year, function ($query, $year) {
$query->whereYear('start', $year);
})
->when($request->company, function ($query, $company) {
$query->whereHas('company', function ($q) use ($company) {
$q->where('name', 'like', "%$company%");
});
})
->when($request->study_programe, function ($query, $studyPrograme) {
$query->whereHas('student.studentData', function ($q) use ($studyPrograme) {
$q->where('study_field', 'like', "%$studyPrograme%");
});
})
->when($request->student, function ($query, $student) {
$query->whereHas('student', function ($q) use ($student) {
$q->where('name', 'like', "%$student%");
});
})
->when($user->role === 'STUDENT', function ($query) use ($user) {
$query->where('user_id', '=', $user->id);
})
->when($user->role === 'EMPLOYER', function ($query) use ($user) {
$query->whereHas('company', function ($q) use ($user) {
$q->where('contact', 'like', $user->id);
});
})
->paginate($perPage);
return $internships;
}
}

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\InternshipStatus;
use Illuminate\Http\Request;
class InternshipStatusController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(InternshipStatus $internshipStatus)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(InternshipStatus $internshipStatus)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, InternshipStatus $internshipStatus)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(InternshipStatus $internshipStatus)
{
//
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers;
use App\Enums\InternshipStatus;
use App\Mail\InternshipStatusUpdated;
use App\Models\Internship;
use App\Models\InternshipStatusData;
use Illuminate\Http\Request;
use Mail;
class InternshipStatusDataController extends Controller
{
public function get(int $id)
{
$user = auth()->user();
$internship_statuses = InternshipStatusData::whereInternshipId($id)->orderByDesc('changed')->get();
if (!$internship_statuses) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
$internship = Internship::find($id);
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
return response()->json($internship_statuses);
}
public function get_next_states(int $id)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if ($user->role !== 'ADMIN' && $internship->user_id !== $user->id && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
$nextPossibleStatuses = $internship->nextStates($user->role);
return response()->json($nextPossibleStatuses);
}
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(InternshipStatusData $internshipStatus)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(InternshipStatusData $internshipStatus)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(int $id, Request $request)
{
$user = auth()->user();
$internship = Internship::find($id);
if (!$internship) {
return response()->json([
'message' => 'No such internship exists.'
], 400);
}
if ($user->role !== 'ADMIN' && $user->id !== $internship->company->contact) {
abort(403, 'Unauthorized');
}
$internshipStatus = $internship->status;
$newStatusValidator = 'in:' . implode(',', $internship->nextStates($user->role));
$request->validate([
'status' => ['required', 'string', 'uppercase', $newStatusValidator],
'note' => ['required', 'string', 'min:1']
]);
$newStatus = InternshipStatusData::make([
'internship_id' => $id,
'status' => $request->status,
'note' => $request->note,
'changed' => now(),
'modified_by' => $user->id
]);
// mail študentovi
Mail::to($internship->student)
->sendNow(new InternshipStatusUpdated(
$internship,
$internshipStatus->status,
$newStatus->status,
$request->note,
$user,
recipiantIsStudent: true,
));
// ak zmenu nevykonala firma, posleme mail aj firme
if ($user->id !== $internship->company->contactPerson->id) {
Mail::to($internship->company->contactPerson->email)
->sendNow(new InternshipStatusUpdated(
$internship,
$internshipStatus->status,
$newStatus->status,
$request->note,
$user,
recipiantIsStudent: false,
));
}
$newStatus->save();
return response()->noContent();
}
/**
* Remove the specified resource from storage.
*/
public function destroy(InternshipStatusData $internshipStatus)
{
//
}
}

View File

@@ -2,11 +2,123 @@
namespace App\Http\Controllers;
use App\Models\Internship;
use App\Models\StudentData;
use App\Models\User;
use App\Models\InternshipStatusData;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StudentDataController extends Controller
{
/**
* Display a listing of all students with their data.
*/
public function all()
{
// Iba admin môže vidieť zoznam študentov
$user = auth()->user();
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
$students = User::where('role', 'STUDENT')
->with('studentData')
->get();
return response()->json($students);
}
/**
* Get a specific student with their data.
*/
public function get(int $id)
{
$user = auth()->user();
$student = User::find($id);
if (!$student) {
return response()->json([
'message' => 'No such student exists.'
], 400);
}
if ($student->role !== 'STUDENT') {
return response()->json([
'message' => 'User is not a student.'
], 400);
}
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
$student->load('studentData');
return response()->json($student);
}
/**
* Update student's basic information and student data.
*/
public function update_all(int $id, Request $request)
{
$user = auth()->user();
$student = User::find($id);
if (!$student) {
return response()->json([
'message' => 'No such student exists.'
], 400);
}
if ($student->role !== 'STUDENT') {
return response()->json([
'message' => 'User is not a student.'
], 400);
}
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
// Validácia dát
$request->validate([
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $id],
'phone' => ['nullable', 'string', 'max:20'],
'student_data.study_field' => ['nullable', 'string', 'max:255'],
'student_data.personal_email' => ['nullable', 'email', 'max:255'],
'student_data.address' => ['nullable', 'string', 'max:500'],
]);
// Aktualizácia User údajov
$student->update([
'name' => $request->first_name . ' ' . $request->last_name,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'email' => $request->email,
'phone' => $request->phone,
]);
// Aktualizácia alebo vytvorenie StudentData
if ($request->has('student_data')) {
$studentData = $student->studentData;
if ($studentData) {
$studentData->update($request->student_data);
} else {
$student->studentData()->create($request->student_data);
}
}
return response()->noContent();
}
/**
* Display a listing of the resource.
*/
@@ -62,4 +174,54 @@ class StudentDataController extends Controller
{
//
}
/**
* Delete a student and all related data.
*/
public function delete(int $id)
{
$user = auth()->user();
// Admin kontrola
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
$student = User::find($id);
if (!$student) {
return response()->json([
'message' => 'No such student exists.'
], 400);
}
if ($student->role !== 'STUDENT') {
return response()->json([
'message' => 'User is not a student.'
], 400);
}
DB::beginTransaction();
// mazanie praxov
$internships = Internship::whereUserId($student->id);
// mazanie statusov
$internships->each(function ($internship) {
InternshipStatusData::whereInternshipId($internship->id)->delete();
});
// mazanie praxov
$internships->delete();
// mazanie firmy
StudentData::whereUserId($student->id);
// mazanie účtu firmy
$student->delete();
DB::commit();
return response()->noContent();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdministratorOnly
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null) {
abort(403, 'Unauthorized');
}
if ($user->role !== 'ADMIN') {
abort(403, 'Unauthorized');
}
return $next($request);
}
}

View File

@@ -49,6 +49,15 @@ class LoginRequest extends FormRequest
]);
}
// Check if the authenticated user's account is active
if (! Auth::user()->active) {
Auth::logout();
throw ValidationException::withMessages([
'email' => __('auth.inactive_account'),
]);
}
RateLimiter::clear($this->throttleKey());
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Mail;
use App\Enums\InternshipStatus;
use App\Models\Internship;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class InternshipStatusUpdated extends Mailable
{
use Queueable, SerializesModels;
private Internship $internship;
private InternshipStatus $oldStatus;
private InternshipStatus $newStatus;
private string $note;
private User $changedBy;
private bool $recipiantIsStudent;
/**
* Create a new message instance.
*/
public function __construct(Internship $internship, InternshipStatus $oldStatus, InternshipStatus $newStatus, string $note, User $changedBy, bool $recipiantIsStudent)
{
$this->internship = $internship;
$this->oldStatus = $oldStatus;
$this->newStatus = $newStatus;
$this->note = $note;
$this->changedBy = $changedBy;
$this->recipiantIsStudent = $recipiantIsStudent;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Internship Status Updated',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'mail.internship.status_updated',
with: [
"internship" => $this->internship,
"oldStatus" => $this->prettyStatus($this->oldStatus->value),
"newStatus" => $this->prettyStatus($this->newStatus->value),
"note" => $this->note,
"recipiantIsStudent" => $this->recipiantIsStudent,
"changedBy" => $this->changedBy,
]
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
private function prettyStatus(string $status): string
{
return match ($status) {
'SUBMITTED' => 'Zadané',
'CONFIRMED_BY_COMPANY' => 'Potvrdená firmou',
'CONFIRMED_BY_ADMIN' => 'Potvrdená garantom',
'DENIED_BY_COMPANY' => 'Zamietnutá firmou',
'DENIED_BY_ADMIN' => 'Zamietnutá garantom',
'DEFENDED' => 'Obhájená',
'NOT_DEFENDED' => 'Neobhájená',
default => throw new \Exception("Invalid status: $status")
};
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class UserAccountActivated extends Mailable
{
use Queueable, SerializesModels;
private string $name;
/**
* Create a new message instance.
*/
public function __construct(string $name)
{
$this->name = $name;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'User Account Activated',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'mail.activation.completed',
with: [
"name" => $this->name,
]
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -14,15 +14,15 @@ class UserRegistrationCompleted extends Mailable
use Queueable, SerializesModels;
private string $name;
private string $password;
private string $activation_token;
/**
* Create a new message instance.
*/
public function __construct(string $name, string $password)
public function __construct(string $name, string $activation_token)
{
$this->name = $name;
$this->password = $password;
$this->activation_token = $activation_token;
}
/**
@@ -44,7 +44,7 @@ class UserRegistrationCompleted extends Mailable
view: 'mail.registration.completed',
with: [
"name" => $this->name,
"password" => $this->password
"activation_token" => $this->activation_token
]
);
}

View File

@@ -22,4 +22,30 @@ class Company extends Model
'contact',
'hiring'
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'created_at',
'updated_at',
];
/**
* Get the internships for the company.
*/
public function internships()
{
return $this->hasMany(Internship::class, 'company_id');
}
/**
* Get the contact person (user) for the company.
*/
public function contactPerson()
{
return $this->belongsTo(User::class, 'contact');
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Models;
use App\Enums\InternshipStatus;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -23,6 +25,124 @@ class Internship extends Model
'year_of_study',
'semester',
'position_description',
'agreement',
'proof',
'report',
'report_confirmed',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'created_at',
'updated_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'report_confirmed' => 'boolean',
];
}
public function student()
{
return $this->belongsTo(User::class, 'user_id');
}
public function company()
{
return $this->belongsTo(Company::class, 'company_id');
}
public function status()
{
return $this->hasOne(InternshipStatusData::class, 'internship_id')->latestOfMany();
}
public function nextStates(string $userRole)
{
$current_status = $this->status->status;
$report_confirmed = $this->report_confirmed;
// študent nemôže meniť stav
if ($userRole === 'STUDENT')
return [];
/*
nasledujúci platný stav je určený podľa:
- aktuálneho stavu
- roly používateľa, ktorý ide meniť stav
- či bol výkaz potvrdený firmou
*/
return match (true) {
// prax bola iba vytvorená a ide ju meniť admin alebo firma
$current_status === InternshipStatus::SUBMITTED
=> ['CONFIRMED_BY_COMPANY', 'DENIED_BY_COMPANY'],
// prax bola potvrdená firmou a ide ju meniť admin
$current_status === InternshipStatus::CONFIRMED_BY_COMPANY && $userRole === "ADMIN"
=> ['CONFIRMED_BY_ADMIN', 'DENIED_BY_ADMIN'],
// prax bola potvrdená firmou a ide ju meniť firma
$current_status === InternshipStatus::CONFIRMED_BY_COMPANY && $userRole === "EMPLOYER"
=> ['DENIED_BY_COMPANY'],
// prax bola zamietnutá firmou a ide ju meniť admin
$current_status === InternshipStatus::DENIED_BY_COMPANY && $userRole === "ADMIN"
=> ['CONFIRMED_BY_COMPANY', 'SUBMITTED'],
// prax bola potvrdená garantom, ide ju meniť admin a výkaz bol potvrdený firmou
$current_status === InternshipStatus::CONFIRMED_BY_ADMIN && $userRole === "ADMIN" && $report_confirmed
=> ['DENIED_BY_ADMIN', 'CONFIRMED_BY_COMPANY', 'DENIED_BY_COMPANY', 'DEFENDED', 'NOT_DEFENDED'],
// prax bola potvrdená garantom, ide ju meniť admin a výkaz nebol potvrdený firmou
$current_status === InternshipStatus::CONFIRMED_BY_ADMIN && $userRole === "ADMIN" && !$report_confirmed
=> ['DENIED_BY_ADMIN', 'CONFIRMED_BY_COMPANY', 'DENIED_BY_COMPANY'],
// prax bola zamietnutá garantom a ide ju meniť garant
$current_status === InternshipStatus::DENIED_BY_ADMIN && $userRole === "ADMIN"
=> ['CONFIRMED_BY_COMPANY', 'CONFIRMED_BY_ADMIN', 'DENIED_BY_COMPANY'],
// prax bola obhájená a ide ju meniť admin
$current_status === InternshipStatus::DEFENDED && $userRole === "ADMIN"
=> ['NOT_DEFENDED'],
// prax nebola obhájená a ide ju meniť admin
$current_status === InternshipStatus::NOT_DEFENDED && $userRole === "ADMIN"
=> ['DEFENDED'],
default => []
};
}
/**
* Prepare the model for JSON serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'student' => $this->student,
'company' => $this->company,
'start' => Carbon::parse($this->start)->format('d.m.Y'),
'end' => Carbon::parse($this->end)->format('d.m.Y'),
'year_of_study' => $this->year_of_study,
'semester' => $this->semester,
'position_description' => $this->position_description,
'proof' => $this->proof !== null,
'report' => $this->report !== null,
'report_confirmed' => $this->report_confirmed,
'status' => $this->status,
];
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InternshipStatus extends Model
{
/** @use HasFactory<\Database\Factories\InternshipStatusFactory> */
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'internship_id',
'status',
'changed',
'note',
'modified_by'
];
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InternshipStatusData extends Model
{
/** @use HasFactory<\Database\Factories\InternshipStatusFactory> */
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'internship_id',
'status',
'changed',
'note',
'modified_by'
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'created_at',
'updated_at',
];
protected $table = 'internship_statuses';
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => '\App\Enums\InternshipStatus',
];
}
public function modifiedBy()
{
return $this->belongsTo(User::class, 'modified_by');
}
public function toArray()
{
return [
'id' => $this->id,
'internship_id' => $this->internship_id,
'status' => $this->status->value,
'changed' => $this->changed,
'note' => $this->note,
'modified_by' => $this->modifiedBy,
];
}
}

View File

@@ -21,4 +21,14 @@ class StudentData extends Model
'personal_email',
'study_field',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'created_at',
'updated_at',
];
}

View File

@@ -6,11 +6,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
@@ -25,6 +26,9 @@ class User extends Authenticatable
'email',
'role',
'password',
'active',
'needs_password_change',
'activation_token',
];
/**
@@ -35,6 +39,12 @@ class User extends Authenticatable
protected $hidden = [
'password',
'remember_token',
'activation_token',
'created_at',
'updated_at',
'email_verified_at',
'active',
'needs_password_change'
];
/**
@@ -47,6 +57,24 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'active' => 'boolean',
'needs_password_change' => 'boolean'
];
}
/**
* Get the student data associated with the user.
*/
public function studentData()
{
return $this->hasOne(StudentData::class, 'user_id');
}
/**
* Get the internships for the user.
*/
public function internships()
{
return $this->hasMany(Internship::class, 'user_id');
}
}

View File

@@ -12,7 +12,8 @@
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10",
"mpdf/mpdf": "^8.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",

1262
backend/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ class CompanyFactory extends Factory
{
return [
'name' => fake()->company(),
'address' => fake()->address(),
'address' => fake()->streetAddress() . ", " . fake()->city() . ", " . fake()->postcode(),
'ico' => fake()->numberBetween(111111, 999999),
'contact' => 0,
'hiring' => fake()->boolean(),

View File

@@ -16,15 +16,20 @@ class InternshipFactory extends Factory
*/
public function definition(): array
{
$start = fake()->dateTime()->setTime(0, 0, 0, 0);
$end = (clone $start)->modify('+' . fake()->numberBetween(150, 160) . ' hours')->setTime(0, 0, 0, 0);
return [
'user_id' => 0,
'company_id' => 0,
'start' => fake()->dateTime(),
'end' => fake()->dateTime("+30 days"),
'start' => $start,
'end' => $end,
'year_of_study' => fake()->randomElement([1, 2, 3, 4, 5]),
'semester' => fake()->randomElement(["WINTER", "SUMMER"]),
'position_description' => fake()->jobTitle(),
'agreement' => null,
'proof' => null,
'report' => null,
'report_confirmed' => false,
];
}
}

View File

@@ -2,12 +2,13 @@
namespace Database\Factories;
use App\Enums\InternshipStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InternshipStatus>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InternshipStatusData>
*/
class InternshipStatusFactory extends Factory
class InternshipStatusDataFactory extends Factory
{
/**
* Define the model's default state.
@@ -18,7 +19,7 @@ class InternshipStatusFactory extends Factory
{
return [
'internship_id' => 0,
'status' => fake()->randomElement(["SUBMITTED", "CONFIRMED", "DENIED", "DEFENDED", "NOT_DEFENDED"]),
'status' => fake()->randomElement(InternshipStatus::all()),
'changed' => fake()->dateTime(),
'note' => null,
'modified_by' => 0,

View File

@@ -18,7 +18,7 @@ class StudentDataFactory extends Factory
{
return [
'user_id' => 0,
'address' => fake()->address(),
'address' => fake()->streetAddress() . ", " . fake()->city() . ", " . fake()->postcode(),
'personal_email' => fake()->safeEmail(),
'study_field' => fake()->randomElement(["AI22m", "AI22b"]),
];

View File

@@ -36,6 +36,8 @@ class UserFactory extends Factory
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'active' => true,
'needs_password_change' => false,
];
}

View File

@@ -17,6 +17,9 @@ return new class extends Migration
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('active')->default(false);
$table->boolean('needs_password_change')->default(false);
$table->string('activation_token')->nullable();
$table->rememberToken();
$table->timestamps();
});

View File

@@ -4,8 +4,7 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
return new class extends Migration {
/**
* Run the migrations.
*/
@@ -20,7 +19,9 @@ return new class extends Migration
$table->unsignedSmallInteger("year_of_study")->nullable(false);
$table->enum("semester", ["WINTER", "SUMMER"])->nullable(false);
$table->string("position_description")->nullable(false);
$table->binary("agreement")->nullable(true);
$table->binary("proof")->nullable(true);
$table->binary("report")->nullable(true);
$table->boolean("report_confirmed")->nullable(false)->default(false);
$table->timestamps();
});
}

View File

@@ -1,11 +1,11 @@
<?php
use App\Enums\InternshipStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
return new class extends Migration {
/**
* Run the migrations.
*/
@@ -14,7 +14,7 @@ return new class extends Migration
Schema::create('internship_statuses', function (Blueprint $table) {
$table->id();
$table->foreignId("internship_id")->nullable(false)->constrained("internships")->onDelete("cascade");
$table->enum("status", ["SUBMITTED", "CONFIRMED", "DENIED", "DEFENDED", "NOT_DEFENDED"])->nullable(false)->default("SUBMITTED");
$table->enum("status", InternshipStatus::all())->nullable(false)->default(InternshipStatus::SUBMITTED);
$table->dateTimeTz("changed")->nullable(false);
$table->string("note")->nullable(true)->default(null);
$table->foreignId("modified_by")->nullable(false)->constrained("users")->onDelete("cascade");

View File

@@ -4,7 +4,7 @@ namespace Database\Seeders;
use App\Models\Company;
use App\Models\Internship;
use App\Models\InternshipStatus;
use App\Models\InternshipStatusData;
use App\Models\StudentData;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
@@ -28,7 +28,7 @@ class DatabaseSeeder extends Seeder
]);
// create employers and companies
User::factory(10)
User::factory(20)
->create([
'role' => 'EMPLOYER'
])
@@ -39,11 +39,15 @@ class DatabaseSeeder extends Seeder
});
// create students
User::factory(10)
User::factory(20)
->create([
'role' => 'STUDENT'
])
->each(function ($user) use ($admin) {
$user->update([
'email' => fake()->unique()->userName() . '@student.ukf.sk',
]);
StudentData::factory()->create([
'user_id' => $user->id
]);
@@ -53,12 +57,17 @@ class DatabaseSeeder extends Seeder
'company_id' => Company::inRandomOrder()->value('id'),
]);
InternshipStatus::factory()->create([
InternshipStatusData::factory()->create([
'internship_id' => $internship->id,
'status' => "SUBMITTED",
'note' => 'made by seeder',
'modified_by' => $admin->id,
]);
});
// create some random external API keys
for ($i = 0; $i < 4; $i++) {
$admin->createToken(fake()->userName());
}
}
}

View File

@@ -0,0 +1,8 @@
@include("parts.header")
<p>Vážená/ý {{ $name }},</p>
<p>úspešne ste aktivovali váš účet!</p>
<br />
<p>s pozdravom</p>
<p>Systém ISOP UKF</p>
@include("parts.footer")

View File

@@ -0,0 +1,21 @@
@include("parts.header")
@if($recipiantIsStudent)
<p>Vážená/ý {{ $internship->student->name }},</p>
@else
<p>Vážená/ý {{ $internship->company->contactPerson->name }},</p>
@endif
<p>oznamujeme Vás o zmene praxe:</p>
@if(!$recipiantIsStudent)
<p>Študent: <em>{{ $internship->student->name }} ({{ $internship->student->email }},
{{ $internship->student->phone }})</em></p>
@endif
<p>Stav: <em>{{ $oldStatus }}</em> <strong>-></strong> <em>{{ $newStatus }}</em></p>
<p>Poznámka: <em>{{ $note }}</em>.</p>
<p>Zmenu vykonal <em>{{ $changedBy->name }}</em>.</p>
<br />
<p>S pozdravom.</p>
<p>Systém ISOP UKF</p>
@include("parts.footer")

View File

@@ -3,7 +3,12 @@
<p>vaša registrácia do systému ISOP UKF prebehla úspešne!</p>
<br />
<p>Vaše heslo je: <em>{{ $password }}</em></p>
<p>Aktivujte účet pomocou nasledujúceho linku:</p>
<br />
<p>
<a
href="{{ config('app.frontend_url') }}/account/activation/{{ $activation_token }}">{{ config('app.frontend_url') }}/account/activation/{{ $activation_token }}</a>
</p>
<br />

View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dohoda o odbornej praxi študenta</title>
<style>
@page {
margin: 2cm;
}
body {
font-family: "Calibri", Times, serif;
font-size: 11pt;
line-height: 1.1;
color: #000;
max-width: 210mm;
margin: 0 auto;
padding: 20mm;
background: #fff;
text-align: justify;
}
.header-right {
text-align: right;
font-style: italic;
margin-bottom: 20px;
}
.title {
text-align: center;
font-weight: bold;
}
.subtitle {
text-align: center;
font-size: 11pt;
margin-bottom: 20px;
}
.section-header {
font-weight: bold;
margin-top: 20px;
margin-bottom: 10px;
}
.university-info,
.provider-info,
.student-info {
margin-bottom: 20px;
font-size: 11pt;
}
.indented {
margin-left: 20px;
}
.field-line {
border-bottom: 1px dotted #000;
display: inline-block;
min-width: 300px;
}
.roman-numeral {
text-align: center;
font-weight: bold;
margin: 25px 0 15px;
}
.list-item {
margin-bottom: 4px;
}
.sub-list {
margin-left: 30px;
}
#signature-section {
margin-top: 50px;
display: flex;
justify-content: space-between;
}
.signature-block {
text-align: center;
}
.signature-line {
border-top: 1px dotted #000;
margin-top: 40px;
padding-top: 5px;
min-width: 200px;
}
.date-line {
display: inline-block;
border-bottom: 1px dotted #000;
min-width: 100px;
}
ul {
list-style: none;
padding-left: 0;
}
</style>
</head>
<body>
<div class="header-right">
Platnosť tlačiva od 1.10.2024 (aplikovaná informatika)
</div>
<div class="title">
DOHODA O ODBORNEJ PRAXI ŠTUDENTA
</div>
<div class="subtitle">
uzatvorená v zmysle § 51 Občianskeho zákonníka a Zákona č. 131/2002 Z.z. o vysokých školách
</div>
<div class="university-info">
<strong>Univerzita Konštantína Filozofa v Nitre</strong><br>
<div class="indented">
Fakulta prírodných vied a informatiky<br>
Trieda A. Hlinku 1, 949 01 Nitra<br>
v zastúpení prof. RNDr. František Petrovič, PhD., MBA - dekan fakulty<br>
e-mail: dfpvai@ukf.sk &nbsp;&nbsp;&nbsp; tel. 037/6408 555
</div>
</div>
<div class="provider-info">
<strong>Poskytovateľ odbornej praxe (organizácia, resp. inštitúcia)</strong><br>
<div class="indented">
<em>{{ $company->name }}</em> v zastúpení <em>{{ $companyContact->name }}</em>
</div>
</div>
<div class="student-info">
<strong>Študent:</strong><br>
<div class="indented">
<table style="border: none; width: 100%; line-height: 1;">
<tr>
<td style="width: 260px;">Meno a priezvisko:</td>
<td><em>{{ $student->name }}</em></td>
</tr>
<tr>
<td>Adresa trvalého bydliska:</td>
<td><em>{{ $student_address }}</em></td>
</tr>
<tr>
<td>Kontakt študenta FPVaI UKF v Nitre:</td>
<td><em>{{ $student->email }}</em></td>
</tr>
<tr>
<td>Študijný program:</td>
<td>aplikovaná informatika</td>
</tr>
</table>
</div>
</div>
<div style="margin: 20px 0;">
uzatvárajú túto dohodu o odbornej praxi študenta.
</div>
<div class="roman-numeral">I. Predmet dohody</div>
<div>
Predmetom tejto dohody je vykonanie odbornej praxe študenta v rozsahu 150 hodín, v termíne od
<em>{{ \Carbon\Carbon::parse($internship->start)->format('d.m.Y') }}</em> do
<em>{{ \Carbon\Carbon::parse($internship->end)->format('d.m.Y') }}</em> bezodplatne.
</div>
<div class="roman-numeral">II. Práva a povinnosti účastníkov dohody</div>
<div class="section-header">1. Fakulta prírodných vied a informatiky Univerzity Konštantína Filozofa v Nitre:
</div>
<div class="list-item">
1.1 Poverí svojho zamestnanca: Mgr. Dominik Halvoník, PhD. (ďalej garant odbornej praxe) garanciou odbornej
praxe.
</div>
<div class="list-item">
1.2 Prostredníctvom garanta odbornej praxe:
<div class="sub-list">
a) poskytne študentovi:
<div style="margin-left: 20px;">
- informácie o organizácii praxe, o podmienkach dojednania dohody o odbornej praxi, o obsahovom
zameraní odbornej praxe a o požiadavkách na obsahovú náplň správy z odbornej praxe,<br>
- návrh dohody o odbornej praxi študenta,
</div>
b) rozhodne o udelení hodnotenia „ABS" (absolvoval) študentovi na základe dokladu „Výkaz o vykonanej
odbornej praxi", vydaného poskytovateľom odbornej praxe a na základe študentom vypracovanej správy o
odbornej praxi, ktorej súčasťou je verejná obhajoba výsledkov odbornej praxe,<br><br>
c) spravuje vyplnenú a účastníkmi podpísanú dohodu o odbornej praxi.
</div>
</div>
<div class="section-header">2. Poskytovateľ odbornej praxe:</div>
<div class="list-item">
2.1 poverí svojho zamestnanca (tútor - zodpovedný za odbornú prax v organizácii)
<em>{{ $companyContact->name }}</em>, ktorý bude dohliadať na dodržiavanie dohody o
odbornej praxi, plnenie obsahovej náplne odbornej praxe a bude nápomocný pri získavaní potrebných údajov pre
vypracovanie správy z odbornej praxe,
</div>
<div class="list-item">
2.2 na začiatku praxe vykoná poučenie o bezpečnosti a ochrane zdravia pri práci v zmysle platných predpisov,
</div>
<div style="page-break-before: always;"></div>
<div class="list-item">
2.3 vzniknuté organizačné problémy súvisiace s plnením dohody rieši spolu s garantom odbornej praxe,
</div>
<div class="list-item">
2.4 po ukončení odbornej praxe vydá študentovi „Výkaz o vykonanej odbornej praxi", ktorý obsahuje popis
vykonávaných činností a stručné hodnotenie študenta a je jedným z predpokladov úspešného ukončenia predmetu
Odborná prax,
</div>
<div class="list-item">
2.5 umožní garantovi odbornej praxe a garantovi študijného predmetu kontrolu študentom plnených úloh.
</div>
<div class="section-header">3. Študent FPVaI UKF v Nitre:</div>
<div class="list-item">
3.1 osobne zabezpečí podpísanie tejto dohody o odbornej praxi študenta,
</div>
<div class="list-item">
3.2 zodpovedne vykonáva činnosti pridelené tútorom odbornej praxe,
</div>
<div class="list-item">
3.3 zabezpečí doručenie dokladu „Výkaz o vykonanej odbornej praxi" najneskôr v termínoch predpísaných
garantom pre daný semester,
</div>
<div class="list-item">
3.4 okamžite, bez zbytočného odkladu informuje garanta odbornej praxe o problémoch, ktoré bránia plneniu
odbornej praxe.
</div>
<div class="roman-numeral">III. Všeobecné a záverečné ustanovenia</div>
<div class="list-item">
1. Dohoda sa uzatvára na dobu určitú. Dohoda nadobúda platnosť a účinnosť dňom podpísania obidvomi zmluvnými
stranami. Obsah dohody sa môže meniť písomne len po súhlase jej zmluvných strán.
</div>
<div class="list-item">
2. Diela vytvorené študentom sa spravujú režimom zamestnaneckého diela podľa § 90 zákona č. 185/2015 Z. z.
(Autorský zákon). V prípade, že sa dielo stane školským dielom podľa § 93 citovaného zákona, Fakulta
prírodných vied a informatiky Univerzity Konštantína Filozofa v Nitre týmto udeľuje Poskytovateľovi odbornej
praxe výhradnú, časovo a teritoriálne neobmedzenú, bezodplatnú licenciu na akékoľvek použitie alebo
sublicenciu diel vytvorených študentom počas trvania odbornej praxe.
</div>
<div class="list-item">
3. Dohoda sa uzatvára v 3 vyhotoveniach, každá zmluvná strana obdrží jedno vyhotovenie dohody.
</div>
<div id="signature-section" style="margin-top: 40px;">
<div>V Nitre, dňa <em>{{ \Carbon\Carbon::parse(now())->format('d.m.Y') }}</em>.</div>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 60px;">
<div class="signature-block">
<div class="signature-line">
prof. RNDr. František Petrovič, PhD., MBA<br>
dekan FPVaI UKF v Nitre
</div>
</div>
<br>
<br>
<br>
<div class="signature-block">
<div class="signature-line">
{{ $companyContact->name }}<br>
štatutárny zástupca pracoviska odb. praxe
</div>
</div>
</div>
<div style="text-align: center; margin-top: 60px;">
<div class="signature-line" style="display: inline-block;">
{{ $student->name }}
</div>
</div>
</body>
</html>

View File

@@ -1,6 +1,12 @@
<?php
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\CompanyController;
use App\Http\Controllers\ExternalApiController;
use App\Http\Controllers\InternshipController;
use App\Http\Controllers\StudentDataController;
use App\Http\Controllers\InternshipStatusDataController;
use App\Http\Middleware\AdministratorOnly;
use App\Models\Company;
use App\Models\StudentData;
use Illuminate\Http\Request;
@@ -18,6 +24,61 @@ Route::middleware(['auth:sanctum'])->get('/user', function (Request $request) {
return $user;
});
Route::prefix('/account')->group(function () {
Route::post("/activate", [RegisteredUserController::class, 'activate']);
Route::post('/change-password', [RegisteredUserController::class, 'change_password']);
});
Route::middleware(['auth:sanctum'])->prefix('/students')->group(function () {
Route::get('/', [StudentDataController::class, 'all']);
Route::get('/{id}', [StudentDataController::class, 'get']);
Route::post('/{id}', [StudentDataController::class, 'update_all']);
Route::delete('/{id}', [StudentDataController::class, 'delete']);
});
Route::post('/password-reset', [RegisteredUserController::class, 'reset_password'])
->middleware(['guest', 'throttle:6,1'])
->name('password.reset');
Route::prefix('/internships')->group(function () {
Route::get("/", [InternshipController::class, 'all'])->middleware(['auth:sanctum'])->name("api.internships");
Route::get("/export", [InternshipController::class, 'export'])->middleware(AdministratorOnly::class)->name("api.internships.export");
Route::prefix('/{id}')->middleware("auth:sanctum")->group(function () {
Route::get("/", [InternshipController::class, 'get'])->name("api.internships.get");
Route::delete("/", [InternshipController::class, 'destroy'])->middleware(AdministratorOnly::class)->name("api.internships.delete");
Route::put("/status", [InternshipStatusDataController::class, 'update'])->name("api.internships.status.update");
Route::get("/statuses", [InternshipStatusDataController::class, 'get'])->name("api.internships.get");
Route::get("/next-statuses", [InternshipStatusDataController::class, 'get_next_states'])->name("api.internships.status.next.get");
Route::get("/default-proof", [InternshipController::class, 'get_default_proof'])->name("api.internships.proof.default.get");
Route::get("/proof", [InternshipController::class, 'get_proof'])->name("api.internships.proof.get");
Route::get("/report", [InternshipController::class, 'get_report'])->name("api.internships.report.get");
Route::post("/documents", [InternshipController::class, 'update_documents'])->name("api.internships.documents.set");
Route::post("/basic", [InternshipController::class, 'update_basic'])->name("api.internships.update.basic");
});
Route::put("/new", [InternshipController::class, 'store'])->name("api.internships.create");
});
Route::prefix('/companies')->middleware("auth:sanctum")->group(function () {
Route::get("/simple", [CompanyController::class, 'all_simple']);
Route::get("/{id}", [CompanyController::class, 'get']);
Route::post("/{id}", [CompanyController::class, 'update_all']);
Route::delete("/{id}", [CompanyController::class, 'delete']);
});
Route::prefix('/external')->group(function () {
Route::prefix('/keys')->middleware(['auth:sanctum', AdministratorOnly::class])->group(function () {
Route::get("/", [ExternalApiController::class, 'all_keys'])->name("api.external.keys.create");
Route::put("/", [ExternalApiController::class, 'create_key'])->name("api.external.keys.list");
Route::delete("/{id}", [ExternalApiController::class, 'destroy_key'])->name("api.external.keys.delete");
});
Route::prefix('/internships')->group(function () {
Route::prefix('/{id}')->middleware("auth:sanctum")->group(function () {
Route::put("/status", [ExternalApiController::class, 'update_internship_status'])->name("api.external.internships.status.update");
});
});
});

5
frontend/.gitignore vendored
View File

@@ -22,3 +22,8 @@ logs
.env
.env.*
!.env.example
# Cypress
cypress/screenshots
cypress/videos
cypress/downloads

View File

@@ -0,0 +1,21 @@
<template>
<div>
<v-alert density="compact" :text="error" :title="title || 'Chyba'" type="error" class="mb-2 mt-2"></v-alert>
</div>
</template>
<script lang="ts">
export default {
props: {
title: {
type: String,
required: false,
default: "Chyba"
},
error: {
type: String,
required: true,
}
}
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<div>
<v-alert density="compact" :text="text" :title="title" type="info" class="mb-2 mt-2"></v-alert>
</div>
</template>
<script lang="ts">
export default {
props: {
title: {
type: String,
required: true,
},
text: {
type: String,
required: true,
}
}
}
</script>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { FetchError } from 'ofetch';
const props = defineProps<{
internship_id: number,
block?: boolean,
}>();
const client = useSanctumClient();
const loading = ref(false);
async function requestDownload() {
loading.value = true;
try {
const proof = await client<Blob>(`/api/internships/${props.internship_id}/default-proof`);
triggerDownload(proof, `default-proof-${props.internship_id}`, 'pdf');
} catch (e) {
if (e instanceof FetchError) {
alert(`Nepodarilo sa vygenerovať zmluvu: ${e.statusMessage}`);
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2 mb-2" :disabled="loading"
:block="block ?? undefined" @click="requestDownload">
<span v-show="!loading">Stiahnuť originálnu zmluvu</span>
<span v-show="loading">Prosím čakajte...</span>
</v-btn>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import type { Internship } from '~/types/internships';
import { FetchError } from 'ofetch';
import type { User } from '~/types/user';
import { Role } from '~/types/role';
const props = defineProps<{
internship: Internship
}>();
const emit = defineEmits(['successfulSubmit']);
const rules = {
isPdf: (v: File | null) =>
!v || v.type === "application/pdf" || 'Povolený je iba PDF súbor.',
maxSize: (v: File | null) =>
!v || v.size <= (10 * 1024 * 1024 /* 10 MB */) || 'Maximálna veľkosť súboru je 10 MB.',
};
const loading = ref(false);
const error = ref<string | null>(null);
const proof = ref<File | null>(null);
const report = ref<File | null>(null);
const report_confirmed = ref(props.internship.report_confirmed);
const client = useSanctumClient();
const user = useSanctumUser<User>();
function triggerDownload(file: Blob, file_name: string) {
const url = window.URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = `${file_name}.pdf`;
link.target = "_blank";
link.click();
window.URL.revokeObjectURL(url);
}
async function downloadProof() {
const proof: Blob = await client(`/api/internships/${props.internship.id}/proof`);
triggerDownload(proof, `proof-${props.internship.id}`);
}
async function downloadReport() {
const report: Blob = await client(`/api/internships/${props.internship.id}/report`);
triggerDownload(report, `report-${props.internship.id}`);
}
async function onSubmit() {
error.value = null;
loading.value = true;
const formData = new FormData();
formData.append('report_confirmed', report_confirmed.value ? '1' : '0');
if (proof.value) {
formData.append('proof', proof.value);
}
if (report.value) {
formData.append('report', report.value);
}
try {
await client(`/api/internships/${props.internship.id}/documents`, {
method: 'POST',
body: formData
});
proof.value = null;
report.value = null;
emit('successfulSubmit');
} catch (e) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<!-- Chybová hláška -->
<ErrorAlert v-if="error" :error="error" />
<v-form @submit.prevent="onSubmit" :disabled="loading">
<div>
<h4 class="mb-2">Dokument o vykonaní praxe</h4>
<p>Zmluva/dohoda o brigádnickej praxi alebo 3 faktúry v pre živnostníkov.</p>
<InternshipAgreementDownloader :internship_id="internship.id" />
<WarningAlert v-if="props.internship.proof" title="Existujúci dokument"
text="V systéme je už nahratý dokument. Ak chcete nahradiť existujúcu verziu, vyberte súbor, alebo v opačnom prípade nechajte toto pole nevyplnené.">
<br />
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2" @click="downloadProof">
Stiahnuť
</v-btn>
</WarningAlert>
<v-file-input v-model="proof" :rules="[rules.isPdf, rules.maxSize]" accept=".pdf,application/pdf"
prepend-icon="mdi-handshake" label="Nahrať PDF dokument" variant="outlined" show-size clearable
hint="Povolené: PDF, max 10 MB" persistent-hint />
</div>
<br />
<div>
<h4 class="mb-2">Výkaz</h4>
<p>Dokument o hodnotení praxe.</p>
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2 mb-2" target="_blank"
href="https://www.fpvai.ukf.sk/images/Organizacia_studia/odborna_prax/aplikovana_informatika/Priloha_Vykaz_o_vykonanej_odbornej_praxi-AI.docx">
<span>Stiahnuť šablónu na výkaz</span>
</v-btn>
<WarningAlert v-if="props.internship.report" title="Existujúci dokument"
text="V systéme je už nahratý výkaz. Ak chcete nahradiť existujúcu verziu, vyberte súbor, alebo v opačnom prípade nechajte toto pole nevyplnené.">
<br />
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2" @click="downloadReport">
Stiahnuť
</v-btn>
</WarningAlert>
<v-file-input v-model="report" :rules="[rules.isPdf, rules.maxSize]" accept=".pdf,application/pdf"
prepend-icon="mdi-chart-box-outline" label="Nahrať PDF výkaz" variant="outlined" show-size clearable
hint="Povolené: PDF, max 10 MB" persistent-hint />
<v-checkbox v-if="user?.role === Role.EMPLOYER"
:disabled="!props.internship.proof || !props.internship.report" v-model="report_confirmed"
label="Výkaz je správny"></v-checkbox>
</div>
<br />
<v-btn type="submit" color="success" size="large" block
:disabled="!proof && !report && (!props.internship.proof || !props.internship.report)">
Uloziť
</v-btn>
</v-form>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { Internship } from '~/types/internships';
const props = defineProps<{
internship: Internship
}>();
const client = useSanctumClient();
async function downloadAgreement() {
const proof: Blob = await client(`/api/internships/${props.internship.id}/proof`);
triggerDownload(proof, `proof-${props.internship.id}`, 'pdf');
}
async function downloadReport() {
const report: Blob = await client(`/api/internships/${props.internship.id}/report`);
triggerDownload(report, `report-${props.internship.id}`, 'pdf');
}
</script>
<template>
<div>
<v-row class="d-flex">
<!-- Podpísaný dokument k praxe -->
<v-col cols="12" md="6">
<v-card variant="outlined" class="h-100">
<v-card-title class="d-flex align-center ga-2">
<v-icon icon="mdi mdi-file-document-outline" />
Dokument o vykonaní praxe
</v-card-title>
<v-card-text>
<InternshipAgreementDownloader :internship_id="internship.id" block />
<WarningAlert v-if="!props.internship.proof" title="Neodovzdané"
text="Dokument zatiaľ nebol nahratý." />
<div v-else>
<SuccessAlert title="Odovzdané" text="Zmluva bola nahratá." />
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2" block
@click="downloadAgreement">
Stiahnuť
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Výkaz -->
<v-col cols="12" md="6">
<v-card variant="outlined" class="h-100">
<v-card-title class="d-flex align-center ga-2">
<v-icon icon="mdi-file-clock-outline" />
Výkaz
</v-card-title>
<v-card-text>
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2" block target="_blank"
href="https://www.fpvai.ukf.sk/images/Organizacia_studia/odborna_prax/aplikovana_informatika/Priloha_Vykaz_o_vykonanej_odbornej_praxi-AI.docx">
<span>Stiahnuť šablónu na výkaz</span>
</v-btn>
<WarningAlert v-if="!props.internship.report" title="Neodovzdané"
text="Výkaz zatiaľ nebol nahratý." />
<div v-else>
<ErrorAlert v-if="!props.internship.report_confirmed" title="Nepotvrdené"
error="Výkaz bol nahratý, ale zatiaľ nebol potvrdený firmou." />
<SuccessAlert v-else title="Potvrdené" text="Výkaz bol nahratý, aj potvrdený firmou." />
<v-btn prepend-icon="mdi-download" color="blue" class="mr-2 mt-2" block
@click="downloadReport">
Stiahnuť
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import type { CompanyData } from '~/types/company_data';
import { convertDate, type Internship, type NewInternship } from '~/types/internships';
import type { User } from '~/types/user';
const rules = {
required: (v: any) => (!!v && String(v).trim().length > 0) || 'Povinné pole',
mustAgree: (v: boolean) => v === true || 'Je potrebné potvrdiť',
};
const year_of_study_choices = [
{
title: '1',
subtitle: 'bakalárske',
},
{
title: '2',
subtitle: 'bakalárske',
},
{
title: '3',
subtitle: 'bakalárske',
},
{
title: '4',
subtitle: 'magisterské',
},
{
title: '5',
subtitle: 'magisterské',
}
];
const semester_choices = [
{
title: "Zimný",
value: "WINTER"
},
{
title: "Letný",
value: "SUMMER"
}
];
const props = defineProps<{
internship?: Internship,
submit: (new_internship: NewInternship) => void,
}>();
const isValid = ref(false);
const form = ref({
start: props.internship?.start ? convertDate(props.internship.start) : null,
end: props.internship?.end ? convertDate(props.internship.end) : null,
year_of_study: props.internship?.year_of_study || 2025,
semester: props.internship?.semester || "WINTER",
company_id: props.internship?.company?.id == undefined ? null : props.internship.company.id,
description: props.internship?.position_description || "",
consent: false,
});
const user = useSanctumUser<User>();
function dateTimeFixup(datetime: Date) {
const year = datetime.getFullYear()
const month = String(datetime.getMonth() + 1).padStart(2, '0')
const day = String(datetime.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`;
}
function triggerSubmit() {
const new_internship: NewInternship = {
user_id: user.value?.id!,
company_id: form.value.company_id!,
start: dateTimeFixup(form.value.start!),
end: dateTimeFixup(form.value.end!),
year_of_study: form.value.year_of_study,
semester: form.value.semester,
position_description: form.value.description
};
props.submit(new_internship);
}
function companyListProps(company: CompanyData) {
return {
title: company.name,
subtitle: `IČO: ${company.ico}, Zodpovedný: ${company.contact.name}, ${!company.hiring ? "ne" : ""}prijímajú nových študentov`
};
}
function yearOfStudyValueHandler(item: { title: string, subtitle: string }) {
return parseInt(item.title) || 0;
}
const { data, pending, error } = await useLazySanctumFetch<CompanyData[]>('/api/companies/simple');
</script>
<template>
<v-form v-model="isValid" @submit.prevent="triggerSubmit">
<v-date-input v-model="form.start" :rules="[rules.required]" clearable label="Začiatok"></v-date-input>
<v-date-input v-model="form.end" :rules="[rules.required]" clearable label="Koniec"></v-date-input>
<v-select v-model="form.year_of_study" clearable label="Ročník" :items="year_of_study_choices"
:item-props="(item) => { return { title: item.title, subtitle: item.subtitle } }"
:item-value="yearOfStudyValueHandler" :rules="[rules.required]"></v-select>
<v-select v-model="form.semester" clearable label="Semester" :items="semester_choices"
:rules="[rules.required]"></v-select>
<!-- Výber firmy -->
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error.message" />
<v-select v-else v-model="form.company_id" clearable label="Firma" :items="data" :item-props="companyListProps"
item-value="id" :rules="[rules.required]"></v-select>
<v-textarea v-model="form.description" clearable label="Popis práce" :rules="[rules.required]"></v-textarea>
<v-checkbox v-model="form.consent" :rules="[rules.mustAgree]" label="Potvrdzujem, že zadané údaje pravdivé"
density="comfortable" />
<v-btn type="submit" color="success" size="large" block :disabled="!isValid || !form.consent">
Uloziť
</v-btn>
</v-form>
</template>
<style scoped>
form {
width: 80%;
margin: 0 auto;
}
.alert {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import type { Internship, InternshipFilter } from '~/types/internships';
import type { Paginated } from '~/types/pagination';
import { prettyInternshipStatus } from '~/types/internship_status';
import { FetchError } from 'ofetch';
const props = defineProps<{
mode: 'admin' | 'company' | 'student';
}>();
const emit = defineEmits<{
filterApplied: [value: InternshipFilter],
itemsAvailable: [value: boolean]
}>();
const filters = ref<InternshipFilter>({
year: null,
company: null,
study_programe: null,
student: null,
});
const page = ref(1);
const itemsPerPage = ref(15);
const totalItems = ref(0);
const deleteConfirmDialog = ref(false);
const internshipToDelete = ref<Internship | null>(null);
const rules = {
minFilterLen: (v: string) => (v.length >= 3) || 'Min. 3 znaky',
minYear: (v: number | null) => (v === null ? true : v >= 1000) || 'Min. 4-ciferné číslo'
};
const allHeaders = [
{ title: "Študent", key: "student.name", sortable: false },
{ title: "Firma", key: "company.name", sortable: false },
{ title: "Od", key: "start", sortable: false },
{ title: "Do", key: "end", sortable: false },
{ title: "Semester", key: "semester", sortable: false },
{ title: "Ročník", key: "year_of_study", sortable: false },
{ title: "Študijný odbor", key: "student.student_data.study_field", sortable: false },
{ title: "Stav", key: "status", sortable: false },
{ title: "Operácie", key: "operations", sortable: false }
];
const headers = props.mode === 'company'
? allHeaders.filter(header => header.key !== "company.name")
: allHeaders;
const client = useSanctumClient();
const { data, error, pending, refresh } = await useLazySanctumFetch<Paginated<Internship>>('/api/internships', () => ({
params: {
...filters.value,
page: page.value,
per_page: itemsPerPage.value,
}
}), {
watch: [filters.value, page, itemsPerPage]
});
watch(data, (newData) => {
totalItems.value = newData?.total ?? 0;
emit('itemsAvailable', totalItems.value > 0);
});
watch(filters, async () => {
page.value = 1;
emit('filterApplied', filters.value);
}, { deep: true });
async function delteInternship(internship: Internship) {
pending.value = true;
try {
await client(`/api/internships/${internship.id}`, {
method: 'DELETE',
});
await refresh();
} catch (e) {
if (e instanceof FetchError) {
alert(`Chyba pri mazaní stáže: ${e.statusMessage ?? e.message}`);
}
} finally {
pending.value = false;
}
}
function openDeleteDialog(internship: Internship) {
internshipToDelete.value = internship;
deleteConfirmDialog.value = true;
}
async function confirmDeletion(confirm: boolean) {
if (confirm && internshipToDelete.value) {
await delteInternship(internshipToDelete.value);
}
deleteConfirmDialog.value = false;
internshipToDelete.value = null;
}
</script>
<template>
<div>
<ErrorAlert v-if="error" :error="error.statusMessage ?? error.message" />
<v-row>
<v-col cols="12" md="3">
<v-text-field v-model="filters.year" label="Rok" type="number" clearable density="compact"
:rules="[rules.minYear]" />
</v-col>
<v-col cols="12" md="3" v-if="mode !== 'company'">
<v-text-field v-model="filters.company" label="Názov firmy" clearable density="compact"
:rules="[rules.minFilterLen]" />
</v-col>
<v-col cols="12" md="3" v-if="mode !== 'student'">
<v-text-field v-model="filters.study_programe" label="Študijný program" clearable density="compact"
:rules="[rules.minFilterLen]" />
</v-col>
<v-col cols="12" md="3" v-if="mode !== 'student'">
<v-text-field v-model="filters.student" label="Študent" clearable density="compact"
:rules="[rules.minFilterLen]" />
</v-col>
</v-row>
<v-data-table-server v-model:items-per-page="itemsPerPage" v-model:page="page" :headers="headers"
:items="data?.data" :items-length="totalItems" :loading="pending">
<template #item.status="{ item }">
{{ prettyInternshipStatus(item.status.status) }}
</template>
<template #item.semester="{ item }">
{{ item.semester === "WINTER" ? "Zimný" : "Letný" }}
</template>
<template #item.operations="{ item }">
<v-tooltip text="Editovať">
<template #activator="{ props }">
<v-btn icon="mdi-pencil" size="small" variant="text"
:to="`/dashboard/${mode}/internships/edit/${item.id}`" class="internship-edit-btn" />
</template>
</v-tooltip>
<v-tooltip text="Vymazať" v-if="mode === 'admin'">
<template #activator="{ props }">
<v-btn icon="mdi-delete" size="small" variant="text" color="error"
@click="() => openDeleteDialog(item)" class="internship-delete-btn" />
</template>
</v-tooltip>
</template>
</v-data-table-server>
<!-- Delete confirm dialog -->
<v-dialog v-model="deleteConfirmDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Potvrdiť vymazanie praxe
</v-card-title>
<v-card-text>
<p>Ste si istý, že chcete vymazať tento záznam?</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="red" variant="text" @click="async () => await confirmDeletion(true)"
:loading="pending">
Áno
</v-btn>
<v-btn color="black" variant="text" @click="async () => await confirmDeletion(false)">
Zrusiť
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<style scoped>
:deep(.v-data-table-header__content) {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { Internship } from '~/types/internships';
import { InternshipStatus, prettyInternshipStatus, type NewInternshipStatusData } from '~/types/internship_status';
import type { User } from '~/types/user';
import { FetchError } from 'ofetch';
const props = defineProps<{
internship: Internship
}>();
const emit = defineEmits(['successfulSubmit']);
const user = useSanctumUser<User>();
const rules = {
required: (v: any) => (!!v && String(v).trim().length > 0) || 'Povinné pole',
};
const isValid = ref(false);
const new_state = ref(null as InternshipStatus | null);
const note = ref("");
const loading = ref(false);
const save_error = ref(null as null | string);
const client = useSanctumClient();
const { data, error: load_error, refresh } = await useLazySanctumFetch(`/api/internships/${props.internship.id}/next-statuses`, undefined, {
transform: (statuses: InternshipStatus[]) => statuses.map((state) => ({
title: prettyInternshipStatus(state),
value: state
}))
});
async function submit() {
save_error.value = null;
loading.value = true;
const new_status: NewInternshipStatusData = {
status: new_state.value!,
note: note.value,
};
try {
await client(`/api/internships/${props.internship.id}/status`, {
method: 'PUT',
body: new_status
});
new_state.value = null;
note.value = "";
await refresh();
emit('successfulSubmit');
} catch (e) {
if (e instanceof FetchError) {
save_error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<!-- Chybová hláška -->
<ErrorAlert v-if="save_error" :error="`Nepodarilo uložiť: ${save_error}`" />
<!-- Chybová hláška -->
<ErrorAlert v-if="load_error" :error="`Nepodarilo sa načítať stavy: ${load_error}`" />
<!-- Chybová hláška -->
<WarningAlert v-else-if="data?.length === 0" title="Blokované"
text="Stav praxe už nie je možné meniť, pretože bola (ne)obhájená alebo zamietnutá. V prípade, že ste prax zamietli omylom, alebo ak máte technické problémy, prosíme kontaktovať garanta praxe." />
<v-form v-else v-model="isValid" @submit.prevent="submit" :disabled="loading">
<v-select v-model="new_state" label="Stav" :items="data" item-value="value"></v-select>
<v-text-field v-model="note" :rules="[rules.required]" label="Poznámka"></v-text-field>
<v-btn type="submit" color="success" size="large" block :disabled="!isValid">
Uloziť
</v-btn>
</v-form>
</div>
</template>
<style scoped>
.alert {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { prettyInternshipStatus, type InternshipStatusData } from '~/types/internship_status';
import type { Internship } from '~/types/internships';
const props = defineProps<{
internship: Internship
}>();
const headers = [
{ title: 'Stav', key: 'status', align: 'left' },
{ title: 'Zmenené', key: 'changed', align: 'left' },
{ title: 'Poznámka', key: 'start', align: 'left' },
{ title: 'Zmenu vykonal', key: 'modified_by', align: 'left' },
];
const { data, error, pending, refresh } = await useLazySanctumFetch<InternshipStatusData[]>(`/api/internships/${props.internship.id}/statuses`);
watch(() => props.internship, async () => {
await refresh();
});
</script>
<template>
<div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-if="error" :error="error?.message" />
<v-table v-else>
<thead>
<tr>
<th v-for="header in headers" :class="'text-' + header.align">
<strong>{{ header.title }}</strong>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data">
<td>{{ prettyInternshipStatus(item.status) }}</td>
<td>{{ item.changed }}</td>
<td>{{ item.note }}</td>
<td>{{ item.modified_by.name }}</td>
</tr>
</tbody>
</v-table>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div>
<v-alert density="compact" text="Prosím čakajte..." title="Spracovávam" type="info" class="mb-2 mt-2"></v-alert>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<div>
<v-alert density="compact" :text="text" :title="title" type="success" class="mb-2 mt-2"></v-alert>
</div>
</template>
<script lang="ts">
export default {
props: {
title: {
type: String,
required: true,
},
text: {
type: String,
required: true,
}
}
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div>
<v-alert density="compact" :text="text" :title="title" type="warning" class="mb-2 mt-2">
<slot />
</v-alert>
</div>
</template>
<script lang="ts">
export default {
props: {
title: {
type: String,
required: false,
default: "Upozornenie"
},
text: {
type: String,
required: true,
}
}
}
</script>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { FetchError } from 'ofetch';
const route = useRoute();
const client = useSanctumClient();
definePageMeta({
middleware: ['sanctum:guest'],
});
useSeoMeta({
title: "Aktivácia účtu | ISOP",
ogTitle: "Aktivácia účtu",
description: "Aktivácia účtu ISOP",
ogDescription: "Aktivácia účtu",
});
const rules = {
required: (v: string) => (!!v && v.trim().length > 0) || 'Povinné pole',
};
const isValid = ref(false);
const showPassword = ref(false);
const password = ref('');
const loading = ref(false);
const error = ref(null as null | string);
const success = ref(false);
async function handleLogin() {
error.value = null;
loading.value = true;
success.value = false;
try {
await client('/api/account/activate', {
method: 'POST',
body: {
token: route.params.token,
password: password.value
}
});
success.value = true;
} catch (e) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<v-container fluid class="page-container form-wrap">
<v-card id="page-container-card">
<h2 class="page-title">Aktivácia účtu</h2>
<SuccessAlert v-show="success" title="Aktivácia ukončená" text="">
<p>Váš účet bol úspešne aktivovaný! Prihláste sa <NuxtLink to="/login">tu</NuxtLink>.
</p>
</SuccessAlert>
<div v-show="!success">
<!-- Chybová hláška -->
<ErrorAlert v-if="error" :error="error" />
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<v-form v-else v-model="isValid" @submit.prevent="handleLogin">
<v-text-field v-model="password" :rules="[rules.required]"
:type="showPassword ? 'text' : 'password'" label="Heslo:" variant="outlined"
density="comfortable"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
@click:append-inner="showPassword = !showPassword" />
<v-btn type="submit" color="success" size="large" block :disabled="!isValid">
Aktivovať
</v-btn>
</v-form>
</div>
</v-card>
</v-container>
</template>
<style scoped>
.alert {
margin-bottom: 10px;
}
.page-container {
max-width: 1120px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
#page-container-card {
padding: 10px;
}
.form-wrap {
max-width: 640px;
}
.page-title {
font-size: 24px;
line-height: 1.2;
font-weight: 700;
margin: 24px 0 16px;
color: #1f1f1f;
}
.actions-row {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.forgot-btn {
text-transform: none;
font-weight: 600;
border-radius: 999px;
}
.mb-1 {
margin-bottom: 6px;
}
.mb-2 {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth'],
});
useSeoMeta({
title: "Zmena hesla | ISOP",
ogTitle: "Zmena hesla",
description: "Zmena hesla študenta",
ogDescription: "Zmena hesla študenta",
});
const client = useSanctumClient();
const password = ref('');
const password_confirmation = ref('');
const loading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
// Funkcia na zmenu hesla
const changePassword = async () => {
error.value = null;
loading.value = true;
try {
await client('/api/account/change-password', {
method: 'POST',
body: {
password: password.value,
}
});
success.value = true;
// Vyčisti formulár
password.value = '';
password_confirmation.value = '';
} catch (e) {
if (e instanceof FetchError) {
error.value = e.data?.message;
}
} finally {
loading.value = false;
}
};
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Zmena hesla</h1>
<!-- Error alert -->
<ErrorAlert v-if="error" :error="error" />
<!-- Success alert -->
<SuccessAlert v-else-if="success" title="Heslo aktualizované" text="Heslo bolo úspešne zmenené." />
<v-form v-else :disabled="loading" @submit.prevent="changePassword">
<!-- Nové heslo -->
<v-text-field v-model="password" label="Nové heslo" type="password" variant="outlined" class="mb-3"
hint="Minimálne 8 znakov" persistent-hint></v-text-field>
<!-- Potvrdenie hesla -->
<v-text-field v-model="password_confirmation" label="Potvrďte nové heslo" type="password"
variant="outlined" class="mb-3"></v-text-field>
<!-- Submit button -->
<v-btn type="submit" color="primary" :disabled="password !== password_confirmation" class="mb-2">
Zmeniť heslo
</v-btn>
</v-form>
<!-- Zrušiť -->
<v-btn type="submit" color="yellow" to="/dashboard" class="mb-2">
Späť na dashboard
</v-btn>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth'],
});
useSeoMeta({
title: 'Môj profil | ISOP',
ogTitle: 'Môj profil',
description: 'Môj profil ISOP',
ogDescription: 'Môj profil ISOP',
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Môj profil</h1>
<v-divider class="my-4" />
<!-- Osobné údaje -->
<h3>Osobné údaje</h3>
<v-list density="compact" class="readonly-list">
<v-list-item>
<v-list-item-title>Meno</v-list-item-title>
<v-list-item-subtitle>{{ user?.first_name }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Priezvisko</v-list-item-title>
<v-list-item-subtitle>{{ user?.last_name }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<!-- Údaje študenta -->
<div v-if="user?.student_data">
<h3>Študentské údaje</h3>
<v-list density="compact" class="readonly-list">
<v-list-item>
<v-list-item-title>Osobný e-mail</v-list-item-title>
<v-list-item-subtitle>{{ user?.student_data.personal_email }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Študijný odbor</v-list-item-title>
<v-list-item-subtitle>{{ user?.student_data.study_field }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Adresa</v-list-item-title>
<v-list-item-subtitle>{{ user?.student_data.address }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</div>
<!-- Údaje firmy -->
<div v-if="user?.company_data">
<h3>Firemné údaje</h3>
<v-list density="compact" class="readonly-list">
<v-list-item>
<v-list-item-title>Názov</v-list-item-title>
<v-list-item-subtitle>{{ user?.company_data.name }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Adresa</v-list-item-title>
<v-list-item-subtitle>{{ user?.company_data.address }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>IČO</v-list-item-title>
<v-list-item-subtitle>{{ user?.company_data.ico }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</div>
<v-list density="compact" class="readonly-list">
<v-list-item>
<v-btn prepend-icon="mdi mdi-pencil" color="orange" to="/account/change-password">
Zmeniť heslo
</v-btn>
</v-list-item>
</v-list>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
.readonly-list {
--v-list-padding-start: 0px;
}
.readonly-list :deep(.v-list-item) {
--v-list-item-padding-start: 0px;
padding-left: 0 !important;
}
.readonly-list :deep(.v-list-item__content) {
padding-left: 0 !important;
}
.readonly-list :deep(.v-list-item-subtitle) {
white-space: pre-line;
}
.readonly-list :deep(.v-list-item-title) {
font-weight: 600;
}
</style>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "Portál administrátora | ISOP",
ogTitle: "Portál administrátora",
description: "Portál administrátora ISOP",
ogDescription: "Portál administrátora",
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="footer-card">
<h1>Vitajte, {{ user?.name }}</h1>
</v-card>
</v-container>
</template>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { CompanyData } from '~/types/company_data';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only']
})
const route = useRoute();
const client = useSanctumClient();
const companyId = route.params.id;
const loading = ref(true);
const saving = ref(false);
// Delete state
const company = ref<CompanyData | null>(null);
const form = ref({
name: '',
address: '',
ico: 0,
hiring: false,
contact: {
first_name: '',
last_name: '',
email: '',
phone: ''
}
});
// Načítanie dát firmy
const { data } = await useLazySanctumFetch<CompanyData>(`/api/companies/${companyId}`);
watch(data, (newData) => {
if (newData) {
company.value = newData;
form.value.name = newData.name;
form.value.address = newData.address;
form.value.ico = newData.ico;
form.value.hiring = !!newData.hiring;
form.value.contact.first_name = newData.contact?.first_name;
form.value.contact.last_name = newData.contact?.last_name;
form.value.contact.email = newData.contact?.email;
form.value.contact.phone = newData.contact?.phone;
loading.value = false;
}
}, { immediate: true });
// Uloženie zmien
async function saveChanges() {
saving.value = true;
try {
await client(`/api/companies/${companyId}`, {
method: 'POST',
body: form.value
});
alert('Údaje firmy boli úspešne aktualizované');
navigateTo("/dashboard/admin/companies");
} catch (e) {
if (e instanceof FetchError) {
console.error('Error saving company:', e.response?._data.message);
alert('Chyba:\n' + e.response?._data.message);
}
} finally {
saving.value = false;
}
}
function cancel() {
navigateTo('/dashboard/admin/companies');
}
</script>
<template>
<v-container class="h-100">
<div v-if="loading" class="text-center">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else>
<v-row class="mb-4">
<v-col>
<h1>Editovať firmu</h1>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="8">
<v-card>
<v-card-title>Údaje firmy</v-card-title>
<v-card-text>
<v-form>
<v-text-field v-model="form.name" label="Názov firmy" required variant="outlined"
class="mb-3"></v-text-field>
<v-textarea v-model="form.address" label="Adresa" required variant="outlined" rows="3"
class="mb-3"></v-textarea>
<v-text-field v-model.number="form.ico" label="IČO" type="number" required
variant="outlined" class="mb-3"></v-text-field>
<v-checkbox v-model="form.hiring" label="Prijíma študentov na prax"
class="mb-3"></v-checkbox>
<v-divider class="my-4"></v-divider>
<h3 class="mb-3">Kontaktná osoba</h3>
<v-text-field v-model="form.contact.first_name" label="Meno" required variant="outlined"
class="mb-3"></v-text-field>
<v-text-field v-model="form.contact.last_name" label="Priezvisko" required
variant="outlined" class="mb-3"></v-text-field>
<v-text-field v-model="form.contact.email" label="E-mail" type="email" required
variant="outlined" class="mb-3"></v-text-field>
<v-text-field v-model="form.contact.phone" label="Telefón"
variant="outlined"></v-text-field>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-4">
<v-btn color="primary" @click="saveChanges" :loading="saving" :disabled="saving">
Uložiť zmeny
</v-btn>
<v-btn @click="cancel" :disabled="saving">
Zrušiť
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
</v-container>
</template>
<style scoped>
.h-100 {
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { CompanyData } from '~/types/company_data';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "Partnerské firmy | ISOP",
ogTitle: "Partnerské firmy",
description: "Partnerské firmy ISOP",
ogDescription: "Partnerské firmy",
});
const headers = [
{ title: 'Názov', key: 'name', align: 'left' },
{ title: 'IČO', key: 'ico', align: 'left' },
{ title: 'Kontaktná osoba', key: 'contact_name', align: 'left' },
{ title: 'Telefón', key: 'phone', align: 'middle' },
{ title: 'E-mail', key: 'email', align: 'middle' },
{ title: 'Prijímajú študentov', key: 'hiring', align: 'middle' },
{ title: 'Operácie', key: 'ops', align: 'middle' },
];
const client = useSanctumClient();
const { data, error, pending, refresh } = await useLazySanctumFetch<CompanyData[]>('/api/companies/simple');
// State pre delete dialog
const deleteDialog = ref(false);
const companyToDelete = ref<CompanyData | null>(null);
const deleteLoading = ref(false);
const deleteError = ref<string | null>(null);
// Funkcia na otvorenie delete dialogu
const openDeleteDialog = (company: CompanyData) => {
companyToDelete.value = company;
deleteDialog.value = true;
deleteError.value = null;
};
// Funkcia na zatvorenie dialogu
const closeDeleteDialog = () => {
deleteDialog.value = false;
companyToDelete.value = null;
deleteError.value = null;
};
// Funkcia na vymazanie firmy
const deleteCompany = async () => {
if (!companyToDelete.value) return;
deleteLoading.value = true;
deleteError.value = null;
try {
await client(`/api/companies/${companyToDelete.value.id}`, {
method: 'DELETE'
});
refresh();
closeDeleteDialog();
} catch (err) {
if (err instanceof FetchError) {
deleteError.value = err.data?.message;
}
} finally {
deleteLoading.value = false;
}
};
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Partnerské firmy</h1>
<hr />
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error?.message" />
<div v-else>
<p>Aktuálne spolupracujeme s {{ data?.length }} firmami.</p>
<br />
<v-table>
<thead>
<tr>
<th v-for="header in headers" :class="'text-' + header.align">
<strong>{{ header.title }}</strong>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data">
<td>{{ item.name }}</td>
<td>{{ item.ico }}</td>
<td>{{ item.contact.name }}</td>
<td>{{ item.contact.phone }}</td>
<td>{{ item.contact.email }}</td>
<td>{{ item.hiring ? "Áno" : "Nie" }}</td>
<td class="text-left">
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-pencil" base-color="orange"
:to="'/dashboard/admin/companies/edit/' + item.id">Editovať</v-btn>
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-delete" base-color="red"
@click="openDeleteDialog(item)">Vymazať</v-btn>
</td>
</tr>
</tbody>
</v-table>
</div>
</v-card>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="deleteDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Potvrdiť vymazanie
</v-card-title>
<v-card-text>
<p>
Naozaj chcete vymazať firmu <strong>{{ companyToDelete?.name }}</strong>?
</p>
<p class="text-error mt-2">
Táto akcia vymaže aj kontaktnú osobu (EMPLOYER), všetky praxe a statusy spojené s touto firmou a
<strong>nie je možné ju vrátiť späť</strong>.
</p>
<!-- Error message -->
<ErrorAlert v-if="deleteError" :error="deleteError" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="closeDeleteDialog" :disabled="deleteLoading">
Zrušiť
</v-btn>
<v-btn color="red" variant="text" @click="deleteCompany" :loading="deleteLoading">
Vymazať
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
.op-btn {
margin: 10px;
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { FetchError } from 'ofetch';
import type { ApiKey, NewApiKey } from '~/types/api_keys';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "API Manažment | ISOP",
ogTitle: "API Manažment",
description: "API Manažment ISOP",
ogDescription: "API Manažment",
});
const headers = [
{ title: 'Názov', key: 'name', align: 'left' },
{ title: 'Vytvorené', key: 'created_at', align: 'left' },
{ title: 'Naposledy použité', key: 'last_used_at', align: 'left' },
{ title: 'Vlastník', key: 'owner', align: 'left' },
{ title: 'Operácie', key: 'ops', align: 'middle' },
];
const creationDialog = ref(false);
const keyDisplayDialog = ref(false);
const deletionConfirmDialog = ref<{ open: boolean, key: ApiKey | null }>({ open: false, key: null });
const newKey = ref<string>("");
const newKeyName = ref("");
const waiting = ref(false);
const { data, error, pending, refresh } = useLazySanctumFetch<ApiKey[]>('/api/external/keys');
const client = useSanctumClient();
function closeKeyDisplayDialog() {
keyDisplayDialog.value = false;
newKey.value = "";
}
async function copyNewKeyToClipboard() {
await navigator.clipboard.writeText(newKey.value);
}
function openDeletionConfirmDialog(key: ApiKey) {
deletionConfirmDialog.value = { open: true, key: key };
}
async function confirmDeletion(confirm: boolean) {
const key = deletionConfirmDialog.value.key!;
if (!confirm) {
deletionConfirmDialog.value = { open: false, key: null };
return;
};
await deleteKey(key);
deletionConfirmDialog.value = { open: false, key: null };
}
async function requestNewKey() {
waiting.value = true;
try {
const result = await client<NewApiKey>('/api/external/keys', {
method: 'PUT',
body: {
name: newKeyName.value
}
});
newKey.value = result.key;
await refresh();
creationDialog.value = false;
keyDisplayDialog.value = true;
} catch (e) {
if (e instanceof FetchError) {
alert(`Chyba: ${e.data?.message}`);
}
} finally {
waiting.value = false;
}
}
async function deleteKey(key: ApiKey) {
waiting.value = true;
try {
await client<NewApiKey>(`/api/external/keys/${key.id}`, {
method: 'DELETE',
});
await refresh();
deletionConfirmDialog.value = { open: false, key: null };
} catch (e) {
if (e instanceof FetchError) {
alert(`Chyba: ${e.data?.message}`);
}
} finally {
waiting.value = false;
}
}
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>API Manažment</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<v-btn prepend-icon="mdi-plus" color="blue" class="mr-2" @click="() => creationDialog = true"
:disabled="waiting">
Pridať
</v-btn>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error?.message" />
<v-table v-else>
<thead>
<tr>
<th v-for="header in headers" :class="'text-' + header.align">
<strong>{{ header.title }}</strong>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data">
<td>{{ item.name }}</td>
<td>{{ item.created_at }}</td>
<td>{{ item.last_used_at ?? "Nikdy" }}</td>
<td>{{ item.owner }}</td>
<td class="text-left">
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-delete" base-color="red"
@click="() => openDeletionConfirmDialog(item)">Vymazať</v-btn>
</td>
</tr>
</tbody>
</v-table>
<!-- spacer -->
<div style="height: 40px;"></div>
</v-card>
<!-- New API key dialog -->
<v-dialog v-model="creationDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Nový API kľúč
</v-card-title>
<v-card-text>
<v-text-field label="Názov kľúča" required v-model="newKeyName" id="newKeyName"></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="green" variant="text" @click="requestNewKey" :loading="waiting">
Vytvoriť
</v-btn>
<v-btn color="black" variant="text" @click="creationDialog = false">
Zavrieť
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- New API key display dialog -->
<v-dialog v-model="keyDisplayDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Nový API kľúč
</v-card-title>
<v-card-text>
<p>Nový klúč bol úspešne vytvorený!</p>
<p id="key-copy-warn">Nezabudnite skopírovať kľúč! Po zavretí tohto okna ho nebudete vedieť
zobraziť znovu!</p>
<v-text-field label="API kľúč" required readonly v-model="newKey"></v-text-field>
<v-btn prepend-icon="mdi-content-copy" color="blue" block
@click="copyNewKeyToClipboard">Copy</v-btn>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="black" variant="text" @click="closeKeyDisplayDialog">
Zavrieť
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Key deletion confirmation dialog -->
<v-dialog v-model="deletionConfirmDialog.open" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Vymazať API kľúč
</v-card-title>
<v-card-text>
<p>Ste si istý že chcete deaktivovať a vymazať vybraný API kľúč?</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="red" variant="text" @click="async () => { confirmDeletion(true) }" :loading="waiting">
Áno
</v-btn>
<v-btn color="black" variant="text" @click="async () => { confirmDeletion(false) }">
Zrušiť
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
.op-btn {
margin: 10px;
}
#key-copy-warn {
color: red;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "Portál administrátora | ISOP",
ogTitle: "Portál administrátora",
description: "Portál administrátora ISOP",
ogDescription: "Portál administrátora",
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Vitajte, {{ user?.name }}</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<v-btn prepend-icon="mdi-account-circle" color="blue" class="mr-2" to="/dashboard/admin/students">
Študenti
</v-btn>
<v-btn prepend-icon="mdi-domain" color="blue" class="mr-2" to="/dashboard/admin/companies">
Firmy
</v-btn>
<v-btn prepend-icon="mdi-account-hard-hat" color="blue" class="mr-2" to="/dashboard/admin/internships">
Praxe
</v-btn>
<v-btn prepend-icon="mdi-key" color="blue" class="mr-2" to="/dashboard/admin/external_api">
API Manažment
</v-btn>
<v-btn prepend-icon="mdi-pencil" color="orange" class="mr-2" to="/account">
Môj profil
</v-btn>
<!-- spacer -->
<div style="height: 40px;"></div>
<p>...</p>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { Internship, NewInternship } from '~/types/internships';
import { prettyInternshipStatus } from '~/types/internship_status';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "Edit praxe | ISOP",
ogTitle: "Edit praxe",
description: "Edit praxe ISOP",
ogDescription: "Edit praxe",
});
const route = useRoute();
const client = useSanctumClient();
const loading = ref(false);
const action_error = ref(null as null | string);
const refreshKey = ref(0);
const { data, error, pending, refresh } = await useLazySanctumFetch<Internship>(`/api/internships/${route.params.id}`);
async function handleUpdateOfBasicInfo(internship: NewInternship) {
action_error.value = null;
loading.value = true;
try {
await client(`/api/internships/${route.params.id}/basic`, {
method: 'POST',
body: internship
});
navigateTo("/dashboard/admin/internships");
} catch (e) {
if (e instanceof FetchError) {
action_error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
async function forceRefresh() {
await refresh();
refreshKey.value++;
}
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Edit praxe</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<!-- Chybová hláška -->
<ErrorAlert v-if="action_error" :error="action_error" />
<div v-else>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error?.message" />
<div v-else>
<div>
<h2>Základné informácie</h2>
<InternshipEditor :internship="data!" :submit="handleUpdateOfBasicInfo" />
<hr />
</div>
<div>
<h2>Stav</h2>
<h4>Aktuálny stav</h4>
<p>{{ prettyInternshipStatus(data?.status.status!) }}</p>
<p>Poznámka: <em>{{ data?.status.note }}</em></p>
<p>Posledná zmena: <em>{{ data?.status.changed }}, {{ data?.status.modified_by.name }}</em></p>
<br />
<h4>História</h4>
<InternshipStatusHistoryView :internship="data!" />
<br />
<h4>Zmena stavu</h4>
<InternshipStatusEditor :internship="data!" @successful-submit="forceRefresh" />
</div>
<hr />
<h2>Dokumenty</h2>
<InternshipDocumentViewer :internship="data!" />
</div>
</div>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
}
.alert {
margin-bottom: 10px;
}
hr {
margin-top: 20px;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import type { InternshipFilter } from '~/types/internships';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "Praxe študentov | ISOP",
ogTitle: "Praxe študentov",
description: "Praxe študentov ISOP",
ogDescription: "Praxe študentov",
});
const headers = [
{ title: 'Firma', key: 'company', align: 'left' },
{ title: 'Študent', key: 'student', align: 'left' },
{ title: 'Od', key: 'start', align: 'left' },
{ title: 'Do', key: 'end', align: 'left' },
{ title: 'Ročník', key: 'year_of_study', align: 'middle' },
{ title: 'Semester', key: 'semester', align: 'middle' },
{ title: 'Stav', key: 'status', align: 'middle' },
{ title: 'Operácie', key: 'ops', align: 'middle' },
];
const internshipFilters = ref<InternshipFilter | null>(null);
const exportPending = ref(false);
const exportAvailable = ref(true);
const client = useSanctumClient();
async function requestExport() {
exportPending.value = true;
try {
const file = await client<Blob>(`/api/internships/export`, {
method: 'GET',
query: internshipFilters.value ?? {},
});
triggerDownload(file, 'internships_export', 'csv');
} catch (e) {
if (e instanceof FetchError) {
alert(`Chyba pri exportovaní: ${e.statusMessage ?? e.message}`);
}
} finally {
exportPending.value = false;
}
}
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Praxe študentov</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<v-btn prepend-icon="mdi-file-export-outline" color="green" class="mr-2 mb-2" @click="requestExport"
:disabled="!exportAvailable" :loading="exportPending">
<v-tooltip activator="parent" location="top">
Exportovať aktuálne zobrazené výsledky do CSV súboru
</v-tooltip>
Export výsledkov
</v-btn>
<InternshipListView mode="admin" @filterApplied="(filters) => internshipFilters = filters"
@itemsAvailable="(available) => exportAvailable = available" />
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
.alert {
margin-bottom: 10px;
}
.op-btn {
margin: 10px;
}
</style>

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import type { User } from '~/types/user';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only']
})
const route = useRoute();
const client = useSanctumClient();
const studentId = route.params.id;
const student = ref<User | null>(null);
const loading = ref(true);
const saving = ref(false);
// Delete state
const deleteDialog = ref(false);
const deleteLoading = ref(false);
const deleteError = ref<string | null>(null);
const deleteSuccess = ref(false);
const form = ref({
first_name: '',
last_name: '',
email: '',
phone: '',
student_data: {
study_field: '',
personal_email: '',
address: ''
}
});
const { data } = await useLazySanctumFetch<User>(`/api/students/${studentId}`);
// Načítanie dát študenta
watch(data, (newData) => {
if (newData) {
student.value = newData;
form.value.first_name = newData.first_name;
form.value.last_name = newData.last_name;
form.value.email = newData.email;
form.value.phone = newData.phone;
form.value.student_data.study_field = newData.student_data!.study_field;
form.value.student_data.personal_email = newData.student_data!.personal_email;
form.value.student_data.address = newData.student_data!.address;
loading.value = false;
}
}, { immediate: true });
// Uloženie zmien
async function saveChanges() {
saving.value = true;
try {
await client(`/api/students/${studentId}`, {
method: 'POST',
body: form.value
});
alert('Údaje študenta boli úspešne aktualizované');
navigateTo("/dashboard/admin/students");
} catch (e) {
if (e instanceof FetchError) {
alert('Chyba:\n' + e.response?._data.message);
}
} finally {
saving.value = false;
}
}
function cancel() {
navigateTo('/dashboard/admin/students');
}
// Funkcia na otvorenie delete dialogu
const openDeleteDialog = () => {
deleteDialog.value = true;
deleteError.value = null;
};
// Funkcia na zatvorenie dialogu
const closeDeleteDialog = () => {
deleteDialog.value = false;
deleteError.value = null;
deleteSuccess.value = false;
};
// Funkcia na vymazanie študenta
const deleteStudent = async () => {
if (!studentId) return;
deleteLoading.value = true;
deleteError.value = null;
try {
await client(`/api/students/${studentId}`, {
method: 'DELETE'
});
deleteSuccess.value = true;
// Presmeruj na zoznam po 1.5 sekundách
setTimeout(() => {
navigateTo('/dashboard/admin/students');
}, 1500);
} catch (e) {
if (e instanceof FetchError) {
deleteError.value = e.response?._data?.message || 'Chyba pri mazaní študenta.';
}
} finally {
deleteLoading.value = false;
}
};
</script>
<template>
<v-container class="h-100">
<div v-if="loading" class="text-center">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else>
<v-row class="mb-4">
<v-col>
<h1>Editovať študenta</h1>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="8">
<v-card>
<v-card-title>Základné údaje</v-card-title>
<v-card-text>
<v-form>
<v-text-field v-model="form.first_name" label="Meno" required variant="outlined"
class="mb-3"></v-text-field>
<v-text-field v-model="form.last_name" label="Priezvisko" required variant="outlined"
class="mb-3"></v-text-field>
<v-text-field v-model="form.email" label="E-mail (prihlasovací)" type="email" required
variant="outlined" class="mb-3"></v-text-field>
<v-text-field v-model="form.phone" label="Telefón" variant="outlined"
class="mb-3"></v-text-field>
<v-divider class="my-4"></v-divider>
<h3 class="mb-3">Študijné údaje</h3>
<v-text-field v-model="form.student_data.study_field" label="Študijný program"
variant="outlined" class="mb-3"></v-text-field>
<v-text-field v-model="form.student_data.personal_email" label="Osobný e-mail"
type="email" variant="outlined" class="mb-3"></v-text-field>
<v-textarea v-model="form.student_data.address" label="Adresa" variant="outlined"
rows="3"></v-textarea>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-4">
<v-btn color="primary" @click="saveChanges" :loading="saving" :disabled="saving">
Uložiť zmeny
</v-btn>
<v-btn @click="cancel" :disabled="saving">
Zrušiť
</v-btn>
<v-spacer></v-spacer>
<v-btn color="red" variant="outlined" @click="openDeleteDialog" :disabled="saving">
Vymazať študenta
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="deleteDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Potvrdiť vymazanie
</v-card-title>
<v-card-text>
<p v-if="!deleteSuccess">
Naozaj chcete vymazať študenta <strong>{{ student?.name }}</strong>?
</p>
<p v-if="!deleteSuccess" class="text-error mt-2">
Táto akcia vymaže aj všetky súvisiace dáta (praxe, statusy, atď.) a <strong>nie je možné ju
vrátiť späť</strong>.
</p>
<!-- Error message -->
<ErrorAlert v-if="deleteError" :error="deleteError" />
<!-- Success message -->
<SuccessAlert v-if="deleteSuccess" title="Úspech" text="Študent bol úspešne vymazaný." />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="closeDeleteDialog" :disabled="deleteLoading">
Zrušiť
</v-btn>
<v-btn color="red" variant="text" @click="deleteStudent" :loading="deleteLoading"
:disabled="deleteSuccess">
Vymazať
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.h-100 {
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { User } from '~/types/user';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'admin-only'],
});
useSeoMeta({
title: "Študenti | ISOP",
ogTitle: "Študenti",
description: "Študenti ISOP",
ogDescription: "Študenti",
});
const headers = [
{ title: 'Meno', key: 'name', align: 'left' },
{ title: 'E-mail', key: 'email', align: 'left' },
{ title: 'Telefón', key: 'phone', align: 'left' },
{ title: 'Študijný program', key: 'study_field', align: 'left' },
{ title: 'Osobný e-mail', key: 'personal_email', align: 'middle' },
{ title: 'Adresa', key: 'address', align: 'middle' },
{ title: 'Operácie', key: 'ops', align: 'middle' },
];
const client = useSanctumClient();
// Načítame všetkých študentov
const { data: students, error, pending, refresh } = await useLazySanctumFetch<User[]>('/api/students');
// State pre delete dialog
const deleteDialog = ref(false);
const studentToDelete = ref<User | null>(null);
const deleteLoading = ref(false);
const deleteError = ref<string | null>(null);
// Funkcia na otvorenie delete dialogu
const openDeleteDialog = (student: User) => {
studentToDelete.value = student;
deleteDialog.value = true;
deleteError.value = null;
};
// Funkcia na zatvorenie dialogu
const closeDeleteDialog = () => {
deleteDialog.value = false;
studentToDelete.value = null;
deleteError.value = null;
};
// Funkcia na vymazanie študenta
const deleteStudent = async () => {
if (!studentToDelete.value) return;
deleteLoading.value = true;
deleteError.value = null;
try {
await client(`/api/students/${studentToDelete.value.id}`, {
method: 'DELETE'
});
await refresh();
closeDeleteDialog();
} catch (err) {
if (err instanceof FetchError) {
deleteError.value = err.data?.message;
}
} finally {
deleteLoading.value = false;
}
};
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Študenti</h1>
<hr />
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error?.message" />
<div v-else>
<p>Aktuálne evidujeme {{ students?.length || 0 }} študentov.</p>
<br />
<v-table v-if="students && students.length > 0">
<thead>
<tr>
<th v-for="header in headers" :class="'text-' + header.align">
<strong>{{ header.title }}</strong>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in students" :key="item.id">
<td>{{ item.name }}</td>
<td>{{ item.email }}</td>
<td>{{ item.phone || '-' }}</td>
<td>{{ item.student_data?.study_field || '-' }}</td>
<td>{{ item.student_data?.personal_email || '-' }}</td>
<td>{{ item.student_data?.address || '-' }}</td>
<td class="text-left">
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-pencil" base-color="orange"
:to="'/dashboard/admin/students/edit/' + item.id">Editovať</v-btn>
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-delete" base-color="red"
@click="openDeleteDialog(item)">Vymazať</v-btn>
</td>
</tr>
</tbody>
</v-table>
<InfoAlert v-else title="Informácia" text="Zatiaľ nie sú zaregistrovaní žiadni študenti." />
</div>
</v-card>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="deleteDialog" max-width="500px">
<v-card>
<v-card-title class="text-h5">
Potvrdiť vymazanie
</v-card-title>
<v-card-text>
<p>
Naozaj chcete vymazať študenta <strong>{{ studentToDelete?.name }}</strong>?
</p>
<p class="text-error mt-2">
Táto akcia vymaže aj všetky súvisiace dáta (praxe, statusy, atď.) a <strong>nie je možné ju
vrátiť späť</strong>.
</p>
<!-- Error message -->
<ErrorAlert v-if="deleteError" :error="deleteError" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="closeDeleteDialog" :disabled="deleteLoading">
Zrušiť
</v-btn>
<v-btn color="red" variant="text" @click="deleteStudent" :loading="deleteLoading">
Vymazať
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
.op-btn {
margin: 10px;
}
.alert {
margin-top: 10px;
}
</style>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'company-only'],
});
useSeoMeta({
title: "Portál firmy | ISOP",
ogTitle: "Portál firmy",
description: "Portál firmy ISOP",
ogDescription: "Portál firmy",
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="footer-card">
<h1>Vitajte, {{ user?.name }} <em>({{ user?.company_data?.name }})</em></h1>
</v-card>
</v-container>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'company-only'],
});
useSeoMeta({
title: "Portál firmy | ISOP",
ogTitle: "Portál firmy",
description: "Portál firmy ISOP",
ogDescription: "Portál firmy",
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Vitajte, {{ user?.name }} <em>({{ user?.company_data?.name }})</em></h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<v-btn prepend-icon="mdi-briefcase" color="blue" class="mr-2" to="/dashboard/company/internships">
Praxe
</v-btn>
<v-btn prepend-icon="mdi-account-circle" color="blue" class="mr-2" to="/account">
Môj profil
</v-btn>
<!-- spacer -->
<div style="height: 40px;"></div>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { InternshipStatus, prettyInternshipStatus } from '~/types/internship_status';
import type { Internship, NewInternship } from '~/types/internships';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'company-only'],
});
useSeoMeta({
title: "Edit praxe | ISOP",
ogTitle: "Edit praxe",
description: "Edit praxe ISOP",
ogDescription: "Edit praxe",
});
const route = useRoute();
const client = useSanctumClient();
const loading = ref(false);
const action_error = ref(null as null | string);
const refreshKey = ref(0);
const { data, error: load_error, pending, refresh } = await useLazySanctumFetch<Internship>(`/api/internships/${route.params.id}`);
async function handleUpdateOfBasicInfo(internship: NewInternship) {
action_error.value = null;
loading.value = true;
try {
await client(`/api/internships/${route.params.id}/basic`, {
method: 'POST',
body: internship
});
navigateTo("/dashboard/company/internships");
} catch (e) {
if (e instanceof FetchError) {
action_error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Edit praxe</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="load_error" :error="load_error.message" />
<div v-else>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="action_error" :error="action_error" />
<div v-else>
<div>
<h2>Základné informácie</h2>
<ErrorAlert v-if="data?.status.status !== InternshipStatus.SUBMITTED" title="Blokované"
error='Vaša prax nie je v stave "Zadaná" a teda nemôžete meniť údaje' />
<InternshipEditor v-else :internship="data!" :submit="handleUpdateOfBasicInfo" />
</div>
<hr />
<div>
<h2>Stav</h2>
<h4>Aktuálny stav</h4>
<p>{{ prettyInternshipStatus(data?.status.status!) }}</p>
<p>Poznámka: <em>{{ data?.status.note }}</em></p>
<p>Posledná zmena: <em>{{ data?.status.changed }}, {{ data?.status.modified_by.name }}</em></p>
<br />
<h4>História</h4>
<InternshipStatusHistoryView :internship="data!" />
<br />
<h4>Zmena stavu</h4>
<InternshipStatusEditor :internship="data!"
@successful-submit="() => { refresh(); refreshKey++; }" />
</div>
<hr />
<div>
<h2>Nahratie dokumentov</h2>
<ErrorAlert v-if="data?.status.status !== InternshipStatus.CONFIRMED_BY_COMPANY"
title="Blokované"
error='Vaša prax nie je v stave "Schválená" a teda nemôžete nahrať dokumenty.' />
<InternshipDocumentEditor v-else :internship="data!" @successful-submit="refresh" />
</div>
</div>
</div>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
}
hr {
margin-top: 20px;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
definePageMeta({
middleware: ['sanctum:auth', 'company-only'],
});
useSeoMeta({
title: "Portál firmy - praxe | ISOP",
ogTitle: "Portál firmy - praxe",
description: "Portál firmy - praxe ISOP",
ogDescription: "Portál firmy - praxe",
});
const headers = [
{ title: 'Študent', key: 'student', align: 'left' },
{ title: 'Od', key: 'start', align: 'left' },
{ title: 'Do', key: 'end', align: 'left' },
{ title: 'Ročník', key: 'year_of_study', align: 'middle' },
{ title: 'Semester', key: 'semester', align: 'middle' },
{ title: 'Stav', key: 'status', align: 'middle' },
{ title: 'Operácie', key: 'ops', align: 'middle' },
];
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Praxe študentov</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<InternshipListView mode="company" />
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'company-only'],
});
useSeoMeta({
title: 'Môj profil | ISOP',
ogTitle: 'Môj profil',
description: 'Profil firmy ISOP',
ogDescription: 'Profil firmy ISOP',
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid class="page-container">
<v-card id="profile-card">
<template v-if="user">
<div class="header">
<v-avatar size="64" class="mr-4">
<v-icon size="48">mdi-account-circle</v-icon>
</v-avatar>
<div>
<h2 class="title">Môj profil</h2>
<p class="subtitle">{{ user?.company_data?.name || user?.name }}</p>
</div>
</div>
<v-divider class="my-4" />
<v-row>
<!-- Údaje firmy -->
<v-col cols="12" md="6">
<h3 class="section-title">Údaje firmy</h3>
<v-list density="compact" class="readonly-list">
<v-list-item>
<v-list-item-title>Názov</v-list-item-title>
<v-list-item-subtitle>{{ user?.company_data?.name || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Adresa</v-list-item-title>
<v-list-item-subtitle>{{ user?.company_data?.address || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>IČO</v-list-item-title>
<v-list-item-subtitle>{{ user?.company_data?.ico ?? '—' }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-col>
<!-- Kontaktná osoba -->
<v-col cols="12" md="6">
<h3 class="section-title">Kontaktná osoba</h3>
<v-list density="compact" class="readonly-list">
<v-list-item>
<v-list-item-title>Meno</v-list-item-title>
<v-list-item-subtitle>{{ user?.first_name || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Priezvisko</v-list-item-title>
<v-list-item-subtitle>{{ user?.last_name || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Email</v-list-item-title>
<v-list-item-subtitle>{{ user?.email || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Telefón</v-list-item-title>
<v-list-item-subtitle>{{ user?.phone || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item class="mt-4">
<v-btn prepend-icon="mdi mdi-pencil" color="blue" class="mr-2">
Zmeniť heslo
</v-btn>
</v-list-item>
</v-list>
</v-col>
</v-row>
</template>
<template v-else>
<v-skeleton-loader
type="heading, text, text, divider, list-item-two-line, list-item-two-line, list-item-two-line"
/>
</template>
</v-card>
</v-container>
</template>
<style scoped>
.page-container {
max-width: 1120px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
#profile-card { padding: 16px; }
.header { display: flex; align-items: center; }
.title { font-size: 24px; font-weight: 700; margin: 0; }
.subtitle { color: #555; margin-top: 4px; }
.section-title { font-size: 16px; font-weight: 700; margin: 8px 0 8px; }
.readonly-list {
--v-list-padding-start: 0px;
}
.readonly-list :deep(.v-list-item) {
--v-list-item-padding-start: 0px;
padding-left: 0 !important;
}
.readonly-list :deep(.v-list-item__content) {
padding-left: 0 !important;
}
.readonly-list :deep(.v-list-item-subtitle) {
white-space: pre-line;
}
.readonly-list :deep(.v-list-item-title) { font-weight: 600; }
</style>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'student-only'],
});
useSeoMeta({
title: "Portál študenta | ISOP",
ogTitle: "Portál študenta",
description: "Portál študenta ISOP",
ogDescription: "Portál študenta",
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="footer-card">
<h1>Vitajte, {{ user?.name }}</h1>
</v-card>
</v-container>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { CompanyData } from '~/types/company_data';
definePageMeta({
middleware: ['sanctum:auth', 'student-only'],
});
useSeoMeta({
title: "Partnerské firmy | ISOP",
ogTitle: "Partnerské firmy",
description: "Partnerské firmy ISOP",
ogDescription: "Partnerské firmy",
});
const headers = [
{ title: 'Názov', key: 'name', align: 'left' },
{ title: 'IČO', key: 'ico', align: 'left' },
{ title: 'Kontaktná osoba', key: 'contact_name', align: 'left' },
{ title: 'Telefón', key: 'phone', align: 'middle' },
{ title: 'E-mail', key: 'email', align: 'middle' },
{ title: 'Prijímajú študentov', key: 'hiring', align: 'middle' },
];
const { data, error, pending } = await useLazySanctumFetch<CompanyData[]>('/api/companies/simple');
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Partnerské firmy</h1>
<hr />
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error?.message" />
<div v-else>
<p>Aktuálne spolupracujeme s {{ data?.length }} firmami.</p>
<br />
<v-table>
<thead>
<tr>
<th v-for="header in headers" :class="'text-' + header.align">
<strong>{{ header.title }}</strong>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data">
<td>{{ item.name }}</td>
<td>{{ item.ico }}</td>
<td>{{ item.contact.name }}</td>
<td>{{ item.contact.phone }}</td>
<td>{{ item.contact.email }}</td>
<td>{{ item.hiring ? "Áno" : "Nie" }}</td>
</tr>
</tbody>
</v-table>
</div>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { User } from '~/types/user';
definePageMeta({
middleware: ['sanctum:auth', 'student-only'],
});
useSeoMeta({
title: "Portál študenta | ISOP",
ogTitle: "Portál študenta",
description: "Portál študenta ISOP",
ogDescription: "Portál študenta",
});
const user = useSanctumUser<User>();
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Vitajte, {{ user?.name }}</h1>
<hr />
<small>{{ user?.student_data?.study_field }}, {{ user?.student_data?.personal_email }}</small>
<!-- spacer -->
<div style="height: 40px;"></div>
<v-btn prepend-icon="mdi-plus" color="blue" class="mr-2" to="/dashboard/student/internships/create">
Pridať
</v-btn>
<v-btn prepend-icon="mdi-domain" color="blue" class="mr-2" to="/dashboard/student/companies">
Firmy
</v-btn>
<v-btn prepend-icon="mdi-pencil" color="orange" class="mr-2" to="/account">
Môj profil
</v-btn>
<!-- spacer -->
<div style="height: 40px;"></div>
<h3>Moje praxe</h3>
<InternshipListView mode="student" />
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { NewInternship } from '~/types/internships';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'student-only'],
});
useSeoMeta({
title: "Vytvorenie praxe | ISOP",
ogTitle: "Vytvorenie praxe",
description: "Vytvorenie praxe ISOP",
ogDescription: "Vytvorenie praxe",
});
const loading = ref(false);
const error = ref(null as null | string);
const client = useSanctumClient();
async function handleInternshipRegistration(internship: NewInternship) {
try {
await client("/api/internships/new", {
method: 'PUT',
body: internship
});
navigateTo("/dashboard/student");
} catch (e) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Vytvorenie praxe</h1>
<br />
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<!-- Chybová hláška -->
<ErrorAlert v-if="error" :error="error" />
<InternshipEditor v-show="!loading" :submit="handleInternshipRegistration" />
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
}
.alert {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { InternshipStatus, prettyInternshipStatus } from '~/types/internship_status';
import type { Internship, NewInternship } from '~/types/internships';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:auth', 'student-only'],
});
useSeoMeta({
title: "Edit praxe | ISOP",
ogTitle: "Edit praxe",
description: "Edit praxe ISOP",
ogDescription: "Edit praxe",
});
const route = useRoute();
const client = useSanctumClient();
const loading = ref(false);
const action_error = ref(null as null | string);
const { data, error, pending, refresh } = await useLazySanctumFetch<Internship>(`/api/internships/${route.params.id}`);
async function handleUpdateOfBasicInfo(internship: NewInternship) {
action_error.value = null;
loading.value = true;
try {
await client(`/api/internships/${route.params.id}/basic`, {
method: 'POST',
body: internship
});
navigateTo("/dashboard/student");
} catch (e) {
if (e instanceof FetchError) {
action_error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<v-container fluid>
<v-card id="page-container-card">
<h1>Edit praxe</h1>
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="pending" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="error" :error="error.message" />
<div v-else>
<!-- Čakajúca hláška -->
<LoadingAlert v-if="loading" />
<!-- Chybová hláška -->
<ErrorAlert v-else-if="action_error" :error="action_error" />
<div v-else>
<div>
<h2>Základné informácie</h2>
<ErrorAlert v-if="data?.status.status !== InternshipStatus.SUBMITTED"
error='Vaša prax nie je v stave "Zadaná" a teda nemôžete meniť údaje.' />
<InternshipEditor v-else :internship="data!" :submit="handleUpdateOfBasicInfo" />
</div>
<hr />
<div>
<h2>Stav</h2>
<h4>Aktuálny stav</h4>
<p>{{ prettyInternshipStatus(data?.status.status!) }}</p>
<p>Poznámka: <em>{{ data?.status.note }}</em></p>
<p>Posledná zmena: <em>{{ data?.status.changed }}, {{ data?.status.modified_by.name }}</em></p>
<br />
<h4>História</h4>
<InternshipStatusHistoryView :internship="data!" />
</div>
<hr />
<div>
<h2>Nahratie dokumentov</h2>
<ErrorAlert v-if="data?.status.status !== InternshipStatus.CONFIRMED_BY_COMPANY"
title="Blokované"
error='Vaša prax nie je v stave "Schválená" a teda nemôžete nahrať dokumenty.' />
<InternshipDocumentEditor v-else :internship="data!" @successful-submit="refresh" />
</div>
</div>
</div>
</v-card>
</v-container>
</template>
<style scoped>
#page-container-card {
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
}
hr {
margin-top: 20px;
margin-bottom: 20px;
}
</style>

View File

@@ -21,7 +21,7 @@ useSeoMeta({
<v-row class="pc" align="stretch" justify="start">
<InfoCard title="Rozsah a účasť"
description="Absolvovanie praxe v minimálnom rozsahu 130 hodín a povinná účasť na úvodnom stretnutí"
description="Absolvovanie praxe v minimálnom rozsahu 150 hodín a povinná účasť na úvodnom stretnutí"
icon="mdi-clock-time-five-outline" />
<InfoCard title="Denník praxe"
description="Priebežné vedenie denníka praxe podľa predpísanej štruktúry a jeho odovzdanie na konci obdobia."

View File

@@ -35,7 +35,7 @@ async function handleLogin() {
try {
await login(form.value);
} catch (e) {
if (e instanceof FetchError && e.response?.status === 422) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
@@ -50,16 +50,14 @@ async function handleLogin() {
<h2 class="page-title">Prihlásenie</h2>
<!-- Chybová hláška -->
<v-alert v-if="error !== null" density="compact" :text="error" title="Chyba" type="error"
id="login-error-alert" class="mx-auto alert"></v-alert>
<ErrorAlert v-if="error" :error="error" />
<!-- Čakajúca hláška -->
<v-alert v-if="loading" density="compact" text="Prosím čakajte..." title="Spracovávam" type="info"
id="login-error-alert" class="mx-auto alert"></v-alert>
<LoadingAlert v-if="loading" />
<v-form v-else v-model="isValid" @submit.prevent="handleLogin">
<v-text-field v-model="form.email" :rules="[rules.required, rules.email]" label="Email:"
variant="outlined" density="comfortable" />
variant="outlined" density="comfortable" type="email" />
<v-text-field v-model="form.password" :rules="[rules.required]"
:type="showPassword ? 'text' : 'password'" label="Heslo:" variant="outlined" density="comfortable"

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { NewRole } from '~/types/role';
import type { NewUser } from '~/types/user';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:guest'],
@@ -18,7 +19,8 @@ useSeoMeta({
const rules = {
required: (v: any) => (!!v && String(v).trim().length > 0) || 'Povinné pole',
email: (v: string) => /.+@.+\..+/.test(v) || 'Zadajte platný email',
phone: (v: string) => (!v || /^[0-9 +()-]{6,}$/.test(v)) || 'Zadajte platné telefónne číslo',
phone: (v: string) =>
(!v || /^\+[0-9]{6,13}$/.test(v)) || 'Zadajte platné telefónne číslo. Príklad: +421908123456',
mustAgree: (v: boolean) => v === true || 'Je potrebné súhlasiť',
};
@@ -63,8 +65,10 @@ async function handleRegistration() {
});
navigateTo("/");
} catch (e: any) {
error.value = e.data?.message as string;
} catch (e) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
@@ -77,12 +81,10 @@ async function handleRegistration() {
<h4 class="page-title">Registrácia firmy</h4>
<!-- Chybová hláška -->
<v-alert v-if="error !== null" density="compact" :text="error" title="Chyba" type="error"
id="login-error-alert" class="mx-auto"></v-alert>
<ErrorAlert v-if=error :error="error" />
<!-- Čakajúca hláška -->
<v-alert v-if="loading" density="compact" text="Prosím čakajte..." title="Spracovávam" type="info"
id="login-error-alert" class="mx-auto"></v-alert>
<LoadingAlert v-if="loading" />
<v-form v-else v-model="isValid" @submit.prevent="handleRegistration">
<v-text-field v-model="form.name" :rules="[rules.required]" label="Názov firmy:" variant="outlined"
@@ -100,8 +102,8 @@ async function handleRegistration() {
density="comfortable" />
<v-text-field v-model="form.email" :rules="[rules.required, rules.email]" label="Email:"
variant="outlined" density="comfortable" />
<v-text-field v-model="form.phone" :rules="[rules.phone]" label="Telefón:" variant="outlined"
density="comfortable" />
<v-text-field v-model="form.phone" :rules="[rules.phone]" label="Telefón (s predvoľbou):"
variant="outlined" density="comfortable" />
<v-checkbox v-model="form.consent" :rules="[rules.mustAgree]"
label="Súhlasím s podmienkami spracúvania osobných údajov" density="comfortable" />

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { NewRole } from '~/types/role';
import type { NewUser } from '~/types/user';
import { FetchError } from 'ofetch';
definePageMeta({
middleware: ['sanctum:guest'],
@@ -22,11 +23,14 @@ const rules = {
personal_email: (v: string) =>
/.+@.+\..+/.test(v) || 'Zadajte platný osobný email',
phone: (v: string) =>
(!v || /^[0-9 +()-]{6,}$/.test(v)) || 'Zadajte platné telefónne číslo',
(!v || /^\+[0-9]{6,13}$/.test(v)) || 'Zadajte platné telefónne číslo. Príklad: +421908123456',
mustAgree: (v: boolean) => v === true || 'Je potrebné súhlasiť',
};
const programs = [
'Aplikovaná informatika',
{ title: 'Aplikovaná informatika, Bc. (AI22b)', value: 'AI22b' },
{ title: 'Aplikovaná informatika, Bc. (AI15b)', value: 'AI15b' },
{ title: 'Aplikovaná informatika, Mgr. (AI22m)', value: 'AI22m' },
{ title: 'Aplikovaná informatika, Mgr. (AI15m)', value: 'AI15m' },
];
const isValid = ref(false);
@@ -37,10 +41,11 @@ const form = ref({
studentEmail: '',
personalEmail: '',
phone: '',
studyProgram: programs[0] as string,
studyProgram: programs[0]!.value,
year_of_study: 1,
consent: false,
});
const maxYearOfStudy = ref(0);
const loading = ref(false);
const error = ref(null as null | string);
@@ -71,12 +76,18 @@ async function handleRegistration() {
});
navigateTo("/");
} catch (e: any) {
error.value = e.data?.message as string;
} catch (e) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
}
watch(form, (newForm) => {
maxYearOfStudy.value = newForm.studyProgram.slice(-1) === 'b' ? 3 : 2;
}, { deep: true, immediate: true });
</script>
<template>
@@ -85,12 +96,10 @@ async function handleRegistration() {
<h4 class="page-title">Registrácia študenta</h4>
<!-- Chybová hláška -->
<v-alert v-if="error !== null" density="compact" :text="error" title="Chyba" type="error"
id="login-error-alert" class="mx-auto alert"></v-alert>
<ErrorAlert v-if="error" :error="error" />
<!-- Čakajúca hláška -->
<v-alert v-if="loading" density="compact" text="Prosím čakajte..." title="Spracovávam" type="info"
id="login-error-alert" class="mx-auto alert"></v-alert>
<LoadingAlert v-if="loading" />
<v-form v-else v-model="isValid" @submit.prevent="handleRegistration">
<v-text-field v-model="form.firstName" :rules="[rules.required]" label="Meno:" variant="outlined"
@@ -108,14 +117,14 @@ async function handleRegistration() {
<v-text-field v-model="form.personalEmail" :rules="[rules.required, rules.personal_email]"
label="Alternatívny email:" variant="outlined" density="comfortable" />
<v-text-field v-model="form.phone" :rules="[rules.required, rules.phone]" label="Telefón:"
variant="outlined" density="comfortable" />
<v-text-field v-model="form.phone" :rules="[rules.required, rules.phone]"
label="Telefón (s predvoľbou):" variant="outlined" density="comfortable" />
<v-select v-model="form.studyProgram" :items="programs" :rules="[rules.required]"
label="Študijný odbor:" variant="outlined" density="comfortable" />
<v-number-input control-variant="split" v-model="form.year_of_study" :rules="[rules.required]"
label="Ročník:" :min="1" :max="5"></v-number-input>
label="Ročník:" :min="1" :max="maxYearOfStudy"></v-number-input>
<v-checkbox v-model="form.consent" :rules="[rules.mustAgree]"
label="Súhlasím s podmienkami spracúvania osobných údajov" density="comfortable" />

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { FetchError } from 'ofetch';
const client = useSanctumClient();
definePageMeta({
@@ -36,8 +38,10 @@ async function handleReset() {
});
navigateTo("/reset_psw/request_sent");
} catch (e: any) {
error.value = e.data?.message as string;
} catch (e) {
if (e instanceof FetchError) {
error.value = e.response?._data.message;
}
} finally {
loading.value = false;
}
@@ -50,12 +54,10 @@ async function handleReset() {
<h2 class="page-title">Reset hesla</h2>
<!-- Chybová hláška -->
<v-alert v-if="error !== null" density="compact" :text="error" title="Chyba" type="error"
id="login-error-alert" class="alert mx-auto"></v-alert>
<ErrorAlert v-if="error" :error="error" />
<!-- Čakajúca hláška -->
<v-alert v-if="loading" density="compact" text="Prosím čakajte..." title="Spracovávam" type="info"
id="login-error-alert" class="alert mx-auto"></v-alert>
<LoadingAlert v-if="loading" />
<v-form v-else v-model="isValid" @submit.prevent="handleReset">
<v-text-field v-model="email" :rules="[rules.required, rules.email]" label="Email:" variant="outlined"

View File

@@ -16,8 +16,7 @@ useSeoMeta({
<v-card id="page-container-card">
<h2 class="page-title">Reset hesla</h2>
<v-alert density="compact" text="Nové heslo vám bolo zaslané na e-mail" title="Reset hesla" type="info"
class="mx-auto"></v-alert>
<SuccessAlert title="Reset hesla" text="Nové heslo vám bolo zaslané na e-mail" />
</v-card>
</v-container>
</template>

View File

@@ -0,0 +1,11 @@
export type ApiKey = {
id: number,
name: string,
created_at: string,
last_used_at: string,
owner: string,
};
export type NewApiKey = {
key: string,
};

View File

@@ -1,9 +1,11 @@
import type { User } from "./user";
export interface CompanyData {
id: number;
name: string;
address: string;
ico: number;
contact: number;
contact: User;
hiring: boolean;
};

View File

@@ -0,0 +1,47 @@
import type { User } from "./user";
export interface InternshipStatusData {
status: InternshipStatus;
changed: string;
note: string;
modified_by: User;
};
export interface NewInternshipStatusData {
status: InternshipStatus;
note: string;
};
export enum InternshipStatus {
SUBMITTED = 'SUBMITTED',
CONFIRMED_BY_COMPANY = 'CONFIRMED_BY_COMPANY',
CONFIRMED_BY_ADMIN = 'CONFIRMED_BY_ADMIN',
DENIED_BY_COMPANY = 'DENIED_BY_COMPANY',
DENIED_BY_ADMIN = 'DENIED_BY_ADMIN',
DEFENDED = 'DEFENDED',
NOT_DEFENDED = 'NOT_DEFENDED',
}
export function prettyInternshipStatus(status: InternshipStatus) {
switch (status) {
case InternshipStatus.SUBMITTED:
return "Zadané";
case InternshipStatus.CONFIRMED_BY_COMPANY:
return "Potvrdené firmou";
case InternshipStatus.CONFIRMED_BY_ADMIN:
return "Potvrdené garantom";
case InternshipStatus.DENIED_BY_COMPANY:
return "Zamietnuté firmou";
case InternshipStatus.DENIED_BY_ADMIN:
return "Zamietnuté garantom";
case InternshipStatus.DEFENDED:
return "Obhájené";
case InternshipStatus.NOT_DEFENDED:
return "Neobhájené";
default:
throw new Error(`Unknown internship status: '${status}'`);
}
}

View File

@@ -0,0 +1,59 @@
import type { CompanyData } from "./company_data";
import type { InternshipStatusData } from "./internship_status";
import type { User } from "./user";
export interface Internship {
id: number;
student: User;
company: CompanyData;
start: string;
end: string;
year_of_study: number;
semester: string;
position_description: string;
proof: boolean;
report: boolean;
report_confirmed: boolean;
status: InternshipStatusData;
};
export interface NewInternship {
user_id: number;
company_id: number;
start: string;
end: string;
year_of_study: number;
semester: string;
position_description: string;
};
export interface InternshipFilter {
year: number | null;
company: string | null;
study_programe: string | null;
student: string | null;
};
export function convertDate(date: string): Date {
const matcher = /^\d\d.\d\d.\d\d\d\d$/;
if (!matcher.test(date)) {
throw new Error(`Invalid date or format: '${date}'`);
}
const [day, month, year] = date.split('.').map(Number);
if (day === undefined || month === undefined || year === undefined) {
throw new Error(`Unable to parse date parts: '${date}'`);
}
if (month < 1 || month > 12) {
throw new Error(`Invalid month: ${month}`);
}
if (day < 1 || day > 31) {
throw new Error(`Invalid day: ${day}`);
}
return new Date(year, month - 1, day);
}

View File

@@ -0,0 +1,4 @@
export type Paginated<T> = {
data: T[],
total: number;
};

View File

@@ -0,0 +1,9 @@
export function triggerDownload(file: Blob, file_name: string, ext: string) {
const url = window.URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = `${file_name}.${ext}`;
link.target = "_blank";
link.click();
window.URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
},
baseUrl: 'http://localhost:3000',
video: true,
screenshotOnRunFailure: true,
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
}
});

View File

@@ -0,0 +1,86 @@
describe('Admin API Key Management CRUD', () => {
beforeEach(() => {
cy.visit('/login')
cy.wait(3000)
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
cy.contains("API Manažment").click()
cy.url().should('include', '/dashboard/admin/external_api')
})
it('should load the list of API keys', () => {
cy.get('table').within(() => {
cy.validateColumn('Názov', (name) => {
expect(name).to.not.be.empty
})
cy.validateColumn('Vytvorené', (created_at) => {
expect(created_at).to.not.be.empty
})
cy.validateColumn('Naposledy použité', (last_used_at) => {
expect(last_used_at).to.not.be.empty
})
cy.validateColumn('Vlastník', (owner) => {
const nameParts = owner.trim().split(' ')
expect(nameParts).to.have.length.at.least(2)
expect(nameParts[0]).to.not.be.empty
expect(nameParts[1]).to.not.be.empty
})
cy.contains('th', 'Operácie').parent('tr')
})
})
it('should be able to create an API key', () => {
// vytvorenie nového kľúča
cy.contains("Pridať").click()
cy.get('#newKeyName').type('cypress-e2e-test-key')
cy.contains("Vytvoriť").click()
cy.wait(3000)
cy.contains("Zavrieť").click()
// mali by sme mať práve 1 kľúč
cy.get('table tbody tr').its('length').then((count) => {
expect(count).to.be.greaterThan(0)
})
// Kontrola názvu
cy.contains('cypress-e2e-test-key').should('be.visible')
})
it('should be able to delete an API key', () => {
let initialRowCount = 0
cy.get('table tbody tr').its('length').then((count) => {
initialRowCount = count
})
// získanie prvého riadka z tabuľky
cy.get('table tbody tr').then($rows => {
const selectedRow = [...$rows].find(row =>
Cypress.$(row).find('td').first().text().trim() === 'cypress-e2e-test-key'
);
cy.wrap(selectedRow).as('selectedRow');
})
// kliknutie na "Vymazať"
cy.get('@selectedRow').within(() => {
cy.contains('Vymazať').click()
})
// potvrdenie
cy.contains(' Áno ').click()
cy.wait(3000)
cy.contains('cypress-e2e-test-key').should('not.exist')
})
})

View File

@@ -0,0 +1,150 @@
describe('Admin Company CRUD', () => {
beforeEach(() => {
cy.visit('/login')
cy.wait(3000)
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
cy.contains("Firmy").click()
cy.url().should('include', '/dashboard/admin/companies')
})
it('should load the list of companies in a proper format', () => {
cy.get('table').within(() => {
cy.validateColumn('Názov', (name) => {
expect(name).to.not.be.empty
})
cy.validateColumn('IČO', (ico) => {
expect(ico.trim()).to.not.be.empty
expect(Number(ico.trim())).to.be.a('number').and.not.be.NaN
})
cy.validateColumn('Kontaktná osoba', (contact_name) => {
const nameParts = contact_name.trim().split(' ')
expect(nameParts).to.have.length.at.least(2)
expect(nameParts[0]).to.not.be.empty
expect(nameParts[1]).to.not.be.empty
})
cy.validateColumn('Telefón', (program) => {
expect(program.trim()).to.not.be.empty
})
cy.validateColumn('E-mail', (email) => {
expect(email.trim()).to.not.be.empty
expect(email.trim()).to.include("@")
})
cy.validateColumn('Prijímajú študentov', (hiring) => {
expect(hiring.trim()).to.be.oneOf(['Áno', 'Nie'])
})
cy.contains('th', 'Operácie').parent('tr')
})
})
it('should be able to delete a company', () => {
let initialRowCount = 0
cy.get('table tbody tr').its('length').then((count) => {
initialRowCount = count
})
cy.get('table tbody tr').first().within(() => {
cy.contains('Vymazať').click()
})
cy.contains("Potvrdiť vymazanie").parent().should('be.visible')
cy.contains("Potvrdiť vymazanie").parent().contains("Vymazať").click()
cy.contains("Potvrdiť vymazanie").should('not.exist')
cy.wait(1000)
cy.get('table tbody tr').its('length').then((count) => {
expect(count).to.be.eq(initialRowCount - 1)
})
})
it('should be able to edit a student', () => {
// Náhodné sety
const companyNames = [
'Tech Solutions s.r.o.',
'Digital Systems a.s.',
'Innovation Labs s.r.o.',
'Software House Slovakia',
'Data Analytics Group',
'Cloud Services s.r.o.',
'IT Consulting a.s.',
'Web Development Studio',
'Mobile Apps Company',
'Cyber Security s.r.o.'
]
// Výber náhodného študenta
cy.get('table tbody tr').then($rows => {
const randomIndex = Math.floor(Math.random() * $rows.length)
const randomRow = $rows.eq(randomIndex)
cy.wrap(randomIndex).as('selectedIndex')
cy.wrap(randomRow).as('selectedRow')
})
// Kliknutie na "Editovať"
cy.get('@selectedRow').within(() => {
cy.contains('Editovať').click()
})
// Generovanie náhodného mena
const randomCompanyName = companyNames[Math.floor(Math.random() * companyNames.length)]
const randomHouseNumber = Math.floor(Math.random() * 200 + 1)
const randomAddress = `Hlavná ${randomHouseNumber}/1, Komárno, 946 01`
const randomICO = String(Math.floor(Math.random() * 90000000000) + 10000000000)
// Kontrola cesty
cy.url().should('include', '/dashboard/admin/companies/edit/')
// Zmena názvu
cy.get('#input-v-1-1').clear().type(randomCompanyName)
// Zmena adresy
cy.get('#input-v-1-4').clear().type(randomAddress)
// Zmena IČO
cy.get('#input-v-1-7').clear().type(randomICO)
// Uložiť zmeny
cy.contains('Uložiť zmeny').click()
// Počkanie na uloženie
cy.wait(2000)
cy.url().should('include', '/dashboard/admin/companies')
// Overenie zmien v tabuľke
cy.get('@selectedIndex').then((index) => {
cy.get('table tbody tr').eq(index as number).within(() => {
const expectedValues = [
`${randomCompanyName}`,
randomICO,
null, // netestuje sa
null, // netestuje sa
null, // netestuje sa
null // netestuje sa
]
cy.get('td').then($cells => {
expectedValues.forEach((expectedValue, i) => {
if (expectedValue === null) return; // skip checking
expect($cells.eq(i).text().trim()).to.equal(expectedValue)
})
})
})
})
})
})

View File

@@ -0,0 +1,48 @@
describe('Admin Dashboard', () => {
beforeEach(() => {
cy.visit('/login')
cy.wait(1000)
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
})
it('should load the list of companies', () => {
cy.contains("Firmy").click()
cy.url().should('include', '/dashboard/admin/companies')
cy.get("table").find("tr").should("have.length.greaterThan", 1)
})
it('should load the list of students', () => {
cy.contains("Študenti").click()
cy.url().should('include', '/dashboard/admin/students')
cy.get("table").find("tr").should("have.length.greaterThan", 1)
})
it('should load the list of internships', () => {
cy.contains("Praxe").click()
cy.url().should('include', '/dashboard/admin/internships')
cy.get("table").find("tr").should("have.length.greaterThan", 1)
})
it('should load the my account details', () => {
cy.contains("Môj profil").click()
cy.url().should('include', '/account')
cy.contains("Môj profil")
cy.contains("Osobné údaje")
cy.contains("Meno")
cy.contains("Priezvisko")
// check if the names match the default test user
cy.get('#page-container-card > div:nth-child(5) > div:nth-child(1) > div > div.v-list-item-subtitle').should('have.text', 'Test')
cy.get("#page-container-card > div:nth-child(5) > div:nth-child(2) > div > div.v-list-item-subtitle").should('have.text', 'User')
// check if the "change my password" button is there
cy.get("#page-container-card > div:nth-child(6) > div > div > a > span.v-btn__content").invoke('text').then(text => text.trim()).should('equal', 'Zmeniť heslo')
})
})

View File

@@ -0,0 +1,37 @@
describe('Admin Student Document Downloads', () => {
beforeEach(() => {
cy.visit('/login')
cy.wait(1000)
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
cy.contains("Praxe").click()
cy.url().should('include', '/dashboard/admin/internships')
cy.wait(2000)
})
it('should be able to generate and download the default proof', () => {
cy.get('table').within(() => {
cy.get('tbody tr')
.then(rows => {
const count = rows.length
const randomIndex = Math.floor(Math.random() * count)
cy.wrap(rows[randomIndex]).as('randomRow')
})
cy.get('@randomRow').within(() => {
cy.get('td').get('.internship-edit-btn').click()
})
})
cy.url().should('include', '/dashboard/admin/internships/edit/')
cy.contains('Stiahnuť originálnu zmluvu').click()
cy.wait(2000)
})
})

View File

@@ -0,0 +1,79 @@
describe('Admin Internship CRUD', () => {
beforeEach(() => {
cy.visit('/login')
cy.wait(3000)
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
cy.contains("Praxe").click()
cy.url().should('include', '/dashboard/admin/internships')
cy.wait(2000)
})
it('should load the list of internships in a proper format', () => {
cy.get('table').within(() => {
cy.validateColumn('Firma', (company) => {
expect(company).to.not.be.empty
})
cy.validateColumn('Študent', (student) => {
const nameParts = student.trim().split(' ')
expect(nameParts).to.have.length.at.least(2)
expect(nameParts[0]).to.not.be.empty
expect(nameParts[1]).to.not.be.empty
})
cy.validateColumn('Od', (from) => {
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/
expect(from.trim()).to.match(datePattern)
const [day, month, year] = from.trim().split('.').map(Number)
const date = new Date(year, month - 1, day)
expect(date.getDate()).to.equal(day)
expect(date.getMonth()).to.equal(month - 1)
expect(date.getFullYear()).to.equal(year)
})
cy.validateColumn('Do', (to) => {
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/
expect(to.trim()).to.match(datePattern)
const [day, month, year] = to.trim().split('.').map(Number)
const date = new Date(year, month - 1, day)
expect(date.getDate()).to.equal(day)
expect(date.getMonth()).to.equal(month - 1)
expect(date.getFullYear()).to.equal(year)
})
cy.validateColumn('Ročník', (grade) => {
const gradeNum = Number(grade.trim())
expect(gradeNum).to.be.a('number')
expect(gradeNum).to.be.at.least(1)
expect(gradeNum).to.be.at.most(5)
})
cy.validateColumn('Semester', (semester) => {
expect(semester.trim()).to.be.oneOf(['Zimný', 'Letný'])
})
// stav netestujeme
cy.contains('th', 'Operácie').parent('tr')
})
})
it('should be able to delete an internship', () => {
cy.get('table tbody tr').first().within(() => {
cy.get('.internship-delete-btn').click()
})
cy.contains("Potvrdiť vymazanie").parent().should('be.visible')
cy.contains("Potvrdiť vymazanie").parent().contains("Áno").click()
cy.contains("Potvrdiť vymazanie").should('not.exist')
cy.wait(1000)
})
})

View File

@@ -0,0 +1,149 @@
describe('Admin Student CRUD', () => {
beforeEach(() => {
cy.visit('/login')
cy.wait(3000)
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
cy.contains("Študenti").click()
cy.url().should('include', '/dashboard/admin/students')
})
it('should load the list of students in a proper format', () => {
cy.get('table').within(() => {
cy.validateColumn('Meno', (name) => {
const nameParts = name.trim().split(' ')
expect(nameParts).to.have.length.at.least(2)
expect(nameParts[0]).to.not.be.empty
expect(nameParts[1]).to.not.be.empty
})
cy.validateColumn('E-mail', (email) => {
expect(email).to.include("@student.ukf.sk")
})
cy.validateColumn('Telefón', (phone) => {
expect(phone.trim()).to.not.be.empty
})
cy.validateColumn('Študijný program', (program) => {
expect(program.trim()).to.not.be.empty
})
cy.validateColumn('Osobný e-mail', (personalEmail) => {
expect(personalEmail.trim()).to.not.be.empty
expect(personalEmail.trim()).to.include("@")
})
cy.validateColumn('Adresa', (address) => {
expect(address.trim()).to.not.be.empty
})
})
})
it('should be able to delete a student', () => {
let initialRowCount = 0
cy.get('table tbody tr').its('length').then((count) => {
initialRowCount = count
})
cy.get('table tbody tr').first().within(() => {
cy.contains('Vymazať').click()
})
cy.contains("Potvrdiť vymazanie").parent().should('be.visible')
cy.contains("Potvrdiť vymazanie").parent().contains("Vymazať").click()
cy.contains("Potvrdiť vymazanie").should('not.exist')
cy.wait(1000)
cy.get('table tbody tr').its('length').then((count) => {
expect(count).to.be.eq(initialRowCount - 1)
})
})
it('should be able to edit a student', () => {
// Náhodné sety
const firstNames = ['Ján', 'Peter', 'Martin', 'Tomáš', 'Michal', 'Anna', 'Mária', 'Eva', 'Katarína', 'Lucia', 'Adam', 'Cypress']
const lastNames = ['Novák', 'Kováč', 'Horváth', 'Varga', 'Molnár', 'Tóth', 'Nagy', 'Lukáč', 'Szabó', 'Kiss', 'Jablko', 'Tester']
// Výber náhodného študenta
cy.get('table tbody tr').then($rows => {
const randomIndex = Math.floor(Math.random() * $rows.length)
const randomRow = $rows.eq(randomIndex)
cy.wrap(randomIndex).as('selectedIndex')
cy.wrap(randomRow).as('selectedRow')
})
// Kliknutie na "Editovať"
cy.get('@selectedRow').within(() => {
cy.contains('Editovať').click()
})
// Generovanie náhodného mena
const randomFirstName = firstNames[Math.floor(Math.random() * firstNames.length)]
const randomLastName = lastNames[Math.floor(Math.random() * lastNames.length)]
const randomStudentEmail = `cypress.test.${Date.now()}@student.ukf.sk`
const randomPhone = `+421${Math.floor(Math.random() * 900000000 + 100000000)}`
const randomPersonalEmail = `cypress.test.${Date.now()}@gmail.com`
const randomHouseNumber = Math.floor(Math.random() * 200 + 1)
const randomAddress = `Hlavná ${randomHouseNumber}/1, Komárno, 946 01`
// Kontrola cesty
cy.url().should('include', '/dashboard/admin/students/edit/')
// Zmena mena
cy.get('#input-v-1-1').clear().type(randomFirstName) // meno
cy.get('#input-v-1-4').clear().type(randomLastName) // priezvisko
// Zmena e-mailu
cy.get('#input-v-1-7').clear().type(randomStudentEmail)
// Zmena telefónu
cy.get('#input-v-1-10').clear().type(randomPhone)
// Zmena študijného programu
cy.get('#input-v-1-13').clear().type('aplikovaná informatika')
// Zmena osobného e-mailu
cy.get('#input-v-1-16').clear().type(randomPersonalEmail)
// Zmena adresy
cy.get('#input-v-1-19').clear().type(randomAddress)
// Uložiť zmeny
cy.contains('Uložiť zmeny').click()
// Počkanie na uloženie
cy.wait(2000)
cy.url().should('include', '/dashboard/admin/students')
// Overenie zmien v tabuľke
cy.get('@selectedIndex').then((index) => {
cy.get('table tbody tr').eq(index as number).within(() => {
const expectedValues = [
`${randomFirstName} ${randomLastName}`,
randomStudentEmail,
randomPhone,
'aplikovaná informatika',
randomPersonalEmail,
randomAddress
]
cy.get('td').then($cells => {
expectedValues.forEach((expectedValue, i) => {
expect($cells.eq(i).text().trim()).to.equal(expectedValue)
})
})
})
})
})
})

View File

@@ -0,0 +1,17 @@
describe('Home Page', () => {
it('should display the home page', () => {
cy.visit('/')
cy.wait(1000)
cy.contains("Domov")
cy.contains("Register")
cy.contains("Login")
cy.contains("Informácie o odbornej praxi pre študentov")
cy.contains("Informácie o odbornej praxi pre firmy")
cy.contains("O aplikácii")
cy.contains("(c) Fakulta prírodných vied a informatiky, UKF v Nitre")
})
})

View File

@@ -0,0 +1,21 @@
describe('Info Pages', () => {
it('should load the info page for students', () => {
cy.visit('/')
cy.contains("Informácie o odbornej praxi pre študentov").click()
cy.location('pathname').should('eq', '/info/student')
cy.contains("Informácie o odbornej praxi pre študentov")
cy.contains("Podmienky absolvovania predmetu")
})
it('should load the info page for companies', () => {
cy.visit('/')
cy.contains("Informácie o odbornej praxi pre firmy").click()
cy.location('pathname').should('eq', '/info/company')
cy.contains("Detaily a pravidlá odbornej praxe pre firmy")
cy.contains("Zmluvné podmienky")
cy.contains("Pravidlá a povinnost počas praxe")
cy.contains("Hodnotenie a ukončenie praxe")
})
})

View File

@@ -0,0 +1,24 @@
describe('Log in as admin', () => {
it('should be able to log in as the default administrator', () => {
cy.visit('/login')
cy.wait(1500)
cy.contains("Prihlásenie")
cy.get('input[type="email"]').type('test@example.com')
cy.get('input[type="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.wait(1000)
cy.contains("Vitajte")
cy.location('pathname').should('eq', '/dashboard/admin')
cy.wait(1000)
cy.contains("Logout").click()
cy.location('pathname').should('eq', '/')
cy.wait(1000)
})
})

View File

@@ -0,0 +1,39 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
// Custom command to validate table columns
Cypress.Commands.add('validateColumn', (columnName: string, validator: (value: string) => void) => {
cy.contains('th', columnName).parent('tr').then(($headerRow) => {
const colIndex = $headerRow.find('th').index(cy.$$(`th:contains("${columnName}")`)[0])
cy.get('tbody tr').each(($row) => {
cy.wrap($row).find('td').eq(colIndex).invoke('text').then((cellText) => {
validator(cellText as string)
})
})
})
})

View File

@@ -0,0 +1,17 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'

13
frontend/cypress/support/index.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
/**
* Custom command to validate all cells in a table column
* @param columnName - The name of the column header to validate
* @param validator - A function that receives the cell text and performs assertions
* @example cy.validateColumn('Email', (email) => { expect(email).to.include('@') })
*/
validateColumn(columnName: string, validator: (value: string) => void): Chainable<void>
}
}

View File

@@ -39,5 +39,10 @@ export default defineNuxtConfig({
origin: 'http://localhost:8080', // NUXT_PUBLIC_SANCTUM_ORIGIN
},
},
},
typescript: {
strict: true,
typeCheck: true,
}
});

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More