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) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 17:07:37 +02:00
co-authored by Claude Opus 5
parent 2710baa615
commit 80e46910fd
5 changed files with 43 additions and 7 deletions
+4 -1
View File
@@ -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,
+1
View File
@@ -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)],
+5
View File
@@ -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,
+24 -4
View File
@@ -75,6 +75,9 @@ const TimeReport: React.FC = () => {
const [customerId, setCustomerId] = useState<number | ''>('');
const [team, setTeam] = useState<number | ''>('');
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<Customer[]>([]);
const [leads, setLeads] = useState<DirectoryUser[]>([]);
// 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 = () => {
</span>
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
</p>
<label className="flex cursor-pointer items-center gap-2 pt-1 text-xs font-semibold text-ink-700">
<input
type="checkbox"
checked={hideNonBillable}
onChange={(e) => setHideNonBillable(e.target.checked)}
className="h-4 w-4 accent-[var(--color-brand-500)]"
/>
Nicht abrechenbare Projekte ausblenden
</label>
</div>
</div>
<div>
+9 -2
View File
@@ -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<TimeReport>(`/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<TimeReport>(
`/reports/time${qs({
...params,
include_fixed: params.include_fixed ? 1 : undefined,
hide_non_billable: params.hide_non_billable ? 1 : undefined,
})}`
),
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
};