feat: perinco Pulse - Zeiterfassung fuer perinco

Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
import express from 'express';
import { pool, withTransaction } from '../db.js';
import { hasRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { parseDate } from '../lib/dates.js';
import { BookingError, isBillable, loadProjects, resolvePot } from '../lib/projects.js';
const router = express.Router();
function publicEntry(entry, showAmounts) {
return {
id: entry.id,
user_id: entry.user_id,
user_name: entry.user_name,
project_id: entry.project_id,
project_name: entry.project_name,
project_code: entry.project_code,
project_display: entry.project_display,
project_type: entry.project_type,
customer_id: entry.customer_id,
customer_name: entry.customer_name,
customer_color: entry.customer_color,
task_id: entry.task_id,
task_name: entry.task_name,
pot_id: entry.pot_id,
pot_label: entry.pot_label,
entry_date: entry.entry_date,
minutes: entry.minutes,
note: entry.note,
billable: entry.billable,
amount: showAmounts ? entry.amount : null,
created_at: entry.created_at,
updated_at: entry.updated_at,
};
}
router.get('/', async (req, res, next) => {
try {
const { from, to } = req.query;
if (!parseDate(from) || !parseDate(to)) return res.status(400).json({ error: 'Zeitraum von/bis fehlt.' });
let userIds = [req.user.id];
if (req.query.user_id && Number(req.query.user_id) !== req.user.id) {
const visible = await visibleUserIds(req.user);
const wanted = Number(req.query.user_id);
if (visible !== null && !visible.includes(wanted)) return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
userIds = [wanted];
}
const settings = await getSettings();
const entries = await loadEntries(settings, { from, to, userIds });
res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead'))));
} catch (err) {
next(err);
}
});
async function validate(conn, user, body, existing) {
const projectId = Number(body.project_id ?? existing?.project_id);
const date = body.entry_date ?? existing?.entry_date;
const minutes = Number(body.minutes ?? existing?.minutes);
const taskId = body.task_id !== undefined ? (body.task_id ? Number(body.task_id) : null) : existing?.task_id ?? null;
const note = body.note !== undefined ? String(body.note || '').trim().slice(0, 1000) || null : existing?.note ?? null;
if (user.role === 'admin' && !existing) throw new BookingError('Administratoren buchen keine Zeiten.');
if (!parseDate(date)) throw new BookingError('Datum fehlt.');
if (!Number.isInteger(minutes) || minutes < 1) throw new BookingError('Bitte eine Zeit größer 0:00 eingeben.');
if (minutes > 24 * 60) throw new BookingError('Mehr als 24 Stunden an einem Tag gehen nicht.');
const [projects] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]);
const project = projects[0];
if (!project) throw new BookingError('Projekt nicht gefunden.');
if (!project.is_active) throw new BookingError('Das Projekt ist archiviert und nicht mehr buchbar.');
// Gebucht wird nur auf zugewiesene Projekte - fuer jede Rolle.
const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [
project.id,
existing?.user_id ?? user.id,
]);
if (!member.length) throw new BookingError('Dieses Projekt ist dir nicht zugewiesen.');
let task = null;
if (taskId) {
const [tasks] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, project.id]);
task = tasks[0];
if (!task) throw new BookingError('Der Task gehört nicht zu diesem Projekt.');
if (!task.is_active && task.id !== existing?.task_id) throw new BookingError('Der Task ist nicht mehr aktiv.');
}
const [[day]] = await conn.query(
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ? AND id <> ?',
[existing?.user_id ?? user.id, date, existing?.id || 0]
);
if (Number(day.total) + minutes > 24 * 60) throw new BookingError('An diesem Tag wären es mehr als 24 Stunden.');
const billable = isBillable(project, task);
const pot = await resolvePot(conn, project, date, minutes, existing?.id, billable);
return { project, task, pot, date, minutes, note, billable: billable ? 1 : 0 };
}
async function respond(req, res, id, status) {
const settings = await getSettings();
const [[row]] = await pool.query('SELECT entry_date, user_id, project_id, pot_id FROM pulse_time_entries WHERE id = ?', [id]);
const entries = await loadEntries(settings, { from: row.entry_date, to: row.entry_date, userIds: [row.user_id] });
const entry = entries.find((e) => e.id === id);
// Hinweis zum Topf gleich mitliefern, damit der Dialog warnen kann.
let warning = null;
if (row.pot_id) {
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [row.project_id] });
warning = project?.pots.find((p) => p.id === row.pot_id)?.warning ?? null;
}
res.status(status).json({ entry: publicEntry(entry, hasRole(req.user, 'lead')), warning });
}
router.post('/', async (req, res, next) => {
try {
const id = await withTransaction(async (conn) => {
const v = await validate(conn, req.user, req.body || {}, null);
const [result] = await conn.query(
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable]
);
return result.insertId;
});
await respond(req, res, id, 201);
} catch (err) {
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
next(err);
}
});
async function loadOwned(req) {
const [rows] = await pool.query('SELECT * FROM pulse_time_entries WHERE id = ? LIMIT 1', [Number(req.params.id)]);
const entry = rows[0];
if (!entry) return { error: [404, 'Buchung nicht gefunden.'] };
// Eigene Buchungen; Administratoren duerfen korrigieren.
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
return { error: [403, 'Nur eigene Buchungen können geändert werden.'] };
}
return { entry };
}
router.put('/:id', async (req, res, next) => {
try {
const { entry, error } = await loadOwned(req);
if (error) return res.status(error[0]).json({ error: error[1] });
await withTransaction(async (conn) => {
const v = await validate(conn, req.user, req.body || {}, entry);
await conn.query(
`UPDATE pulse_time_entries SET project_id = ?, task_id = ?, pot_id = ?, entry_date = ?, minutes = ?, note = ?, billable = ?
WHERE id = ?`,
[v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable, entry.id]
);
});
await respond(req, res, entry.id, 200);
} catch (err) {
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const { entry, error } = await loadOwned(req);
if (error) return res.status(error[0]).json({ error: error[1] });
await pool.query('DELETE FROM pulse_time_entries WHERE id = ?', [entry.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;