Neue Komponente EntryList zeigt einzelne Buchungen als sortierbare Tabelle mit Datum, Kunde, Projekt, Task und der Zeit getrennt nach billable und non-billable, inklusive Summen. - Buchung: Umschalter Kalender/Liste neben Woche/Monat/Jahr, Auswahl wird im Browser gemerkt. Klick auf eine Zeile oeffnet die Buchung. - Reports: Klick auf einen Mitarbeiter oeffnet dessen Buchungen des Reportzeitraums als Liste; ein gesetzter Kundenfilter wird uebernommen. - Kunden: Umschalter Karten/Liste mit Ort, Projekten, Stunden und Umsatz. - GET /api/entries akzeptiert dafuer customer_id, Segmented nimmt Icons, Modal kennt die Groesse 2xl. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
7.6 KiB
JavaScript
178 lines
7.6 KiB
JavaScript
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();
|
|
// Optionaler Kundenfilter, damit die Listenansicht im Report den dort
|
|
// gesetzten Filter uebernehmen kann.
|
|
const customerId = req.query.customer_id ? Number(req.query.customer_id) : undefined;
|
|
const entries = await loadEntries(settings, { from, to, userIds, customerId });
|
|
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;
|