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:
co-authored by
Claude Opus 5
parent
ff71cb40e1
commit
dd42fba1cb
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user