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:
co-authored by
Claude Opus 5
parent
2af516ec20
commit
8c93df11d9
@@ -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 | |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
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.');
|
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
|
||||||
for (const input of potInputs) {
|
for (const input of potInputs) {
|
||||||
const result = validatePot(input, out, settings);
|
const result = validatePot(input, out, settings);
|
||||||
errors.push(...result.errors);
|
errors.push(...result.errors);
|
||||||
pots.push(result.pot);
|
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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user