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
+14 -1
View File
@@ -19,6 +19,7 @@ Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` (
| Rolle | Startseite | Menü | | Rolle | Startseite | Menü |
| --- | --- | --- | | --- | --- | --- |
| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge) | | Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge) |
| Externe (`external`) | Buchung | wie Mitarbeiter – die Rolle trennt nur, wer zum Haus gehört; `is_external` wird automatisch gesetzt |
| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Team, Kunden, Reports | | Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Team, Kunden, Reports |
| Management | Dashboard | wie Vorgesetzter, sieht alles; Einstellungen nur „SSO-Verwaltung“ | | Management | Dashboard | wie Vorgesetzter, sieht alles; Einstellungen nur „SSO-Verwaltung“ |
| Administrator | Einstellungen | Dashboard, Projekte, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf | | Administrator | Einstellungen | Dashboard, Projekte, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf |
@@ -80,6 +81,14 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
mindestens Vorgesetzter ist. mindestens Vorgesetzter ist.
## Auslastung
Die Auslastung vergleicht **gebuchte Stunden bis heute** mit der **Kapazität bis heute**
(`minutes_until_today` in Report und Dashboard). Die Kapazität endete schon immer am heutigen
Tag; wurden Zeiten im Voraus gebucht – etwa eine ganze Woche Urlaub am Montag – standen sonst
40 Stunden gegen 8 Stunden Kapazität und die Anzeige sprang auf 500 %. Die Spalte *Stunden*
zeigt weiter alles, was im Zeitraum gebucht ist.
## Sätze: Topf, Projekt, Task ## Sätze: Topf, Projekt, Task
Der Satz kommt in dieser Reihenfolge: **Task → Budgettopf → Projekt** (letzteres nur bei *Abruf*). Der Satz kommt in dieser Reihenfolge: **Task → Budgettopf → Projekt** (letzteres nur bei *Abruf*).
@@ -87,7 +96,8 @@ Ein Satz am Task ist freiwillig und deckt Fälle ab, in denen dieselbe Leistung
vergütet wird – typisch „vor Ort" neben „remote". Bleibt er leer, ändert sich nichts gegenüber vergütet wird – typisch „vor Ort" neben „remote". Bleibt er leer, ändert sich nichts gegenüber
vorher. Die Einheit (Stunde oder Tag) steht am Projekt und gilt auch für den Task-Satz. vorher. Die Einheit (Stunde oder Tag) steht am Projekt und gilt auch für den Task-Satz.
Das Budget bleibt in **Stunden** – gemischte Sätze ändern also nichts daran, wann ein Topf voll Der Satz eines Tasks lässt sich direkt in der Taskzeile der Projektansicht ändern (leeres Feld =
Satz des Topfs). Das Budget bleibt in **Stunden** – gemischte Sätze ändern also nichts daran, wann ein Topf voll
ist. Der Umsatz eines Topfes wird dafür je Task-Satz aufsummiert (`loadProjects` gruppiert den ist. Der Umsatz eines Topfes wird dafür je Task-Satz aufsummiert (`loadProjects` gruppiert den
Verbrauch nach Task-Satz und gibt das Ergebnis an `potStats`). Mitarbeiter sehen keine Sätze, Verbrauch nach Task-Satz und gibt das Ergebnis an `potStats`). Mitarbeiter sehen keine Sätze,
auch nicht die am Task. auch nicht die am Task.
@@ -246,6 +256,9 @@ Aus Auftrag und Call-Mitschrift.
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste | | Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
| Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten | | Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten |
| Abwesenheiten automatisch aus HRworks | ✅ täglich 5:30 Uhr | Einstellungen → Abwesenheiten (HRworks) | | Abwesenheiten automatisch aus HRworks | ✅ täglich 5:30 Uhr | Einstellungen → Abwesenheiten (HRworks) |
| Bestellnummer je Budgettopf, erscheint im Stundennachweis | ✅ | Projekt → Budgettopf |
| Rolle „Externe" mit den Rechten eines Mitarbeiters | ✅ | Team → Bearbeiten |
| Export: Kunde und Projekt nur aus den eigenen Zuweisungen | ✅ | Buchung → Export |
| Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste | | Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste |
| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog | | Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog |
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog | | Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
+28
View File
@@ -44,6 +44,8 @@ export async function initDatabase() {
await migrateTaskRate(); await migrateTaskRate();
await migrateHarvestId(); await migrateHarvestId();
await migrateHrworksRef(); await migrateHrworksRef();
await migrateOrderNumber();
await migrateExternalRole();
await ensureAdminUser(); await ensureAdminUser();
await ensureInternalCustomer(); await ensureInternalCustomer();
} }
@@ -132,6 +134,32 @@ async function migrateHrworksRef() {
} }
} }
/* Bestellnummer am Budgettopf - viele Kunden verlangen sie auf der Rechnung. */
async function migrateOrderNumber() {
const [columns] = await pool.query(
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_budget_pots' AND COLUMN_NAME = 'order_number'`
);
if (!columns.length) {
await pool.query('ALTER TABLE pulse_budget_pots ADD COLUMN order_number VARCHAR(80) NULL AFTER budget_hours');
console.log('[Pulse] Spalte pulse_budget_pots.order_number ergaenzt.');
}
}
/* Rolle "external" nachtraeglich ergaenzen - gleiche Rechte wie "user". */
async function migrateExternalRole() {
const [[column]] = await pool.query(
`SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_users' AND COLUMN_NAME = 'role'`
);
if (column && !column.COLUMN_TYPE.includes("'external'")) {
await pool.query(
"ALTER TABLE pulse_users MODIFY role ENUM('user','external','lead','management','admin') NOT NULL DEFAULT 'user'"
);
console.log('[Pulse] Rolle "external" ergaenzt.');
}
}
async function ensureAdminUser() { async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users'); const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
if (rows[0].n > 0) return; if (rows[0].n > 0) return;
+2 -1
View File
@@ -49,7 +49,8 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
p.rate AS project_rate, p.rate AS project_rate,
c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color, c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
t.name AS task_name, t.rate AS task_rate, t.name AS task_name, t.rate AS task_rate,
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount,
b.order_number AS pot_order_number
FROM pulse_time_entries e FROM pulse_time_entries e
JOIN pulse_users u ON u.id = e.user_id JOIN pulse_users u ON u.id = e.user_id
JOIN pulse_projects p ON p.id = e.project_id JOIN pulse_projects p ON p.id = e.project_id
+9 -2
View File
@@ -1,8 +1,15 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { pool } from '../db.js'; import { pool } from '../db.js';
export const ROLES = ['user', 'lead', 'management', 'admin']; /*
const RANK = { user: 0, lead: 1, management: 2, admin: 3 }; * Rollen. "external" hat dieselben Rechte wie "user" - eigene Zeiten buchen und
* zugewiesene Projekte ohne Betraege sehen - und steht deshalb auf demselben
* Rang. Die Rolle trennt nur, wer zum Haus gehoert und wer nicht; dafuer gibt
* es zusaetzlich das Kennzeichen is_external am Benutzer, das bei dieser Rolle
* automatisch gesetzt wird.
*/
export const ROLES = ['user', 'external', 'lead', 'management', 'admin'];
const RANK = { user: 0, external: 0, lead: 1, management: 2, admin: 3 };
function secret() { function secret() {
const value = process.env.JWT_SECRET; const value = process.env.JWT_SECRET;
+8 -1
View File
@@ -71,8 +71,13 @@ router.get('/', async (req, res, next) => {
// Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus. // Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus.
const capTo = current.to > now ? now : current.to; const capTo = current.to > now ? now : current.to;
const capacity = users.reduce((s, u) => s + capacityMinutes(u, settings, current.from, capTo), 0); const capacity = users.reduce((s, u) => s + capacityMinutes(u, settings, current.from, capTo), 0);
// Auslastung nur bis heute - im Voraus gebuchte Zeiten (Urlaub) wuerden
// sie sonst gegen eine Kapazitaet rechnen, die noch nicht angefallen ist.
const minutesUntilToday = entries
.filter((e) => e.entry_date >= current.from && e.entry_date <= capTo)
.reduce((s, e) => s + e.minutes, 0);
const kpis = { const kpis = {
current: { ...series[series.length - 1], capacity_minutes: capacity }, current: { ...series[series.length - 1], capacity_minutes: capacity, minutes_until_today: minutesUntilToday },
previous: series[series.length - 2] ?? null, previous: series[series.length - 2] ?? null,
period_label: current.label, period_label: current.label,
previous_label: previous?.label ?? null, previous_label: previous?.label ?? null,
@@ -82,12 +87,14 @@ router.get('/', async (req, res, next) => {
const ranking = users.map((u) => { const ranking = users.map((u) => {
const own = entries.filter((e) => e.user_id === u.id && e.entry_date >= rankRange.from && e.entry_date <= rankRange.to); const own = entries.filter((e) => e.user_id === u.id && e.entry_date >= rankRange.from && e.entry_date <= rankRange.to);
const totals = sum(own); const totals = sum(own);
const ownUntilToday = own.filter((e) => e.entry_date <= rankTo);
return { return {
user_id: u.id, user_id: u.id,
name: u.name, name: u.name,
color: u.color, color: u.color,
avatar: u.avatar ?? null, avatar: u.avatar ?? null,
job_title: u.job_title, job_title: u.job_title,
minutes_until_today: ownUntilToday.reduce((s, e) => s + e.minutes, 0),
is_external: Boolean(u.is_external), is_external: Boolean(u.is_external),
...totals, ...totals,
entries: own.length, entries: own.length,
+2 -1
View File
@@ -9,7 +9,8 @@ import { ALLOWED_DOMAINS, importUsers, listDirectory, ssoConfigured, syncUsers }
* Administratoren vorbehalten (routes/settings.js). * Administratoren vorbehalten (routes/settings.js).
*/ */
const router = express.Router(); const router = express.Router();
const IMPORT_ROLES = ['user', 'lead', 'management']; // Administratoren werden nie aus dem AD uebernommen - die vergibt Pulse selbst.
const IMPORT_ROLES = ['user', 'external', 'lead', 'management'];
router.get('/ad-users', async (req, res) => { router.get('/ad-users', async (req, res) => {
const { sso } = await getSettings(); const { sso } = await getSettings();
+1
View File
@@ -27,6 +27,7 @@ function publicEntry(entry, showAmounts) {
task_name: entry.task_name, task_name: entry.task_name,
pot_id: entry.pot_id, pot_id: entry.pot_id,
pot_label: entry.pot_label, pot_label: entry.pot_label,
pot_order_number: entry.pot_order_number ?? null,
entry_date: entry.entry_date, entry_date: entry.entry_date,
minutes: entry.minutes, minutes: entry.minutes,
note: entry.note, note: entry.note,
+5
View File
@@ -110,6 +110,8 @@ router.get('/timesheet', async (req, res, next) => {
summary.set(key, item); summary.set(key, item);
} }
const orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))];
const doc = { const doc = {
title: settings.export.title || 'Stundennachweis', title: settings.export.title || 'Stundennachweis',
subtitle: monthName, subtitle: monthName,
@@ -120,6 +122,9 @@ router.get('/timesheet', async (req, res, next) => {
['Kunde', customerName], ['Kunde', customerName],
['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`], ['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`],
['Projekt', projectName], ['Projekt', projectName],
// Bestellnummern der beteiligten Budgettoepfe - viele Kunden verlangen
// sie auf dem Nachweis. Ohne Nummer bleibt die Zeile weg.
...(orderNumbers.length ? [[orderNumbers.length > 1 ? 'Bestellnummern' : 'Bestellnummer', orderNumbers.join(', ')]] : []),
], ],
kpis: [ kpis: [
['Gesamtstunden', hoursDe(total)], ['Gesamtstunden', hoursDe(total)],
+13 -11
View File
@@ -46,6 +46,7 @@ function validatePot(input, project, settings) {
valid_to: input.valid_to, valid_to: input.valid_to,
budget_amount: num(input.budget_amount), budget_amount: num(input.budget_amount),
rate: num(input.rate), rate: num(input.rate),
order_number: str(input.order_number, 80),
notes: str(input.notes, 500), notes: str(input.notes, 500),
}; };
if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.'); if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.');
@@ -151,7 +152,8 @@ async function refreshBillable(conn, projectId) {
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const settings = await getSettings(); const settings = await getSettings();
const mineOnly = req.user.role === 'user' || req.query.scope === 'mine'; // Mitarbeiter und Externe sehen nur zugewiesene Projekte.
const mineOnly = !hasRole(req.user, 'lead') || req.query.scope === 'mine';
const projects = await loadProjects( const projects = await loadProjects(
settings, settings,
mineOnly mineOnly
@@ -167,7 +169,7 @@ router.get('/', async (req, res, next) => {
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
if (req.user.role === 'user') { if (!hasRole(req.user, 'lead')) {
const [rows] = await pool.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [id, req.user.id]); const [rows] = await pool.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [id, req.user.id]);
if (!rows.length) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); if (!rows.length) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
} }
@@ -221,9 +223,9 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
const projectId = result.insertId; const projectId = result.insertId;
for (const pot of pots) { for (const pot of pots) {
await conn.query( await conn.query(
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes) `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, order_number, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes] [projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.order_number, pot.notes]
); );
} }
for (const task of taskInputs) { for (const task of taskInputs) {
@@ -334,9 +336,9 @@ router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
if (errors.length) return res.status(400).json({ error: errors.join(' ') }); if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => { await withTransaction(async (conn) => {
await conn.query( await conn.query(
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes) `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, order_number, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes] [project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.order_number, pot.notes]
); );
await reassignEntries(conn, project.id); await reassignEntries(conn, project.id);
}); });
@@ -359,9 +361,9 @@ router.put('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
if (errors.length) return res.status(400).json({ error: errors.join(' ') }); if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => { await withTransaction(async (conn) => {
await conn.query( await conn.query(
`UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, notes = ? `UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?,
WHERE id = ?`, order_number = ?, notes = ? WHERE id = ?`,
[pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes, existing[0].id] [pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.order_number, pot.notes, existing[0].id]
); );
await reassignEntries(conn, project.id); await reassignEntries(conn, project.id);
}); });
+34 -2
View File
@@ -55,6 +55,15 @@ export async function buildTimeReport(viewer, query) {
); );
const capTo = to > today() ? today() : to; const capTo = to > today() ? today() : to;
/*
* Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag
* (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am
* Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die
* Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter
* alles, was im Zeitraum gebucht ist.
*/
const bisHeute = entries.filter((e) => e.entry_date <= capTo);
const minutes = entries.reduce((s, e) => s + e.minutes, 0); const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const amount = entries.reduce((s, e) => s + e.amount, 0); const amount = entries.reduce((s, e) => s + e.amount, 0);
@@ -70,10 +79,13 @@ export async function buildTimeReport(viewer, query) {
} }
} }
const byId = new Map(users.map((u) => [u.id, u])); const byId = new Map(users.map((u) => [u.id, u]));
const minutenBisHeute = new Map();
for (const e of bisHeute) minutenBisHeute.set(e.user_id, (minutenBisHeute.get(e.user_id) || 0) + e.minutes);
for (const row of userRows) { for (const row of userRows) {
const u = byId.get(row.user_id); const u = byId.get(row.user_id);
row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0; row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0;
row.utilization = row.capacity_minutes ? Math.round((row.minutes / row.capacity_minutes) * 100) : null; row.minutes_until_today = minutenBisHeute.get(row.user_id) || 0;
row.utilization = row.capacity_minutes ? Math.round((row.minutes_until_today / row.capacity_minutes) * 100) : null;
} }
return { return {
@@ -87,6 +99,8 @@ export async function buildTimeReport(viewer, query) {
amount_without_fixed: r2(amount), amount_without_fixed: r2(amount),
fixed_amount: r2(fixedAmount), fixed_amount: r2(fixedAmount),
entries: entries.length, entries: entries.length,
// Fuer die Auslastung: nur was bis heute gebucht ist.
minutes_until_today: bisHeute.reduce((s, e) => s + e.minutes, 0),
}, },
clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed), clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed),
projects: group( projects: group(
@@ -153,6 +167,15 @@ function buildProjectDetail(project, entries) {
}; };
}); });
/*
* Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag
* (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am
* Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die
* Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter
* alles, was im Zeitraum gebucht ist.
*/
const bisHeute = entries.filter((e) => e.entry_date <= capTo);
const minutes = entries.reduce((s, e) => s + e.minutes, 0); const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
return { return {
@@ -205,7 +228,16 @@ router.get('/customer/:id', async (req, res, next) => {
cursor.setUTCMonth(cursor.getUTCMonth() + 1); cursor.setUTCMonth(cursor.getUTCMonth() + 1);
} }
const minutes = entries.reduce((s, e) => s + e.minutes, 0); /*
* Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag
* (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am
* Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die
* Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter
* alles, was im Zeitraum gebucht ist.
*/
const bisHeute = entries.filter((e) => e.entry_date <= capTo);
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
res.json({ res.json({
customer, customer,
+7 -3
View File
@@ -15,15 +15,16 @@ const router = express.Router();
*/ */
function assignableRoles(actor) { function assignableRoles(actor) {
if (actor.role === 'admin') return ROLES; if (actor.role === 'admin') return ROLES;
if (actor.role === 'management') return ['user', 'lead', 'management']; if (actor.role === 'management') return ['user', 'external', 'lead', 'management'];
return ['user']; return ['user', 'external'];
} }
async function canManage(actor, target) { async function canManage(actor, target) {
if (actor.role === 'admin') return true; if (actor.role === 'admin') return true;
if (actor.role === 'management') return target.role !== 'admin'; if (actor.role === 'management') return target.role !== 'admin';
if (actor.role === 'lead') { if (actor.role === 'lead') {
if (target.role !== 'user') return false; // Vorgesetzte verwalten Mitarbeiter und Externe ihres Teams.
if (target.role !== 'user' && target.role !== 'external') return false;
const team = await visibleUserIds(actor); const team = await visibleUserIds(actor);
return team.includes(target.id); return team.includes(target.id);
} }
@@ -80,6 +81,9 @@ function normalize(body, { creating }) {
if (body.manager_id !== undefined) out.manager_id = body.manager_id ? Number(body.manager_id) : null; if (body.manager_id !== undefined) out.manager_id = body.manager_id ? Number(body.manager_id) : null;
if (body.job_title !== undefined) out.job_title = str(body.job_title, 120); if (body.job_title !== undefined) out.job_title = str(body.job_title, 120);
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0; if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
// Die Rolle "Externe" zieht das Kennzeichen mit, damit Liste und Kennzeichnung
// nicht widersprechen.
if (out.role === 'external') out.is_external = 1;
if (body.company !== undefined) out.company = str(body.company, 160); if (body.company !== undefined) out.company = str(body.company, 160);
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null; if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
if (body.avatar !== undefined) { if (body.avatar !== undefined) {
+4 -1
View File
@@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS pulse_users (
email VARCHAR(190) NULL, email VARCHAR(190) NULL,
name VARCHAR(160) NOT NULL, name VARCHAR(160) NOT NULL,
password_hash VARCHAR(100) NULL, password_hash VARCHAR(100) NULL,
role ENUM('user','lead','management','admin') NOT NULL DEFAULT 'user', -- external: gleiche Rechte wie user, nur als eigene Rolle ausgewiesen.
role ENUM('user','external','lead','management','admin') NOT NULL DEFAULT 'user',
manager_id INT UNSIGNED NULL, manager_id INT UNSIGNED NULL,
job_title VARCHAR(120) NULL, job_title VARCHAR(120) NULL,
is_external TINYINT(1) NOT NULL DEFAULT 0, is_external TINYINT(1) NOT NULL DEFAULT 0,
@@ -90,6 +91,8 @@ CREATE TABLE IF NOT EXISTS pulse_budget_pots (
budget_amount DECIMAL(12,2) NULL, budget_amount DECIMAL(12,2) NULL,
rate DECIMAL(10,2) NULL, rate DECIMAL(10,2) NULL,
budget_hours DECIMAL(10,2) NULL, budget_hours DECIMAL(10,2) NULL,
-- Bestell- oder Belegnummer des Kunden; steht auf der Rechnung.
order_number VARCHAR(80) NULL,
notes VARCHAR(500) NULL, notes VARCHAR(500) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+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 entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]);
const projects = useLoader(() => projectsApi.list('mine'), []); const projects = useLoader(() => projectsApi.list('mine'), []);
const [team, setTeam] = useState<DirectoryUser[]>([]); const [team, setTeam] = useState<DirectoryUser[]>([]);
const [allProjects, setAllProjects] = useState<Project[] | null>(null);
useEffect(() => { useEffect(() => {
if (!isLead) return; if (!isLead) return;
@@ -101,10 +100,13 @@ export const Booking: React.FC = () => {
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) }); setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
}; };
const openExport = () => { /*
if (isLead && !allProjects) projectsApi.list().then(setAllProjects).catch(() => undefined); * Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
setExportOpen(true); * 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. // Tastenkuerzel: N = neue Buchung, Pfeile = blaettern.
useEffect(() => { useEffect(() => {
@@ -276,7 +278,7 @@ export const Booking: React.FC = () => {
{exportOpen && ( {exportOpen && (
<ExportDialog <ExportDialog
month={anchor.slice(0, 7)} month={anchor.slice(0, 7)}
projects={isLead ? allProjects ?? projects.data ?? [] : projects.data ?? []} projects={projects.data ?? []}
team={team} team={team}
viewUserId={viewUserId} viewUserId={viewUserId}
onClose={() => setExportOpen(false)} onClose={() => setExportOpen(false)}
+3 -2
View File
@@ -31,7 +31,8 @@ export const Dashboard: React.FC = () => {
const prev = data.kpis.previous; const prev = data.kpis.previous;
const delta = (a: number, b: number | undefined) => (b ? ((a - b) / b) * 100 : null); 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 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 ( return (
<div className={`mx-auto max-w-[1500px] space-y-4 transition-opacity ${loading ? 'opacity-70' : ''}`}> <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 pos = positions.get(r.user_id) ?? 0;
const a = filter === 'non_billable' ? 0 : r.billable_minutes; const a = filter === 'non_billable' ? 0 : r.billable_minutes;
const b = filter === 'billable' ? 0 : r.non_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 ( return (
<div <div
key={r.user_id} 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 { Avatar, Badge, BudgetBar, Button, Card, IconButton, inputClass } from '../ui/controls';
import { ConfirmDialog, Modal } from '../ui/Modal'; import { ConfirmDialog, Modal } from '../ui/Modal';
import { Icon } from '../ui/Icon'; import { Icon } from '../ui/Icon';
@@ -9,7 +9,7 @@ import { projectsApi } from '../../lib/api';
import { formatDate, formatEuro, formatHours, formatNumber } from '../../lib/format'; import { formatDate, formatEuro, formatHours, formatNumber } from '../../lib/format';
import { useRouter } from '../../lib/router'; import { useRouter } from '../../lib/router';
import { useToast } from '../Toast'; 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' }> = { const POT_STATUS: Record<BudgetPot['status'], { label: string; tone: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' }> = {
active: { label: 'aktiv', tone: 'brand' }, 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"> <p className="text-xs text-ink-400">
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} {formatDate(pot.valid_from)} – {formatDate(pot.valid_to)}
{pot.status === 'active' && ` · noch ${pot.days_left} Tage`} {pot.status === 'active' && ` · noch ${pot.days_left} Tage`}
{pot.order_number && ` · Bestellnr. ${pot.order_number}`}
</p> </p>
</div> </div>
{canEdit && ( {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>} {!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
</span> </span>
<span className="flex shrink-0 items-center gap-2"> <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"> <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.'} {formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'}
</span> </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 className="h-full rounded-full bg-brand-400 transition-[width] duration-700" style={{ width: `${(t.used_minutes / maxTask) * 100}%` }} />
</div> </div>
</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' && ( {p.type !== 'non_billable' && (
<button <button
disabled={!canEdit} 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() })); 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 ? ( {!t.is_active ? (
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} /> <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> </ul>
{canEdit && ( {canEdit && (
<div className="mt-2 flex gap-2"> /* flex-wrap und min-w-0: die Spalte ist schmal, sonst laufen die
<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()} /> 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' && ( {p.type !== 'non_billable' && (
<input <input
className={`${inputClass} w-28 shrink-0 py-2`} className={`${inputClass} !w-24 shrink-0 py-2`}
value={newTaskRate} value={newTaskRate}
placeholder={p.billing_unit === 'day' ? '€/Tag' : '€/Std.'} placeholder={p.billing_unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs" 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('.', ','), rate: pot.rate == null ? '' : String(pot.rate).replace('.', ','),
budget_hours: pot.budget_hours == null ? '' : String(pot.budget_hours).replace('.', ','), budget_hours: pot.budget_hours == null ? '' : String(pot.budget_hours).replace('.', ','),
budget_unit: 'hours', budget_unit: 'hours',
order_number: pot.order_number ?? '',
notes: pot.notes ?? '', notes: pot.notes ?? '',
} }
: emptyPot(project.pots.length ? project.pots[project.pots.length - 1].valid_to : project.start_date, `Budget ${project.pots.length + 1}`) : 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> </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; rate: string;
budget_hours: string; budget_hours: string;
budget_unit: 'hours' | 'days'; budget_unit: 'hours' | 'days';
order_number: string;
notes: string; notes: string;
} }
export function emptyPot(start = todayIso(), label = 'Budget 1'): PotDraft { 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". */ /** 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), budget_amount: num(draft.budget_amount),
rate: num(draft.rate), rate: num(draft.rate),
budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours, budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours,
order_number: draft.order_number.trim() || null,
notes: draft.notes || null, notes: draft.notes || null,
}; };
} }
@@ -71,7 +83,7 @@ export const PotFields: React.FC<{
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<Field label="Bezeichnung" required className="sm:col-span-1"> <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>
<Field label="Laufzeit von" required> <Field label="Laufzeit von" required>
<input type="date" className={inputClass} value={draft.valid_from} onChange={(e) => set({ valid_from: e.target.value })} /> <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> <Field label="Laufzeit bis" required>
<input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} /> <input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} />
</Field> </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>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
{type !== 'non_billable' && ( {type !== 'non_billable' && (
@@ -354,7 +369,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
{type !== 'non_billable' && ( {type !== 'non_billable' && (
<> <>
<input <input
className={`${inputClass} w-28 shrink-0`} className={`${inputClass} !w-28 shrink-0`}
value={task.rate} value={task.rate}
disabled={!task.billable} disabled={!task.billable}
placeholder={unit === 'day' ? '€/Tag' : '€/Std.'} placeholder={unit === 'day' ? '€/Tag' : '€/Std.'}
+1 -1
View File
@@ -212,7 +212,7 @@ const TimeReport: React.FC = () => {
</div> </div>
<div> <div>
<p className="text-sm font-semibold text-ink-500">Auslastung</p> <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> <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>} {!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
</div> </div>
+3 -3
View File
@@ -6,7 +6,7 @@ import { directoryApi, usersApi, type AdUser } from '../../lib/api';
import { useLoader } from '../../lib/hooks'; import { useLoader } from '../../lib/hooks';
import { relativeTime } from '../../lib/format'; import { relativeTime } from '../../lib/format';
import { useToast } from '../Toast'; 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'>; type ImportRole = Exclude<Role, 'admin'>;
@@ -81,7 +81,7 @@ export const SsoDirectory: React.FC = () => {
addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success'); addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success');
setSelected(new Set()); setSelected(new Set());
await reload(); await reload();
if (role !== 'user') loadManagers(); if (role !== 'user' && role !== 'external') loadManagers();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error'); addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally { } finally {
@@ -224,7 +224,7 @@ export const SsoDirectory: React.FC = () => {
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1"> <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_active === false && <Badge tone="red">inaktiv</Badge>}
{!u.pulse_user_id && u.local_match_id && ( {!u.pulse_user_id && u.local_match_id && (
<Badge tone="amber" title="Ein lokales Konto mit dieser Mailadresse wird beim Übernehmen verknüpft"> <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 { useLoader, usePersistentState } from '../../lib/hooks';
import { relativeTime } from '../../lib/format'; import { relativeTime } from '../../lib/format';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { ROLE_LABELS, type ManagedUser, type Role } from '../../types'; import { ROLE_LABELS, ROLE_TONE, type ManagedUser } from '../../types';
export const ROLE_TONE: Record<Role, 'neutral' | 'sky' | 'violet' | 'brand'> = { user: 'neutral', lead: 'sky', management: 'violet', admin: 'brand' };
/** Team: Organigramm und Liste. `embedded` fuer die Benutzerverwaltung in den Einstellungen. */ /** Team: Organigramm und Liste. `embedded` fuer die Benutzerverwaltung in den Einstellungen. */
export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => { 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> <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>} {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"> <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)} />} {u.can_manage && <IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(u)} />}
</div> </div>
</div> </div>
+9 -2
View File
@@ -285,7 +285,9 @@ export const customersApi = {
remove: (id: number) => send<{ ok: true }>('DELETE', `/customers/${id}`), 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 = { export const projectsApi = {
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`), list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`),
@@ -334,7 +336,7 @@ export interface DashboardData {
team_size: number; team_size: number;
series: SeriesBucket[]; series: SeriesBucket[];
kpis: { kpis: {
current: SeriesBucket & { capacity_minutes: number }; current: SeriesBucket & { capacity_minutes: number; minutes_until_today: number };
previous: SeriesBucket | null; previous: SeriesBucket | null;
period_label: string; period_label: string;
previous_label: string | null; previous_label: string | null;
@@ -346,6 +348,8 @@ export interface DashboardData {
avatar: string | null; avatar: string | null;
job_title: string | null; job_title: string | null;
is_external: boolean; is_external: boolean;
/** Nur bis heute - Grundlage der Auslastung. */
minutes_until_today: number;
minutes: number; minutes: number;
billable_minutes: number; billable_minutes: number;
non_billable_minutes: number; non_billable_minutes: number;
@@ -392,6 +396,7 @@ export interface GroupRow {
type?: string; type?: string;
is_external?: boolean; is_external?: boolean;
capacity_minutes?: number; capacity_minutes?: number;
minutes_until_today?: number;
utilization?: number | null; utilization?: number | null;
} }
@@ -406,6 +411,8 @@ export interface TimeReport {
amount_without_fixed: number; amount_without_fixed: number;
fixed_amount: number; fixed_amount: number;
entries: number; entries: number;
/** Nur bis heute gebucht - Grundlage der Auslastung. */
minutes_until_today: number;
}; };
clients: GroupRow[]; clients: GroupRow[];
projects: 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' }, 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> = { export const ROLE_LABELS: Record<Role, string> = {
user: 'Mitarbeiter', user: 'Mitarbeiter',
external: 'Externe',
lead: 'Vorgesetzter', lead: 'Vorgesetzter',
management: 'Management', management: 'Management',
admin: 'Administrator', 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) { export function atLeast(user: { role: Role } | null | undefined, role: Role) {
return !!user && ROLE_RANK[user.role] >= ROLE_RANK[role]; return !!user && ROLE_RANK[user.role] >= ROLE_RANK[role];
@@ -105,6 +120,8 @@ export interface BudgetPot {
budget_amount: number | null; budget_amount: number | null;
rate: number | null; rate: number | null;
budget_hours: number | null; budget_hours: number | null;
/** Bestell- oder Belegnummer des Kunden (optional). */
order_number: string | null;
notes: string | null; notes: string | null;
/** Minuten, die gegen das Volumen zaehlen (Billable-Projekt: nur abrechenbare). */ /** Minuten, die gegen das Volumen zaehlen (Billable-Projekt: nur abrechenbare). */
used_minutes: number; used_minutes: number;
@@ -193,6 +210,7 @@ export interface TimeEntry {
task_name: string | null; task_name: string | null;
pot_id: number | null; pot_id: number | null;
pot_label: string | null; pot_label: string | null;
pot_order_number: string | null;
entry_date: string; entry_date: string;
minutes: number; minutes: number;
note: string | null; note: string | null;
@@ -226,6 +244,7 @@ export const VIEW_LABELS: Record<ViewKey, string> = {
export function viewsFor(role: Role): ViewKey[] { export function viewsFor(role: Role): ViewKey[] {
switch (role) { switch (role) {
case 'user': case 'user':
case 'external':
return ['buchung', 'projekte']; return ['buchung', 'projekte'];
case 'lead': case 'lead':
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports']; return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports'];