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 = ({ projects, date: initialDate, entry {PROJECT_TYPE_LABELS[project.type]} · {project.code}

- {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 && (
diff --git a/src/components/projects/ProjectDetail.tsx b/src/components/projects/ProjectDetail.tsx index 0468366..2535dde 100644 --- a/src/components/projects/ProjectDetail.tsx +++ b/src/components/projects/ProjectDetail.tsx @@ -55,6 +55,7 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC setNewTask(''); }; + const isAbruf = p.type === 'abruf'; 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); @@ -65,7 +66,7 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC
{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'}`} {p.code} {p.tags.map((t) => ( @@ -127,15 +128,33 @@ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onC

- Budgettöpfe + {isAbruf ? 'Abrechnung nach Aufwand' : 'Budgettöpfe'}

- {canEdit && ( + {canEdit && !isAbruf && ( )}
+ {isAbruf && ( + /* Abruf hat kein Volumen und kein Ablaufdatum - nur einen Satz. */ +
+
+ + + + +
+

+ Kein Budgettopf: gebucht wird innerhalb der Projektlaufzeit ohne Limit, der Umsatz ergibt sich aus den abrechenbaren Stunden. +

+
+ )} + {p.pots.length > 1 && (

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 = ({ project, customers, users, onClos const [name, setName] = useState(project?.name ?? ''); const [type, setType] = useState(project?.type ?? 'billable'); 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 [end, setEnd] = useState(project?.end_date ?? ''); const isAdmin = user!.role === 'admin'; @@ -172,6 +174,7 @@ export const ProjectForm: React.FC = ({ project, customers, users, onClos name, type, billing_unit: unit, + rate: type === 'abruf' ? num(rate) : null, start_date: start, end_date: end || null, owner_id: ownerId || null, @@ -184,7 +187,8 @@ export const ProjectForm: React.FC = ({ project, customers, users, onClos : await projectsApi.create({ ...base, 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'); onSaved(saved); @@ -254,10 +258,11 @@ export const ProjectForm: React.FC = ({ project, customers, users, onClos
Projekttyp -
+
{( [ - ['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'], ['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'], ] as const @@ -274,11 +279,20 @@ export const ProjectForm: React.FC = ({ project, customers, users, onClos ))}
- {type === 'billable' && ( + {(type === 'billable' || type === 'abruf') && ( )} + {type === 'abruf' && ( + + setRate(e.target.value)} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} /> + + )}
setStart(e.target.value)} /> @@ -289,7 +303,14 @@ export const ProjectForm: React.FC = ({ project, customers, users, onClos
- {!editing && ( + {!editing && type === 'abruf' && ( +

+ + Kein Budgettopf: gebucht werden kann jederzeit innerhalb der Projektlaufzeit, der Umsatz ergibt sich aus den abrechenbaren Stunden mal dem Satz. +

+ )} + + {!editing && type !== 'abruf' && (

Budgettöpfe

diff --git a/src/components/projects/Projects.tsx b/src/components/projects/Projects.tsx index 49f523d..794a156 100644 --- a/src/components/projects/Projects.tsx +++ b/src/components/projects/Projects.tsx @@ -6,11 +6,11 @@ import { ProjectDetail } from './ProjectDetail'; import { useAuth } from '../../lib/auth'; import { customersApi, projectsApi, usersApi } from '../../lib/api'; 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 { 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 = () => { 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

+ ) : p.type === 'abruf' ? ( + <> +

{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 - + {p.type === 'fixed' ? ( <>

{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}

+ ) : p.type === 'abruf' ? ( + <> +

{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 = { billable: 'Billable', non_billable: 'Non-billable', fixed: 'Festpreis', + abruf: 'Abruf', }; export type PotStatus = 'upcoming' | 'active' | 'expired' | 'exhausted'; @@ -143,6 +144,10 @@ export interface Project { display_name: string; type: ProjectType; 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; end_date: string | null; tags: string[];