diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index 14fe60d..6133dd7 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -1,6 +1,7 @@ import express from 'express'; import { pool, withTransaction } from '../db.js'; import { hasRole, requireRole } from '../middleware/auth.js'; +import { visibleUserIds } from '../lib/access.js'; import { getSettings } from '../lib/settings.js'; import { parseDate } from '../lib/dates.js'; import { @@ -148,16 +149,29 @@ async function refreshBillable(conn, projectId) { } // 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= 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) => { try { 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. - 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( settings, 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)); diff --git a/src/components/booking/ExportDialog.tsx b/src/components/booking/ExportDialog.tsx index fb23c6c..422bba3 100644 --- a/src/components/booking/ExportDialog.tsx +++ b/src/components/booking/ExportDialog.tsx @@ -1,9 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Modal } from '../ui/Modal'; import { Button, Field, inputClass, Toggle } from '../ui/controls'; import { Icon, type IconName } from '../ui/Icon'; import { useAuth } from '../../lib/auth'; -import { download, qs } from '../../lib/api'; +import { download, projectsApi, qs } from '../../lib/api'; import { MONTHS } from '../../lib/format'; import { useToast } from '../Toast'; import { atLeast, type DirectoryUser, type Project } from '../../types'; @@ -33,6 +33,35 @@ export const ExportDialog: React.FC<{ const [busy, setBusy] = useState(false); 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(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 * 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 * 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 map = new Map(); waehlbar.forEach((p) => map.set(p.customer.id, p.customer.name)); return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de')); }, [waehlbar]); + + // Wechselt die Person, passt die alte Auswahl nicht mehr. + useEffect(() => { + setCustomerId(''); + setProjectId(''); + }, [userId]); const projectOptions = waehlbar.filter((p) => !customerId || p.customer_id === customerId); // Monate: 18 zurueck, 2 voraus. @@ -137,13 +172,14 @@ export const ExportDialog: React.FC<{ {t.is_external ? ' (extern)' : ''} ))} - + )} - + - setProjectId(e.target.value ? Number(e.target.value) : '')}> {projectOptions.map((p) => (