feat(export): Nachweis fuer andere Berater mit deren Projekten

Wer in der Buchung einen Kollegen ausgewaehlt hatte und dann exportierte,
bekam im Dialog die eigenen Kunden und Projekte zur Auswahl - fuer einen
Nachweis, der jemand anderem gehoert.

Der Dialog laedt die Projekte jetzt zu der Person, fuer die der Nachweis
entsteht, und laedt neu, wenn man die Person darin wechselt; die alte
Kunden- und Projektauswahl wird dabei verworfen, weil sie nicht mehr
passt. Bei "Alle Mitarbeiter" stehen alle sichtbaren Projekte zur Wahl.

Dafuer nimmt GET /projects ein ?user_id an. Wer fremde Projekte sehen
darf, entscheidet dieselbe Sicht wie bei den Zeiten: Mitarbeiter bekommen
403, Vorgesetzte nur ihr Team, Management alle. Die Sammelauswahl heisst
fuer Management "Alle Mitarbeiter" statt "Alle meine Mitarbeiter" - sie
sieht ohnehin jeden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-22 10:44:49 +02:00
co-authored by Claude Opus 5
parent ff71cb40e1
commit dd42fba1cb
3 changed files with 60 additions and 10 deletions
+17 -3
View File
@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { pool, withTransaction } from '../db.js'; import { pool, withTransaction } from '../db.js';
import { hasRole, requireRole } from '../middleware/auth.js'; import { hasRole, requireRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js'; import { getSettings } from '../lib/settings.js';
import { parseDate } from '../lib/dates.js'; import { parseDate } from '../lib/dates.js';
import { import {
@@ -148,16 +149,29 @@ async function refreshBillable(conn, projectId) {
} }
// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege. // Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege.
// ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog). /*
* ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog).
* ?user_id=<id> die Projekte einer anderen Person - gebraucht, wenn eine
* Fuehrungskraft den Stundennachweis fuer jemanden aus ihrem Team baut. Wer
* eingesehen werden darf, entscheidet dieselbe Sicht wie bei den Zeiten.
*/
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const settings = await getSettings(); const settings = await getSettings();
let memberId = req.user.id;
if (req.query.user_id && Number(req.query.user_id) !== req.user.id) {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Projekte.' });
const visible = await visibleUserIds(req.user);
const wanted = Number(req.query.user_id);
if (visible !== null && !visible.includes(wanted)) return res.status(403).json({ error: 'Keine Einsicht in diese Projekte.' });
memberId = wanted;
}
// Mitarbeiter und Externe sehen nur zugewiesene Projekte. // Mitarbeiter und Externe sehen nur zugewiesene Projekte.
const mineOnly = !hasRole(req.user, 'lead') || req.query.scope === 'mine'; const mineOnly = !hasRole(req.user, 'lead') || req.query.scope === 'mine' || memberId !== req.user.id;
const projects = await loadProjects( const projects = await loadProjects(
settings, settings,
mineOnly mineOnly
? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [req.user.id] } ? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [memberId] }
: {} : {}
); );
res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts)); res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts));
+42 -6
View File
@@ -1,9 +1,9 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { Button, Field, inputClass, Toggle } from '../ui/controls'; import { Button, Field, inputClass, Toggle } from '../ui/controls';
import { Icon, type IconName } from '../ui/Icon'; import { Icon, type IconName } from '../ui/Icon';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
import { download, qs } from '../../lib/api'; import { download, projectsApi, qs } from '../../lib/api';
import { MONTHS } from '../../lib/format'; import { MONTHS } from '../../lib/format';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { atLeast, type DirectoryUser, type Project } from '../../types'; import { atLeast, type DirectoryUser, type Project } from '../../types';
@@ -33,6 +33,35 @@ export const ExportDialog: React.FC<{
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const isLead = atLeast(user, 'lead'); const isLead = atLeast(user, 'lead');
/*
* Kunde und Projekt gehoeren zu der Person, fuer die der Nachweis entsteht -
* sonst baut eine Fuehrungskraft den Export ihres Mitarbeiters mit den
* eigenen Projekten. Fuer sich selbst reicht die Liste, die die Buchung
* ohnehin geladen hat; fuer andere wird sie nachgeladen.
*/
const [fremdeProjekte, setFremdeProjekte] = useState<Project[] | null>(null);
const [laden, setLaden] = useState(false);
useEffect(() => {
if (userId === String(user!.id)) {
setFremdeProjekte(null);
return;
}
let abgebrochen = false;
setLaden(true);
// "Alle": die Projekte, die der Fragende ueberhaupt sehen darf.
const anfrage = userId === 'all' ? projectsApi.list() : projectsApi.list('mine', Number(userId));
anfrage
.then((list) => !abgebrochen && setFremdeProjekte(list))
.catch(() => !abgebrochen && setFremdeProjekte([]))
.finally(() => !abgebrochen && setLaden(false));
return () => {
abgebrochen = true;
};
}, [userId, user]);
const basis = fremdeProjekte ?? projects;
/* /*
* Zur Auswahl stehen nur laufende Projekte aktiver Kunden. Archivierte * Zur Auswahl stehen nur laufende Projekte aktiver Kunden. Archivierte
* Projekte und stillgelegte Kunden wuerden die Liste mit Aufträgen fuellen, * Projekte und stillgelegte Kunden wuerden die Liste mit Aufträgen fuellen,
@@ -40,12 +69,18 @@ export const ExportDialog: React.FC<{
* nicht verloren: ohne Filter exportiert der Nachweis alles, was die Person * nicht verloren: ohne Filter exportiert der Nachweis alles, was die Person
* im Monat gebucht hat - und der Projektreport hat einen eigenen Export. * im Monat gebucht hat - und der Projektreport hat einen eigenen Export.
*/ */
const waehlbar = useMemo(() => projects.filter((p) => p.is_active && p.customer.is_active !== false), [projects]); const waehlbar = useMemo(() => basis.filter((p) => p.is_active && p.customer.is_active !== false), [basis]);
const customers = useMemo(() => { const customers = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
waehlbar.forEach((p) => map.set(p.customer.id, p.customer.name)); waehlbar.forEach((p) => map.set(p.customer.id, p.customer.name));
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de')); return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de'));
}, [waehlbar]); }, [waehlbar]);
// Wechselt die Person, passt die alte Auswahl nicht mehr.
useEffect(() => {
setCustomerId('');
setProjectId('');
}, [userId]);
const projectOptions = waehlbar.filter((p) => !customerId || p.customer_id === customerId); const projectOptions = waehlbar.filter((p) => !customerId || p.customer_id === customerId);
// Monate: 18 zurueck, 2 voraus. // Monate: 18 zurueck, 2 voraus.
@@ -137,13 +172,14 @@ export const ExportDialog: React.FC<{
{t.is_external ? ' (extern)' : ''} {t.is_external ? ' (extern)' : ''}
</option> </option>
))} ))}
<option value="all">Alle meine Mitarbeiter</option> <option value="all">{atLeast(user, 'management') ? 'Alle Mitarbeiter' : 'Alle meine Mitarbeiter'}</option>
</select> </select>
</Field> </Field>
)} )}
<Field label="Kunde"> <Field label="Kunde" hint={laden ? 'Lädt die Projekte dieser Person…' : undefined}>
<select <select
className={inputClass} className={inputClass}
disabled={laden}
value={customerId} value={customerId}
onChange={(e) => { onChange={(e) => {
setCustomerId(e.target.value ? Number(e.target.value) : ''); setCustomerId(e.target.value ? Number(e.target.value) : '');
@@ -159,7 +195,7 @@ export const ExportDialog: React.FC<{
</select> </select>
</Field> </Field>
<Field label="Projekt"> <Field label="Projekt">
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}> <select className={inputClass} disabled={laden} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Projekte</option> <option value="">Alle Projekte</option>
{projectOptions.map((p) => ( {projectOptions.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
+1 -1
View File
@@ -311,7 +311,7 @@ export type PotInput = Partial<
>; >;
export const projectsApi = { export const projectsApi = {
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`), list: (scope?: 'mine', userId?: number) => get<Project[]>(`/projects${qs({ scope, user_id: userId })}`),
get: (id: number) => get<Project>(`/projects/${id}`), get: (id: number) => get<Project>(`/projects/${id}`),
create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data), create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data),
update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data), update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data),