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
@@ -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<Props> = ({ projects, date: initialDate, entry
|
||||
{PROJECT_TYPE_LABELS[project.type]} · {project.code}
|
||||
</p>
|
||||
<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>
|
||||
{pot && pot.budget_hours != null && (
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ProjectDetail: React.FC<Props> = ({ 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<Props> = ({ project: p, canEdit, users, onC
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={TYPE_TONE[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>
|
||||
<span className="tabular text-xs font-bold text-ink-400">{p.code}</span>
|
||||
{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">
|
||||
<h3 className="flex items-center gap-2 text-sm text-ink-900">
|
||||
<Icon name="pot" className="h-4 w-4 text-brand-600" />
|
||||
Budgettöpfe
|
||||
{isAbruf ? 'Abrechnung nach Aufwand' : 'Budgettöpfe'}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
{canEdit && !isAbruf && (
|
||||
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
|
||||
Topf
|
||||
</Button>
|
||||
)}
|
||||
</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 && (
|
||||
<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>
|
||||
|
||||
@@ -131,6 +131,8 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
const [name, setName] = useState(project?.name ?? '');
|
||||
const [type, setType] = useState<ProjectType>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ project, customers, users, onClos
|
||||
<section className="grid gap-4 sm:grid-cols-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>
|
||||
<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'],
|
||||
['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'],
|
||||
] as const
|
||||
@@ -274,11 +279,20 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{type === 'billable' && (
|
||||
{(type === 'billable' || type === 'abruf') && (
|
||||
<Field label="Abrechnung">
|
||||
<Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" />
|
||||
</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">
|
||||
<Field label="Startdatum" required>
|
||||
<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>
|
||||
</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>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm text-ink-900">Budgettöpfe</h3>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : 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>
|
||||
)}
|
||||
|
||||
@@ -141,12 +141,20 @@ export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: bool
|
||||
<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} />
|
||||
</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 className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p>
|
||||
<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>
|
||||
|
||||
+6
-1
@@ -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<ProjectType, string> = {
|
||||
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[];
|
||||
|
||||
Reference in New Issue
Block a user