Benutzerverwaltung mit Rollen/Rechten und Urlaubsplanung
Führt Login mit vier Rollen (Admin, Teamleiter, Mitarbeiter, Beobachter) ein, über die Teammitglieder eigenen Urlaub inkl. Pflicht-Vertreter eintragen können. Die Auslastungsberechnung berücksichtigt Urlaub jetzt als reduzierte Kapazität. Neue Konten vergeben ihr Passwort selbst beim ersten Login. Migrationsskript für bestehende Mitarbeiter aus der Vorversion inklusive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+57
-32
@@ -1,47 +1,72 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import LoginPanel from './LoginPanel.vue';
|
||||
import SchedulePanel from './SchedulePanel.vue';
|
||||
import UtilizationPanel from './UtilizationPanel.vue';
|
||||
import VacationPanel from './VacationPanel.vue';
|
||||
import ProjectsPanel from './ProjectsPanel.vue';
|
||||
import EmployeesPanel from './EmployeesPanel.vue';
|
||||
import TeamsPanel from './TeamsPanel.vue';
|
||||
import UsersPanel from './UsersPanel.vue';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'schedule', label: 'Zeitplan' },
|
||||
{ id: 'utilization', label: 'Auslastung' },
|
||||
{ id: 'projects', label: 'Projekte' },
|
||||
{ id: 'employees', label: 'Mitarbeiter' },
|
||||
{ id: 'teams', label: 'Teams' },
|
||||
const ALL_TABS = [
|
||||
{ id: 'schedule', label: 'Zeitplan', roles: ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'] },
|
||||
{ id: 'utilization', label: 'Auslastung', roles: ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'] },
|
||||
{ id: 'vacation', label: 'Urlaub', roles: ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'] },
|
||||
{ id: 'projects', label: 'Projekte', roles: ['admin', 'teamleiter', 'mitarbeiter'] },
|
||||
{ id: 'employees', label: 'Mitarbeiter', roles: ['admin', 'teamleiter', 'mitarbeiter'] },
|
||||
{ id: 'teams', label: 'Teams', roles: ['admin', 'teamleiter', 'mitarbeiter'] },
|
||||
{ id: 'users', label: 'Benutzer', roles: ['admin'] },
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
const role = store.session.user?.role;
|
||||
return ALL_TABS.filter((t) => t.roles.includes(role));
|
||||
});
|
||||
const activeTab = ref('schedule');
|
||||
|
||||
onMounted(() => store.loadAll());
|
||||
onMounted(async () => {
|
||||
await store.restoreSession();
|
||||
if (store.session.user) await store.loadAll();
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await store.logout();
|
||||
activeTab.value = 'schedule';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<h1>Ressourcenplanung</h1>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p v-if="store.loading" class="muted">Lade Daten…</p>
|
||||
<p v-if="store.error" class="muted" style="color: #ef4444">{{ store.error }}</p>
|
||||
<template v-else>
|
||||
<SchedulePanel v-if="activeTab === 'schedule'" />
|
||||
<UtilizationPanel v-if="activeTab === 'utilization'" />
|
||||
<ProjectsPanel v-if="activeTab === 'projects'" />
|
||||
<EmployeesPanel v-if="activeTab === 'employees'" />
|
||||
<TeamsPanel v-if="activeTab === 'teams'" />
|
||||
</template>
|
||||
</main>
|
||||
<LoginPanel v-if="!store.session.user" />
|
||||
<template v-else>
|
||||
<header class="topbar">
|
||||
<h1>Ressourcenplanung</h1>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<span class="muted">{{ store.session.user.email }} ({{ store.session.user.role }})</span>
|
||||
<button class="btn secondary" @click="logout">Abmelden</button>
|
||||
</header>
|
||||
<main>
|
||||
<p v-if="store.loading" class="muted">Lade Daten…</p>
|
||||
<p v-if="store.error" class="muted" style="color: #ef4444">{{ store.error }}</p>
|
||||
<template v-else>
|
||||
<SchedulePanel v-if="activeTab === 'schedule'" />
|
||||
<UtilizationPanel v-if="activeTab === 'utilization'" />
|
||||
<VacationPanel v-if="activeTab === 'vacation'" />
|
||||
<ProjectsPanel v-if="activeTab === 'projects'" />
|
||||
<EmployeesPanel v-if="activeTab === 'employees'" />
|
||||
<TeamsPanel v-if="activeTab === 'teams'" />
|
||||
<UsersPanel v-if="activeTab === 'users'" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { store } from './store.js';
|
||||
|
||||
const canManage = computed(() => ['admin', 'teamleiter'].includes(store.session.user?.role));
|
||||
|
||||
const newEmployee = reactive({ first_name: '', last_name: '', email: '', role: '', weekly_capacity_hours: 40 });
|
||||
|
||||
async function addEmployee() {
|
||||
@@ -36,7 +38,7 @@ async function saveEdit(id) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<div class="panel-section" v-if="canManage">
|
||||
<h2>Neuer Mitarbeiter</h2>
|
||||
<div class="form-row">
|
||||
<input v-model="newEmployee.first_name" placeholder="Vorname" />
|
||||
@@ -63,7 +65,7 @@ async function saveEdit(id) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in store.employees" :key="e.id">
|
||||
<template v-if="editingId === e.id">
|
||||
<template v-if="editingId === e.id && canManage">
|
||||
<td><input v-model="editForm.first_name" style="width: 100%" /></td>
|
||||
<td><input v-model="editForm.last_name" style="width: 100%" /></td>
|
||||
<td><input v-model="editForm.email" style="width: 100%" /></td>
|
||||
@@ -80,10 +82,11 @@ async function saveEdit(id) {
|
||||
<td>{{ e.email }}</td>
|
||||
<td>{{ e.role }}</td>
|
||||
<td>{{ e.weekly_capacity_hours }}</td>
|
||||
<td>
|
||||
<td v-if="canManage">
|
||||
<button class="btn secondary" @click="startEdit(e)">Bearbeiten</button>
|
||||
<button class="btn danger" @click="store.deleteEmployee(e.id)">Löschen</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { store } from './store.js';
|
||||
|
||||
const form = reactive({ email: '', password: '' });
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (!form.email || !form.password) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await store.login(form.email, form.password);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section" style="max-width: 360px; margin: 64px auto">
|
||||
<h2>Anmelden</h2>
|
||||
<p class="muted">
|
||||
Neue Konten haben noch kein Passwort: das beim ersten Login eingegebene Passwort wird übernommen.
|
||||
</p>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="form-row" style="flex-direction: column; align-items: stretch">
|
||||
<input v-model="form.email" type="email" placeholder="E-Mail" autocomplete="username" />
|
||||
<input v-model="form.password" type="password" placeholder="Passwort" autocomplete="current-password" />
|
||||
<button class="btn" type="submit" :disabled="loading">{{ loading ? 'Anmelden…' : 'Anmelden' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="error" class="muted" style="color: #ef4444">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,18 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import { formatDate, fullName } from './helpers.js';
|
||||
|
||||
const canManage = computed(() => ['admin', 'teamleiter'].includes(store.session.user?.role));
|
||||
|
||||
function isAssignedToMe(task) {
|
||||
return task.assignments.some((a) => a.employee_id === store.session.user?.employee_id);
|
||||
}
|
||||
|
||||
function statusEditable(task) {
|
||||
return canManage.value || isAssignedToMe(task);
|
||||
}
|
||||
|
||||
const newProject = reactive({ name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
|
||||
const newTask = reactive({});
|
||||
const newAssignment = reactive({});
|
||||
@@ -60,7 +70,7 @@ async function duplicateTask(projectId, taskId) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<div class="panel-section" v-if="canManage">
|
||||
<h2>Neues Projekt</h2>
|
||||
<div class="form-row">
|
||||
<input v-model="newProject.name" placeholder="Projektname" />
|
||||
@@ -75,7 +85,7 @@ async function duplicateTask(projectId, taskId) {
|
||||
<div class="panel-section" v-for="project in store.projects" :key="project.id">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<h2 :style="{ color: project.color }">{{ project.name }}</h2>
|
||||
<button class="btn secondary" @click="expanded[project.id] = !expanded[project.id]">
|
||||
<button v-if="canManage" class="btn secondary" @click="expanded[project.id] = !expanded[project.id]">
|
||||
{{ expanded[project.id] ? 'Zuklappen' : 'Aufgaben verwalten' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,14 +108,17 @@ async function duplicateTask(projectId, taskId) {
|
||||
<td>{{ formatDate(task.start_date) }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-if="canManage"
|
||||
type="date"
|
||||
:value="task.end_date"
|
||||
@change="store.updateTask(project.id, task.id, { end_date: $event.target.value })"
|
||||
/>
|
||||
<span v-else>{{ formatDate(task.end_date) }}</span>
|
||||
</td>
|
||||
<td>{{ task.estimated_hours }}</td>
|
||||
<td>
|
||||
<select
|
||||
v-if="statusEditable(task)"
|
||||
:value="task.status"
|
||||
@change="store.updateTask(project.id, task.id, { status: $event.target.value })"
|
||||
>
|
||||
@@ -113,17 +126,19 @@ async function duplicateTask(projectId, taskId) {
|
||||
<option value="in Arbeit">in Arbeit</option>
|
||||
<option value="erledigt">erledigt</option>
|
||||
</select>
|
||||
<span v-else>{{ task.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-for="a in task.assignments" :key="a.id" class="tag">
|
||||
{{ a.employee_name }} ({{ a.allocated_hours_per_week }}h)
|
||||
<button
|
||||
v-if="canManage"
|
||||
class="tag-remove"
|
||||
title="Zuweisung entfernen"
|
||||
@click="store.unassignEmployee(project.id, task.id, a.id)"
|
||||
>×</button>
|
||||
</span>
|
||||
<div class="assign-inline">
|
||||
<div v-if="canManage" class="assign-inline">
|
||||
<select v-model="assignmentForm(task.id).employee_id">
|
||||
<option value="" disabled>Mitarbeiter wählen</option>
|
||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</option>
|
||||
@@ -136,7 +151,7 @@ async function duplicateTask(projectId, taskId) {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<template v-if="expanded[project.id]">
|
||||
<template v-if="expanded[project.id] && canManage">
|
||||
<h3>Neue Aufgabe</h3>
|
||||
<div class="form-row">
|
||||
<input v-model="taskForm(project.id).name" placeholder="Aufgabenname" />
|
||||
@@ -167,7 +182,7 @@ async function duplicateTask(projectId, taskId) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div style="margin-top: 12px">
|
||||
<div v-if="canManage" style="margin-top: 12px">
|
||||
<button class="btn danger" @click="store.deleteProject(project.id)">Projekt löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import { fullName } from './helpers.js';
|
||||
|
||||
const canManage = computed(() => ['admin', 'teamleiter'].includes(store.session.user?.role));
|
||||
|
||||
const newTeam = reactive({ name: '', description: '' });
|
||||
const newMember = reactive({});
|
||||
|
||||
@@ -26,7 +28,7 @@ async function addMember(teamId) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<div class="panel-section" v-if="canManage">
|
||||
<h2>Neues Team</h2>
|
||||
<div class="form-row">
|
||||
<input v-model="newTeam.name" placeholder="Teamname" />
|
||||
@@ -41,11 +43,11 @@ async function addMember(teamId) {
|
||||
|
||||
<span v-for="m in team.members" :key="m.id" class="tag">
|
||||
{{ fullName(m) }}
|
||||
<a href="#" @click.prevent="store.removeTeamMember(team.id, m.id)" style="margin-left: 4px">×</a>
|
||||
<a v-if="canManage" href="#" @click.prevent="store.removeTeamMember(team.id, m.id)" style="margin-left: 4px">×</a>
|
||||
</span>
|
||||
<p v-if="team.members.length === 0" class="muted">Keine Mitglieder.</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div v-if="canManage" class="form-row">
|
||||
<select v-model="memberForm(team.id)[team.id]">
|
||||
<option value="" disabled>Mitarbeiter hinzufügen</option>
|
||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</option>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { store } from './store.js';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'teamleiter', label: 'Teamleiter' },
|
||||
{ value: 'mitarbeiter', label: 'Mitarbeiter' },
|
||||
{ value: 'beobachter', label: 'Beobachter' },
|
||||
];
|
||||
|
||||
const availableEmployees = computed(() =>
|
||||
store.employees.filter((e) => !store.users.some((u) => u.employee_id === e.id))
|
||||
);
|
||||
|
||||
const newUser = reactive({ employee_id: '', email: '', role: 'mitarbeiter' });
|
||||
const createError = ref(null);
|
||||
|
||||
function onEmployeeChange() {
|
||||
const employee = store.employees.find((e) => e.id === Number(newUser.employee_id));
|
||||
if (employee?.email) newUser.email = employee.email;
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
createError.value = null;
|
||||
if (!newUser.email || !newUser.role) return;
|
||||
try {
|
||||
await store.createUser({ ...newUser, employee_id: newUser.employee_id || null });
|
||||
Object.assign(newUser, { employee_id: '', email: '', role: 'mitarbeiter' });
|
||||
} catch (e) {
|
||||
createError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(id) {
|
||||
await store.updateUser(id, { reset_password: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Neuer Benutzer</h2>
|
||||
<div class="form-row">
|
||||
<select v-model.number="newUser.employee_id" @change="onEmployeeChange">
|
||||
<option value="">(kein Mitarbeiterbezug)</option>
|
||||
<option v-for="e in availableEmployees" :key="e.id" :value="e.id">{{ e.first_name }} {{ e.last_name }}</option>
|
||||
</select>
|
||||
<input v-model="newUser.email" placeholder="E-Mail" />
|
||||
<select v-model="newUser.role">
|
||||
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
<button class="btn" @click="addUser">Anlegen</button>
|
||||
</div>
|
||||
<p class="muted">Das Passwort wird von der Person selbst beim ersten Login vergeben.</p>
|
||||
<p v-if="createError" class="muted" style="color: #ef4444">{{ createError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2>Benutzer</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in store.users" :key="u.id">
|
||||
<td>{{ u.employee_name || '—' }}</td>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>
|
||||
<select :value="u.role" @change="store.updateUser(u.id, { role: $event.target.value })">
|
||||
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn secondary" @click="resetPassword(u.id)">Passwort zurücksetzen</button>
|
||||
<button class="btn danger" @click="store.deleteUser(u.id)">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="store.users.length === 0" class="muted">Noch keine Benutzer angelegt.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,10 +30,10 @@ function percent(hours, capacity) {
|
||||
<td v-for="(hours, idx) in employee.hours" :key="idx">
|
||||
<div
|
||||
class="heatmap-cell"
|
||||
:style="{ background: utilizationColor(percent(hours, employee.weekly_capacity_hours)) }"
|
||||
:title="`${hours}h von ${employee.weekly_capacity_hours}h`"
|
||||
:style="{ background: utilizationColor(percent(hours, employee.capacity[idx])) }"
|
||||
:title="`${hours}h von ${employee.capacity[idx]}h (Urlaub bereits abgezogen, nominal ${employee.weekly_capacity_hours}h)`"
|
||||
>
|
||||
{{ percent(hours, employee.weekly_capacity_hours) }}%
|
||||
{{ percent(hours, employee.capacity[idx]) }}%
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import { formatDate } from './helpers.js';
|
||||
|
||||
const role = computed(() => store.session.user?.role);
|
||||
const isManager = computed(() => role.value === 'admin' || role.value === 'teamleiter');
|
||||
const ownEmployeeId = computed(() => store.session.user?.employee_id);
|
||||
|
||||
const newVacation = reactive({
|
||||
employee_id: ownEmployeeId.value || '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
substitute_employee_id: '',
|
||||
note: '',
|
||||
});
|
||||
const createError = ref(null);
|
||||
|
||||
function substitutesFor(employeeId) {
|
||||
return store.employees.filter((e) => e.id !== Number(employeeId));
|
||||
}
|
||||
|
||||
async function addVacation() {
|
||||
createError.value = null;
|
||||
if (!newVacation.start_date || !newVacation.end_date || !newVacation.substitute_employee_id) return;
|
||||
try {
|
||||
await store.createVacation({ ...newVacation });
|
||||
Object.assign(newVacation, {
|
||||
employee_id: ownEmployeeId.value || '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
substitute_employee_id: '',
|
||||
note: '',
|
||||
});
|
||||
} catch (e) {
|
||||
createError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function canEdit(vacation) {
|
||||
return isManager.value || vacation.employee_id === ownEmployeeId.value;
|
||||
}
|
||||
|
||||
const editingId = ref(null);
|
||||
const editForm = reactive({ start_date: '', end_date: '', substitute_employee_id: '', note: '' });
|
||||
const editError = ref(null);
|
||||
|
||||
function startEdit(vacation) {
|
||||
editingId.value = vacation.id;
|
||||
editError.value = null;
|
||||
Object.assign(editForm, {
|
||||
start_date: vacation.start_date,
|
||||
end_date: vacation.end_date,
|
||||
substitute_employee_id: vacation.substitute_employee_id,
|
||||
note: vacation.note,
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
editError.value = null;
|
||||
try {
|
||||
await store.updateVacation(id, { ...editForm });
|
||||
editingId.value = null;
|
||||
} catch (e) {
|
||||
editError.value = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Neuer Urlaubseintrag</h2>
|
||||
<div class="form-row">
|
||||
<select v-if="isManager" v-model.number="newVacation.employee_id">
|
||||
<option value="" disabled>Mitarbeiter wählen</option>
|
||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ e.first_name }} {{ e.last_name }}</option>
|
||||
</select>
|
||||
<input v-model="newVacation.start_date" type="date" />
|
||||
<input v-model="newVacation.end_date" type="date" />
|
||||
<select v-model.number="newVacation.substitute_employee_id">
|
||||
<option value="" disabled>Vertreter wählen</option>
|
||||
<option v-for="e in substitutesFor(newVacation.employee_id)" :key="e.id" :value="e.id">
|
||||
{{ e.first_name }} {{ e.last_name }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="newVacation.note" placeholder="Notiz (optional)" />
|
||||
<button class="btn" @click="addVacation">Anlegen</button>
|
||||
</div>
|
||||
<p v-if="createError" class="muted" style="color: #ef4444">{{ createError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2>Urlaubsplanung</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th>Zeitraum</th>
|
||||
<th>Vertreter</th>
|
||||
<th>Notiz</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in store.vacations" :key="v.id">
|
||||
<template v-if="editingId === v.id">
|
||||
<td>{{ v.employee_name }}</td>
|
||||
<td>
|
||||
<input v-model="editForm.start_date" type="date" style="width: 130px" />
|
||||
<input v-model="editForm.end_date" type="date" style="width: 130px" />
|
||||
</td>
|
||||
<td>
|
||||
<select v-model.number="editForm.substitute_employee_id">
|
||||
<option v-for="e in substitutesFor(v.employee_id)" :key="e.id" :value="e.id">
|
||||
{{ e.first_name }} {{ e.last_name }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input v-model="editForm.note" style="width: 100%" /></td>
|
||||
<td>
|
||||
<button class="btn" @click="saveEdit(v.id)">Speichern</button>
|
||||
<button class="btn secondary" @click="cancelEdit">Abbrechen</button>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td>{{ v.employee_name }}</td>
|
||||
<td>{{ formatDate(v.start_date) }} – {{ formatDate(v.end_date) }}</td>
|
||||
<td>{{ v.substitute_name }}</td>
|
||||
<td>{{ v.note }}</td>
|
||||
<td v-if="canEdit(v)">
|
||||
<button class="btn secondary" @click="startEdit(v)">Bearbeiten</button>
|
||||
<button class="btn danger" @click="store.deleteVacation(v.id)">Löschen</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="editError" class="muted" style="color: #ef4444">{{ editError }}</p>
|
||||
<p v-if="store.vacations.length === 0" class="muted">Noch keine Urlaubseinträge vorhanden.</p>
|
||||
</div>
|
||||
</template>
|
||||
+37
-2
@@ -1,18 +1,53 @@
|
||||
let authToken = localStorage.getItem('authToken') || null;
|
||||
|
||||
export function setAuthToken(token) {
|
||||
authToken = token;
|
||||
if (token) localStorage.setItem('authToken', token);
|
||||
else localStorage.removeItem('authToken');
|
||||
}
|
||||
|
||||
async function request(method, url, body) {
|
||||
const headers = {};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${url} fehlgeschlagen: ${res.status} ${text}`);
|
||||
let error;
|
||||
try {
|
||||
error = JSON.parse(text).error;
|
||||
} catch {
|
||||
error = text;
|
||||
}
|
||||
throw new Error(error || `${method} ${url} fehlgeschlagen: ${res.status}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// auth
|
||||
login: (email, password) => request('POST', '/api/auth/login', { email, password }),
|
||||
logout: () => request('POST', '/api/auth/logout'),
|
||||
me: () => request('GET', '/api/auth/me'),
|
||||
|
||||
// vacations
|
||||
getVacations: () => request('GET', '/api/vacations'),
|
||||
createVacation: (data) => request('POST', '/api/vacations', data),
|
||||
updateVacation: (id, data) => request('PUT', `/api/vacations/${id}`, data),
|
||||
deleteVacation: (id) => request('DELETE', `/api/vacations/${id}`),
|
||||
|
||||
// users
|
||||
getUsers: () => request('GET', '/api/users'),
|
||||
createUser: (data) => request('POST', '/api/users', data),
|
||||
updateUser: (id, data) => request('PUT', `/api/users/${id}`, data),
|
||||
deleteUser: (id) => request('DELETE', `/api/users/${id}`),
|
||||
|
||||
// employees
|
||||
getEmployees: () => request('GET', '/api/employees'),
|
||||
createEmployee: (data) => request('POST', '/api/employees', data),
|
||||
|
||||
+84
-2
@@ -1,27 +1,78 @@
|
||||
import { reactive } from 'vue';
|
||||
import { api } from './api.js';
|
||||
import { api, setAuthToken } from './api.js';
|
||||
import { fullName } from './helpers.js';
|
||||
|
||||
const storedUser = localStorage.getItem('authUser');
|
||||
|
||||
export const store = reactive({
|
||||
employees: [],
|
||||
teams: [],
|
||||
projects: [],
|
||||
vacations: [],
|
||||
users: [],
|
||||
utilization: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
session: {
|
||||
token: localStorage.getItem('authToken') || null,
|
||||
user: storedUser ? JSON.parse(storedUser) : null,
|
||||
},
|
||||
|
||||
async restoreSession() {
|
||||
if (!this.session.token) return;
|
||||
try {
|
||||
this.session.user = await api.me();
|
||||
localStorage.setItem('authUser', JSON.stringify(this.session.user));
|
||||
} catch {
|
||||
this.logout();
|
||||
}
|
||||
},
|
||||
|
||||
async login(email, password) {
|
||||
const { token, user } = await api.login(email, password);
|
||||
setAuthToken(token);
|
||||
localStorage.setItem('authUser', JSON.stringify(user));
|
||||
this.session.token = token;
|
||||
this.session.user = user;
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
if (this.session.token) await api.logout();
|
||||
} catch {
|
||||
// Sitzung ggf. schon abgelaufen, trotzdem lokal ausloggen
|
||||
}
|
||||
setAuthToken(null);
|
||||
localStorage.removeItem('authUser');
|
||||
this.session.token = null;
|
||||
this.session.user = null;
|
||||
this.employees = [];
|
||||
this.teams = [];
|
||||
this.projects = [];
|
||||
this.vacations = [];
|
||||
this.users = [];
|
||||
this.utilization = null;
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [employees, teams, projects] = await Promise.all([
|
||||
const [employees, teams, projects, vacations] = await Promise.all([
|
||||
api.getEmployees(),
|
||||
api.getTeams(),
|
||||
api.getProjects(),
|
||||
api.getVacations(),
|
||||
]);
|
||||
this.employees = employees;
|
||||
this.teams = teams;
|
||||
this.projects = projects;
|
||||
this.vacations = vacations;
|
||||
if (this.session.user?.role === 'admin') {
|
||||
this.users = await api.getUsers();
|
||||
}
|
||||
await this.loadUtilization();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
@@ -142,4 +193,35 @@ export const store = reactive({
|
||||
if (task) task.assignments = task.assignments.filter((a) => a.id !== assignmentId);
|
||||
await this.loadUtilization();
|
||||
},
|
||||
|
||||
// vacations
|
||||
async createVacation(data) {
|
||||
this.vacations.unshift(await api.createVacation(data));
|
||||
await this.loadUtilization();
|
||||
},
|
||||
async updateVacation(id, data) {
|
||||
const updated = await api.updateVacation(id, data);
|
||||
const idx = this.vacations.findIndex((v) => v.id === id);
|
||||
if (idx !== -1) this.vacations[idx] = updated;
|
||||
await this.loadUtilization();
|
||||
},
|
||||
async deleteVacation(id) {
|
||||
await api.deleteVacation(id);
|
||||
this.vacations = this.vacations.filter((v) => v.id !== id);
|
||||
await this.loadUtilization();
|
||||
},
|
||||
|
||||
// users
|
||||
async createUser(data) {
|
||||
this.users.push(await api.createUser(data));
|
||||
},
|
||||
async updateUser(id, data) {
|
||||
const updated = await api.updateUser(id, data);
|
||||
const idx = this.users.findIndex((u) => u.id === id);
|
||||
if (idx !== -1) this.users[idx] = updated;
|
||||
},
|
||||
async deleteUser(id) {
|
||||
await api.deleteUser(id);
|
||||
this.users = this.users.filter((u) => u.id !== id);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user