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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
server/data/
client/dist/
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine AS client-build
WORKDIR /app/client
COPY client/package.json ./
RUN npm install
COPY client/ ./
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY server/package.json ./server/
RUN cd server && npm install --omit=dev
COPY server/ ./server/
COPY --from=client-build /app/client/dist ./client/dist
WORKDIR /app/server
ENV PORT=8080
EXPOSE 8080
CMD ["node", "index.js"]
+59
View File
@@ -0,0 +1,59 @@
# Ressourcenplanung
Leichtgewichtige App zur Planung von Projekten, Aufgaben, Mitarbeitern und Teams
mit Zeitplan (Gantt), Auslastungs-Heatmap und Team-Verwaltung. Daten liegen in SQLite.
## Aufbau
```
ressourcenplanung/
├── server/ Node/Express + SQLite (better-sqlite3)
│ ├── schema.sql Tabellen & Beziehungen
│ ├── db.js DB-Init + Beispieldaten
│ └── index.js REST-API (CRUD + Auslastungsberechnung)
└── client/ Vue 3 + Vite
└── src/
├── App.vue Tab-Navigation
├── SchedulePanel.vue Gantt-Zeitplan
├── UtilizationPanel.vue Auslastungs-Heatmap
├── ProjectsPanel.vue Projekte/Aufgaben inkl. Zuweisungen
├── EmployeesPanel.vue Mitarbeiter
├── TeamsPanel.vue Teams (n:m-Mitgliedschaft)
├── store.js Reaktiver State + CRUD gegen die API
├── api.js API-Client
└── helpers.js Datum-/Gantt-/Farbberechnung
```
## Lokale Entwicklung
Zwei Terminals.
**1) Backend**
```bash
cd server
npm install
npm run dev # startet auf http://localhost:8080
```
Beim ersten Start wird `server/data/data.db` angelegt und mit Beispieldaten befüllt.
**2) Frontend**
```bash
cd client
npm install
npm run dev # startet auf http://localhost:5173
```
Browser auf http://localhost:5173 öffnen. Vite leitet `/api` und `/health`
per Proxy an das Backend weiter.
## Deployment (Docker)
Die App läuft als ein Container: Das Backend serviert die REST-API und das
gebaute Frontend über denselben Port 8080.
```bash
docker compose up --build
```
Danach ist die App unter http://localhost:8080 erreichbar. Die SQLite-Datenbank
liegt im Docker-Volume `data`.
+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',
},
},
});
+11
View File
@@ -0,0 +1,11 @@
services:
app:
build: .
ports:
- "8080:8080"
volumes:
- data:/app/server/data
restart: unless-stopped
volumes:
data:
+103
View File
@@ -0,0 +1,103 @@
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'data.db');
const isNew = !fs.existsSync(dbPath);
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
if (isNew) {
seed();
}
function seed() {
const insertEmployee = db.prepare(
'INSERT INTO employees (name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?)'
);
const employees = [
['Anna Keller', 'anna.keller@example.com', 'Frontend-Entwicklerin', 40],
['Jonas Weber', 'jonas.weber@example.com', 'Backend-Entwickler', 40],
['Mira Schulz', 'mira.schulz@example.com', 'UX-Designerin', 32],
['Tom Fischer', 'tom.fischer@example.com', 'Projektleiter', 40],
['Lea Hoffmann', 'lea.hoffmann@example.com', 'QA-Ingenieurin', 30],
];
const employeeIds = employees.map((e) => insertEmployee.run(...e).lastInsertRowid);
const insertTeam = db.prepare('INSERT INTO teams (name, description) VALUES (?, ?)');
const teamWeb = insertTeam.run('Web-Team', 'Frontend & Backend fürs Kundenportal').lastInsertRowid;
const teamDesign = insertTeam.run('Design-Team', 'UX/UI und Prototyping').lastInsertRowid;
const insertMember = db.prepare(
'INSERT INTO team_members (team_id, employee_id) VALUES (?, ?)'
);
insertMember.run(teamWeb, employeeIds[0]);
insertMember.run(teamWeb, employeeIds[1]);
insertMember.run(teamWeb, employeeIds[3]);
insertMember.run(teamDesign, employeeIds[2]);
insertMember.run(teamDesign, employeeIds[3]);
const insertProject = db.prepare(
'INSERT INTO projects (name, description, start_date, end_date, color) VALUES (?, ?, ?, ?, ?)'
);
const projectPortal = insertProject.run(
'Kundenportal Relaunch',
'Neugestaltung des Self-Service-Portals',
'2026-07-01',
'2026-08-28',
'#4f46e5'
).lastInsertRowid;
const projectApp = insertProject.run(
'Mobile App v2',
'Neue Funktionen für die mobile App',
'2026-07-06',
'2026-09-18',
'#059669'
).lastInsertRowid;
const insertTask = db.prepare(
'INSERT INTO tasks (project_id, name, start_date, end_date, estimated_hours, status) VALUES (?, ?, ?, ?, ?, ?)'
);
const t1 = insertTask.run(projectPortal, 'Konzept & Wireframes', '2026-07-01', '2026-07-10', 40, 'in Arbeit').lastInsertRowid;
const t2 = insertTask.run(projectPortal, 'Frontend-Umsetzung', '2026-07-13', '2026-08-07', 120, 'geplant').lastInsertRowid;
const t3 = insertTask.run(projectPortal, 'Backend-API', '2026-07-13', '2026-08-07', 100, 'geplant').lastInsertRowid;
const t4 = insertTask.run(projectPortal, 'QA & Rollout', '2026-08-10', '2026-08-28', 60, 'geplant').lastInsertRowid;
const t5 = insertTask.run(projectApp, 'Anforderungsanalyse', '2026-07-06', '2026-07-17', 30, 'in Arbeit').lastInsertRowid;
const t6 = insertTask.run(projectApp, 'Umsetzung Feature-Set', '2026-07-20', '2026-09-04', 150, 'geplant').lastInsertRowid;
const t7 = insertTask.run(projectApp, 'Test & Release', '2026-09-07', '2026-09-18', 50, 'geplant').lastInsertRowid;
const insertDependency = db.prepare(
'INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)'
);
insertDependency.run(t2, t1);
insertDependency.run(t3, t1);
insertDependency.run(t4, t2);
insertDependency.run(t4, t3);
insertDependency.run(t6, t5);
insertDependency.run(t7, t6);
const insertAssignment = db.prepare(
'INSERT INTO assignments (task_id, employee_id, allocated_hours_per_week) VALUES (?, ?, ?)'
);
insertAssignment.run(t1, employeeIds[2], 20);
insertAssignment.run(t1, employeeIds[3], 10);
insertAssignment.run(t2, employeeIds[0], 32);
insertAssignment.run(t3, employeeIds[1], 32);
insertAssignment.run(t4, employeeIds[4], 24);
insertAssignment.run(t4, employeeIds[3], 8);
insertAssignment.run(t5, employeeIds[3], 16);
insertAssignment.run(t5, employeeIds[2], 12);
insertAssignment.run(t6, employeeIds[0], 20);
insertAssignment.run(t6, employeeIds[1], 24);
insertAssignment.run(t7, employeeIds[4], 24);
}
module.exports = db;
+345
View File
@@ -0,0 +1,345 @@
const express = require('express');
const cors = require('cors');
const db = require('./db');
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.PORT || 8080;
// ---------- helpers ----------
function isoWeekKey(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = (d.getUTCDay() + 6) % 7; // Monday = 0
d.setUTCDate(d.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const week =
1 + Math.round(((d - firstThursday) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7);
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
function mondayOf(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - dayNum);
return d;
}
function addDays(date, days) {
const d = new Date(date);
d.setUTCDate(d.getUTCDate() + days);
return d;
}
function parseDate(s) {
const [y, m, d] = s.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
// ---------- employees ----------
app.get('/api/employees', (req, res) => {
res.json(db.prepare('SELECT * FROM employees ORDER BY name').all());
});
app.post('/api/employees', (req, res) => {
const { name, email, role, weekly_capacity_hours } = req.body;
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
const result = db
.prepare(
'INSERT INTO employees (name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?)'
)
.run(name, email || null, role || null, weekly_capacity_hours ?? 40);
res.status(201).json(db.prepare('SELECT * FROM employees WHERE id = ?').get(result.lastInsertRowid));
});
app.put('/api/employees/:id', (req, res) => {
const { name, email, role, weekly_capacity_hours } = req.body;
const existing = db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
db.prepare(
'UPDATE employees SET name = ?, email = ?, role = ?, weekly_capacity_hours = ? WHERE id = ?'
).run(
name ?? existing.name,
email ?? existing.email,
role ?? existing.role,
weekly_capacity_hours ?? existing.weekly_capacity_hours,
req.params.id
);
res.json(db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id));
});
app.delete('/api/employees/:id', (req, res) => {
db.prepare('DELETE FROM employees WHERE id = ?').run(req.params.id);
res.status(204).end();
});
// ---------- teams ----------
app.get('/api/teams', (req, res) => {
const teams = db.prepare('SELECT * FROM teams ORDER BY name').all();
const members = db
.prepare(
`SELECT tm.team_id, e.* FROM team_members tm
JOIN employees e ON e.id = tm.employee_id`
)
.all();
res.json(
teams.map((t) => ({
...t,
members: members.filter((m) => m.team_id === t.id).map(({ team_id, ...rest }) => rest),
}))
);
});
app.post('/api/teams', (req, res) => {
const { name, description } = req.body;
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
const result = db
.prepare('INSERT INTO teams (name, description) VALUES (?, ?)')
.run(name, description || null);
res.status(201).json({ ...db.prepare('SELECT * FROM teams WHERE id = ?').get(result.lastInsertRowid), members: [] });
});
app.put('/api/teams/:id', (req, res) => {
const { name, description } = req.body;
const existing = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
db.prepare('UPDATE teams SET name = ?, description = ? WHERE id = ?').run(
name ?? existing.name,
description ?? existing.description,
req.params.id
);
res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id));
});
app.delete('/api/teams/:id', (req, res) => {
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
res.status(204).end();
});
app.post('/api/teams/:id/members', (req, res) => {
const { employee_id } = req.body;
db.prepare('INSERT OR IGNORE INTO team_members (team_id, employee_id) VALUES (?, ?)').run(
req.params.id,
employee_id
);
res.status(201).end();
});
app.delete('/api/teams/:id/members/:employeeId', (req, res) => {
db.prepare('DELETE FROM team_members WHERE team_id = ? AND employee_id = ?').run(
req.params.id,
req.params.employeeId
);
res.status(204).end();
});
// ---------- projects & tasks ----------
app.get('/api/projects', (req, res) => {
const projects = db.prepare('SELECT * FROM projects ORDER BY start_date').all();
const tasks = db.prepare('SELECT * FROM tasks ORDER BY start_date').all();
const assignments = db
.prepare(
`SELECT a.*, e.name AS employee_name FROM assignments a
JOIN employees e ON e.id = a.employee_id`
)
.all();
const deps = db.prepare('SELECT * FROM task_dependencies').all();
const tasksByProject = projects.map((p) => ({
...p,
tasks: tasks
.filter((t) => t.project_id === p.id)
.map((t) => ({
...t,
assignments: assignments.filter((a) => a.task_id === t.id),
depends_on: deps.filter((d) => d.task_id === t.id).map((d) => d.depends_on_task_id),
})),
}));
res.json(tasksByProject);
});
app.post('/api/projects', (req, res) => {
const { name, description, start_date, end_date, color } = req.body;
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
const result = db
.prepare(
'INSERT INTO projects (name, description, start_date, end_date, color) VALUES (?, ?, ?, ?, ?)'
)
.run(name, description || null, start_date || null, end_date || null, color || '#4f46e5');
res.status(201).json(db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid));
});
app.put('/api/projects/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
const { name, description, start_date, end_date, color } = req.body;
db.prepare(
'UPDATE projects SET name = ?, description = ?, start_date = ?, end_date = ?, color = ? WHERE id = ?'
).run(
name ?? existing.name,
description ?? existing.description,
start_date ?? existing.start_date,
end_date ?? existing.end_date,
color ?? existing.color,
req.params.id
);
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id));
});
app.delete('/api/projects/:id', (req, res) => {
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
res.status(204).end();
});
app.post('/api/tasks', (req, res) => {
const { project_id, name, start_date, end_date, estimated_hours, status } = req.body;
if (!project_id || !name || !start_date || !end_date) {
return res.status(400).json({ error: 'project_id, name, start_date, end_date sind erforderlich' });
}
const result = db
.prepare(
'INSERT INTO tasks (project_id, name, start_date, end_date, estimated_hours, status) VALUES (?, ?, ?, ?, ?, ?)'
)
.run(project_id, name, start_date, end_date, estimated_hours || 0, status || 'geplant');
res.status(201).json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid));
});
app.put('/api/tasks/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
const { name, start_date, end_date, estimated_hours, status } = req.body;
db.prepare(
'UPDATE tasks SET name = ?, start_date = ?, end_date = ?, estimated_hours = ?, status = ? WHERE id = ?'
).run(
name ?? existing.name,
start_date ?? existing.start_date,
end_date ?? existing.end_date,
estimated_hours ?? existing.estimated_hours,
status ?? existing.status,
req.params.id
);
res.json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id));
});
app.delete('/api/tasks/:id', (req, res) => {
db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
res.status(204).end();
});
app.post('/api/tasks/:id/dependencies', (req, res) => {
const { depends_on_task_id } = req.body;
db.prepare(
'INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)'
).run(req.params.id, depends_on_task_id);
res.status(201).end();
});
app.delete('/api/tasks/:id/dependencies/:dependsOnId', (req, res) => {
db.prepare(
'DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?'
).run(req.params.id, req.params.dependsOnId);
res.status(204).end();
});
// ---------- assignments ----------
app.post('/api/assignments', (req, res) => {
const { task_id, employee_id, allocated_hours_per_week } = req.body;
if (!task_id || !employee_id) {
return res.status(400).json({ error: 'task_id und employee_id sind erforderlich' });
}
const result = db
.prepare(
`INSERT INTO assignments (task_id, employee_id, allocated_hours_per_week) VALUES (?, ?, ?)
ON CONFLICT (task_id, employee_id) DO UPDATE SET allocated_hours_per_week = excluded.allocated_hours_per_week`
)
.run(task_id, employee_id, allocated_hours_per_week || 0);
const id = result.lastInsertRowid || db.prepare(
'SELECT id FROM assignments WHERE task_id = ? AND employee_id = ?'
).get(task_id, employee_id).id;
res.status(201).json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(id));
});
app.put('/api/assignments/:id', (req, res) => {
const { allocated_hours_per_week } = req.body;
db.prepare('UPDATE assignments SET allocated_hours_per_week = ? WHERE id = ?').run(
allocated_hours_per_week,
req.params.id
);
res.json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id));
});
app.delete('/api/assignments/:id', (req, res) => {
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
res.status(204).end();
});
// ---------- utilization heatmap ----------
app.get('/api/utilization', (req, res) => {
const from = req.query.from ? parseDate(req.query.from) : mondayOf(new Date());
const weekCount = Math.min(Number(req.query.weeks) || 12, 52);
const weeks = [];
for (let i = 0; i < weekCount; i++) {
const weekStart = addDays(mondayOf(from), i * 7);
weeks.push({ key: isoWeekKey(weekStart), start: weekStart });
}
const employees = db.prepare('SELECT * FROM employees ORDER BY name').all();
const rows = db
.prepare(
`SELECT a.employee_id, a.allocated_hours_per_week, t.start_date, t.end_date
FROM assignments a JOIN tasks t ON t.id = a.task_id`
)
.all();
const data = {};
for (const e of employees) data[e.id] = {};
for (const row of rows) {
const taskStart = parseDate(row.start_date);
const taskEnd = parseDate(row.end_date);
for (const week of weeks) {
const weekEnd = addDays(week.start, 6);
const overlapStart = taskStart > week.start ? taskStart : week.start;
const overlapEnd = taskEnd < weekEnd ? taskEnd : weekEnd;
const overlapDays = Math.max(0, Math.round((overlapEnd - overlapStart) / 86400000) + 1);
if (overlapDays <= 0) continue;
const hours = row.allocated_hours_per_week * (overlapDays / 7);
data[row.employee_id][week.key] = (data[row.employee_id][week.key] || 0) + hours;
}
}
res.json({
weeks: weeks.map((w) => w.key),
employees: employees.map((e) => ({
id: e.id,
name: e.name,
weekly_capacity_hours: e.weekly_capacity_hours,
hours: weeks.map((w) => Math.round((data[e.id][w.key] || 0) * 10) / 10),
})),
});
});
app.get('/health', (req, res) => res.json({ ok: true }));
// serve built frontend in production
const path = require('path');
const fs = require('fs');
const clientDist = path.join(__dirname, '..', 'client', 'dist');
if (fs.existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get(/^(?!\/api|\/health).*/, (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Ressourcenplanung-API läuft auf http://localhost:${PORT}`);
});
+1279
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "ressourcenplanung-server",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "index.js",
"scripts": {
"dev": "node index.js",
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"express": "^4.19.2"
}
}
+54
View File
@@ -0,0 +1,54 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
role TEXT,
weekly_capacity_hours REAL NOT NULL DEFAULT 40
);
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT
);
CREATE TABLE IF NOT EXISTS team_members (
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
PRIMARY KEY (team_id, employee_id)
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
color TEXT DEFAULT '#4f46e5'
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
estimated_hours REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'geplant'
);
CREATE TABLE IF NOT EXISTS task_dependencies (
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
depends_on_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, depends_on_task_id)
);
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
allocated_hours_per_week REAL NOT NULL DEFAULT 0,
UNIQUE (task_id, employee_id)
);