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:
co-authored by
Claude Opus 5
parent
532b15ce99
commit
79bfd561bd
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user