Auslastung Verglichen werden jetzt die bis heute gebuchten Stunden mit der Kapazitaet bis heute. Die Kapazitaet endete schon immer am heutigen Tag; wurde im Voraus gebucht - eine ganze Woche Urlaub am Montag - standen 40 Stunden gegen 8 Stunden Kapazitaet und die Wochenansicht zeigte 500 Prozent. Die Spalte "Stunden" zeigt weiter den ganzen Zeitraum. Tasks Das Feld zum Hinzufuegen lief aus der Karte heraus: die Zeile darf jetzt umbrechen, und die Breitenangaben tragen "!", weil inputClass w-full mitbringt und sonst gewinnt. Der Satz eines Tasks steht als schmales Feld direkt in der Zeile und wird beim Verlassen gespeichert - vorher lag er hinter einem Abfragedialog und war praktisch nicht zu finden. Export der Buchungen Kunde und Projekt kommen nur noch aus den eigenen Zuweisungen, auch fuer Vorgesetzte. Eine Liste aller Projekte des Hauses war dort nur Suchaufwand. Budgettopf Neues Feld Bestellnummer (optional). Es erscheint im Stundennachweis als eigene Kopfzeile, wenn die gebuchten Toepfe eine tragen. Rolle "Externe" Gleiche Rechte wie "Mitarbeiter" und derselbe Rang, die Rolle trennt nur, wer zum Haus gehoert. Sie setzt is_external automatisch, damit Liste und Kennzeichnung nicht widersprechen. Vorgesetzte duerfen sie vergeben. Stellen, die bisher role === 'user' pruefen, fragen nun hasRole(lead) ab - sonst haetten Externe alle Projekte gesehen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
267 lines
12 KiB
JavaScript
267 lines
12 KiB
JavaScript
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;
|