diff --git a/README.md b/README.md index df53cf2..e48ffc0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` ( | Rolle | Startseite | Menü | | --- | --- | --- | | 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 | | 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 | @@ -80,6 +81,14 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un 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 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 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 Verbrauch nach Task-Satz und gibt das Ergebnis an `potStats`). Mitarbeiter sehen keine Sätze, auch nicht die am Task. @@ -246,6 +256,9 @@ Aus Auftrag und Call-Mitschrift. | Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste | | Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten | | 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 | | Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog | | Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog | diff --git a/server/src/db.js b/server/src/db.js index 4121293..a871120 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -44,6 +44,8 @@ export async function initDatabase() { await migrateTaskRate(); await migrateHarvestId(); await migrateHrworksRef(); + await migrateOrderNumber(); + await migrateExternalRole(); await ensureAdminUser(); 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() { const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users'); if (rows[0].n > 0) return; diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js index fd42a50..d16785f 100644 --- a/server/src/lib/entries.js +++ b/server/src/lib/entries.js @@ -49,7 +49,8 @@ export async function loadEntries(settings, { from, to, userIds = null, customer 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, 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 JOIN pulse_users u ON u.id = e.user_id JOIN pulse_projects p ON p.id = e.project_id diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index 353c006..59fd3cf 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -1,8 +1,15 @@ import jwt from 'jsonwebtoken'; 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() { const value = process.env.JWT_SECRET; diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index ec90aa5..9e4278c 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -71,8 +71,13 @@ router.get('/', async (req, res, next) => { // Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus. const capTo = current.to > now ? now : current.to; 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 = { - 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, period_label: current.label, previous_label: previous?.label ?? null, @@ -82,12 +87,14 @@ router.get('/', async (req, res, next) => { 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 totals = sum(own); + const ownUntilToday = own.filter((e) => e.entry_date <= rankTo); return { user_id: u.id, name: u.name, color: u.color, avatar: u.avatar ?? null, job_title: u.job_title, + minutes_until_today: ownUntilToday.reduce((s, e) => s + e.minutes, 0), is_external: Boolean(u.is_external), ...totals, entries: own.length, diff --git a/server/src/routes/directory.js b/server/src/routes/directory.js index a2eb65f..fe19b46 100644 --- a/server/src/routes/directory.js +++ b/server/src/routes/directory.js @@ -9,7 +9,8 @@ import { ALLOWED_DOMAINS, importUsers, listDirectory, ssoConfigured, syncUsers } * Administratoren vorbehalten (routes/settings.js). */ 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) => { const { sso } = await getSettings(); diff --git a/server/src/routes/entries.js b/server/src/routes/entries.js index 26beb62..f2d63c7 100644 --- a/server/src/routes/entries.js +++ b/server/src/routes/entries.js @@ -27,6 +27,7 @@ function publicEntry(entry, showAmounts) { task_name: entry.task_name, pot_id: entry.pot_id, pot_label: entry.pot_label, + pot_order_number: entry.pot_order_number ?? null, entry_date: entry.entry_date, minutes: entry.minutes, note: entry.note, diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index 535730f..23f4484 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -110,6 +110,8 @@ router.get('/timesheet', async (req, res, next) => { summary.set(key, item); } + const orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))]; + const doc = { title: settings.export.title || 'Stundennachweis', subtitle: monthName, @@ -120,6 +122,9 @@ router.get('/timesheet', async (req, res, next) => { ['Kunde', customerName], ['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`], ['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: [ ['Gesamtstunden', hoursDe(total)], diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index cf38adb..c35da9d 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -46,6 +46,7 @@ function validatePot(input, project, settings) { valid_to: input.valid_to, budget_amount: num(input.budget_amount), rate: num(input.rate), + order_number: str(input.order_number, 80), notes: str(input.notes, 500), }; if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.'); @@ -151,7 +152,8 @@ async function refreshBillable(conn, projectId) { router.get('/', async (req, res, next) => { try { 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( settings, mineOnly @@ -167,7 +169,7 @@ router.get('/', async (req, res, next) => { router.get('/:id', async (req, res, next) => { try { 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]); 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; for (const pot of pots) { await conn.query( - `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes] + `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, order_number, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [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) { @@ -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(' ') }); await withTransaction(async (conn) => { await conn.query( - `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes] + `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, order_number, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [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); }); @@ -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(' ') }); await withTransaction(async (conn) => { await conn.query( - `UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, notes = ? - WHERE id = ?`, - [pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes, existing[0].id] + `UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, + order_number = ?, notes = ? WHERE 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); }); diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js index dba22f0..8f31a48 100644 --- a/server/src/routes/reports.js +++ b/server/src/routes/reports.js @@ -55,6 +55,15 @@ export async function buildTimeReport(viewer, query) { ); 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 billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 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 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) { const u = byId.get(row.user_id); 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 { @@ -87,6 +99,8 @@ export async function buildTimeReport(viewer, query) { amount_without_fixed: r2(amount), fixed_amount: r2(fixedAmount), 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), 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 billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); return { @@ -205,7 +228,16 @@ router.get('/customer/:id', async (req, res, next) => { 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); res.json({ customer, diff --git a/server/src/routes/users.js b/server/src/routes/users.js index cd1ca0b..03418d9 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -15,15 +15,16 @@ const router = express.Router(); */ function assignableRoles(actor) { if (actor.role === 'admin') return ROLES; - if (actor.role === 'management') return ['user', 'lead', 'management']; - return ['user']; + if (actor.role === 'management') return ['user', 'external', 'lead', 'management']; + return ['user', 'external']; } async function canManage(actor, target) { if (actor.role === 'admin') return true; if (actor.role === 'management') return target.role !== 'admin'; 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); 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.job_title !== undefined) out.job_title = str(body.job_title, 120); 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.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null; if (body.avatar !== undefined) { diff --git a/server/src/schema.sql b/server/src/schema.sql index a25f968..94cb4fc 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS pulse_users ( email VARCHAR(190) NULL, name VARCHAR(160) NOT 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, job_title VARCHAR(120) NULL, 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, rate 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx index 39cbea4..a58955f 100644 --- a/src/components/booking/Booking.tsx +++ b/src/components/booking/Booking.tsx @@ -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([]); - const [allProjects, setAllProjects] = useState(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 && ( setExportOpen(false)} diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 7b9dd0d..d9fd018 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -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 (
@@ -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 (
= { active: { label: 'aktiv', tone: 'brand' }, @@ -194,6 +194,7 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC

{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} {pot.status === 'active' && ` · noch ${pot.days_left} Tage`} + {pot.order_number && ` · Bestellnr. ${pot.order_number}`}

{canEdit && ( @@ -263,7 +264,7 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC {!t.is_active && (inaktiv)} - {t.rate != null && ( + {t.rate != null && !canEdit && ( {formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'} @@ -275,6 +276,10 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC
+ {p.type !== 'non_billable' && canEdit && t.billable && ( + /* Satz direkt in der Zeile: leer heisst "Satz des Topfs". */ + run(() => projectsApi.updateTask(p.id, t.id, { rate }))} /> + )} {p.type !== 'non_billable' && (