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 * (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
* intern, in Reports per Schalter zuschaltbar. * 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. // Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"]; const where = ["u.role <> 'admin'"];
const params = []; const params = [];
@@ -39,6 +39,9 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
where.push('e.task_id = ?'); where.push('e.task_id = ?');
params.push(Number(taskId)); 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( 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, `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: [ info: [
['Kunde', customerName || 'Alle Kunden'], ['Kunde', customerName || 'Alle Kunden'],
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'], ['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
...(report.hide_non_billable ? [['Nicht abrechenbare Projekte', 'ausgeblendet']] : []),
], ],
kpis: [ kpis: [
['Gesamtstunden', hoursDe(s.minutes)], ['Gesamtstunden', hoursDe(s.minutes)],
+5
View File
@@ -39,6 +39,9 @@ export async function buildTimeReport(viewer, query) {
const from = query.from; const from = query.from;
const to = query.to; const to = query.to;
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true'; 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 userIds = await reportUserIds(viewer, query.team);
const entries = await loadEntries(settings, { const entries = await loadEntries(settings, {
from, from,
@@ -46,6 +49,7 @@ export async function buildTimeReport(viewer, query) {
userIds, userIds,
customerId: query.customer_id, customerId: query.customer_id,
projectId: query.project_id, projectId: query.project_id,
skipNonBillableProjects: hideNonBillable,
}); });
const filter = userFilter(userIds, 'id'); const filter = userFilter(userIds, 'id');
@@ -91,6 +95,7 @@ export async function buildTimeReport(viewer, query) {
return { return {
range: { from, to }, range: { from, to },
include_fixed: includeFixed, include_fixed: includeFixed,
hide_non_billable: hideNonBillable,
summary: { summary: {
minutes, minutes,
billable_minutes: billable, billable_minutes: billable,
+24 -4
View File
@@ -75,6 +75,9 @@ const TimeReport: React.FC = () => {
const [customerId, setCustomerId] = useState<number | ''>(''); const [customerId, setCustomerId] = useState<number | ''>('');
const [team, setTeam] = useState<number | ''>(''); const [team, setTeam] = useState<number | ''>('');
const [includeFixed, setIncludeFixed] = useState(false); 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 [customers, setCustomers] = useState<Customer[]>([]);
const [leads, setLeads] = useState<DirectoryUser[]>([]); const [leads, setLeads] = useState<DirectoryUser[]>([]);
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste. // 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 range = periodRange(period, anchor, custom);
const report = useLoader( 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) => { const shift = (step: number) => {
@@ -101,7 +112,7 @@ const TimeReport: React.FC = () => {
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`); 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 groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
const exportOptions = useMemo( 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' }, { 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 // 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; const data = report.data;
@@ -197,6 +208,15 @@ const TimeReport: React.FC = () => {
</span> </span>
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b> <b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
</p> </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> </div>
<div> <div>
+9 -2
View File
@@ -403,6 +403,7 @@ export interface GroupRow {
export interface TimeReport { export interface TimeReport {
range: { from: string; to: string }; range: { from: string; to: string };
include_fixed: boolean; include_fixed: boolean;
hide_non_billable: boolean;
summary: { summary: {
minutes: number; minutes: number;
billable_minutes: number; billable_minutes: number;
@@ -447,8 +448,14 @@ export interface CustomerReport {
} }
export const reportsApi = { export const reportsApi = {
time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean }) => 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 })}`), 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}`), project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`), customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
}; };