import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Modal, ConfirmDialog } from '../ui/Modal'; import { BudgetBar, Button, Field, inputClass } from '../ui/controls'; import { Icon } from '../ui/Icon'; import { useAuth } from '../../lib/auth'; import { entriesApi } from '../../lib/api'; import { formatDate, formatDuration, parseDuration } from '../../lib/format'; import { useToast } from '../Toast'; import { checkPot } from './budget'; import { PROJECT_TYPE_LABELS, type Project, type TimeEntry } from '../../types'; interface Props { projects: Project[]; date: string; entry?: TimeEntry | null; onClose: () => void; onSaved: () => void; onDuplicate?: (entry: TimeEntry) => void; } /** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */ function inListOrder(projects: Project[]) { const map = new Map(); for (const p of projects) map.set(p.customer.name, [...(map.get(p.customer.name) || []), p]); return [...map.values()].flat(); } /** * Vorauswahl fuer neue Buchungen: das erste billable Projekt der Liste - * bevorzugt eines, auf das am Datum auch gebucht werden kann (Topf-Laufzeit, * Budget). Gibt es kein billable Projekt, bleibt nur ein einziges Projekt * vorausgewaehlt. */ function defaultProject(projects: Project[], date: string, settings: { hours_per_day: number; budget_warn_percent: number; budget_warn_days: number }): number | '' { const ordered = inListOrder(projects); // 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 ); if (bookableNow) return bookableNow.id; if (billable.length) return billable[0].id; return ordered.length === 1 ? ordered[0].id : ''; } /** Erster aktiver Task in Listenreihenfolge; ohne Tasks bleibt "Ohne Task". */ function firstTask(project: Project | undefined): number | '' { return project?.tasks.find((t) => t.is_active)?.id ?? ''; } /** * Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit * rechts daneben. Die Zeit versteht "1:23" und "1,5". */ export const EntryModal: React.FC = ({ projects, date: initialDate, entry, onClose, onSaved, onDuplicate }) => { const { settings } = useAuth(); const { addToast } = useToast(); const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]); const [projectId, setProjectId] = useState(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings)); // Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts. const [taskId, setTaskId] = useState(() => entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId)) ); const [note, setNote] = useState(entry?.note ?? ''); const [date, setDate] = useState(entry?.entry_date ?? initialDate); const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00'); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const timeRef = useRef(null); const projectRef = useRef(null); /* * Schnell buchen: ist ein Projekt vorausgewaehlt, liegt der Fokus sofort auf * der Zeit (Inhalt markiert, also direkt ueberschreiben). Sonst zuerst das * Projekt waehlen. Kurz verzoegert, damit die Dialog-Animation nicht stoert. */ useEffect(() => { const timer = setTimeout(() => { if (projectId) { timeRef.current?.focus(); timeRef.current?.select(); } else { projectRef.current?.focus(); } }, 60); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const project = bookable.find((p) => p.id === projectId) ?? null; const tasks = project?.tasks.filter((t) => t.is_active || t.id === entry?.task_id) ?? []; const minutes = parseDuration(timeText); const validTime = minutes != null && minutes > 0 && minutes <= 24 * 60; // Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen. useEffect(() => { if (taskId && tasks.some((t) => t.id === taskId)) return; if (!taskId && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht setTaskId(firstTask(project ?? undefined)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); const task = tasks.find((t) => t.id === taskId); const billable = project ? project.type !== 'non_billable' && (task ? task.billable : true) : true; const check = project ? checkPot( project, date, validTime ? minutes! : 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days, entry ? { potId: entry.pot_id, minutes: project.type === 'billable' && !entry.billable ? 0 : entry.minutes } : null, billable ) : null; // Gruppiert nach Kunde fuer die Auswahl. const groups = useMemo(() => { const map = new Map(); for (const p of bookable) { const key = p.customer.name; map.set(key, [...(map.get(key) || []), p]); } return [...map.entries()]; }, [bookable]); const save = async () => { if (!project) return addToast('Bitte ein Projekt wählen.', 'error'); if (!validTime) { timeRef.current?.focus(); return addToast('Bitte eine Zeit eingeben, z. B. 1:30 oder 1,5.', 'error'); } setBusy(true); try { const payload = { project_id: project.id, task_id: taskId || null, entry_date: date, minutes: minutes!, note }; const result = entry ? await entriesApi.update(entry.id, payload) : await entriesApi.create(payload); addToast(`${formatDuration(minutes!)} h auf ${project.display_name} ${entry ? 'aktualisiert' : 'gebucht'}.`, 'success'); if (result.warning) addToast(result.warning, 'info'); onSaved(); onClose(); } catch (err) { addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); } finally { setBusy(false); } }; const remove = async () => { if (!entry) return; setBusy(true); try { await entriesApi.remove(entry.id); addToast('Buchung gelöscht.', 'success'); onSaved(); onClose(); } catch (err) { addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error'); setBusy(false); } }; const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); save(); } }; const pot = check?.pot; return ( <> {entry ? (
{onDuplicate && ( )}
) : ( Enter im Zeitfeld bucht · sonst Strg + Enter )}
} > {bookable.length === 0 ? (
Dir ist noch kein Projekt zugewiesen. Bitte deinen Vorgesetzten, dich einem Projekt zuzuordnen.
) : (