From 80e46910fdaeb6a1cbdd5021e5447500b005cbd8 Mon Sep 17 00:00:00 2001
From: schakal00783
Date: Mon, 21 Sep 2026 17:07:37 +0200
Subject: [PATCH] feat(reports): nicht abrechenbare Projekte ausblenden
Im Zeitreport stehen interne Projekte und Abwesenheiten zwischen den
Kundenzahlen: 2025 sind das 19.451 von 45.762 Stunden, und die Auslastung
zeigt 146 Prozent, weil Urlaub und Krankheit mitzaehlen.
Ein Haken neben der Billable-Aufteilung blendet Projekte vom Typ
"nicht abrechenbar" aus. Er wirkt auf den ganzen Report - Summen,
Auslastung, alle vier Gruppierungen - und auf die Exporte, die denselben
Report bauen; im PDF-Kopf steht dann "Nicht abrechenbare Projekte:
ausgeblendet". Gefiltert wird in loadEntries, damit Kunden-, Projekt- und
Taskliste dieselbe Grundmenge sehen.
Co-Authored-By: Claude Opus 5 (1M context)
---
server/src/lib/entries.js | 5 ++++-
server/src/routes/exports.js | 1 +
server/src/routes/reports.js | 5 +++++
src/components/reports/Reports.tsx | 28 ++++++++++++++++++++++++----
src/lib/api.ts | 11 +++++++++--
5 files changed, 43 insertions(+), 7 deletions(-)
diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js
index d16785f..c3258dd 100644
--- a/server/src/lib/entries.js
+++ b/server/src/lib/entries.js
@@ -12,7 +12,7 @@ import { userFilter } from './access.js';
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
* intern, in Reports per Schalter zuschaltbar.
*/
-export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) {
+export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId, skipNonBillableProjects = false } = {}) {
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"];
const params = [];
@@ -39,6 +39,9 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
where.push('e.task_id = ?');
params.push(Number(taskId));
}
+ // Fuer Auswertungen, die nur das Kundengeschaeft zeigen sollen: interne
+ // Projekte und Abwesenheiten bleiben draussen.
+ if (skipNonBillableProjects) where.push("p.type <> 'non_billable'");
const [rows] = await pool.query(
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js
index 23f4484..b0b4d94 100644
--- a/server/src/routes/exports.js
+++ b/server/src/routes/exports.js
@@ -244,6 +244,7 @@ router.get('/report', async (req, res, next) => {
info: [
['Kunde', customerName || 'Alle Kunden'],
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
+ ...(report.hide_non_billable ? [['Nicht abrechenbare Projekte', 'ausgeblendet']] : []),
],
kpis: [
['Gesamtstunden', hoursDe(s.minutes)],
diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js
index 4cb2053..578da54 100644
--- a/server/src/routes/reports.js
+++ b/server/src/routes/reports.js
@@ -39,6 +39,9 @@ export async function buildTimeReport(viewer, query) {
const from = query.from;
const to = query.to;
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true';
+ // Schalter im Report: nicht abrechenbare Projekte (intern, Abwesenheiten)
+ // ausblenden - dann zeigt die Auswertung nur das Kundengeschaeft.
+ const hideNonBillable = query.hide_non_billable === '1' || query.hide_non_billable === 'true';
const userIds = await reportUserIds(viewer, query.team);
const entries = await loadEntries(settings, {
from,
@@ -46,6 +49,7 @@ export async function buildTimeReport(viewer, query) {
userIds,
customerId: query.customer_id,
projectId: query.project_id,
+ skipNonBillableProjects: hideNonBillable,
});
const filter = userFilter(userIds, 'id');
@@ -91,6 +95,7 @@ export async function buildTimeReport(viewer, query) {
return {
range: { from, to },
include_fixed: includeFixed,
+ hide_non_billable: hideNonBillable,
summary: {
minutes,
billable_minutes: billable,
diff --git a/src/components/reports/Reports.tsx b/src/components/reports/Reports.tsx
index d182c06..0d418eb 100644
--- a/src/components/reports/Reports.tsx
+++ b/src/components/reports/Reports.tsx
@@ -75,6 +75,9 @@ const TimeReport: React.FC = () => {
const [customerId, setCustomerId] = useState('');
const [team, setTeam] = useState('');
const [includeFixed, setIncludeFixed] = useState(false);
+ // Blendet interne Projekte und Abwesenheiten aus - der Report zeigt dann
+ // nur das Kundengeschaeft, inklusive Auslastung und Export.
+ const [hideNonBillable, setHideNonBillable] = useState(false);
const [customers, setCustomers] = useState([]);
const [leads, setLeads] = useState([]);
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste.
@@ -90,8 +93,16 @@ const TimeReport: React.FC = () => {
const range = periodRange(period, anchor, custom);
const report = useLoader(
- () => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
- [range.from, range.to, customerId, team, includeFixed]
+ () =>
+ reportsApi.time({
+ from: range.from,
+ to: range.to,
+ customer_id: customerId || null,
+ team: team || null,
+ include_fixed: includeFixed,
+ hide_non_billable: hideNonBillable,
+ }),
+ [range.from, range.to, customerId, team, includeFixed, hideNonBillable]
);
const shift = (step: number) => {
@@ -101,7 +112,7 @@ const TimeReport: React.FC = () => {
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
};
- const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined };
+ const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined, hide_non_billable: hideNonBillable ? 1 : undefined };
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
const exportOptions = useMemo(
() => [
@@ -113,7 +124,7 @@ const TimeReport: React.FC = () => {
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
- [tab, range.from, range.to, customerId, team, includeFixed]
+ [tab, range.from, range.to, customerId, team, includeFixed, hideNonBillable]
);
const data = report.data;
@@ -197,6 +208,15 @@ const TimeReport: React.FC = () => {
{formatHours(s!.non_billable_minutes)}
+
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 2348d76..ff8c3fe 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -403,6 +403,7 @@ export interface GroupRow {
export interface TimeReport {
range: { from: string; to: string };
include_fixed: boolean;
+ hide_non_billable: boolean;
summary: {
minutes: number;
billable_minutes: number;
@@ -447,8 +448,14 @@ export interface CustomerReport {
}
export const reportsApi = {
- time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean }) =>
- get
(`/reports/time${qs({ ...params, include_fixed: params.include_fixed ? 1 : undefined })}`),
+ time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean; hide_non_billable?: boolean }) =>
+ get(
+ `/reports/time${qs({
+ ...params,
+ include_fixed: params.include_fixed ? 1 : undefined,
+ hide_non_billable: params.hide_non_billable ? 1 : undefined,
+ })}`
+ ),
project: (id: number) => get(`/reports/project/${id}`),
customer: (id: number) => get(`/reports/customer/${id}`),
};