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

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

View File

@@ -16,5 +16,10 @@
"vue": "^3.5.22",
"vue-router": "^4.5.1",
"vuetify-nuxt-module": "^0.18.8"
},
"devDependencies": {
"cypress": "^15.6.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.3"
}
}

View File

@@ -14,5 +14,9 @@
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}
],
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
}
}