Initial commit: Ressourcenplanung-App

Vue 3 + Vite Frontend und Node/Express + SQLite Backend für
Projekt-, Aufgaben-, Mitarbeiter- und Teamverwaltung inkl.
Gantt-Zeitplan und Auslastungs-Heatmap.
This commit is contained in:
2026-07-03 22:45:07 +02:00
commit fc25eff643
24 changed files with 3938 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ressourcenplanung</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1207
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "ressourcenplanung-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.29"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"vite": "^5.3.1"
}
}
+47
View File
@@ -0,0 +1,47 @@
<script setup>
import { onMounted, ref } from 'vue';
import { store } from './store.js';
import SchedulePanel from './SchedulePanel.vue';
import UtilizationPanel from './UtilizationPanel.vue';
import ProjectsPanel from './ProjectsPanel.vue';
import EmployeesPanel from './EmployeesPanel.vue';
import TeamsPanel from './TeamsPanel.vue';
const tabs = [
{ id: 'schedule', label: 'Zeitplan' },
{ id: 'utilization', label: 'Auslastung' },
{ id: 'projects', label: 'Projekte' },
{ id: 'employees', label: 'Mitarbeiter' },
{ id: 'teams', label: 'Teams' },
];
const activeTab = ref('schedule');
onMounted(() => store.loadAll());
</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>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup>
import { reactive } from 'vue';
import { store } from './store.js';
const newEmployee = reactive({ name: '', email: '', role: '', weekly_capacity_hours: 40 });
async function addEmployee() {
if (!newEmployee.name) return;
await store.createEmployee({ ...newEmployee });
Object.assign(newEmployee, { name: '', email: '', role: '', weekly_capacity_hours: 40 });
}
</script>
<template>
<div class="panel-section">
<h2>Neuer Mitarbeiter</h2>
<div class="form-row">
<input v-model="newEmployee.name" placeholder="Name" />
<input v-model="newEmployee.email" placeholder="E-Mail" />
<input v-model="newEmployee.role" placeholder="Rolle" />
<input v-model.number="newEmployee.weekly_capacity_hours" type="number" style="width: 100px" placeholder="Std/Woche" />
<button class="btn" @click="addEmployee">Anlegen</button>
</div>
</div>
<div class="panel-section">
<h2>Mitarbeiter</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Kapazität (Std/Woche)</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="e in store.employees" :key="e.id">
<td>{{ e.name }}</td>
<td>{{ e.email }}</td>
<td>{{ e.role }}</td>
<td>{{ e.weekly_capacity_hours }}</td>
<td><button class="btn danger" @click="store.deleteEmployee(e.id)">Löschen</button></td>
</tr>
</tbody>
</table>
<p v-if="store.employees.length === 0" class="muted">Noch keine Mitarbeiter angelegt.</p>
</div>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup>
import { reactive } from 'vue';
import { store } from './store.js';
import { formatDate } from './helpers.js';
const newProject = reactive({ name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
const newTask = reactive({});
const newAssignment = reactive({});
const expanded = reactive({});
async function addProject() {
if (!newProject.name) return;
await store.createProject({ ...newProject });
Object.assign(newProject, { name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
}
function taskForm(projectId) {
if (!newTask[projectId]) newTask[projectId] = { name: '', start_date: '', end_date: '', estimated_hours: 0, status: 'geplant' };
return newTask[projectId];
}
async function addTask(projectId) {
const form = taskForm(projectId);
if (!form.name || !form.start_date || !form.end_date) return;
await store.createTask(projectId, { ...form });
Object.assign(form, { name: '', start_date: '', end_date: '', estimated_hours: 0, status: 'geplant' });
}
function assignmentForm(taskId) {
if (!newAssignment[taskId]) newAssignment[taskId] = { employee_id: '', allocated_hours_per_week: 8 };
return newAssignment[taskId];
}
async function addAssignment(projectId, taskId) {
const form = assignmentForm(taskId);
if (!form.employee_id) return;
await store.assignEmployee(projectId, taskId, Number(form.employee_id), Number(form.allocated_hours_per_week));
Object.assign(form, { employee_id: '', allocated_hours_per_week: 8 });
}
</script>
<template>
<div class="panel-section">
<h2>Neues Projekt</h2>
<div class="form-row">
<input v-model="newProject.name" placeholder="Projektname" />
<input v-model="newProject.description" placeholder="Beschreibung" />
<input v-model="newProject.start_date" type="date" />
<input v-model="newProject.end_date" type="date" />
<input v-model="newProject.color" type="color" />
<button class="btn" @click="addProject">Anlegen</button>
</div>
</div>
<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]">
{{ expanded[project.id] ? 'Zuklappen' : 'Aufgaben verwalten' }}
</button>
</div>
<p class="muted">{{ project.description }} · {{ formatDate(project.start_date) }} {{ formatDate(project.end_date) }}</p>
<table>
<thead>
<tr>
<th>Aufgabe</th>
<th>Start</th>
<th>Ende</th>
<th>Stunden</th>
<th>Status</th>
<th>Zugewiesen</th>
</tr>
</thead>
<tbody>
<tr v-for="task in project.tasks" :key="task.id">
<td>{{ task.name }}</td>
<td>{{ formatDate(task.start_date) }}</td>
<td>{{ formatDate(task.end_date) }}</td>
<td>{{ task.estimated_hours }}</td>
<td>{{ task.status }}</td>
<td>
<span v-for="a in task.assignments" :key="a.id" class="tag">
{{ a.employee_name }} ({{ a.allocated_hours_per_week }}h)
</span>
</td>
</tr>
</tbody>
</table>
<template v-if="expanded[project.id]">
<h3>Neue Aufgabe</h3>
<div class="form-row">
<input v-model="taskForm(project.id).name" placeholder="Aufgabenname" />
<input v-model="taskForm(project.id).start_date" type="date" />
<input v-model="taskForm(project.id).end_date" type="date" />
<input v-model.number="taskForm(project.id).estimated_hours" type="number" placeholder="Stunden" style="width: 90px" />
<select v-model="taskForm(project.id).status">
<option value="geplant">geplant</option>
<option value="in Arbeit">in Arbeit</option>
<option value="erledigt">erledigt</option>
</select>
<button class="btn" @click="addTask(project.id)">Hinzufügen</button>
</div>
<div v-for="task in project.tasks" :key="'assign-' + task.id" class="form-row">
<span style="width: 180px">{{ task.name }}:</span>
<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">{{ e.name }}</option>
</select>
<input v-model.number="assignmentForm(task.id).allocated_hours_per_week" type="number" style="width: 90px" placeholder="Std/Woche" />
<button class="btn secondary" @click="addAssignment(project.id, task.id)">Zuweisen</button>
<button class="btn danger" @click="store.deleteTask(project.id, task.id)">Aufgabe löschen</button>
</div>
</template>
<div style="margin-top: 12px">
<button class="btn danger" @click="store.deleteProject(project.id)">Projekt löschen</button>
</div>
</div>
<p v-if="store.projects.length === 0" class="muted">Noch keine Projekte angelegt.</p>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { store } from './store.js';
import { ganttRange, dayOffset, daysBetween, formatDate, statusColor } from './helpers.js';
const range = computed(() => ganttRange(store.projects));
const totalDays = computed(() => Math.max(1, dayOffset(range.value.min, range.value.max.toISOString()) + 1));
function barStyle(task) {
const left = (dayOffset(range.value.min, task.start_date) / totalDays.value) * 100;
const width = (daysBetween(task.start_date, task.end_date) / totalDays.value) * 100;
return {
left: `${left}%`,
width: `${Math.max(width, 1.5)}%`,
background: statusColor(task.status),
};
}
</script>
<template>
<div class="panel-section">
<h2>Zeitplan</h2>
<p class="muted">
{{ formatDate(range.min.toISOString()) }} {{ formatDate(range.max.toISOString()) }}
</p>
<div v-for="project in store.projects" :key="project.id" style="margin-bottom: 20px">
<strong :style="{ color: project.color }">{{ project.name }}</strong>
<div v-for="task in project.tasks" :key="task.id" class="gantt-row">
<div class="gantt-label">{{ task.name }}</div>
<div class="gantt-track">
<div class="gantt-bar" :style="barStyle(task)" :title="`${formatDate(task.start_date)} ${formatDate(task.end_date)}`">
{{ task.name }}
</div>
</div>
</div>
<p v-if="project.tasks.length === 0" class="muted">Keine Aufgaben.</p>
</div>
<p v-if="store.projects.length === 0" class="muted">Keine Projekte vorhanden.</p>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup>
import { reactive } from 'vue';
import { store } from './store.js';
const newTeam = reactive({ name: '', description: '' });
const newMember = reactive({});
async function addTeam() {
if (!newTeam.name) return;
await store.createTeam({ ...newTeam });
Object.assign(newTeam, { name: '', description: '' });
}
function memberForm(teamId) {
if (!newMember[teamId]) newMember[teamId] = '';
return newMember;
}
async function addMember(teamId) {
const employeeId = newMember[teamId];
if (!employeeId) return;
await store.addTeamMember(teamId, Number(employeeId));
newMember[teamId] = '';
}
</script>
<template>
<div class="panel-section">
<h2>Neues Team</h2>
<div class="form-row">
<input v-model="newTeam.name" placeholder="Teamname" />
<input v-model="newTeam.description" placeholder="Beschreibung" />
<button class="btn" @click="addTeam">Anlegen</button>
</div>
</div>
<div class="panel-section" v-for="team in store.teams" :key="team.id">
<h2>{{ team.name }}</h2>
<p class="muted">{{ team.description }}</p>
<span v-for="m in team.members" :key="m.id" class="tag">
{{ m.name }}
<a 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">
<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">{{ e.name }}</option>
</select>
<button class="btn secondary" @click="addMember(team.id)">Hinzufügen</button>
<button class="btn danger" @click="store.deleteTeam(team.id)">Team löschen</button>
</div>
</div>
<p v-if="store.teams.length === 0" class="muted">Noch keine Teams angelegt.</p>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup>
import { computed } from 'vue';
import { store } from './store.js';
import { utilizationColor } from './helpers.js';
const utilization = computed(() => store.utilization);
function percent(hours, capacity) {
if (!capacity) return 0;
return Math.round((hours / capacity) * 100);
}
</script>
<template>
<div class="panel-section">
<h2>Auslastung</h2>
<p class="muted">Geplante Stunden pro Woche im Verhältnis zur Kapazität je Mitarbeiter.</p>
<div v-if="utilization" style="overflow-x: auto">
<table class="heatmap-table">
<thead>
<tr>
<th style="text-align: left">Mitarbeiter</th>
<th v-for="week in utilization.weeks" :key="week">{{ week }}</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in utilization.employees" :key="employee.id">
<td style="text-align: left">{{ employee.name }}</td>
<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`"
>
{{ percent(hours, employee.weekly_capacity_hours) }}%
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="muted">Lade Auslastung</p>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
async function request(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${url} fehlgeschlagen: ${res.status} ${text}`);
}
if (res.status === 204) return null;
return res.json();
}
export const api = {
// employees
getEmployees: () => request('GET', '/api/employees'),
createEmployee: (data) => request('POST', '/api/employees', data),
updateEmployee: (id, data) => request('PUT', `/api/employees/${id}`, data),
deleteEmployee: (id) => request('DELETE', `/api/employees/${id}`),
// teams
getTeams: () => request('GET', '/api/teams'),
createTeam: (data) => request('POST', '/api/teams', data),
updateTeam: (id, data) => request('PUT', `/api/teams/${id}`, data),
deleteTeam: (id) => request('DELETE', `/api/teams/${id}`),
addTeamMember: (teamId, employeeId) =>
request('POST', `/api/teams/${teamId}/members`, { employee_id: employeeId }),
removeTeamMember: (teamId, employeeId) =>
request('DELETE', `/api/teams/${teamId}/members/${employeeId}`),
// projects & tasks
getProjects: () => request('GET', '/api/projects'),
createProject: (data) => request('POST', '/api/projects', data),
updateProject: (id, data) => request('PUT', `/api/projects/${id}`, data),
deleteProject: (id) => request('DELETE', `/api/projects/${id}`),
createTask: (data) => request('POST', '/api/tasks', data),
updateTask: (id, data) => request('PUT', `/api/tasks/${id}`, data),
deleteTask: (id) => request('DELETE', `/api/tasks/${id}`),
addDependency: (taskId, dependsOnId) =>
request('POST', `/api/tasks/${taskId}/dependencies`, { depends_on_task_id: dependsOnId }),
removeDependency: (taskId, dependsOnId) =>
request('DELETE', `/api/tasks/${taskId}/dependencies/${dependsOnId}`),
// assignments
createAssignment: (data) => request('POST', '/api/assignments', data),
updateAssignment: (id, data) => request('PUT', `/api/assignments/${id}`, data),
deleteAssignment: (id) => request('DELETE', `/api/assignments/${id}`),
// utilization
getUtilization: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return request('GET', `/api/utilization${qs ? `?${qs}` : ''}`);
},
};
+49
View File
@@ -0,0 +1,49 @@
export function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export function daysBetween(startStr, endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
return Math.round((end - start) / 86400000) + 1;
}
export function ganttRange(projects) {
const allDates = projects
.flatMap((p) => p.tasks.flatMap((t) => [t.start_date, t.end_date]))
.filter(Boolean)
.map((d) => new Date(d));
if (allDates.length === 0) {
const today = new Date();
return { min: today, max: new Date(today.getTime() + 30 * 86400000) };
}
return {
min: new Date(Math.min(...allDates)),
max: new Date(Math.max(...allDates)),
};
}
export function dayOffset(min, dateStr) {
return Math.round((new Date(dateStr) - min) / 86400000);
}
export function utilizationColor(percent) {
if (percent === 0) return '#e5e7eb';
if (percent < 50) return '#bbf7d0';
if (percent <= 100) return '#4ade80';
if (percent <= 120) return '#fbbf24';
return '#f87171';
}
export function statusColor(status) {
switch (status) {
case 'erledigt':
return '#22c55e';
case 'in Arbeit':
return '#3b82f6';
default:
return '#94a3b8';
}
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './panel.css';
createApp(App).mount('#app');
+190
View File
@@ -0,0 +1,190 @@
:root {
color-scheme: light;
--bg: #f8fafc;
--panel: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--accent: #4f46e5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
header.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 24px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
header.topbar h1 {
font-size: 18px;
margin: 0;
margin-right: auto;
}
nav.tabs {
display: flex;
gap: 4px;
}
nav.tabs button {
border: none;
background: transparent;
padding: 8px 14px;
border-radius: 6px;
cursor: pointer;
color: var(--muted);
font-size: 14px;
}
nav.tabs button.active {
background: var(--accent);
color: white;
}
main {
flex: 1;
padding: 24px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.panel-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.panel-section h2 {
margin-top: 0;
font-size: 16px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
text-align: left;
padding: 8px;
border-bottom: 1px solid var(--border);
}
input, select {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 14px;
}
button.btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button.btn.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button.btn.danger {
background: #ef4444;
}
.form-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
.gantt-row {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
}
.gantt-track {
position: relative;
flex: 1;
height: 20px;
background: #f1f5f9;
border-radius: 4px;
}
.gantt-bar {
position: absolute;
top: 0;
height: 100%;
border-radius: 4px;
color: white;
font-size: 11px;
display: flex;
align-items: center;
padding-left: 6px;
white-space: nowrap;
overflow: hidden;
}
.gantt-label {
width: 220px;
font-size: 13px;
flex-shrink: 0;
}
.heatmap-table td, .heatmap-table th {
text-align: center;
min-width: 56px;
}
.heatmap-cell {
border-radius: 4px;
padding: 4px;
font-size: 12px;
}
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
background: #eef2ff;
color: var(--accent);
margin-right: 4px;
}
.muted {
color: var(--muted);
font-size: 13px;
}
+138
View File
@@ -0,0 +1,138 @@
import { reactive } from 'vue';
import { api } from './api.js';
export const store = reactive({
employees: [],
teams: [],
projects: [],
utilization: null,
loading: false,
error: null,
async loadAll() {
this.loading = true;
this.error = null;
try {
const [employees, teams, projects] = await Promise.all([
api.getEmployees(),
api.getTeams(),
api.getProjects(),
]);
this.employees = employees;
this.teams = teams;
this.projects = projects;
await this.loadUtilization();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
},
async loadUtilization(params) {
this.utilization = await api.getUtilization(params);
},
// employees
async createEmployee(data) {
this.employees.push(await api.createEmployee(data));
await this.loadUtilization();
},
async updateEmployee(id, data) {
const updated = await api.updateEmployee(id, data);
const idx = this.employees.findIndex((e) => e.id === id);
if (idx !== -1) this.employees[idx] = updated;
},
async deleteEmployee(id) {
await api.deleteEmployee(id);
this.employees = this.employees.filter((e) => e.id !== id);
await this.loadAll();
},
// teams
async createTeam(data) {
this.teams.push(await api.createTeam(data));
},
async updateTeam(id, data) {
const updated = await api.updateTeam(id, data);
const idx = this.teams.findIndex((t) => t.id === id);
if (idx !== -1) this.teams[idx] = { ...updated, members: this.teams[idx].members };
},
async deleteTeam(id) {
await api.deleteTeam(id);
this.teams = this.teams.filter((t) => t.id !== id);
},
async addTeamMember(teamId, employeeId) {
await api.addTeamMember(teamId, employeeId);
const team = this.teams.find((t) => t.id === teamId);
const employee = this.employees.find((e) => e.id === employeeId);
if (team && employee && !team.members.some((m) => m.id === employeeId)) {
team.members.push(employee);
}
},
async removeTeamMember(teamId, employeeId) {
await api.removeTeamMember(teamId, employeeId);
const team = this.teams.find((t) => t.id === teamId);
if (team) team.members = team.members.filter((m) => m.id !== employeeId);
},
// projects
async createProject(data) {
this.projects.push({ ...(await api.createProject(data)), tasks: [] });
},
async updateProject(id, data) {
const updated = await api.updateProject(id, data);
const project = this.projects.find((p) => p.id === id);
if (project) Object.assign(project, updated);
},
async deleteProject(id) {
await api.deleteProject(id);
this.projects = this.projects.filter((p) => p.id !== id);
},
// tasks
async createTask(projectId, data) {
const task = await api.createTask({ ...data, project_id: projectId });
const project = this.projects.find((p) => p.id === projectId);
if (project) project.tasks.push({ ...task, assignments: [], depends_on: [] });
await this.loadUtilization();
},
async updateTask(projectId, taskId, data) {
const updated = await api.updateTask(taskId, data);
const project = this.projects.find((p) => p.id === projectId);
const task = project?.tasks.find((t) => t.id === taskId);
if (task) Object.assign(task, updated);
await this.loadUtilization();
},
async deleteTask(projectId, taskId) {
await api.deleteTask(taskId);
const project = this.projects.find((p) => p.id === projectId);
if (project) project.tasks = project.tasks.filter((t) => t.id !== taskId);
await this.loadUtilization();
},
// assignments
async assignEmployee(projectId, taskId, employeeId, hoursPerWeek) {
const assignment = await api.createAssignment({
task_id: taskId,
employee_id: employeeId,
allocated_hours_per_week: hoursPerWeek,
});
const project = this.projects.find((p) => p.id === projectId);
const task = project?.tasks.find((t) => t.id === taskId);
if (task) {
const employee = this.employees.find((e) => e.id === employeeId);
const existing = task.assignments.find((a) => a.employee_id === employeeId);
if (existing) Object.assign(existing, assignment, { employee_name: employee?.name });
else task.assignments.push({ ...assignment, employee_name: employee?.name });
}
await this.loadUtilization();
},
async unassignEmployee(projectId, taskId, assignmentId) {
await api.deleteAssignment(assignmentId);
const project = this.projects.find((p) => p.id === projectId);
const task = project?.tasks.find((t) => t.id === taskId);
if (task) task.assignments = task.assignments.filter((a) => a.id !== assignmentId);
await this.loadUtilization();
},
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/health': 'http://localhost:8080',
},
},
});