feat(saetze): Satz je Task plus Import-Endpunkt fuer Harvest

Satz je Task
  Ein Task kann einen eigenen Satz tragen; leer bedeutet weiter "Satz des
  Budgettopfs" bzw. bei Abruf "Satz des Projekts". Damit lassen sich
  "remote" und "vor Ort" im selben Projekt abrechnen, ohne dafuer getrennte
  Projekte anzulegen.

  Das Budget bleibt in Stunden - gemischte Saetze aendern nichts daran, wann
  ein Topf voll ist. Der Umsatz wird je Task-Satz aufsummiert: loadProjects
  gruppiert den Topfverbrauch nach Task-Satz und gibt das Ergebnis an
  potStats, Abruf rechnet je Task. Mitarbeiter sehen keine Saetze, auch
  nicht die am Task.

Import
  POST /api/imports/entries (nur Administrator) schreibt Buchungen fuer
  beliebige Personen - POST /api/entries bucht bewusst nur auf den
  angemeldeten Benutzer. Der Import laeuft durch dieselben Regeln: Projekt
  aktiv, Person zugewiesen, Topf passend zum Datum, maximal 24 h am Tag.
  dry_run schreibt nichts.

  Jede uebernommene Buchung traegt ihre harvest_id (unique). Ein zweiter
  Lauf legt nichts doppelt an. DELETE /api/imports/entries entfernt genau
  die uebernommenen Zeiten; handgetippte bleiben unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 11:37:26 +02:00
co-authored by Claude Opus 5
parent 7606df9280
commit 7e7188d476
12 changed files with 403 additions and 37 deletions
+24
View File
@@ -80,6 +80,30 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
mindestens Vorgesetzter ist.
## Sätze: Topf, Projekt, Task
Der Satz kommt in dieser Reihenfolge: **Task → Budgettopf → Projekt** (letzteres nur bei *Abruf*).
Ein Satz am Task ist freiwillig und deckt Fälle ab, in denen dieselbe Leistung je Task anders
vergütet wird – typisch „vor Ort" neben „remote". Bleibt er leer, ändert sich nichts gegenüber
vorher. Die Einheit (Stunde oder Tag) steht am Projekt und gilt auch für den Task-Satz.
Das Budget bleibt in **Stunden** – gemischte Sätze ändern also nichts daran, wann ein Topf voll
ist. Der Umsatz eines Topfes wird dafür je Task-Satz aufsummiert (`loadProjects` gruppiert den
Verbrauch nach Task-Satz und gibt das Ergebnis an `potStats`). Mitarbeiter sehen keine Sätze,
auch nicht die am Task.
## Übernahme aus Harvest
`POST /api/imports/entries` (nur Administrator) schreibt Buchungen für beliebige Personen –
`POST /api/entries` bucht bewusst nur auf den angemeldeten Benutzer. Der Import läuft durch
dieselben Regeln: Projekt aktiv, Person zugewiesen, Topf passend zum Leistungsdatum, höchstens
24 Stunden am Tag. Mit `dry_run: true` schreibt er nichts und meldet nur, was passieren würde.
Jede übernommene Buchung trägt ihre `harvest_id` (`pulse_time_entries.harvest_id`, unique). Ein
zweiter Lauf legt deshalb nichts doppelt an, sondern meldet „schon vorhanden". `GET
/api/imports/entries` listet alles Übernommene, `DELETE /api/imports/entries` entfernt es wieder –
handgetippte Zeiten bleiben unberührt, weil sie keine `harvest_id` haben.
## Profilbilder
Jeder kann sein eigenes Bild hochladen (*Mein Konto*), Vorgesetzte und Administratoren
+33
View File
@@ -41,6 +41,8 @@ export async function initDatabase() {
await migrateAbrufType();
await migrateAvatarColumn();
await migrateTaskRate();
await migrateHarvestId();
await ensureAdminUser();
await ensureInternalCustomer();
}
@@ -85,6 +87,37 @@ async function migrateAvatarColumn() {
}
}
/*
* Satz je Task: deckt "remote" und "vor Ort" ab, ohne dafuer eigene Projekte
* zu brauchen. Leer bedeutet weiterhin "Satz des Budgettopfs".
*/
async function migrateTaskRate() {
const [columns] = await pool.query(
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_tasks' AND COLUMN_NAME = 'rate'`
);
if (!columns.length) {
await pool.query('ALTER TABLE pulse_tasks ADD COLUMN rate DECIMAL(10,2) NULL AFTER billable');
console.log('[Pulse] Spalte pulse_tasks.rate ergaenzt.');
}
}
/*
* Herkunftsschluessel fuer uebernommene Buchungen (Harvest). Verhindert
* Dubletten bei einem zweiten Import und erlaubt einen gezielten Rueckbau.
*/
async function migrateHarvestId() {
const [columns] = await pool.query(
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_time_entries' AND COLUMN_NAME = 'harvest_id'`
);
if (!columns.length) {
await pool.query('ALTER TABLE pulse_time_entries ADD COLUMN harvest_id BIGINT UNSIGNED NULL AFTER billable');
await pool.query('ALTER TABLE pulse_time_entries ADD UNIQUE KEY uq_pulse_entries_harvest (harvest_id)');
console.log('[Pulse] Spalte pulse_time_entries.harvest_id ergaenzt.');
}
}
async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
if (rows[0].n > 0) return;
+8 -2
View File
@@ -48,7 +48,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
p.rate AS project_rate,
c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
t.name AS task_name,
t.name AS task_name, t.rate AS task_rate,
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
FROM pulse_time_entries e
JOIN pulse_users u ON u.id = e.user_id
@@ -78,8 +78,14 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
let amount = 0;
let fixedAmount = 0;
// Billable rechnet ueber den Satz des Topfs ab, Abruf ueber den am Projekt.
// Traegt der Task einen eigenen Satz (z. B. "vor Ort"), gewinnt dieser.
if (r.billable && (r.project_type === 'billable' || r.project_type === 'abruf')) {
const rate = hourlyRate(r.project_type === 'abruf' ? null : { rate: r.pot_rate }, project, settings);
const rate = hourlyRate(
r.project_type === 'abruf' ? null : { rate: r.pot_rate },
project,
settings,
r.task_rate == null ? null : { rate: r.task_rate }
);
amount = rate ? hours * rate : 0;
}
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
+61 -16
View File
@@ -39,10 +39,13 @@ function prettyShort(customer) {
/**
* Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet.
*
* Bei "Abruf" gibt es keinen Budgettopf - der Satz steht dann am Projekt.
* Reihenfolge: Satz am Task, sonst Satz des Budgettopfs, sonst - bei "Abruf" -
* der Satz am Projekt. Der Task-Satz deckt Faelle wie "vor Ort" ab, in denen
* dieselbe Leistung je Task anders verguetet wird; ohne Angabe aendert sich
* nichts gegenueber vorher.
*/
export function hourlyRate(pot, project, settings) {
const raw = pot?.rate ?? (project?.type === 'abruf' ? project.rate : null);
export function hourlyRate(pot, project, settings, task) {
const raw = task?.rate ?? pot?.rate ?? (project?.type === 'abruf' ? project.rate : null);
if (raw == null) return null;
return project.billing_unit === 'day' ? Number(raw) / Number(settings.hours_per_day || 8) : Number(raw);
}
@@ -64,7 +67,7 @@ export function deriveBudgetHours(input, project, settings) {
* status: upcoming | active | expired | exhausted
* earned: bereits erbrachter Umsatz, open: noch offener Umsatz
*/
export function potStats(pot, project, usedMinutes, settings) {
export function potStats(pot, project, usedMinutes, settings, earnedOverride = null) {
const now = today();
const usedHours = usedMinutes / 60;
const budgetHours = pot.budget_hours == null ? null : Number(pot.budget_hours);
@@ -78,7 +81,9 @@ export function potStats(pot, project, usedMinutes, settings) {
let earned = 0;
if (project.type === 'billable') {
earned = rateHour ? usedHours * rateHour : 0;
// Tragen Tasks eigene Saetze, ist der Umsatz je Buchung verschieden -
// dann rechnet loadProjects ihn aus und gibt ihn hier herein.
earned = earnedOverride != null ? earnedOverride : rateHour ? usedHours * rateHour : 0;
} else if (project.type === 'fixed' && amount) {
if (budgetHours) {
earned = amount * Math.min(1, usedHours / budgetHours);
@@ -192,9 +197,12 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
ids
),
pool.query(
`SELECT pot_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes
FROM pulse_time_entries
WHERE project_id IN (${inList}) AND pot_id IS NOT NULL GROUP BY pot_id`,
// Nach Task-Satz gruppiert, damit "remote" und "vor Ort" im selben Topf
// getrennt bewertet werden koennen.
`SELECT e.pot_id, t.rate AS task_rate, SUM(e.minutes) AS minutes,
SUM(CASE WHEN e.billable = 1 THEN e.minutes ELSE 0 END) AS billable_minutes
FROM pulse_time_entries e LEFT JOIN pulse_tasks t ON t.id = e.task_id
WHERE e.project_id IN (${inList}) AND e.pot_id IS NOT NULL GROUP BY e.pot_id, t.rate`,
ids
),
pool.query(
@@ -204,15 +212,31 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
ids
),
pool.query(
`SELECT task_id, SUM(minutes) AS minutes FROM pulse_time_entries
WHERE project_id IN (${inList}) AND task_id IS NOT NULL GROUP BY task_id`,
`SELECT project_id, task_id, SUM(minutes) AS minutes,
SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes
FROM pulse_time_entries
WHERE project_id IN (${inList}) GROUP BY project_id, task_id`,
ids
),
]);
const potMinutes = new Map(potUsage.map((r) => [r.pot_id, r]));
// Je Topf: Gesamtsummen plus die Aufteilung nach Task-Satz.
const potMinutes = new Map();
for (const r of potUsage) {
const item = potMinutes.get(r.pot_id) || { minutes: 0, billable_minutes: 0, groups: [] };
item.minutes += Number(r.minutes);
item.billable_minutes += Number(r.billable_minutes);
item.groups.push({ task_rate: r.task_rate, minutes: Number(r.minutes), billable_minutes: Number(r.billable_minutes) });
potMinutes.set(r.pot_id, item);
}
const projectMinutes = new Map(projectUsage.map((r) => [r.project_id, r]));
const taskMinutes = new Map(taskUsage.map((r) => [r.task_id, Number(r.minutes)]));
const taskMinutes = new Map(taskUsage.filter((r) => r.task_id != null).map((r) => [r.task_id, Number(r.minutes)]));
// Abrechenbare Minuten je Task (und je Projekt fuer Buchungen ohne Task) -
// gebraucht, um bei "Abruf" Task-Saetze zu beruecksichtigen.
const taskBillable = new Map(taskUsage.filter((r) => r.task_id != null).map((r) => [r.task_id, Number(r.billable_minutes)]));
const withoutTask = new Map(
taskUsage.filter((r) => r.task_id == null).map((r) => [r.project_id, Number(r.billable_minutes)])
);
return projects.map((p) => {
const project = { ...p, tags: parseTags(p.tags), is_active: Boolean(p.is_active) };
@@ -222,8 +246,19 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
.map((pot) => {
const usage = potMinutes.get(pot.id);
const booked = Number(usage?.minutes || 0);
const counted = countsAgainstBudget(project) === 'billable' ? Number(usage?.billable_minutes || 0) : booked;
const stats = potStats(pot, project, counted, settings);
const onlyBillable = countsAgainstBudget(project) === 'billable';
const counted = onlyBillable ? Number(usage?.billable_minutes || 0) : booked;
// Umsatz je Task-Satz aufsummieren; ohne Task-Saetze ist das Ergebnis
// identisch zu "Stunden mal Topfsatz".
const earned =
project.type === 'billable'
? (usage?.groups ?? []).reduce((sum, g) => {
const minutes = onlyBillable ? g.billable_minutes : g.minutes;
const rate = hourlyRate(pot, project, settings, g.task_rate == null ? null : { rate: g.task_rate });
return sum + (minutes / 60) * (rate ?? 0);
}, 0)
: null;
const stats = potStats(pot, project, counted, settings, earned);
return { ...pot, ...stats, booked_minutes: booked, warning: potWarning(pot, stats, settings) };
});
const usage = projectMinutes.get(p.id);
@@ -239,7 +274,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
pots: projectPots,
tasks: tasks
.filter((t) => t.project_id === p.id)
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), rate: t.rate == null ? null : Number(t.rate), used_minutes: taskMinutes.get(t.id) || 0 })),
members: members
.filter((m) => m.project_id === p.id)
.map((m) => ({ id: m.id, name: m.name, color: m.color, avatar: m.avatar ?? null, is_external: Boolean(m.is_external) })),
@@ -259,7 +294,16 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
*/
earned:
project.type === 'abruf'
? round2((Number(usage?.billable_minutes || 0) / 60) * (hourlyRate(null, project, settings) ?? 0))
? round2(
// Abruf kennt keine Toepfe: je Task mit seinem Satz rechnen,
// Buchungen ohne Task mit dem Satz am Projekt.
tasks
.filter((t) => t.project_id === p.id)
.reduce(
(sum, t) => sum + ((taskBillable.get(t.id) || 0) / 60) * (hourlyRate(null, project, settings, t) ?? 0),
((withoutTask.get(p.id) || 0) / 60) * (hourlyRate(null, project, settings) ?? 0)
)
)
: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
open: project.type === 'abruf' ? 0 : round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
hourly_rate: hourlyRate(null, project, settings),
@@ -270,6 +314,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
export function stripAmounts(project) {
const clean = { ...project, budget_value: null, earned: null, open: null, rate: null, hourly_rate: null };
clean.tasks = project.tasks.map((task) => ({ ...task, rate: null }));
clean.pots = project.pots.map((pot) => ({
...pot,
budget_amount: null,
+149
View File
@@ -0,0 +1,149 @@
import express from 'express';
import { pool, withTransaction } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { parseDate } from '../lib/dates.js';
import { isBillable, resolvePot } from '../lib/projects.js';
/*
* Buchungen aus einem Fremdsystem uebernehmen (Harvest).
*
* Warum ein eigener Endpunkt: POST /api/entries bucht immer auf den
* angemeldeten Benutzer - das ist richtig, denn jeder erfasst seine eigenen
* Zeiten. Ein Import schreibt aber fuer viele Personen. Statt dafuer direkt in
* die Datenbank zu schreiben und die Fachregeln zu umgehen, laeuft er hier
* durch dieselben Pruefungen: Projekt aktiv, Person dem Projekt zugewiesen,
* Budgettopf passend zum Leistungsdatum, hoechstens 24 Stunden am Tag.
*
* Wiedererkennung ueber harvest_id: ein zweiter Lauf legt nichts doppelt an,
* sondern meldet die Buchung als "schon vorhanden".
*/
const router = express.Router();
const MAX_BATCH = 1000;
router.post('/entries', async (req, res, next) => {
const body = req.body || {};
const rows = Array.isArray(body.entries) ? body.entries : [];
const dryRun = body.dry_run === true;
if (rows.length === 0) return res.status(400).json({ error: 'Keine Buchungen uebergeben.' });
if (rows.length > MAX_BATCH) return res.status(400).json({ error: `Hoechstens ${MAX_BATCH} Buchungen pro Aufruf.` });
try {
const settings = await getSettings();
const created = [];
const skipped = [];
const failed = [];
for (const row of rows) {
const harvestId = row.harvest_id == null ? null : Number(row.harvest_id);
const label = harvestId ?? `${row.entry_date} ${row.minutes} min`;
try {
const userId = Number(row.user_id);
const projectId = Number(row.project_id);
const taskId = row.task_id == null || row.task_id === '' ? null : Number(row.task_id);
const date = row.entry_date;
const minutes = Number(row.minutes);
const note = row.note == null ? null : String(row.note).trim().slice(0, 1000) || null;
if (!parseDate(date)) throw new Error('Datum fehlt oder ist unguelltig.');
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 24 * 60) throw new Error('Minuten muessen zwischen 1 und 1440 liegen.');
// Schon importiert? Dann nicht erneut anlegen.
if (harvestId) {
const [[dup]] = await pool.query('SELECT id FROM pulse_time_entries WHERE harvest_id = ? LIMIT 1', [harvestId]);
if (dup) {
skipped.push({ harvest_id: harvestId, entry_id: dup.id, reason: 'schon vorhanden' });
continue;
}
}
const [[user]] = await pool.query("SELECT id, role FROM pulse_users WHERE id = ? AND is_active = 1 LIMIT 1", [userId]);
if (!user) throw new Error('Benutzer nicht gefunden oder deaktiviert.');
if (user.role === 'admin') throw new Error('Administratoren buchen keine Zeiten.');
const id = await withTransaction(async (conn) => {
const [[project]] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]);
if (!project) throw new Error('Projekt nicht gefunden.');
if (!project.is_active) throw new Error('Projekt ist archiviert.');
const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]);
if (!member.length) throw new Error('Person ist dem Projekt nicht zugewiesen.');
let task = null;
if (taskId) {
const [[found]] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ? LIMIT 1', [taskId, projectId]);
if (!found) throw new Error('Task gehoert nicht zu diesem Projekt.');
task = found;
}
const [[day]] = await conn.query(
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ?',
[userId, date]
);
if (Number(day.total) + minutes > 24 * 60) throw new Error('An diesem Tag waeren es mehr als 24 Stunden.');
const billable = isBillable(project, task);
const pot = await resolvePot(conn, project, date, minutes, null, billable);
if (dryRun) return 0;
const [result] = await conn.query(
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable, harvest_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, projectId, task?.id ?? null, pot?.id ?? null, date, minutes, note, billable ? 1 : 0, harvestId]
);
return result.insertId;
});
created.push({ harvest_id: harvestId, entry_id: id });
} catch (err) {
failed.push({ harvest_id: harvestId, label, error: err.message });
}
}
res.json({
dry_run: dryRun,
requested: rows.length,
created: created.length,
skipped: skipped.length,
failed: failed.length,
details: { created, skipped, failed },
});
} catch (err) {
next(err);
}
});
/** Was wurde bisher importiert? Fuer Kontrolle und Rueckbau. */
router.get('/entries', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT e.harvest_id, e.id, e.entry_date, e.minutes, u.name AS user_name, p.code AS project_code
FROM pulse_time_entries e
JOIN pulse_users u ON u.id = e.user_id
JOIN pulse_projects p ON p.id = e.project_id
WHERE e.harvest_id IS NOT NULL ORDER BY e.entry_date, e.id`
);
res.json({ count: rows.length, entries: rows });
} catch (err) {
next(err);
}
});
/**
* Rueckbau: entfernt genau die Buchungen, die dieser Import angelegt hat.
* Ohne `harvest_ids` alle - handgetippte Zeiten bleiben unberuehrt, weil sie
* keine harvest_id tragen.
*/
router.delete('/entries', async (req, res, next) => {
try {
const ids = Array.isArray(req.body?.harvest_ids) ? req.body.harvest_ids.map(Number).filter(Number.isFinite) : null;
if (ids && ids.length === 0) return res.status(400).json({ error: 'Keine IDs uebergeben.' });
const [result] = ids
? await pool.query(`DELETE FROM pulse_time_entries WHERE harvest_id IN (${ids.map(() => '?').join(',')})`, ids)
: await pool.query('DELETE FROM pulse_time_entries WHERE harvest_id IS NOT NULL');
res.json({ deleted: result.affectedRows });
} catch (err) {
next(err);
}
});
export default router;
+43 -8
View File
@@ -19,6 +19,20 @@ const NEEDS_POT = (type) => type !== 'abruf';
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
const num = (v) => (v === '' || v == null ? null : Number(v));
/**
* Satz am Task: leer bedeutet "Satz des Budgettopfs". Ein eigener Satz ist nur
* bei abrechenbaren Tasks sinnvoll - auf nicht abrechenbaren hat er keine
* Wirkung und wird deshalb verworfen, damit im Formular nichts Falsches
* stehen bleibt.
*/
function taskRate(value, billable) {
if (!billable) return null;
const rate = num(value);
if (rate == null) return null;
if (!Number.isFinite(rate) || rate < 0 || rate > 100000) return { error: 'Satz am Task: 0 bis 100.000.' };
return Math.round(rate * 100) / 100;
}
function validatePot(input, project, settings) {
const errors = [];
const pot = {
@@ -178,6 +192,15 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
}
if (out.type !== 'abruf') out.rate = null;
// Tasks samt Satz vorab pruefen, damit ein Fehler eine 400 gibt und nicht
// mitten in der Transaktion auffaellt.
const taskInputs = [];
for (const task of (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160))) {
const billable = out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1;
const rate = taskRate(task.rate, billable);
if (rate?.error) errors.push(rate.error);
else taskInputs.push({ name: str(task.name, 160), billable, rate });
}
const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]);
if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
@@ -198,12 +221,12 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
[projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
);
}
const tasks = (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160));
for (const task of tasks) {
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
for (const task of taskInputs) {
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable, rate) VALUES (?, ?, ?, ?)', [
projectId,
str(task.name, 160),
out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1,
task.name,
task.billable,
task.rate,
]);
}
await setMembers(conn, projectId, body.member_ids);
@@ -368,10 +391,14 @@ router.post('/:id/tasks', requireRole('lead'), async (req, res, next) => {
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const name = str(req.body?.name, 160);
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
const billable = project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1;
const rate = taskRate(req.body?.rate, billable);
if (rate?.error) return res.status(400).json({ error: rate.error });
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable, rate) VALUES (?, ?, ?, ?)', [
project.id,
name,
project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1,
billable,
rate,
]);
await respondProject(req, res, project.id, 201);
} catch (err) {
@@ -388,8 +415,16 @@ router.put('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) =>
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
const billable = req.body?.billable !== undefined ? (req.body.billable ? 1 : 0) : rows[0].billable;
const active = req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : rows[0].is_active;
const rate = req.body?.rate !== undefined ? taskRate(req.body.rate, billable) : taskRate(rows[0].rate, billable);
if (rate?.error) return res.status(400).json({ error: rate.error });
await withTransaction(async (conn) => {
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, is_active = ? WHERE id = ?', [name, billable, active, rows[0].id]);
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, rate = ?, is_active = ? WHERE id = ?', [
name,
billable,
rate,
active,
rows[0].id,
]);
if (billable !== rows[0].billable) {
await refreshBillable(conn, projectId);
await reassignEntries(conn, projectId);
+8
View File
@@ -103,6 +103,9 @@ CREATE TABLE IF NOT EXISTS pulse_tasks (
project_id INT UNSIGNED NOT NULL,
name VARCHAR(160) NOT NULL,
billable TINYINT(1) NOT NULL DEFAULT 1,
-- Eigener Satz fuer diesen Task (z. B. "vor Ort"). Leer = Satz des
-- Budgettopfs bzw. des Abruf-Projekts. Einheit wie am Projekt (Std./Tag).
rate DECIMAL(10,2) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
@@ -130,9 +133,14 @@ CREATE TABLE IF NOT EXISTS pulse_time_entries (
minutes INT UNSIGNED NOT NULL,
note VARCHAR(1000) NULL,
billable TINYINT(1) NOT NULL DEFAULT 0,
-- Herkunft aus Harvest. Nur gesetzt, wenn die Buchung uebernommen wurde;
-- der Schluessel verhindert Dubletten bei einem zweiten Import und erlaubt
-- einen gezielten Rueckbau, ohne handgetippte Zeiten anzufassen.
harvest_id BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_pulse_entries_harvest (harvest_id),
KEY idx_pulse_entries_user_date (user_id, entry_date),
KEY idx_pulse_entries_project_date (project_id, entry_date),
KEY idx_pulse_entries_pot (pot_id),
+3
View File
@@ -13,6 +13,7 @@ import reportRoutes from './routes/reports.js';
import exportRoutes from './routes/exports.js';
import settingsRoutes from './routes/settings.js';
import directoryRoutes from './routes/directory.js';
import importRoutes from './routes/imports.js';
const app = express();
const PORT = Number(process.env.PORT || 5010);
@@ -49,6 +50,8 @@ app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
app.use('/api/exports', requireAuth, exportRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
// Uebernahme aus Fremdsystemen - nur Administratoren.
app.use('/api/imports', requireAuth, requireRole('admin'), importRoutes);
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
+49 -2
View File
@@ -36,6 +36,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
const [potDialog, setPotDialog] = useState<{ pot: BudgetPot | null } | null>(null);
const [membersOpen, setMembersOpen] = useState(false);
const [newTask, setNewTask] = useState('');
const [newTaskRate, setNewTaskRate] = useState('');
const [confirm, setConfirm] = useState<{ title: string; message: string; action: () => Promise<unknown>; label?: string } | null>(null);
const hpd = settings.hours_per_day;
@@ -51,8 +52,17 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
const addTask = async () => {
if (!newTask.trim()) return;
await run(() => projectsApi.addTask(p.id, { name: newTask.trim(), billable: p.type !== 'non_billable' }));
const rate = newTaskRate.trim().replace(',', '.');
if (rate !== '' && !Number.isFinite(Number(rate))) return addToast('Der Satz ist keine Zahl.', 'error');
await run(() =>
projectsApi.addTask(p.id, {
name: newTask.trim(),
billable: p.type !== 'non_billable',
rate: rate === '' ? null : Number(rate),
})
);
setNewTask('');
setNewTaskRate('');
};
const isAbruf = p.type === 'abruf';
@@ -252,7 +262,14 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
{t.name}
{!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
</span>
<span className="tabular shrink-0 text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</span>
<span className="flex shrink-0 items-center gap-2">
{t.rate != null && (
<span className="tabular rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-bold text-brand-700" title="Eigener Satz für diesen Task">
{formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'}
</span>
)}
<span className="tabular text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</span>
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-ink-100">
<div className="h-full rounded-full bg-brand-400 transition-[width] duration-700" style={{ width: `${(t.used_minutes / maxTask) * 100}%` }} />
@@ -278,6 +295,26 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
if (name && name.trim() !== t.name) run(() => projectsApi.updateTask(p.id, t.id, { name: name.trim() }));
}}
/>
{t.billable && p.type !== 'non_billable' && (
<IconButton
icon="euro"
label="Satz für diesen Task"
onClick={() => {
const eingabe = window.prompt(
`Satz für "${t.name}" in € pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}.
Leer lassen = Satz des Budgettopfs.`,
t.rate == null ? '' : String(t.rate).replace('.', ',')
);
if (eingabe === null) return;
const wert = eingabe.trim().replace(',', '.');
if (wert !== '' && !Number.isFinite(Number(wert))) return addToast('Das ist keine Zahl.', 'error');
run(
() => projectsApi.updateTask(p.id, t.id, { rate: wert === '' ? null : Number(wert) }),
wert === '' ? 'Satz entfernt – es gilt der Topfsatz.' : 'Satz am Task gespeichert.'
);
}}
/>
)}
{!t.is_active ? (
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} />
) : (
@@ -291,6 +328,16 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
{canEdit && (
<div className="mt-2 flex gap-2">
<input className={`${inputClass} py-2`} value={newTask} placeholder="Neuer Task, z. B. Administration Server" onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} />
{p.type !== 'non_billable' && (
<input
className={`${inputClass} w-28 shrink-0 py-2`}
value={newTaskRate}
placeholder={p.billing_unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
onChange={(e) => setNewTaskRate(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
)}
<Button size="sm" icon="plus" onClick={addTask} disabled={!newTask.trim()}>
Hinzufügen
</Button>
+21 -7
View File
@@ -142,7 +142,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
const [notes, setNotes] = useState(project?.notes ?? '');
const [members, setMembers] = useState<number[]>(project?.members.map((m) => m.id) ?? (isAdmin ? [] : [user!.id]));
const [memberFilter, setMemberFilter] = useState('');
const [tasks, setTasks] = useState<{ name: string; billable: boolean }[]>([{ name: '', billable: true }]);
const [tasks, setTasks] = useState<{ name: string; billable: boolean; rate: string }[]>([{ name: '', billable: true, rate: '' }]);
const [pots, setPots] = useState<PotDraft[]>([emptyPot()]);
const [busy, setBusy] = useState(false);
const [newCustomer, setNewCustomer] = useState(false);
@@ -186,7 +186,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
? await projectsApi.update(project!.id, base)
: await projectsApi.create({
...base,
tasks: tasks.filter((t) => t.name.trim()),
tasks: tasks.filter((t) => t.name.trim()).map((t) => ({ name: t.name.trim(), billable: t.billable, rate: num(t.rate) })),
// Abruf kommt ohne Topf.
pots: type === 'abruf' ? [] : pots.map((p) => potPayload(p, settings.hours_per_day)),
});
@@ -334,6 +334,10 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
{!editing && (
<section>
<h3 className="mb-2 text-sm text-ink-900">Tasks</h3>
<p className="mb-2 text-[11px] leading-snug text-ink-400">
Der Satz je Task ist freiwillig. Bleibt er leer, gilt der Satz des Budgettopfs bzw. des Abruf-Projekts – ein
eigener Satz lohnt etwa für „vor Ort“ neben „remote“.
</p>
<div className="space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center gap-2">
@@ -343,15 +347,25 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
placeholder="z. B. Administration Server"
onChange={(e) => {
const next = tasks.map((t, j) => (j === i ? { ...t, name: e.target.value } : t));
if (i === tasks.length - 1 && e.target.value) next.push({ name: '', billable: true });
if (i === tasks.length - 1 && e.target.value) next.push({ name: '', billable: true, rate: '' });
setTasks(next);
}}
/>
{type !== 'non_billable' && (
<label className="flex shrink-0 items-center gap-1.5 text-xs font-bold text-ink-500">
<input type="checkbox" checked={task.billable} onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, billable: e.target.checked } : t)))} />
billable
</label>
<>
<input
className={`${inputClass} w-28 shrink-0`}
value={task.rate}
disabled={!task.billable}
placeholder={unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, rate: e.target.value } : t)))}
/>
<label className="flex shrink-0 items-center gap-1.5 text-xs font-bold text-ink-500">
<input type="checkbox" checked={task.billable} onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, billable: e.target.checked, rate: e.target.checked ? t.rate : '' } : t)))} />
billable
</label>
</>
)}
</div>
))}
+2 -2
View File
@@ -232,8 +232,8 @@ export const projectsApi = {
addPot: (id: number, pot: PotInput) => send<Project>('POST', `/projects/${id}/pots`, pot),
updatePot: (id: number, potId: number, pot: PotInput) => send<Project>('PUT', `/projects/${id}/pots/${potId}`, pot),
removePot: (id: number, potId: number) => send<Project>('DELETE', `/projects/${id}/pots/${potId}`),
addTask: (id: number, task: { name: string; billable?: boolean }) => send<Project>('POST', `/projects/${id}/tasks`, task),
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean }>) =>
addTask: (id: number, task: { name: string; billable?: boolean; rate?: number | null }) => send<Project>('POST', `/projects/${id}/tasks`, task),
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean; rate: number | null }>) =>
send<Project>('PUT', `/projects/${id}/tasks/${taskId}`, task),
removeTask: (id: number, taskId: number) => send<Project>('DELETE', `/projects/${id}/tasks/${taskId}`),
};
+2
View File
@@ -128,6 +128,8 @@ export interface Task {
project_id: number;
name: string;
billable: boolean;
/** Eigener Satz fuer diesen Task (z. B. "vor Ort"); null = Satz des Budgettopfs. */
rate: number | null;
is_active: boolean;
used_minutes: number;
}