Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen, steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports, Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen. Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto werden wenige Kilobyte, und der Server muss kein Bild verarbeiten. Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten werden muesste. Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit Pulse arbeitet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
10 KiB
JavaScript
235 lines
10 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;
|
|
|
|
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]));
|
|
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.utilization = row.capacity_minutes ? Math.round((row.minutes / 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,
|
|
},
|
|
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,
|
|
};
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|