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
+95
View File
@@ -0,0 +1,95 @@
import { pool } from '../db.js';
import { displayName, hourlyRate } from './projects.js';
import { userFilter } from './access.js';
/*
* Buchungen fuer Auswertungen laden - mit allem, was Dashboard, Reports,
* Kundenansicht und Exporte brauchen, und mit dem Betrag je Buchung:
*
* amount abrechenbarer Betrag (Stunden x Stundensatz des Topfs);
* nur fuer Projekte vom Typ "billable"
* fixed_amount Festpreisprojekte: Stunden x virtueller Stundensatz
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
* intern, in Reports per Schalter zuschaltbar.
*/
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) {
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"];
const params = [];
if (from) {
where.push('e.entry_date >= ?');
params.push(from);
}
if (to) {
where.push('e.entry_date <= ?');
params.push(to);
}
const users = userFilter(userIds);
where.push(users.sql);
params.push(...users.params);
if (customerId) {
where.push('p.customer_id = ?');
params.push(Number(customerId));
}
if (projectId) {
where.push('e.project_id = ?');
params.push(Number(projectId));
}
if (taskId) {
where.push('e.task_id = ?');
params.push(Number(taskId));
}
const [rows] = await pool.query(
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
e.created_at, e.updated_at,
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
u.manager_id,
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
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,
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
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
JOIN pulse_customers c ON c.id = p.customer_id
LEFT JOIN pulse_tasks t ON t.id = e.task_id
LEFT JOIN pulse_budget_pots b ON b.id = e.pot_id
WHERE ${where.join(' AND ')}
ORDER BY e.entry_date, e.id`,
params
);
// Virtueller Stundensatz braucht die Stunden des ganzen Topfs, nicht nur des Zeitraums.
const fixedPots = [...new Set(rows.filter((r) => r.project_type === 'fixed' && r.pot_id).map((r) => r.pot_id))];
const potMinutes = new Map();
if (fixedPots.length) {
const [usage] = await pool.query(
`SELECT pot_id, SUM(minutes) AS minutes FROM pulse_time_entries WHERE pot_id IN (${fixedPots.map(() => '?').join(',')}) GROUP BY pot_id`,
fixedPots
);
for (const u of usage) potMinutes.set(u.pot_id, Number(u.minutes));
}
return rows.map((r) => {
const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit };
const hours = r.minutes / 60;
let amount = 0;
let fixedAmount = 0;
if (r.billable && r.project_type === 'billable') {
const rate = hourlyRate({ rate: r.pot_rate }, project, settings);
amount = rate ? hours * rate : 0;
}
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
fixedAmount = (Number(r.pot_amount) / (potMinutes.get(r.pot_id) / 60)) * hours;
}
return {
...r,
billable: Boolean(r.billable),
is_external: Boolean(r.is_external),
project_display: displayName(settings, project, { name: r.customer_name, short_name: r.customer_short }),
amount: Math.round(amount * 100) / 100,
fixed_amount: Math.round(fixedAmount * 100) / 100,
};
});
}