feat(mehrere): Auslastung bis heute, Satz je Task bearbeitbar, Bestellnummer, Rolle Externe

Auslastung
  Verglichen werden jetzt die bis heute gebuchten Stunden mit der Kapazitaet
  bis heute. Die Kapazitaet endete schon immer am heutigen Tag; wurde im
  Voraus gebucht - eine ganze Woche Urlaub am Montag - standen 40 Stunden
  gegen 8 Stunden Kapazitaet und die Wochenansicht zeigte 500 Prozent. Die
  Spalte "Stunden" zeigt weiter den ganzen Zeitraum.

Tasks
  Das Feld zum Hinzufuegen lief aus der Karte heraus: die Zeile darf jetzt
  umbrechen, und die Breitenangaben tragen "!", weil inputClass w-full
  mitbringt und sonst gewinnt. Der Satz eines Tasks steht als schmales Feld
  direkt in der Zeile und wird beim Verlassen gespeichert - vorher lag er
  hinter einem Abfragedialog und war praktisch nicht zu finden.

Export der Buchungen
  Kunde und Projekt kommen nur noch aus den eigenen Zuweisungen, auch fuer
  Vorgesetzte. Eine Liste aller Projekte des Hauses war dort nur Suchaufwand.

Budgettopf
  Neues Feld Bestellnummer (optional). Es erscheint im Stundennachweis als
  eigene Kopfzeile, wenn die gebuchten Toepfe eine tragen.

Rolle "Externe"
  Gleiche Rechte wie "Mitarbeiter" und derselbe Rang, die Rolle trennt nur,
  wer zum Haus gehoert. Sie setzt is_external automatisch, damit Liste und
  Kennzeichnung nicht widersprechen. Vorgesetzte duerfen sie vergeben.
  Stellen, die bisher role === 'user' pruefen, fragen nun hasRole(lead) ab -
  sonst haetten Externe alle Projekte gesehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 15:06:26 +02:00
co-authored by Claude Opus 5
parent 532b15ce99
commit 79bfd561bd
21 changed files with 246 additions and 72 deletions
+8 -6
View File
@@ -63,7 +63,6 @@ export const Booking: React.FC = () => {
const entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]);
const projects = useLoader(() => projectsApi.list('mine'), []);
const [team, setTeam] = useState<DirectoryUser[]>([]);
const [allProjects, setAllProjects] = useState<Project[] | null>(null);
useEffect(() => {
if (!isLead) return;
@@ -101,10 +100,13 @@ export const Booking: React.FC = () => {
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
};
const openExport = () => {
if (isLead && !allProjects) projectsApi.list().then(setAllProjects).catch(() => undefined);
setExportOpen(true);
};
/*
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
* dem, was er selbst gebucht hat; eine Liste aller Projekte des Hauses war
* dort nur Suchaufwand.
*/
const openExport = () => setExportOpen(true);
// Tastenkuerzel: N = neue Buchung, Pfeile = blaettern.
useEffect(() => {
@@ -276,7 +278,7 @@ export const Booking: React.FC = () => {
{exportOpen && (
<ExportDialog
month={anchor.slice(0, 7)}
projects={isLead ? allProjects ?? projects.data ?? [] : projects.data ?? []}
projects={projects.data ?? []}
team={team}
viewUserId={viewUserId}
onClose={() => setExportOpen(false)}
+3 -2
View File
@@ -31,7 +31,8 @@ export const Dashboard: React.FC = () => {
const prev = data.kpis.previous;
const delta = (a: number, b: number | undefined) => (b ? ((a - b) / b) * 100 : null);
const share = cur.minutes ? (cur.billable_minutes / cur.minutes) * 100 : 0;
const utilization = cur.capacity_minutes ? (cur.minutes / cur.capacity_minutes) * 100 : 0;
// Nur bis heute: im Voraus gebuchte Zeiten wuerden die Auslastung sonst sprengen.
const utilization = cur.capacity_minutes ? (cur.minutes_until_today / cur.capacity_minutes) * 100 : 0;
return (
<div className={`mx-auto max-w-[1500px] space-y-4 transition-opacity ${loading ? 'opacity-70' : ''}`}>
@@ -195,7 +196,7 @@ const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: Ran
const pos = positions.get(r.user_id) ?? 0;
const a = filter === 'non_billable' ? 0 : r.billable_minutes;
const b = filter === 'billable' ? 0 : r.non_billable_minutes;
const util = r.capacity_minutes ? Math.round((r.minutes / r.capacity_minutes) * 100) : null;
const util = r.capacity_minutes ? Math.round((r.minutes_until_today / r.capacity_minutes) * 100) : null;
return (
<div
key={r.user_id}
+54 -26
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Avatar, Badge, BudgetBar, Button, Card, IconButton, inputClass } from '../ui/controls';
import { ConfirmDialog, Modal } from '../ui/Modal';
import { Icon } from '../ui/Icon';
@@ -9,7 +9,7 @@ import { projectsApi } from '../../lib/api';
import { formatDate, formatEuro, formatHours, formatNumber } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
import { PROJECT_TYPE_LABELS, type BudgetPot, type DirectoryUser, type Project } from '../../types';
import { PROJECT_TYPE_LABELS, type BudgetPot, type DirectoryUser, type Project, type Task } from '../../types';
const POT_STATUS: Record<BudgetPot['status'], { label: string; tone: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' }> = {
active: { label: 'aktiv', tone: 'brand' },
@@ -194,6 +194,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<p className="text-xs text-ink-400">
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)}
{pot.status === 'active' && ` · noch ${pot.days_left} Tage`}
{pot.order_number && ` · Bestellnr. ${pot.order_number}`}
</p>
</div>
{canEdit && (
@@ -263,7 +264,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
{!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
</span>
<span className="flex shrink-0 items-center gap-2">
{t.rate != null && (
{t.rate != null && !canEdit && (
<span className="tabular rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-bold text-brand-700" title="Eigener Satz für diesen Task">
{formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'}
</span>
@@ -275,6 +276,10 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="h-full rounded-full bg-brand-400 transition-[width] duration-700" style={{ width: `${(t.used_minutes / maxTask) * 100}%` }} />
</div>
</div>
{p.type !== 'non_billable' && canEdit && t.billable && (
/* Satz direkt in der Zeile: leer heisst "Satz des Topfs". */
<TaskRate task={t} unit={p.billing_unit} onSave={(rate) => run(() => projectsApi.updateTask(p.id, t.id, { rate }))} />
)}
{p.type !== 'non_billable' && (
<button
disabled={!canEdit}
@@ -295,26 +300,6 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
if (name && name.trim() !== t.name) run(() => projectsApi.updateTask(p.id, t.id, { name: name.trim() }));
}}
/>
{t.billable && p.type !== 'non_billable' && (
<IconButton
icon="euro"
label="Satz für diesen Task"
onClick={() => {
const eingabe = window.prompt(
`Satz für "${t.name}" in € pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}.
Leer lassen = Satz des Budgettopfs.`,
t.rate == null ? '' : String(t.rate).replace('.', ',')
);
if (eingabe === null) return;
const wert = eingabe.trim().replace(',', '.');
if (wert !== '' && !Number.isFinite(Number(wert))) return addToast('Das ist keine Zahl.', 'error');
run(
() => projectsApi.updateTask(p.id, t.id, { rate: wert === '' ? null : Number(wert) }),
wert === '' ? 'Satz entfernt – es gilt der Topfsatz.' : 'Satz am Task gespeichert.'
);
}}
/>
)}
{!t.is_active ? (
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} />
) : (
@@ -326,11 +311,19 @@ Leer lassen = Satz des Budgettopfs.`,
))}
</ul>
{canEdit && (
<div className="mt-2 flex gap-2">
<input className={`${inputClass} py-2`} value={newTask} placeholder="Neuer Task, z. B. Administration Server" onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} />
/* flex-wrap und min-w-0: die Spalte ist schmal, sonst laufen die
Felder aus der Karte heraus. */
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
className={`${inputClass} min-w-[150px] flex-1 py-2`}
value={newTask}
placeholder="Neuer Task"
onChange={(e) => setNewTask(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
{p.type !== 'non_billable' && (
<input
className={`${inputClass} w-28 shrink-0 py-2`}
className={`${inputClass} !w-24 shrink-0 py-2`}
value={newTaskRate}
placeholder={p.billing_unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
@@ -410,6 +403,7 @@ const PotDialog: React.FC<{ project: Project; pot: BudgetPot | null; onClose: ()
rate: pot.rate == null ? '' : String(pot.rate).replace('.', ','),
budget_hours: pot.budget_hours == null ? '' : String(pot.budget_hours).replace('.', ','),
budget_unit: 'hours',
order_number: pot.order_number ?? '',
notes: pot.notes ?? '',
}
: emptyPot(project.pots.length ? project.pots[project.pots.length - 1].valid_to : project.start_date, `Budget ${project.pots.length + 1}`)
@@ -505,3 +499,37 @@ const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClos
</Modal>
);
};
/**
* Satz eines Tasks direkt in der Zeile. Leer bedeutet "Satz des Budgettopfs"
* bzw. bei Abruf "Satz des Projekts". Gespeichert wird beim Verlassen des
* Feldes oder mit der Eingabetaste - ein eigener Dialog waere fuer eine Zahl
* zu viel Aufwand.
*/
const TaskRate: React.FC<{ task: Task; unit: 'hour' | 'day'; onSave: (rate: number | null) => void }> = ({ task, unit, onSave }) => {
const [text, setText] = useState(task.rate == null ? '' : String(task.rate).replace('.', ','));
useEffect(() => setText(task.rate == null ? '' : String(task.rate).replace('.', ',')), [task.rate]);
const speichern = () => {
const wert = text.trim().replace(',', '.');
const zahl = wert === '' ? null : Number(wert);
if (wert !== '' && !Number.isFinite(zahl)) return setText(task.rate == null ? '' : String(task.rate).replace('.', ','));
if (zahl === task.rate) return;
onSave(zahl);
};
return (
<input
className={`${inputClass} !w-[86px] shrink-0 px-2 py-1 text-right text-xs`}
value={text}
placeholder={unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
onChange={(e) => setText(e.target.value)}
onBlur={speichern}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setText(task.rate == null ? '' : String(task.rate).replace('.', ','));
}}
/>
);
};
+18 -3
View File
@@ -17,11 +17,22 @@ export interface PotDraft {
rate: string;
budget_hours: string;
budget_unit: 'hours' | 'days';
order_number: string;
notes: string;
}
export function emptyPot(start = todayIso(), label = 'Budget 1'): PotDraft {
return { label, valid_from: start, valid_to: addDays(addMonths(start, 12), -1), budget_amount: '', rate: '', budget_hours: '', budget_unit: 'hours', notes: '' };
return {
label,
valid_from: start,
valid_to: addDays(addMonths(start, 12), -1),
budget_amount: '',
rate: '',
budget_hours: '',
budget_unit: 'hours',
order_number: '',
notes: '',
};
}
/** Deutsche und englische Schreibweise: "48.000,50", "48000.5", "110,5". */
@@ -42,6 +53,7 @@ export function potPayload(draft: PotDraft, hoursPerDay: number): PotInput {
budget_amount: num(draft.budget_amount),
rate: num(draft.rate),
budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours,
order_number: draft.order_number.trim() || null,
notes: draft.notes || null,
};
}
@@ -71,7 +83,7 @@ export const PotFields: React.FC<{
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Bezeichnung" required className="sm:col-span-1">
<input className={inputClass} value={draft.label} onChange={(e) => set({ label: e.target.value })} placeholder="z. B. Bestellung 4500030022" />
<input className={inputClass} value={draft.label} onChange={(e) => set({ label: e.target.value })} placeholder="z. B. Kontingent 2026" />
</Field>
<Field label="Laufzeit von" required>
<input type="date" className={inputClass} value={draft.valid_from} onChange={(e) => set({ valid_from: e.target.value })} />
@@ -79,6 +91,9 @@ export const PotFields: React.FC<{
<Field label="Laufzeit bis" required>
<input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} />
</Field>
<Field label="Bestellnummer (optional)" hint="Bestell- oder Belegnummer des Kunden, erscheint im Export." className="sm:col-span-3">
<input className={inputClass} value={draft.order_number} onChange={(e) => set({ order_number: e.target.value })} placeholder="z. B. 4500030022" />
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{type !== 'non_billable' && (
@@ -354,7 +369,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
{type !== 'non_billable' && (
<>
<input
className={`${inputClass} w-28 shrink-0`}
className={`${inputClass} !w-28 shrink-0`}
value={task.rate}
disabled={!task.billable}
placeholder={unit === 'day' ? '€/Tag' : '€/Std.'}
+1 -1
View File
@@ -212,7 +212,7 @@ const TimeReport: React.FC = () => {
</div>
<div>
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
<CountUp value={capacity ? (s!.minutes / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
<CountUp value={capacity ? (s!.minutes_until_today / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
{!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
</div>
+3 -3
View File
@@ -6,7 +6,7 @@ import { directoryApi, usersApi, type AdUser } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { relativeTime } from '../../lib/format';
import { useToast } from '../Toast';
import { ROLE_LABELS, type DirectoryUser, type Role } from '../../types';
import { ROLE_LABELS, ROLE_TONE, type DirectoryUser, type Role } from '../../types';
type ImportRole = Exclude<Role, 'admin'>;
@@ -81,7 +81,7 @@ export const SsoDirectory: React.FC = () => {
addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success');
setSelected(new Set());
await reload();
if (role !== 'user') loadManagers();
if (role !== 'user' && role !== 'external') loadManagers();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally {
@@ -224,7 +224,7 @@ export const SsoDirectory: React.FC = () => {
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{u.pulse_role && <Badge tone={u.pulse_role === 'user' ? 'neutral' : u.pulse_role === 'lead' ? 'sky' : u.pulse_role === 'management' ? 'violet' : 'brand'}>{ROLE_LABELS[u.pulse_role]}</Badge>}
{u.pulse_role && <Badge tone={ROLE_TONE[u.pulse_role]}>{ROLE_LABELS[u.pulse_role]}</Badge>}
{u.pulse_active === false && <Badge tone="red">inaktiv</Badge>}
{!u.pulse_user_id && u.local_match_id && (
<Badge tone="amber" title="Ein lokales Konto mit dieser Mailadresse wird beim Übernehmen verknüpft">
+2 -4
View File
@@ -9,9 +9,7 @@ import { usersApi } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { relativeTime } from '../../lib/format';
import { useToast } from '../Toast';
import { ROLE_LABELS, type ManagedUser, type Role } from '../../types';
export const ROLE_TONE: Record<Role, 'neutral' | 'sky' | 'violet' | 'brand'> = { user: 'neutral', lead: 'sky', management: 'violet', admin: 'brand' };
import { ROLE_LABELS, ROLE_TONE, type ManagedUser } from '../../types';
/** Team: Organigramm und Liste. `embedded` fuer die Benutzerverwaltung in den Einstellungen. */
export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => {
@@ -247,7 +245,7 @@ const OrgNode: React.FC<{
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABELS[u.role]}</Badge>
{children.length > 0 && <span className="tabular rounded-full bg-ink-100 px-2 py-0.5 text-[10px] font-extrabold text-ink-500">{children.length}</span>}
<div className="flex opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
{u.role !== 'user' && <IconButton icon="plus" label={`Mitarbeiter unter ${u.name} anlegen`} onClick={() => onAdd(u.id)} />}
{u.role !== 'user' && u.role !== 'external' && <IconButton icon="plus" label={`Mitarbeiter unter ${u.name} anlegen`} onClick={() => onAdd(u.id)} />}
{u.can_manage && <IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(u)} />}
</div>
</div>
+9 -2
View File
@@ -285,7 +285,9 @@ export const customersApi = {
remove: (id: number) => send<{ ok: true }>('DELETE', `/customers/${id}`),
};
export type PotInput = Partial<Pick<BudgetPot, 'label' | 'valid_from' | 'valid_to' | 'budget_amount' | 'rate' | 'budget_hours' | 'notes'>>;
export type PotInput = Partial<
Pick<BudgetPot, 'label' | 'valid_from' | 'valid_to' | 'budget_amount' | 'rate' | 'budget_hours' | 'order_number' | 'notes'>
>;
export const projectsApi = {
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`),
@@ -334,7 +336,7 @@ export interface DashboardData {
team_size: number;
series: SeriesBucket[];
kpis: {
current: SeriesBucket & { capacity_minutes: number };
current: SeriesBucket & { capacity_minutes: number; minutes_until_today: number };
previous: SeriesBucket | null;
period_label: string;
previous_label: string | null;
@@ -346,6 +348,8 @@ export interface DashboardData {
avatar: string | null;
job_title: string | null;
is_external: boolean;
/** Nur bis heute - Grundlage der Auslastung. */
minutes_until_today: number;
minutes: number;
billable_minutes: number;
non_billable_minutes: number;
@@ -392,6 +396,7 @@ export interface GroupRow {
type?: string;
is_external?: boolean;
capacity_minutes?: number;
minutes_until_today?: number;
utilization?: number | null;
}
@@ -406,6 +411,8 @@ export interface TimeReport {
amount_without_fixed: number;
fixed_amount: number;
entries: number;
/** Nur bis heute gebucht - Grundlage der Auslastung. */
minutes_until_today: number;
};
clients: GroupRow[];
projects: GroupRow[];
+21 -2
View File
@@ -11,16 +11,31 @@ export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: st
aura: { name: 'Aura', hint: 'Hell Sunshine mit Sonnenstrahlen, dunkel Mond mit Sternen - ziehende Wolken' },
};
export type Role = 'user' | 'lead' | 'management' | 'admin';
export type Role = 'user' | 'external' | 'lead' | 'management' | 'admin';
export const ROLE_LABELS: Record<Role, string> = {
user: 'Mitarbeiter',
external: 'Externe',
lead: 'Vorgesetzter',
management: 'Management',
admin: 'Administrator',
};
export const ROLE_RANK: Record<Role, number> = { user: 0, lead: 1, management: 2, admin: 3 };
/*
* "Externe" hat dieselben Rechte wie "Mitarbeiter" und steht deshalb auf
* demselben Rang - Vergleiche wie atLeast(user, 'lead') fallen fuer beide
* gleich aus.
*/
/** Farbe der Rollen-Plakette, an einer Stelle statt in jedem Bildschirm. */
export const ROLE_TONE: Record<Role, 'brand' | 'neutral' | 'sky' | 'violet' | 'amber'> = {
user: 'neutral',
external: 'amber',
lead: 'sky',
management: 'violet',
admin: 'brand',
};
export const ROLE_RANK: Record<Role, number> = { user: 0, external: 0, lead: 1, management: 2, admin: 3 };
export function atLeast(user: { role: Role } | null | undefined, role: Role) {
return !!user && ROLE_RANK[user.role] >= ROLE_RANK[role];
@@ -105,6 +120,8 @@ export interface BudgetPot {
budget_amount: number | null;
rate: number | null;
budget_hours: number | null;
/** Bestell- oder Belegnummer des Kunden (optional). */
order_number: string | null;
notes: string | null;
/** Minuten, die gegen das Volumen zaehlen (Billable-Projekt: nur abrechenbare). */
used_minutes: number;
@@ -193,6 +210,7 @@ export interface TimeEntry {
task_name: string | null;
pot_id: number | null;
pot_label: string | null;
pot_order_number: string | null;
entry_date: string;
minutes: number;
note: string | null;
@@ -226,6 +244,7 @@ export const VIEW_LABELS: Record<ViewKey, string> = {
export function viewsFor(role: Role): ViewKey[] {
switch (role) {
case 'user':
case 'external':
return ['buchung', 'projekte'];
case 'lead':
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports'];