diff --git a/README.md b/README.md
index 1e381bb..4da1d5d 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,11 @@ Rechte werden im Menü **und** im Backend (403) geprüft. Jeder bucht nur auf Pr
- **Zeiteingabe**: `1:23` = 1 h 23 min, `1,5` / `1.5` = 1 h 30 min, außerdem `2`, `:45`, `1h 30m`.
- **Projektcode** automatisch nach Muster (Standard `{KUERZEL}-{JAHR}-{NR}` → `KUKA-2026-001`), **Anzeigename** nach Muster (Standard `{KUNDE} - {PROJEKT}` → `KUKA - PMO`). Beides in *Einstellungen → Allgemein*.
-- **Projekttypen**: Billable (Preis pro Stunde oder Tag), Non-billable (intern), Festpreis (virtueller Stundensatz = Festpreis ÷ gebuchte Stunden, nur intern).
+- **Projekttypen**:
+ - **Billable** – Preis pro Stunde oder Tag, Volumen und Laufzeit im Budgettopf.
+ - **Abruf** – Abrechnung nach Aufwand: **kein Budgettopf**, kein Limit, nur ein Stunden- oder Tagessatz am Projekt (`pulse_projects.rate`). Gebucht wird innerhalb der Projektlaufzeit, der Umsatz sind abrechenbare Stunden × Satz.
+ - **Non-billable** – intern.
+ - **Festpreis** – fixes Budget, virtueller Stundensatz = Festpreis ÷ gebuchte Stunden (nur intern).
- **Budgettöpfe** (mind. einer je Projekt, beliebig viele): Bezeichnung, Laufzeit von/bis, Budget € + Satz (→ Volumen in Stunden/Tagen) oder direktes Kontingent.
- Gebucht wird nur auf einen Topf, dessen **Laufzeit das Leistungsdatum enthält**; überlappen Töpfe, zuerst der früher endende.
- Volumen erreicht **oder** Ablaufdatum überschritten → Warnung **und nicht mehr buchbar**.
@@ -123,7 +127,7 @@ Aus Auftrag und Call-Mitschrift.
| Menü je Rolle (Dashboard/Buchung, Projekte, Team, Kunden, Reports, Einstellungen) | ✅ | Seitenleiste / mobile Leiste |
| Projekte: User nur zugewiesene; andere alle inkl. Töpfe, bearbeiten/anlegen/entfernen | ✅ | Projekte |
| Projekt wählen → Tasks darunter | ✅ | Projekte |
-| Projektanlage: Name, Code automatisch, Start/Ende, Budget, Tags, Notizen, Typ billable/non-billable/Festpreis, Stunde/Tag | ✅ | Neues Projekt |
+| Projektanlage: Name, Code automatisch, Start/Ende, Budget, Tags, Notizen, Typ billable/Abruf/non-billable/Festpreis, Stunde/Tag | ✅ | Neues Projekt |
| Budgettopf: Laufzeit, Budget €, Stundensatz, Bezeichnung; n Töpfe; Balken Stunden/Tage/Budget | ✅ | Projekte |
| Hinweis beim Buchen, wann Budget endet und wieviel offen ist | ✅ | Buchungsdialog, Buchung |
| Topf voll oder abgelaufen → nicht buchbar | ✅ serverseitig erzwungen | |
diff --git a/server/src/db.js b/server/src/db.js
index c312b16..a83e855 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -39,10 +39,36 @@ export async function initDatabase() {
conn.release();
}
+ await migrateAbrufType();
await ensureAdminUser();
await ensureInternalCustomer();
}
+/*
+ * Projekttyp "Abruf" nachtraeglich ergaenzen: Abrechnung nach Aufwand, ohne
+ * Budgettopf. Der Satz haengt deshalb am Projekt (pulse_projects.rate) und
+ * nicht am Topf. CREATE TABLE IF NOT EXISTS laesst eine bestehende Tabelle
+ * unberuehrt, deshalb hier.
+ */
+async function migrateAbrufType() {
+ const [columns] = await pool.query(
+ `SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_projects'
+ AND COLUMN_NAME IN ('type', 'rate')`
+ );
+ const type = columns.find((c) => c.COLUMN_NAME === 'type');
+ if (type && !type.COLUMN_TYPE.includes("'abruf'")) {
+ await pool.query(
+ "ALTER TABLE pulse_projects MODIFY type ENUM('billable','non_billable','fixed','abruf') NOT NULL DEFAULT 'billable'"
+ );
+ console.log('[Pulse] Projekttyp "Abruf" ergaenzt.');
+ }
+ if (!columns.some((c) => c.COLUMN_NAME === 'rate')) {
+ await pool.query('ALTER TABLE pulse_projects ADD COLUMN rate DECIMAL(10,2) NULL AFTER billing_unit');
+ console.log('[Pulse] Spalte pulse_projects.rate 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 8bd64f0..295baae 100644
--- a/server/src/lib/entries.js
+++ b/server/src/lib/entries.js
@@ -46,6 +46,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
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,
+ 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,
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
@@ -72,12 +73,13 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
}
return rows.map((r) => {
- const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit };
+ const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit, type: r.project_type, rate: r.project_rate };
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);
+ // Billable rechnet ueber den Satz des Topfs ab, Abruf ueber den am Projekt.
+ if (r.billable && (r.project_type === 'billable' || r.project_type === 'abruf')) {
+ const rate = hourlyRate(r.project_type === 'abruf' ? null : { 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)) {
diff --git a/server/src/lib/projects.js b/server/src/lib/projects.js
index 7f6b789..77dc85f 100644
--- a/server/src/lib/projects.js
+++ b/server/src/lib/projects.js
@@ -36,11 +36,15 @@ function prettyShort(customer) {
return customer.short_name;
}
-/** Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet. */
+/**
+ * Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet.
+ *
+ * Bei "Abruf" gibt es keinen Budgettopf - der Satz steht dann am Projekt.
+ */
export function hourlyRate(pot, project, settings) {
- if (pot?.rate == null) return null;
- const rate = Number(pot.rate);
- return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate;
+ const raw = pot?.rate ?? (project?.type === 'abruf' ? project.rate : null);
+ if (raw == null) return null;
+ return project.billing_unit === 'day' ? Number(raw) / Number(settings.hours_per_day || 8) : Number(raw);
}
/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */
@@ -248,15 +252,24 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
remaining_hours:
totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)),
budget_value: round2(projectPots.reduce((s, pot) => s + (pot.budget_value || 0), 0)),
- earned: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
- open: round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
+ /*
+ * Abruf rechnet nach Aufwand ab: der Umsatz haengt nicht an Toepfen,
+ * sondern an den abrechenbaren Stunden mal dem Satz am Projekt. Offen ist
+ * dort nichts - es gibt kein zugesagtes Volumen.
+ */
+ earned:
+ project.type === 'abruf'
+ ? round2((Number(usage?.billable_minutes || 0) / 60) * (hourlyRate(null, project, settings) ?? 0))
+ : round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
+ open: project.type === 'abruf' ? 0 : round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
+ hourly_rate: hourlyRate(null, project, settings),
};
});
}
/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
export function stripAmounts(project) {
- const clean = { ...project, budget_value: null, earned: null, open: null };
+ const clean = { ...project, budget_value: null, earned: null, open: null, rate: null, hourly_rate: null };
clean.pots = project.pots.map((pot) => ({
...pot,
budget_amount: null,
diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js
index a053de5..247caf8 100644
--- a/server/src/routes/projects.js
+++ b/server/src/routes/projects.js
@@ -12,7 +12,9 @@ import {
} from '../lib/projects.js';
const router = express.Router();
-const TYPES = ['billable', 'non_billable', 'fixed'];
+const TYPES = ['billable', 'non_billable', 'fixed', 'abruf'];
+/** Abruf rechnet nach Aufwand ab: kein Budgettopf, Satz am Projekt. */
+const NEEDS_POT = (type) => type !== 'abruf';
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
const num = (v) => (v === '' || v == null ? null : Number(v));
@@ -62,6 +64,10 @@ function validateProject(body, { creating }) {
if (!TYPES.includes(out.type)) errors.push('Unbekannter Projekttyp.');
}
if (creating || body.billing_unit !== undefined) out.billing_unit = body.billing_unit === 'day' ? 'day' : 'hour';
+ if (creating || body.rate !== undefined) {
+ out.rate = num(body.rate);
+ if (out.rate != null && !(Number.isFinite(out.rate) && out.rate >= 0)) errors.push('Satz ist ungültig.');
+ }
if (creating || body.start_date !== undefined) {
out.start_date = body.start_date;
if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.');
@@ -159,13 +165,19 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
const body = req.body || {};
const { out, errors } = validateProject(body, { creating: true });
const pots = [];
- const potInputs = Array.isArray(body.pots) ? body.pots : [];
- if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
- for (const input of potInputs) {
- const result = validatePot(input, out, settings);
- errors.push(...result.errors);
- pots.push(result.pot);
+ // Abruf kommt ohne Topf, braucht dafuer aber einen Satz am Projekt.
+ const potInputs = NEEDS_POT(out.type) && Array.isArray(body.pots) ? body.pots : [];
+ if (NEEDS_POT(out.type)) {
+ if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
+ for (const input of potInputs) {
+ const result = validatePot(input, out, settings);
+ errors.push(...result.errors);
+ pots.push(result.pot);
+ }
+ } else if (!out.rate) {
+ errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
}
+ if (out.type !== 'abruf') out.rate = null;
const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]);
if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
@@ -212,7 +224,25 @@ router.put('/:id', requireRole('lead'), async (req, res, next) => {
{ ...body, start_date: body.start_date ?? project.start_date },
{ creating: false }
);
- if (errors.length) return res.status(400).json({ error: errors.join(' ') });
+
+ const nextType = out.type ?? project.type;
+ const [[pots]] = await pool.query(
+ `SELECT COUNT(*) AS n,
+ (SELECT COUNT(*) FROM pulse_time_entries e WHERE e.pot_id IN
+ (SELECT id FROM pulse_budget_pots WHERE project_id = ?)) AS booked
+ FROM pulse_budget_pots WHERE project_id = ?`,
+ [project.id, project.id]
+ );
+ if (nextType === 'abruf') {
+ // Der Satz muss stehen, sonst waeren die Buchungen nicht bewertbar.
+ if (!(out.rate ?? project.rate)) errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
+ if (Number(pots.booked) > 0) {
+ errors.push('Auf den Budgettöpfen dieses Projekts sind Zeiten gebucht - ein Wechsel auf Abruf würde sie vom Budget lösen.');
+ }
+ } else if (out.type) {
+ out.rate = null;
+ }
+ if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
await withTransaction(async (conn) => {
const keys = Object.keys(out);
@@ -225,6 +255,8 @@ router.put('/:id', requireRole('lead'), async (req, res, next) => {
if (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids);
if (out.type && out.type !== project.type) {
if (out.type === 'non_billable') await conn.query('UPDATE pulse_budget_pots SET rate = NULL WHERE project_id = ?', [project.id]);
+ // Abruf kennt keine Toepfe; ungenutzte werden beim Wechsel entfernt.
+ if (out.type === 'abruf') await conn.query('DELETE FROM pulse_budget_pots WHERE project_id = ?', [project.id]);
await refreshBillable(conn, project.id);
await reassignEntries(conn, project.id);
}
@@ -267,6 +299,9 @@ router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
+ if (!NEEDS_POT(project.type)) {
+ return res.status(400).json({ error: 'Abruf-Projekte rechnen nach Aufwand ab und haben keine Budgettöpfe.' });
+ }
const { pot, errors } = validatePot(req.body || {}, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
diff --git a/server/src/schema.sql b/server/src/schema.sql
index de5d17a..a557c7c 100644
--- a/server/src/schema.sql
+++ b/server/src/schema.sql
@@ -56,8 +56,11 @@ CREATE TABLE IF NOT EXISTS pulse_projects (
code VARCHAR(40) NOT NULL,
seq INT UNSIGNED NOT NULL DEFAULT 0,
name VARCHAR(190) NOT NULL,
- type ENUM('billable','non_billable','fixed') NOT NULL DEFAULT 'billable',
+ -- abruf: Abrechnung nach Aufwand ohne Budget. Der Satz steht dann in rate,
+ -- weil es keinen Budgettopf gibt, der ihn tragen koennte.
+ type ENUM('billable','non_billable','fixed','abruf') NOT NULL DEFAULT 'billable',
billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour',
+ rate DECIMAL(10,2) NULL,
start_date DATE NOT NULL,
end_date DATE NULL,
tags TEXT NULL,
diff --git a/src/components/booking/EntryModal.tsx b/src/components/booking/EntryModal.tsx
index c68d357..eb65d3b 100644
--- a/src/components/booking/EntryModal.tsx
+++ b/src/components/booking/EntryModal.tsx
@@ -32,7 +32,8 @@ function inListOrder(projects: Project[]) {
*/
function defaultProject(projects: Project[], date: string, settings: { hours_per_day: number; budget_warn_percent: number; budget_warn_days: number }): number | '' {
const ordered = inListOrder(projects);
- const billable = ordered.filter((p) => p.type === 'billable');
+ // Billable und Abruf werden beide nach Aufwand abgerechnet.
+ const billable = ordered.filter((p) => p.type === 'billable' || p.type === 'abruf');
const bookableNow = billable.find(
(p) => checkPot(p, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days).ok
);
@@ -288,7 +289,10 @@ export const EntryModal: React.FC
- {check?.message ?? 'Freies Projekt ohne Budgettopf.'} + {check?.message ?? + (project.type === 'abruf' + ? 'Abruf: Abrechnung nach Aufwand, kein Budgetlimit.' + : 'Freies Projekt ohne Budgettopf.')}
{pot && pot.budget_hours != null && (+ Kein Budgettopf: gebucht wird innerhalb der Projektlaufzeit ohne Limit, der Umsatz ergibt sich aus den abrechenbaren Stunden. +
+Gesamt über alle Töpfe
diff --git a/src/components/projects/ProjectForm.tsx b/src/components/projects/ProjectForm.tsx index 8e39b8e..17bf40e 100644 --- a/src/components/projects/ProjectForm.tsx +++ b/src/components/projects/ProjectForm.tsx @@ -131,6 +131,8 @@ export const ProjectForm: React.FC
+
{showAmounts && p.earned != null ? formatEuro(p.earned) : 'nach Aufwand'}
++ {p.rate != null ? `${formatEuro(p.rate, 2)} / ${p.billing_unit === 'day' ? 'Tag' : 'Std.'}` : 'Abruf, kein Budget'} +
+ > ) : (ohne Stundenlimit
)} diff --git a/src/components/reports/ProjectReport.tsx b/src/components/reports/ProjectReport.tsx index a8a5f9d..0a19f25 100644 --- a/src/components/reports/ProjectReport.tsx +++ b/src/components/reports/ProjectReport.tsx @@ -141,12 +141,20 @@ export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: bool{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}
{formatEuro(p.rate, 2)}
+{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}
diff --git a/src/types.ts b/src/types.ts index 230c5e9..07c3a33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,12 +82,13 @@ export interface Customer { project_count?: number; } -export type ProjectType = 'billable' | 'non_billable' | 'fixed'; +export type ProjectType = 'billable' | 'non_billable' | 'fixed' | 'abruf'; export const PROJECT_TYPE_LABELS: Record