Vorname/Nachname für Mitarbeiter, Aufgaben-Duplizierung, Zeitstrahl im Zeitplan

- employees: name-Feld in first_name/last_name aufgeteilt (Schema, API,
  Formular, alle Namensanzeigen)
- Aufgaben können mit neuem Start-/Enddatum dupliziert werden
  (POST /api/tasks/:id/duplicate übernimmt Name, Stunden, Status und
  Zuweisungen 1:1, nur die Termine ändern sich)
- Zeitplan zeigt jetzt einen Zeitstrahl mit Kalenderwochen und
  Tagesnummern oberhalb der Projekt-Balken
This commit is contained in:
2026-07-04 00:09:41 +02:00
parent 7a7b6408a3
commit 60dd58d31f
11 changed files with 219 additions and 39 deletions
+9 -6
View File
@@ -2,12 +2,12 @@
import { reactive } from 'vue';
import { store } from './store.js';
const newEmployee = reactive({ name: '', email: '', role: '', weekly_capacity_hours: 40 });
const newEmployee = reactive({ first_name: '', last_name: '', email: '', role: '', weekly_capacity_hours: 40 });
async function addEmployee() {
if (!newEmployee.name) return;
if (!newEmployee.first_name || !newEmployee.last_name) return;
await store.createEmployee({ ...newEmployee });
Object.assign(newEmployee, { name: '', email: '', role: '', weekly_capacity_hours: 40 });
Object.assign(newEmployee, { first_name: '', last_name: '', email: '', role: '', weekly_capacity_hours: 40 });
}
</script>
@@ -15,7 +15,8 @@ async function addEmployee() {
<div class="panel-section">
<h2>Neuer Mitarbeiter</h2>
<div class="form-row">
<input v-model="newEmployee.name" placeholder="Name" />
<input v-model="newEmployee.first_name" placeholder="Vorname" />
<input v-model="newEmployee.last_name" placeholder="Nachname" />
<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" />
@@ -28,7 +29,8 @@ async function addEmployee() {
<table>
<thead>
<tr>
<th>Name</th>
<th>Vorname</th>
<th>Nachname</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Kapazität (Std/Woche)</th>
@@ -37,7 +39,8 @@ async function addEmployee() {
</thead>
<tbody>
<tr v-for="e in store.employees" :key="e.id">
<td>{{ e.name }}</td>
<td>{{ e.first_name }}</td>
<td>{{ e.last_name }}</td>
<td>{{ e.email }}</td>
<td>{{ e.role }}</td>
<td>{{ e.weekly_capacity_hours }}</td>
+25 -4
View File
@@ -1,11 +1,12 @@
<script setup>
import { reactive } from 'vue';
import { store } from './store.js';
import { formatDate } from './helpers.js';
import { formatDate, fullName } from './helpers.js';
const newProject = reactive({ name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
const newTask = reactive({});
const newAssignment = reactive({});
const newDuplicate = reactive({});
const expanded = reactive({});
async function addProject() {
@@ -37,6 +38,18 @@ async function addAssignment(projectId, taskId) {
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 });
}
function duplicateForm(taskId) {
if (!newDuplicate[taskId]) newDuplicate[taskId] = { start_date: '', end_date: '' };
return newDuplicate[taskId];
}
async function duplicateTask(projectId, taskId) {
const form = duplicateForm(taskId);
if (!form.start_date || !form.end_date) return;
await store.duplicateTask(projectId, taskId, { ...form });
Object.assign(form, { start_date: '', end_date: '' });
}
</script>
<template>
@@ -103,16 +116,24 @@ async function addAssignment(projectId, taskId) {
<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>
<div v-for="task in project.tasks" :key="'manage-' + task.id" style="border: 1px solid var(--border); border-radius: 6px; padding: 8px; margin-bottom: 8px">
<strong>{{ task.name }}</strong>
<div class="form-row">
<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>
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</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>
</div>
<div class="form-row">
<span class="muted">Aufgabe zu anderem Zeitraum wiederholen:</span>
<input v-model="duplicateForm(task.id).start_date" type="date" />
<input v-model="duplicateForm(task.id).end_date" type="date" />
<button class="btn secondary" @click="duplicateTask(project.id, task.id)">Duplizieren</button>
<button class="btn danger" @click="store.deleteTask(project.id, task.id)">Aufgabe löschen</button>
</div>
</div>
</template>
<div style="margin-top: 12px">
+30 -1
View File
@@ -1,10 +1,11 @@
<script setup>
import { computed } from 'vue';
import { store } from './store.js';
import { ganttRange, dayOffset, daysBetween, formatDate, statusColor } from './helpers.js';
import { ganttRange, dayOffset, daysBetween, formatDate, statusColor, buildTimeline } from './helpers.js';
const range = computed(() => ganttRange(store.projects));
const totalDays = computed(() => Math.max(1, dayOffset(range.value.min, range.value.max.toISOString()) + 1));
const timeline = computed(() => buildTimeline(range.value.min, range.value.max));
function barStyle(task) {
const left = (dayOffset(range.value.min, task.start_date) / totalDays.value) * 100;
@@ -15,6 +16,10 @@ function barStyle(task) {
background: statusColor(task.status),
};
}
function rangeStyle(entry) {
return { left: `${entry.leftPercent}%`, width: `${entry.widthPercent}%` };
}
</script>
<template>
@@ -24,6 +29,30 @@ function barStyle(task) {
{{ formatDate(range.min.toISOString()) }} {{ formatDate(range.max.toISOString()) }}
</p>
<div class="gantt-row">
<div class="gantt-label"></div>
<div class="timeline-track">
<div v-for="(week, idx) in timeline.weeks" :key="'w' + idx" class="timeline-week" :style="rangeStyle(week)">
KW {{ week.week }}
</div>
</div>
</div>
<div class="gantt-row" style="margin-bottom: 10px">
<div class="gantt-label"></div>
<div class="timeline-track">
<div
v-for="(day, idx) in timeline.days"
:key="'d' + idx"
class="timeline-day"
:class="{ weekend: day.isWeekend }"
:style="rangeStyle(day)"
:title="`${day.weekdayLabel}, ${day.date.toLocaleDateString('de-DE')}`"
>
{{ day.dayOfMonth }}
</div>
</div>
</div>
<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">
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { reactive } from 'vue';
import { store } from './store.js';
import { fullName } from './helpers.js';
const newTeam = reactive({ name: '', description: '' });
const newMember = reactive({});
@@ -39,7 +40,7 @@ async function addMember(teamId) {
<p class="muted">{{ team.description }}</p>
<span v-for="m in team.members" :key="m.id" class="tag">
{{ m.name }}
{{ fullName(m) }}
<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>
@@ -47,7 +48,7 @@ async function addMember(teamId) {
<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>
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</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>
+1
View File
@@ -38,6 +38,7 @@ export const api = {
createTask: (data) => request('POST', '/api/tasks', data),
updateTask: (id, data) => request('PUT', `/api/tasks/${id}`, data),
deleteTask: (id) => request('DELETE', `/api/tasks/${id}`),
duplicateTask: (id, data) => request('POST', `/api/tasks/${id}/duplicate`, data),
addDependency: (taskId, dependsOnId) =>
request('POST', `/api/tasks/${taskId}/dependencies`, { depends_on_task_id: dependsOnId }),
removeDependency: (taskId, dependsOnId) =>
+45
View File
@@ -37,6 +37,51 @@ export function utilizationColor(percent) {
return '#f87171';
}
export function fullName(person) {
if (!person) return '';
return `${person.first_name} ${person.last_name}`.trim();
}
function isoWeekNumber(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
return 1 + Math.round(((d - firstThursday) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7);
}
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
export function buildTimeline(min, max) {
const totalDays = Math.max(1, Math.round((max - min) / 86400000) + 1);
const days = [];
for (let i = 0; i < totalDays; i++) {
const date = new Date(min.getTime() + i * 86400000);
const weekday = (date.getDay() + 6) % 7; // Montag = 0
days.push({
date,
leftPercent: (i / totalDays) * 100,
widthPercent: (1 / totalDays) * 100,
dayOfMonth: date.getDate(),
weekdayLabel: WEEKDAY_LABELS[weekday],
isWeekend: weekday >= 5,
week: isoWeekNumber(date),
});
}
const weeks = [];
for (const day of days) {
const last = weeks[weeks.length - 1];
if (last && last.week === day.week) {
last.widthPercent += day.widthPercent;
} else {
weeks.push({ week: day.week, leftPercent: day.leftPercent, widthPercent: day.widthPercent });
}
}
return { days, weeks };
}
export function statusColor(status) {
switch (status) {
case 'erledigt':
+37
View File
@@ -163,6 +163,43 @@ button.btn.danger {
flex-shrink: 0;
}
.timeline-track {
position: relative;
flex: 1;
height: 20px;
}
.timeline-week {
position: absolute;
top: 0;
height: 100%;
box-sizing: border-box;
border-left: 1px solid var(--border);
padding-left: 4px;
font-size: 11px;
font-weight: 600;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
}
.timeline-day {
position: absolute;
top: 0;
height: 100%;
box-sizing: border-box;
border-left: 1px solid #f1f5f9;
font-size: 10px;
line-height: 20px;
text-align: center;
color: var(--muted);
}
.timeline-day.weekend {
background: #f8fafc;
color: #cbd5e1;
}
.heatmap-table td, .heatmap-table th {
text-align: center;
min-width: 56px;
+9 -2
View File
@@ -1,5 +1,6 @@
import { reactive } from 'vue';
import { api } from './api.js';
import { fullName } from './helpers.js';
export const store = reactive({
employees: [],
@@ -110,6 +111,12 @@ export const store = reactive({
if (project) project.tasks = project.tasks.filter((t) => t.id !== taskId);
await this.loadUtilization();
},
async duplicateTask(projectId, taskId, dates) {
const newTask = await api.duplicateTask(taskId, dates);
const project = this.projects.find((p) => p.id === projectId);
if (project) project.tasks.push(newTask);
await this.loadUtilization();
},
// assignments
async assignEmployee(projectId, taskId, employeeId, hoursPerWeek) {
@@ -123,8 +130,8 @@ export const store = reactive({
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 });
if (existing) Object.assign(existing, assignment, { employee_name: fullName(employee) });
else task.assignments.push({ ...assignment, employee_name: fullName(employee) });
}
await this.loadUtilization();
},
+6 -6
View File
@@ -20,14 +20,14 @@ if (isNew) {
function seed() {
const insertEmployee = db.prepare(
'INSERT INTO employees (name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?)'
'INSERT INTO employees (first_name, last_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],
['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);
+46 -11
View File
@@ -41,28 +41,31 @@ function parseDate(s) {
// ---------- employees ----------
app.get('/api/employees', (req, res) => {
res.json(db.prepare('SELECT * FROM employees ORDER BY name').all());
res.json(db.prepare('SELECT * FROM employees ORDER BY last_name, first_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 { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
if (!first_name || !last_name) {
return res.status(400).json({ error: 'first_name und last_name sind erforderlich' });
}
const result = db
.prepare(
'INSERT INTO employees (name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?)'
'INSERT INTO employees (first_name, last_name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?, ?)'
)
.run(name, email || null, role || null, weekly_capacity_hours ?? 40);
.run(first_name, last_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 { first_name, last_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 = ?'
'UPDATE employees SET first_name = ?, last_name = ?, email = ?, role = ?, weekly_capacity_hours = ? WHERE id = ?'
).run(
name ?? existing.name,
first_name ?? existing.first_name,
last_name ?? existing.last_name,
email ?? existing.email,
role ?? existing.role,
weekly_capacity_hours ?? existing.weekly_capacity_hours,
@@ -144,7 +147,7 @@ app.get('/api/projects', (req, res) => {
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
`SELECT a.*, (e.first_name || ' ' || e.last_name) AS employee_name FROM assignments a
JOIN employees e ON e.id = a.employee_id`
)
.all();
@@ -231,6 +234,38 @@ app.delete('/api/tasks/:id', (req, res) => {
res.status(204).end();
});
app.post('/api/tasks/:id/duplicate', (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 { start_date, end_date } = req.body;
if (!start_date || !end_date) {
return res.status(400).json({ error: 'start_date und end_date sind erforderlich' });
}
const result = db
.prepare(
'INSERT INTO tasks (project_id, name, start_date, end_date, estimated_hours, status) VALUES (?, ?, ?, ?, ?, ?)'
)
.run(existing.project_id, existing.name, start_date, end_date, existing.estimated_hours, existing.status);
const newTaskId = result.lastInsertRowid;
const insertAssignment = db.prepare(
'INSERT INTO assignments (task_id, employee_id, allocated_hours_per_week) VALUES (?, ?, ?)'
);
const originalAssignments = db.prepare('SELECT * FROM assignments WHERE task_id = ?').all(req.params.id);
for (const a of originalAssignments) {
insertAssignment.run(newTaskId, a.employee_id, a.allocated_hours_per_week);
}
const newTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(newTaskId);
const newAssignments = db
.prepare(
`SELECT a.*, (e.first_name || ' ' || e.last_name) AS employee_name FROM assignments a
JOIN employees e ON e.id = a.employee_id WHERE a.task_id = ?`
)
.all(newTaskId);
res.status(201).json({ ...newTask, assignments: newAssignments, depends_on: [] });
});
app.post('/api/tasks/:id/dependencies', (req, res) => {
const { depends_on_task_id } = req.body;
db.prepare(
@@ -291,7 +326,7 @@ app.get('/api/utilization', (req, res) => {
weeks.push({ key: isoWeekKey(weekStart), start: weekStart });
}
const employees = db.prepare('SELECT * FROM employees ORDER BY name').all();
const employees = db.prepare('SELECT * FROM employees ORDER BY last_name, first_name').all();
const rows = db
.prepare(
`SELECT a.employee_id, a.allocated_hours_per_week, t.start_date, t.end_date
@@ -320,7 +355,7 @@ app.get('/api/utilization', (req, res) => {
weeks: weeks.map((w) => w.key),
employees: employees.map((e) => ({
id: e.id,
name: e.name,
name: `${e.first_name} ${e.last_name}`,
weekly_capacity_hours: e.weekly_capacity_hours,
hours: weeks.map((w) => Math.round((data[e.id][w.key] || 0) * 10) / 10),
})),
+2 -1
View File
@@ -2,7 +2,8 @@ PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
role TEXT,
weekly_capacity_hours REAL NOT NULL DEFAULT 40