feat: implement internship editing functionality for companies

This commit is contained in:
2025-11-03 20:46:04 +01:00
parent 30973b2ced
commit 8915a7cec8
2 changed files with 175 additions and 298 deletions

View File

@@ -0,0 +1,116 @@
<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, refresh } = await useSanctumFetch<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 -->
<v-alert v-if="loading" density="compact" text="Prosím čakajte..." title="Spracovávam" type="info"
class="mx-auto alert"></v-alert>
<!-- Chybová hláška -->
<v-alert v-if="action_error !== null" density="compact" :text="action_error" title="Chyba" type="error"
class="mx-auto alert"></v-alert>
<div>
<h2>Základné informácie</h2>
<v-alert v-if="data?.status.status !== InternshipStatus.SUBMITTED" density="compact"
text='Vaša prax nie je v stave "Zadaná" a teda nemôžete meniť údaje.' title="Blokované" type="error"
class="mx-auto alert"></v-alert>
<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>
<v-alert v-if="data?.status.status !== InternshipStatus.CONFIRMED" type="error" variant="tonal"
title="Blokované" text='Vaša prax nie je v stave "Schválená" a teda nemôžete nahrať dokumenty.' />
<InternshipDocumentEditor v-else :internship="data!" @successful-submit="refresh" />
</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

@@ -1,326 +1,87 @@
<script setup lang="ts">
import type { User } from '~/types/user';
import type { Internship } from '~/types/internships';
import { prettyInternshipStatus } from '~/types/internship_status';
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",
title: "Portál firmy - praxe | ISOP",
ogTitle: "Portál firmy - praxe",
description: "Portál firmy - praxe ISOP",
ogDescription: "Portál firmy - praxe",
});
const user = useSanctumUser<User>();
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' },
];
type ExistingReport = {
fileName: string
size: number
uploadedBy: 'STUDENT' | 'FIRMA'
uploadedAt: string
url?: string
}
type Decision = 'APPROVE' | 'REJECT' | null
type Student = {
id: number | string
name: string
existingReport: ExistingReport | null
// lokálny klientsky stav (front-only)
reportFile: File | null
localPreviewUrl?: string | null
decision: Decision
rejectNote: string
// UI stav pre daného študenta
loading?: boolean
success?: boolean
error?: string | null
formRef?: any
}
// zoznam studentov
const students = ref<Student[]>([
{
id: 1,
name: 'Ján Novák',
existingReport: {
fileName: 'vykaz-jan-novak.pdf',
size: 356812,
uploadedBy: 'STUDENT',
uploadedAt: '2025-03-04T10:15:00Z',
url: '/api/mock-download/vykaz-jan-novak.pdf',
},
reportFile: null,
localPreviewUrl: null,
decision: null,
rejectNote: '',
loading: false,
success: false,
error: null,
formRef: null,
},
{
id: 2,
name: 'Petra Kováčová',
existingReport: null,
reportFile: null,
localPreviewUrl: null,
decision: null,
rejectNote: '',
loading: false,
success: false,
error: null,
formRef: null,
},
])
// validačné pravidlá
const allowedMimes = ['application/pdf']
const maxSizeBytes = 10 * 1024 * 1024 // 10 MB
const rules = {
reportRequiredIfMissing: (student: Student) => (file: File | null) =>
!!student.existingReport || !!file || 'Výkaz je povinný, ak ešte neexistuje.',
isPdf: (file: File | null) =>
!file || allowedMimes.includes(file.type) || 'Povolený je iba PDF súbor.',
maxSize: (file: File | null) =>
!file || file.size <= maxSizeBytes || 'Maximálna veľkosť súboru je 10 MB.',
decisionRequired: (v: Decision) => !!v || 'Vyberte, či výkaz potvrdzujete alebo zamietate.',
}
// helpers
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function hasReport(s: Student) {
return !!s.existingReport
}
function onReportChange(s: Student, file: File | null) {
if (s.localPreviewUrl) {
URL.revokeObjectURL(s.localPreviewUrl)
s.localPreviewUrl = null
}
s.reportFile = file
if (file) s.localPreviewUrl = URL.createObjectURL(file)
}
async function submitFor(s: Student) {
s.error = null
s.success = false
// validácia
const ok = await s.formRef?.validate?.()
if (!ok?.valid) return
s.loading = true
try {
const fd = new FormData()
if (s.reportFile) {
fd.append('report', s.reportFile) // nový/aktualizovaný výkaz
} else if (hasReport(s)) {
fd.append('report_exists', 'true')
// fd.append('report_id', '...') // neskôr z backendu
}
if (s.decision) fd.append('decision', s.decision)
if (s.rejectNote && s.decision === 'REJECT') fd.append('reject_note', s.rejectNote)
fd.append('student_id', String(s.id))
console.log('Submitting for student', s.name, Array.from(fd.entries()))
await new Promise((r) => setTimeout(r, 600))
s.success = true
if (s.localPreviewUrl) {
URL.revokeObjectURL(s.localPreviewUrl)
s.localPreviewUrl = null
}
s.reportFile = null
s.decision = null
s.rejectNote = ''
s.formRef?.resetValidation?.()
} catch (e: any) {
s.error = e?.message ?? 'Odoslanie zlyhalo. Skúste znova.'
} finally {
s.loading = false
}
}
const { data, error } = await useSanctumFetch<Internship[]>('/api/internships/my');
</script>
<template>
<v-container fluid>
<v-card id="footer-card">
<h2 class="page-container page-title">Výkazy praxe</h2>
<p class="page-container page-lead">
Pre každého študenta si môžete výkaz <strong>otvoriť/stiahnuť</strong>, prípadne ho
<strong>nahrať</strong> za študenta a následne ho <strong>potvrdiť</strong> alebo
<strong>zamietnuť</strong>.
</p>
<v-card id="page-container-card">
<h1>Praxe študentov</h1>
<div class="form-left">
<v-row class="mb-4" dense>
<v-col cols="12">
<v-alert type="info" variant="tonal" title="Postup">
<div> Ak výkaz existuje, otvoríte ho cez Otvoriť alebo Stiahnuť. </div>
<div> Ak výkaz chýba, môžete ho nahrať ako PDF a následne potvrdiť/zamietnuť.</div>
</v-alert>
</v-col>
</v-row>
<!-- spacer -->
<div style="height: 40px;"></div>
<!-- Zoznam študentov -->
<v-row>
<v-col v-for="s in students" :key="s.id" cols="12">
<v-card variant="outlined" class="mb-4">
<div class="student-header">
<div class="student-title">
<h3 class="student-name">{{ s.name }}</h3>
</div>
<!-- Chybová hláška -->
<v-alert v-if="error" density="compact" :text="error?.message" title="Chyba" type="error"
id="login-error-alert" class="mx-auto alert"></v-alert>
<div class="student-actions">
<!-- existujúci výkaz -->
<v-btn v-if="s.existingReport?.url" :href="s.existingReport.url" target="_blank"
variant="tonal" size="small" prepend-icon="mdi-open-in-new">
Otvoriť
</v-btn>
<v-btn v-if="s.existingReport?.url" :href="s.existingReport.url" target="_blank"
download variant="text" size="small" prepend-icon="mdi-download">
Stiahnuť
</v-btn>
</div>
</div>
<v-divider />
<v-card-text>
<!-- info o existujúcom výkaze -->
<v-alert v-if="s.existingReport" type="success" variant="tonal" class="mb-4"
:title="`Výkaz existuje (${s.existingReport.uploadedBy === 'STUDENT' ? 'nahral študent' : 'nahrala firma'})`">
<div><strong>Súbor:</strong> {{ s.existingReport.fileName }} ({{
formatSize(s.existingReport.size) }})</div>
<div><strong>Nahraté:</strong> {{ new
Date(s.existingReport.uploadedAt).toLocaleString() }}</div>
</v-alert>
<v-form :ref="el => (s.formRef = el)" @submit.prevent="submitFor(s)">
<v-row>
<v-col cols="12" md="6">
<h4 class="section-title">Výkaz</h4>
<v-file-input class="no-left-gap" :model-value="s.reportFile"
@update:model-value="onReportChange(s, $event as any)"
:rules="[rules.reportRequiredIfMissing(s), rules.isPdf, rules.maxSize]"
accept=".pdf,application/pdf" prepend-icon="" label="Nahrať PDF výkaz"
variant="outlined" show-size clearable :disabled="s.loading"
hint="Povolené: PDF, max 10 MB" persistent-hint />
<!-- lokálny náhľad/stiahnutie súboru -->
<div v-if="s.localPreviewUrl" class="mt-2 d-flex ga-2">
<v-btn :href="s.localPreviewUrl" target="_blank" variant="tonal"
prepend-icon="mdi-open-in-new">
Otvoriť náhľad
</v-btn>
<v-btn :href="s.localPreviewUrl" :download="s.reportFile?.name"
variant="text" prepend-icon="mdi-download">
Stiahnuť vybraný súbor
</v-btn>
</div>
</v-col>
<v-col cols="12" md="6">
<h4 class="section-title">Rozhodnutie</h4>
<v-radio-group v-model="s.decision" :rules="[rules.decisionRequired]"
inline>
<v-radio label="Potvrdiť" value="APPROVE" />
<v-radio label="Zamietnuť" value="REJECT" />
</v-radio-group>
<v-textarea v-if="s.decision === 'REJECT'" v-model="s.rejectNote"
label="Dôvod zamietnutia (nepovinné)" variant="outlined" rows="3"
auto-grow :disabled="s.loading" />
</v-col>
</v-row>
<div class="actions">
<v-btn :loading="s.loading" color="primary" type="submit">
Odoslať pre {{ s.name }}
</v-btn>
</div>
<v-alert v-if="s.success" type="success" class="mt-3" title="Odoslané"
text="Rozhodnutie bolo zaznamenané." />
<v-alert v-if="s.error" type="error" class="mt-3" title="Chyba" :text="s.error" />
</v-form>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<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.user!.name }}</td>
<td>{{ item.start }}</td>
<td>{{ item.end }}</td>
<td>{{ item.year_of_study }}</td>
<td>{{ item.semester === "WINTER" ? "Zimný" : "Letný" }}</td>
<td>
<v-btn class="m-1" density="compact" base-color="grey">
{{ prettyInternshipStatus(item.status.status) }}
</v-btn>
</td>
<td class="text-left">
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-pencil" base-color="orange"
:to="'/dashboard/company/internships/edit/' + item.id">Editovať</v-btn>
<v-btn class="m-1 op-btn" density="compact" append-icon="mdi-trash-can-outline"
base-color="red" @click="async () => { }">Zmazať</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-container>
</template>
<style scoped>
.page-container {
max-width: 1120px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
#page-container-card {
padding-left: 10px;
padding-right: 10px;
}
.form-left {
padding-left: 24px;
padding-right: 24px;
.alert {
margin-bottom: 10px;
}
.page-title {
font-size: 32px;
line-height: 1.2;
font-weight: 700;
margin: 16px 0 8px;
color: #1f1f1f;
}
.page-lead {
margin: 0 0 24px 0;
color: #6b6b6b;
}
.section-title {
font-size: 18px;
font-weight: 700;
margin: 8px 0 8px;
color: #1f1f1f;
}
.student-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.student-title {
display: flex;
align-items: center;
gap: 8px;
}
.student-name {
font-size: 20px;
font-weight: 700;
margin: 0;
}
.student-actions {
display: flex;
gap: 8px;
}
.actions {
margin-top: 12px;
margin-bottom: 8px;
.op-btn {
margin: 10px;
}
</style>