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:
co-authored by
Claude Opus 5
parent
2710baa615
commit
80e46910fd
@@ -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,
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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}`),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user