diff --git a/README.md b/README.md index 857a160..f334d51 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/src/db.js b/server/src/db.js index 03db3bd..2f7cd30 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -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; diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js index d875cec..fd42a50 100644 --- a/server/src/lib/entries.js +++ b/server/src/lib/entries.js @@ -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)) { diff --git a/server/src/lib/projects.js b/server/src/lib/projects.js index 10ec4d0..689a816 100644 --- a/server/src/lib/projects.js +++ b/server/src/lib/projects.js @@ -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, diff --git a/server/src/routes/imports.js b/server/src/routes/imports.js new file mode 100644 index 0000000..fb18a91 --- /dev/null +++ b/server/src/routes/imports.js @@ -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; diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index 247caf8..6454e8a 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -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); diff --git a/server/src/schema.sql b/server/src/schema.sql index 7c268e7..7cb9f07 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -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), diff --git a/server/src/server.js b/server/src/server.js index e85d872..40aff42 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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.' })); diff --git a/src/components/projects/ProjectDetail.tsx b/src/components/projects/ProjectDetail.tsx index a1b9f97..9412167 100644 --- a/src/components/projects/ProjectDetail.tsx +++ b/src/components/projects/ProjectDetail.tsx @@ -36,6 +36,7 @@ export const ProjectDetail: React.FC = ({ 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; label?: string } | null>(null); const hpd = settings.hours_per_day; @@ -51,8 +52,17 @@ export const ProjectDetail: React.FC = ({ 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 = ({ project: p, canEdit, users, onC {t.name} {!t.is_active && (inaktiv)} - {formatHours(t.used_minutes, 1)} h + + {t.rate != null && ( + + {formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'} + + )} + {formatHours(t.used_minutes, 1)} h +
@@ -278,6 +295,26 @@ export const ProjectDetail: React.FC = ({ 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' && ( + { + 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 ? ( run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} /> ) : ( @@ -291,6 +328,16 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC {canEdit && (
setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} /> + {p.type !== 'non_billable' && ( + setNewTaskRate(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addTask()} + /> + )} diff --git a/src/components/projects/ProjectForm.tsx b/src/components/projects/ProjectForm.tsx index e39db27..b8971b0 100644 --- a/src/components/projects/ProjectForm.tsx +++ b/src/components/projects/ProjectForm.tsx @@ -142,7 +142,7 @@ export const ProjectForm: React.FC = ({ project, customers, users, onClos const [notes, setNotes] = useState(project?.notes ?? ''); const [members, setMembers] = useState(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([emptyPot()]); const [busy, setBusy] = useState(false); const [newCustomer, setNewCustomer] = useState(false); @@ -186,7 +186,7 @@ export const ProjectForm: React.FC = ({ 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 = ({ project, customers, users, onClos {!editing && (

Tasks

+

+ 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“. +

{tasks.map((task, i) => (
@@ -343,15 +347,25 @@ export const ProjectForm: React.FC = ({ 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' && ( - + <> + setTasks(tasks.map((t, j) => (j === i ? { ...t, rate: e.target.value } : t)))} + /> + + )}
))} diff --git a/src/lib/api.ts b/src/lib/api.ts index 88846cd..2318dbb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -232,8 +232,8 @@ export const projectsApi = { addPot: (id: number, pot: PotInput) => send('POST', `/projects/${id}/pots`, pot), updatePot: (id: number, potId: number, pot: PotInput) => send('PUT', `/projects/${id}/pots/${potId}`, pot), removePot: (id: number, potId: number) => send('DELETE', `/projects/${id}/pots/${potId}`), - addTask: (id: number, task: { name: string; billable?: boolean }) => send('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('POST', `/projects/${id}/tasks`, task), + updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean; rate: number | null }>) => send('PUT', `/projects/${id}/tasks/${taskId}`, task), removeTask: (id: number, taskId: number) => send('DELETE', `/projects/${id}/tasks/${taskId}`), }; diff --git a/src/types.ts b/src/types.ts index ed912ee..1fa6784 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; }