feat(projekte): Projekttyp "Abruf" fuer Abrechnung nach Aufwand

Abruf braucht keinen Budgettopf: es gibt kein zugesagtes Volumen und kein
Ablaufdatum, gebucht wird, was anfaellt. Der Satz kann deshalb nicht am
Topf haengen und steht am Projekt (pulse_projects.rate, Stunde oder Tag).
Der Umsatz sind die abrechenbaren Stunden mal diesem Satz; offen ist
nichts.

- Migration ergaenzt den ENUM-Wert und die Spalte rate
- Anlegen verlangt einen Satz und nimmt keine Toepfe an; ein Topf
  nachtraeglich anzulegen wird abgewiesen
- Wechsel auf Abruf nur, solange auf den Toepfen nichts gebucht ist -
  sonst wuerden Buchungen ihr Budget verlieren; leere Toepfe fallen weg
- Buchungsdialog, Projektkarte, Projektdetail und Projektreport zeigen
  Satz und Umsatz statt Budgetbalken

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-18 00:13:35 +02:00
co-authored by Claude Opus 5
parent 2af516ec20
commit 8c93df11d9
12 changed files with 182 additions and 35 deletions
+6 -2
View File
@@ -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`. - **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*. - **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. - **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. - 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**. - 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 | | 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 | | Projekte: User nur zugewiesene; andere alle inkl. Töpfe, bearbeiten/anlegen/entfernen | ✅ | Projekte |
| Projekt wählen → Tasks darunter | ✅ | 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 | | 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 | | Hinweis beim Buchen, wann Budget endet und wieviel offen ist | ✅ | Buchungsdialog, Buchung |
| Topf voll oder abgelaufen → nicht buchbar | ✅ serverseitig erzwungen | | | Topf voll oder abgelaufen → nicht buchbar | ✅ serverseitig erzwungen | |
+26
View File
@@ -39,10 +39,36 @@ export async function initDatabase() {
conn.release(); conn.release();
} }
await migrateAbrufType();
await ensureAdminUser(); await ensureAdminUser();
await ensureInternalCustomer(); 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() { async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users'); const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
if (rows[0].n > 0) return; if (rows[0].n > 0) return;
+5 -3
View File
@@ -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.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
u.manager_id, u.manager_id,
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit, 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, 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.name AS task_name,
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
@@ -72,12 +73,13 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
} }
return rows.map((r) => { 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; const hours = r.minutes / 60;
let amount = 0; let amount = 0;
let fixedAmount = 0; let fixedAmount = 0;
if (r.billable && r.project_type === 'billable') { // Billable rechnet ueber den Satz des Topfs ab, Abruf ueber den am Projekt.
const rate = hourlyRate({ rate: r.pot_rate }, project, settings); 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; amount = rate ? hours * rate : 0;
} }
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) { if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
+20 -7
View File
@@ -36,11 +36,15 @@ function prettyShort(customer) {
return customer.short_name; 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) { export function hourlyRate(pot, project, settings) {
if (pot?.rate == null) return null; const raw = pot?.rate ?? (project?.type === 'abruf' ? project.rate : null);
const rate = Number(pot.rate); if (raw == null) return null;
return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate; return project.billing_unit === 'day' ? Number(raw) / Number(settings.hours_per_day || 8) : Number(raw);
} }
/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */ /** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */
@@ -248,15 +252,24 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
remaining_hours: remaining_hours:
totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)), 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)), 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. */ /** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
export function stripAmounts(project) { 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) => ({ clean.pots = project.pots.map((pot) => ({
...pot, ...pot,
budget_amount: null, budget_amount: null,
+43 -8
View File
@@ -12,7 +12,9 @@ import {
} from '../lib/projects.js'; } from '../lib/projects.js';
const router = express.Router(); 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 str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
const num = (v) => (v === '' || v == null ? null : Number(v)); 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 (!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.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) { if (creating || body.start_date !== undefined) {
out.start_date = body.start_date; out.start_date = body.start_date;
if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.'); 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 body = req.body || {};
const { out, errors } = validateProject(body, { creating: true }); const { out, errors } = validateProject(body, { creating: true });
const pots = []; const pots = [];
const potInputs = Array.isArray(body.pots) ? body.pots : []; // Abruf kommt ohne Topf, braucht dafuer aber einen Satz am Projekt.
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.'); const potInputs = NEEDS_POT(out.type) && Array.isArray(body.pots) ? body.pots : [];
for (const input of potInputs) { if (NEEDS_POT(out.type)) {
const result = validatePot(input, out, settings); if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
errors.push(...result.errors); for (const input of potInputs) {
pots.push(result.pot); 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]); 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 (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') }); 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 }, { ...body, start_date: body.start_date ?? project.start_date },
{ creating: false } { 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) => { await withTransaction(async (conn) => {
const keys = Object.keys(out); 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 (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids);
if (out.type && out.type !== project.type) { 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]); 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 refreshBillable(conn, project.id);
await reassignEntries(conn, project.id); await reassignEntries(conn, project.id);
} }
@@ -267,6 +299,9 @@ router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
try { try {
const project = await findProject(Number(req.params.id)); const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); 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()); const { pot, errors } = validatePot(req.body || {}, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') }); if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => { await withTransaction(async (conn) => {
+4 -1
View File
@@ -56,8 +56,11 @@ CREATE TABLE IF NOT EXISTS pulse_projects (
code VARCHAR(40) NOT NULL, code VARCHAR(40) NOT NULL,
seq INT UNSIGNED NOT NULL DEFAULT 0, seq INT UNSIGNED NOT NULL DEFAULT 0,
name VARCHAR(190) NOT NULL, 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', billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour',
rate DECIMAL(10,2) NULL,
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE NULL, end_date DATE NULL,
tags TEXT NULL, tags TEXT NULL,
+6 -2
View File
@@ -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 | '' { 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 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( const bookableNow = billable.find(
(p) => checkPot(p, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days).ok (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<Props> = ({ projects, date: initialDate, entry
{PROJECT_TYPE_LABELS[project.type]} · {project.code} {PROJECT_TYPE_LABELS[project.type]} · {project.code}
</p> </p>
<p className={`mt-1 text-sm font-semibold ${check?.level === 'error' ? 'text-red-700' : check?.level === 'warn' ? 'text-amber-700' : 'text-ink-700'}`}> <p className={`mt-1 text-sm font-semibold ${check?.level === 'error' ? 'text-red-700' : check?.level === 'warn' ? 'text-amber-700' : 'text-ink-700'}`}>
{check?.message ?? 'Freies Projekt ohne Budgettopf.'} {check?.message ??
(project.type === 'abruf'
? 'Abruf: Abrechnung nach Aufwand, kein Budgetlimit.'
: 'Freies Projekt ohne Budgettopf.')}
</p> </p>
{pot && pot.budget_hours != null && ( {pot && pot.budget_hours != null && (
<div className="mt-3"> <div className="mt-3">
+22 -3
View File
@@ -55,6 +55,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
setNewTask(''); setNewTask('');
}; };
const isAbruf = p.type === 'abruf';
const maxTask = Math.max(1, ...p.tasks.map((t) => t.used_minutes)); const maxTask = Math.max(1, ...p.tasks.map((t) => t.used_minutes));
const totalPotHours = p.pots.reduce((s, pot) => s + (pot.budget_hours ?? 0), 0); const totalPotHours = p.pots.reduce((s, pot) => s + (pot.budget_hours ?? 0), 0);
@@ -65,7 +66,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge tone={TYPE_TONE[p.type]}> <Badge tone={TYPE_TONE[p.type]}>
{PROJECT_TYPE_LABELS[p.type]} {PROJECT_TYPE_LABELS[p.type]}
{p.type === 'billable' && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`} {(p.type === 'billable' || p.type === 'abruf') && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`}
</Badge> </Badge>
<span className="tabular text-xs font-bold text-ink-400">{p.code}</span> <span className="tabular text-xs font-bold text-ink-400">{p.code}</span>
{p.tags.map((t) => ( {p.tags.map((t) => (
@@ -127,15 +128,33 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm text-ink-900"> <h3 className="flex items-center gap-2 text-sm text-ink-900">
<Icon name="pot" className="h-4 w-4 text-brand-600" /> <Icon name="pot" className="h-4 w-4 text-brand-600" />
Budgettöpfe {isAbruf ? 'Abrechnung nach Aufwand' : 'Budgettöpfe'}
</h3> </h3>
{canEdit && ( {canEdit && !isAbruf && (
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}> <Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
Topf Topf
</Button> </Button>
)} )}
</div> </div>
{isAbruf && (
/* Abruf hat kein Volumen und kein Ablaufdatum - nur einen Satz. */
<div className="rounded-xl border border-ink-200 p-3">
<div className="tabular grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
<Kv label="Gebucht" value={`${formatHours(p.used_minutes, 1)} h`} />
<Kv label="davon abrechenbar" value={`${formatHours(p.billable_minutes, 1)} h`} />
<Kv
label={p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz'}
value={canEdit ? formatEuro(p.rate, 2) : '–'}
/>
<Kv label="Umsatz" value={canEdit ? formatEuro(p.earned, 2) : '–'} />
</div>
<p className="mt-2 text-xs text-ink-400">
Kein Budgettopf: gebucht wird innerhalb der Projektlaufzeit ohne Limit, der Umsatz ergibt sich aus den abrechenbaren Stunden.
</p>
</div>
)}
{p.pots.length > 1 && ( {p.pots.length > 1 && (
<div className="mb-3 rounded-xl bg-ink-50 p-3"> <div className="mb-3 rounded-xl bg-ink-50 p-3">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt über alle Töpfe</p> <p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt über alle Töpfe</p>
+26 -5
View File
@@ -131,6 +131,8 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
const [name, setName] = useState(project?.name ?? ''); const [name, setName] = useState(project?.name ?? '');
const [type, setType] = useState<ProjectType>(project?.type ?? 'billable'); const [type, setType] = useState<ProjectType>(project?.type ?? 'billable');
const [unit, setUnit] = useState<'hour' | 'day'>(project?.billing_unit ?? 'hour'); const [unit, setUnit] = useState<'hour' | 'day'>(project?.billing_unit ?? 'hour');
// Nur fuer "Abruf": Satz am Projekt statt am Budgettopf.
const [rate, setRate] = useState(project?.rate == null ? '' : String(project.rate).replace('.', ','));
const [start, setStart] = useState(project?.start_date ?? todayIso()); const [start, setStart] = useState(project?.start_date ?? todayIso());
const [end, setEnd] = useState(project?.end_date ?? ''); const [end, setEnd] = useState(project?.end_date ?? '');
const isAdmin = user!.role === 'admin'; const isAdmin = user!.role === 'admin';
@@ -172,6 +174,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
name, name,
type, type,
billing_unit: unit, billing_unit: unit,
rate: type === 'abruf' ? num(rate) : null,
start_date: start, start_date: start,
end_date: end || null, end_date: end || null,
owner_id: ownerId || null, owner_id: ownerId || null,
@@ -184,7 +187,8 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
: await projectsApi.create({ : await projectsApi.create({
...base, ...base,
tasks: tasks.filter((t) => t.name.trim()), tasks: tasks.filter((t) => t.name.trim()),
pots: pots.map((p) => potPayload(p, settings.hours_per_day)), // Abruf kommt ohne Topf.
pots: type === 'abruf' ? [] : pots.map((p) => potPayload(p, settings.hours_per_day)),
}); });
addToast(editing ? 'Projekt gespeichert.' : `Projekt ${saved.code} angelegt.`, 'success'); addToast(editing ? 'Projekt gespeichert.' : `Projekt ${saved.code} angelegt.`, 'success');
onSaved(saved); onSaved(saved);
@@ -254,10 +258,11 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
<section className="grid gap-4 sm:grid-cols-2"> <section className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Projekttyp</span> <span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Projekttyp</span>
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{( {(
[ [
['billable', 'Billable', 'Abrechnung nach Stunden oder Tagen'], ['billable', 'Billable', 'Budgettopf mit Volumen und Laufzeit'],
['abruf', 'Abruf', 'Nach Aufwand, ohne Budget – nur ein Satz'],
['non_billable', 'Non-billable', 'z. B. perinco intern'], ['non_billable', 'Non-billable', 'z. B. perinco intern'],
['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'], ['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'],
] as const ] as const
@@ -274,11 +279,20 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
))} ))}
</div> </div>
</div> </div>
{type === 'billable' && ( {(type === 'billable' || type === 'abruf') && (
<Field label="Abrechnung"> <Field label="Abrechnung">
<Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" /> <Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" />
</Field> </Field>
)} )}
{type === 'abruf' && (
<Field
label={unit === 'day' ? 'Tagessatz (€)' : 'Stundensatz (€)'}
required
hint="Abruf hat kein Budget: es wird gebucht, was anfällt, und mit diesem Satz bewertet."
>
<input inputMode="decimal" className={inputClass} value={rate} onChange={(e) => setRate(e.target.value)} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} />
</Field>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Startdatum" required> <Field label="Startdatum" required>
<input type="date" className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} /> <input type="date" className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
@@ -289,7 +303,14 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
</div> </div>
</section> </section>
{!editing && ( {!editing && type === 'abruf' && (
<p className="flex items-start gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs font-semibold text-brand-800">
<Icon name="pot" className="mt-0.5 h-4 w-4 shrink-0" />
Kein Budgettopf: gebucht werden kann jederzeit innerhalb der Projektlaufzeit, der Umsatz ergibt sich aus den abrechenbaren Stunden mal dem Satz.
</p>
)}
{!editing && type !== 'abruf' && (
<section> <section>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-sm text-ink-900">Budgettöpfe</h3> <h3 className="text-sm text-ink-900">Budgettöpfe</h3>
+9 -2
View File
@@ -6,11 +6,11 @@ import { ProjectDetail } from './ProjectDetail';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
import { customersApi, projectsApi, usersApi } from '../../lib/api'; import { customersApi, projectsApi, usersApi } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks'; import { useLoader, usePersistentState } from '../../lib/hooks';
import { colorFor, compactEuro, formatHours, formatNumber } from '../../lib/format'; import { colorFor, compactEuro, formatEuro, formatHours, formatNumber } from '../../lib/format';
import { useRouter } from '../../lib/router'; import { useRouter } from '../../lib/router';
import { PROJECT_TYPE_LABELS, atLeast, type Customer, type DirectoryUser, type Project } from '../../types'; import { PROJECT_TYPE_LABELS, atLeast, type Customer, type DirectoryUser, type Project } from '../../types';
export const TYPE_TONE = { billable: 'brand', non_billable: 'neutral', fixed: 'violet' } as const; export const TYPE_TONE = { billable: 'brand', non_billable: 'neutral', fixed: 'violet', abruf: 'sky' } as const;
export const Projects: React.FC = () => { export const Projects: React.FC = () => {
const { user, settings } = useAuth(); const { user, settings } = useAuth();
@@ -228,6 +228,13 @@ const ProjectCard: React.FC<{
von {formatNumber(p.budget_hours, 0)} h · {formatNumber((p.remaining_hours ?? 0) / hoursPerDay, 1)} Tage von {formatNumber(p.budget_hours, 0)} h · {formatNumber((p.remaining_hours ?? 0) / hoursPerDay, 1)} Tage
</p> </p>
</> </>
) : p.type === 'abruf' ? (
<>
<p className="tabular text-sm font-extrabold text-ink-800">{showAmounts && p.earned != null ? formatEuro(p.earned) : 'nach Aufwand'}</p>
<p className="tabular text-[11px] font-semibold text-ink-400">
{p.rate != null ? `${formatEuro(p.rate, 2)} / ${p.billing_unit === 'day' ? 'Tag' : 'Std.'}` : 'Abruf, kein Budget'}
</p>
</>
) : ( ) : (
<p className="text-xs font-bold text-ink-400">ohne Stundenlimit</p> <p className="text-xs font-bold text-ink-400">ohne Stundenlimit</p>
)} )}
+9 -1
View File
@@ -141,12 +141,20 @@ export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: bool
<Row label="Gesamtbudget" value={budgetHours == null ? 'unbegrenzt' : `${formatNumber(budgetHours, 2)} h`} /> <Row label="Gesamtbudget" value={budgetHours == null ? 'unbegrenzt' : `${formatNumber(budgetHours, 2)} h`} />
<BudgetBar className="mt-2" percent={budgetHours ? (p.budget_used_minutes / 60 / budgetHours) * 100 : null} warnAt={settings.budget_warn_percent} /> <BudgetBar className="mt-2" percent={budgetHours ? (p.budget_used_minutes / 60 / budgetHours) * 100 : null} warnAt={settings.budget_warn_percent} />
</Kpi> </Kpi>
<Kpi index={4} title={p.type === 'fixed' ? 'Virtueller Stundensatz' : 'Budget in Tagen'}> <Kpi
index={4}
title={p.type === 'fixed' ? 'Virtueller Stundensatz' : p.type === 'abruf' ? (p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz') : 'Budget in Tagen'}
>
{p.type === 'fixed' ? ( {p.type === 'fixed' ? (
<> <>
<p className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p> <p className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p>
<Row label="Festpreis ÷ Stunden" value="nur intern" /> <Row label="Festpreis ÷ Stunden" value="nur intern" />
</> </>
) : p.type === 'abruf' ? (
<>
<p className="tabular text-3xl font-extrabold text-ink-900">{formatEuro(p.rate, 2)}</p>
<Row label="Abrechnung" value="nach Aufwand, ohne Budget" />
</>
) : ( ) : (
<> <>
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}</p> <p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}</p>
+6 -1
View File
@@ -82,12 +82,13 @@ export interface Customer {
project_count?: number; project_count?: number;
} }
export type ProjectType = 'billable' | 'non_billable' | 'fixed'; export type ProjectType = 'billable' | 'non_billable' | 'fixed' | 'abruf';
export const PROJECT_TYPE_LABELS: Record<ProjectType, string> = { export const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
billable: 'Billable', billable: 'Billable',
non_billable: 'Non-billable', non_billable: 'Non-billable',
fixed: 'Festpreis', fixed: 'Festpreis',
abruf: 'Abruf',
}; };
export type PotStatus = 'upcoming' | 'active' | 'expired' | 'exhausted'; export type PotStatus = 'upcoming' | 'active' | 'expired' | 'exhausted';
@@ -143,6 +144,10 @@ export interface Project {
display_name: string; display_name: string;
type: ProjectType; type: ProjectType;
billing_unit: 'hour' | 'day'; billing_unit: 'hour' | 'day';
/** Nur bei "Abruf": Satz am Projekt, weil es dort keinen Budgettopf gibt. */
rate: number | null;
/** Satz je Stunde, auch wenn nach Tagen abgerechnet wird (nur bei "Abruf" gesetzt). */
hourly_rate: number | null;
start_date: string; start_date: string;
end_date: string | null; end_date: string | null;
tags: string[]; tags: string[];