import express from 'express'; import { pool } from '../db.js'; import { teamOf, userFilter, visibleUserIds } from '../lib/access.js'; import { getSettings } from '../lib/settings.js'; import { loadEntries } from '../lib/entries.js'; import { loadProjects } from '../lib/projects.js'; import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js'; import { capacityMinutes } from './dashboard.js'; const router = express.Router(); const r2 = (n) => Math.round(n * 100) / 100; /** Welche Benutzer fliessen ein: Sicht des Anfragenden, optional auf ein Team verengt. */ export async function reportUserIds(viewer, teamLeadId) { const visible = await visibleUserIds(viewer); if (!teamLeadId) return visible; const team = [Number(teamLeadId), ...(await teamOf(Number(teamLeadId)))]; return visible === null ? team : team.filter((id) => visible.includes(id)); } function group(entries, keyFn, labelFn, includeFixed) { const map = new Map(); for (const e of entries) { const key = keyFn(e); const item = map.get(key) || { key, ...labelFn(e), minutes: 0, billable_minutes: 0, amount: 0 }; item.minutes += e.minutes; if (e.billable) item.billable_minutes += e.minutes; item.amount += e.amount + (includeFixed ? e.fixed_amount : 0); map.set(key, item); } return [...map.values()] .map((i) => ({ ...i, amount: r2(i.amount), non_billable_minutes: i.minutes - i.billable_minutes })) .sort((a, b) => b.minutes - a.minutes); } /** Zeitreport wie in Harvest: Summen plus Aufteilung nach Kunde, Projekt, Task, Mitarbeiter. */ export async function buildTimeReport(viewer, query) { const settings = await getSettings(); const from = query.from; const to = query.to; const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true'; const userIds = await reportUserIds(viewer, query.team); const entries = await loadEntries(settings, { from, to, userIds, customerId: query.customer_id, projectId: query.project_id, }); const filter = userFilter(userIds, 'id'); const [users] = await pool.query( `SELECT id, name, color, avatar, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`, filter.params ); const capTo = to > today() ? today() : to; /* * Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag * (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am * Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die * Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter * alles, was im Zeitraum gebucht ist. */ const bisHeute = entries.filter((e) => e.entry_date <= capTo); const minutes = entries.reduce((s, e) => s + e.minutes, 0); const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); const amount = entries.reduce((s, e) => s + e.amount, 0); const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0); const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, avatar: e.user_avatar ?? null, is_external: e.is_external }), includeFixed); // Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter). if (!query.customer_id && !query.project_id) { for (const u of users) { if (u.is_active && !userRows.some((row) => row.user_id === u.id)) { userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, avatar: u.avatar ?? null, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 }); } } } const byId = new Map(users.map((u) => [u.id, u])); const minutenBisHeute = new Map(); for (const e of bisHeute) minutenBisHeute.set(e.user_id, (minutenBisHeute.get(e.user_id) || 0) + e.minutes); for (const row of userRows) { const u = byId.get(row.user_id); row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0; row.minutes_until_today = minutenBisHeute.get(row.user_id) || 0; row.utilization = row.capacity_minutes ? Math.round((row.minutes_until_today / row.capacity_minutes) * 100) : null; } return { range: { from, to }, include_fixed: includeFixed, summary: { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: r2(amount + (includeFixed ? fixedAmount : 0)), amount_without_fixed: r2(amount), fixed_amount: r2(fixedAmount), entries: entries.length, // Fuer die Auslastung: nur was bis heute gebucht ist. minutes_until_today: bisHeute.reduce((s, e) => s + e.minutes, 0), }, clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed), projects: group( entries, (e) => e.project_id, (e) => ({ project_id: e.project_id, name: e.project_display, code: e.project_code, type: e.project_type, customer_name: e.customer_name, color: e.customer_color }), includeFixed ), tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed), users: userRows.sort((a, b) => a.name.localeCompare(b.name, 'de')), entries, }; } router.get('/time', async (req, res, next) => { try { if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' }); const report = await buildTimeReport(req.user, req.query); delete report.entries; res.json(report); } catch (err) { next(err); } }); /** Projektreport (Harvest-Projektseite): Verlauf, Budget, Tasks, Team, Toepfe. */ router.get('/project/:id', async (req, res, next) => { try { const settings = await getSettings(); const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [Number(req.params.id)] }); if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); const userIds = await visibleUserIds(req.user); const entries = await loadEntries(settings, { projectId: project.id, userIds, from: req.query.from, to: req.query.to }); res.json(buildProjectDetail(project, entries)); } catch (err) { next(err); } }); function buildProjectDetail(project, entries) { const includeFixed = project.type === 'fixed'; // Wochenreihe vom Projektstart bis max(Ende, heute) - hoechstens 104 Wochen. const end = [project.end_date || today(), today(), ...project.pots.map((p) => p.valid_to)].sort().pop(); const lastMonday = mondayOf(end); let start = [mondayOf(project.start_date), addDays(lastMonday, -103 * 7)].sort().pop(); const weeks = []; while (start <= lastMonday) { weeks.push(start); start = addDays(start, 7); } let cumulative = entries.filter((e) => e.entry_date < weeks[0]).reduce((s, e) => s + e.minutes, 0); const weekly = weeks.map((monday) => { const sunday = addDays(monday, 6); const own = entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday); const minutes = own.reduce((s, e) => s + e.minutes, 0); const billable = own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); cumulative += minutes; return { week_start: monday, label: `KW ${isoWeek(monday).week}`, minutes, billable_minutes: billable, cumulative_minutes: monday <= today() ? cumulative : null, }; }); /* * Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag * (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am * Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die * Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter * alles, was im Zeitraum gebucht ist. */ const bisHeute = entries.filter((e) => e.entry_date <= capTo); const minutes = entries.reduce((s, e) => s + e.minutes, 0); const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); return { project, totals: { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)), }, weekly, tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed), team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed), pots: project.pots.map((pot) => { const own = entries.filter((e) => e.pot_id === pot.id); return { ...pot, tasks: group(own, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed), team: group(own, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed), }; }), }; } /** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */ router.get('/customer/:id', async (req, res, next) => { try { const settings = await getSettings(); const [[customer]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]); if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' }); const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] }); const userIds = await visibleUserIds(req.user); const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to }); // Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate. const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort(); const first = months[0] || today().slice(0, 7); const series = []; const cursor = new Date(`${first}-01T00:00:00Z`); const last = today().slice(0, 7); while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) { const month = cursor.toISOString().slice(0, 7); const own = entries.filter((e) => e.entry_date.startsWith(month)); series.push({ month, minutes: own.reduce((s, e) => s + e.minutes, 0), billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0), amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)), }); cursor.setUTCMonth(cursor.getUTCMonth() + 1); } /* * Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag * (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am * Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die * Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter * alles, was im Zeitraum gebucht ist. */ const bisHeute = entries.filter((e) => e.entry_date <= capTo); const minutes = entries.reduce((s, e) => s + e.minutes, 0); const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); res.json({ customer, totals: { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)), earned: r2(projects.reduce((s, p) => s + p.earned, 0)), open: r2(projects.reduce((s, p) => s + p.open, 0)), budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)), }, series: series.slice(-24), projects: projects.map((p) => { const own = entries.filter((e) => e.project_id === p.id); return { ...buildProjectDetail(p, own), weekly: undefined }; }), team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), true), tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), true), }); } catch (err) { next(err); } }); export default router;