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:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
server/data/
|
||||||
|
client/dist/
|
||||||
+18
@@ -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"]
|
||||||
@@ -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`.
|
||||||
@@ -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>
|
||||||
Generated
+1207
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}` : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import './panel.css';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- data:/app/server/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
+103
@@ -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
@@ -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}`);
|
||||||
|
});
|
||||||
Generated
+1279
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user