diff --git a/frontend/src/components/BudgetBanner.tsx b/frontend/src/components/BudgetBanner.tsx new file mode 100644 index 0000000..b9a2eab --- /dev/null +++ b/frontend/src/components/BudgetBanner.tsx @@ -0,0 +1,23 @@ +interface Props { + projectName: string; + timeExceeded: boolean; + costExceeded: boolean; +} + +export function BudgetBanner({ + projectName, + timeExceeded, + costExceeded, +}: Props) { + if (!timeExceeded && !costExceeded) return null; + + const parts: string[] = []; + if (timeExceeded) parts.push("time budget"); + if (costExceeded) parts.push("cost budget"); + + return ( +
+ {projectName} has exceeded its {parts.join(" and ")}. +
+ ); +} diff --git a/frontend/src/components/MonthlyView.tsx b/frontend/src/components/MonthlyView.tsx new file mode 100644 index 0000000..f34e240 --- /dev/null +++ b/frontend/src/components/MonthlyView.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from "react"; +import { type MonthlyReport, api } from "../lib/api"; +import { + centsToEur, + currentYearMonth, + minutesToHmm, + stepYearMonth, +} from "../lib/format"; +import { useUser } from "../state/UserContext"; +import { BudgetBanner } from "./BudgetBanner"; + +function monthLabel(ym: string): string { + const [y, m] = ym.split("-"); + return new Date(Number(y), Number(m) - 1, 1).toLocaleDateString("de-DE", { + month: "long", + year: "numeric", + }); +} + +function ProgressBar({ pct, exceeded }: { pct: number; exceeded: boolean }) { + const clamped = Math.min(pct, 100); + return ( +
+
+
+ ); +} + +export function MonthlyView() { + const { selectedUserId } = useUser(); + const [month, setMonth] = useState(currentYearMonth()); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!selectedUserId) return; + setLoading(true); + setError(null); + try { + const r = await api.reports.monthly(selectedUserId, month); + setReport(r); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load report."); + } finally { + setLoading(false); + } + }, [selectedUserId, month]); + + useEffect(() => { + load(); + }, [load]); + + return ( +
+ {/* Month navigation */} +
+ + {monthLabel(month)} + + +
+ + {error &&

{error}

} + {loading &&

Loading…

} + + {/* Budget warnings */} + {report?.projects + .filter((p) => p.breached) + .map((p) => ( + p.timeBudgetMinutes} + costExceeded={p.costCents > p.costBudgetCents} + /> + ))} + + {/* Per-project table */} + {report && report.projects.length > 0 && ( +
+ + + + + + + + + + + + {report.projects.map((p) => ( + + + + + + + + ))} + +
ProjectHoursCostTime budgetCost budget
+ {p.name} + {p.breached && ( + + ⚠ budget exceeded + + )} + + {minutesToHmm(p.minutes)} + + {centsToEur(p.costCents)} + +
+ + {Math.round(p.timeBudgetPct)}% + + p.timeBudgetMinutes} + /> +
+
+
+ + {Math.round(p.costBudgetPct)}% + + p.costBudgetCents} + /> +
+
+
+ )} + + {report && report.projects.length === 0 && !loading && ( +

+ No time entries for this month. +

+ )} + + {/* Month totals */} + {report && report.projects.length > 0 && ( +
+ Month total: {minutesToHmm(report.monthTotalMinutes)} + {centsToEur(report.monthTotalCostCents)} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ProjectsView.tsx b/frontend/src/components/ProjectsView.tsx new file mode 100644 index 0000000..9e4d6dc --- /dev/null +++ b/frontend/src/components/ProjectsView.tsx @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useState } from "react"; +import { type Project, api } from "../lib/api"; +import { centsToEur, minutesToHmm } from "../lib/format"; +import { BudgetBanner } from "./BudgetBanner"; + +function ProgressBar({ pct, exceeded }: { pct: number; exceeded: boolean }) { + const clamped = Math.min(pct, 100); + return ( +
+
+
+ ); +} + +interface CreateFormState { + name: string; + timeBudgetHours: string; + costBudget: string; + hourlyRate: string; +} + +const emptyForm = (): CreateFormState => ({ + name: "", + timeBudgetHours: "", + costBudget: "", + hourlyRate: "", +}); + +export function ProjectsView() { + const [projects, setProjects] = useState([]); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(emptyForm()); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + const load = useCallback(() => { + api.projects.list().then(setProjects); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + const hours = Number.parseFloat(form.timeBudgetHours); + const cost = Number.parseFloat(form.costBudget); + const rateStr = form.hourlyRate.trim(); + + if (!form.name.trim()) { + setFormError("Name is required."); + return; + } + if (Number.isNaN(hours) || hours <= 0) { + setFormError("Time budget must be a positive number of hours."); + return; + } + if (Number.isNaN(cost) || cost < 0) { + setFormError("Cost budget must be a non-negative number in EUR."); + return; + } + + let hourlyRateCents: number | null = null; + if (rateStr) { + const rate = Math.round(Number.parseFloat(rateStr) * 100); + if (Number.isNaN(rate) || rate < 0) { + setFormError("Hourly rate must be a non-negative number in EUR."); + return; + } + hourlyRateCents = rate; + } + + setSaving(true); + try { + await api.projects.create({ + name: form.name.trim(), + timeBudgetMinutes: Math.round(hours * 60), + costBudgetCents: Math.round(cost * 100), + hourlyRateCents, + }); + setForm(emptyForm()); + setShowForm(false); + load(); + } catch (err) { + setFormError( + err instanceof Error ? err.message : "Failed to create project.", + ); + } finally { + setSaving(false); + } + }; + + const breachedProjects = projects.filter( + (p) => p.timeExceeded || p.costExceeded, + ); + + return ( +
+
+

Projects

+ +
+ + {/* Budget warnings */} + {breachedProjects.map((p) => ( + + ))} + + {/* Create form */} + {showForm && ( +
+

New Project

+ {formError &&

{formError}

} + +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="Project name" + required + className="border rounded px-2 py-1.5 text-sm" + /> +
+ +
+ + + setForm({ ...form, timeBudgetHours: e.target.value }) + } + placeholder="e.g. 160" + min="0.1" + step="0.5" + required + className="border rounded px-2 py-1.5 text-sm" + /> +
+ +
+ + + setForm({ ...form, costBudget: e.target.value }) + } + placeholder="e.g. 12000" + min="0" + step="100" + required + className="border rounded px-2 py-1.5 text-sm" + /> +
+ +
+ + + setForm({ ...form, hourlyRate: e.target.value }) + } + placeholder="e.g. 120 (optional)" + min="0" + step="5" + className="border rounded px-2 py-1.5 text-sm" + /> +
+
+ +
+ +
+
+ )} + + {/* Project list */} + {projects.length > 0 ? ( +
+ + + + + + + + + + + + + {projects.map((p) => { + const timePct = + p.timeBudgetMinutes > 0 + ? (p.minutesLogged * 100) / p.timeBudgetMinutes + : 0; + const costPct = + p.costBudgetCents > 0 + ? (p.costLoggedCents * 100) / p.costBudgetCents + : 0; + + return ( + + + + + + + + + ); + })} + +
ProjectRateLoggedCost loggedTimeCost
+ {p.name} + {(p.timeExceeded || p.costExceeded) && ( + + ⚠ exceeded + + )} + + {p.hourlyRateCents != null + ? `${centsToEur(p.hourlyRateCents)}/h` + : "per user"} + + {minutesToHmm(p.minutesLogged)} /{" "} + {minutesToHmm(p.timeBudgetMinutes)} + + {centsToEur(p.costLoggedCents)} /{" "} + {centsToEur(p.costBudgetCents)} + +
+ + {Math.round(timePct)}% + + +
+
+
+ + {Math.round(costPct)}% + + +
+
+
+ ) : ( +

+ No projects yet. +

+ )} +
+ ); +} diff --git a/frontend/src/components/TimeEntryForm.tsx b/frontend/src/components/TimeEntryForm.tsx new file mode 100644 index 0000000..c0586c1 --- /dev/null +++ b/frontend/src/components/TimeEntryForm.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { type Project, type TimeEntry, api } from "../lib/api"; +import { hmmToMinutes, minutesToHmm } from "../lib/format"; + +interface Props { + userId: number; + defaultDate?: string; + editEntry?: TimeEntry; + onSaved: () => void; + onCancel?: () => void; +} + +export function TimeEntryForm({ + userId, + defaultDate, + editEntry, + onSaved, + onCancel, +}: Props) { + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(""); + const [entryDate, setEntryDate] = useState( + editEntry?.entryDate ?? + defaultDate ?? + new Date().toISOString().slice(0, 10), + ); + const [duration, setDuration] = useState( + editEntry ? minutesToHmm(editEntry.durationMinutes) : "", + ); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.projects.list().then(setProjects); + }, []); + + useEffect(() => { + if (projects.length === 0) return; + if (editEntry) { + setProjectId(String(editEntry.projectId)); + } else { + const first = projects[0]; + if (first !== undefined) setProjectId(String(first.id)); + } + }, [editEntry, projects]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const minutes = hmmToMinutes(duration); + if (minutes === null || minutes <= 0 || minutes > 1440) { + setError( + "Duration must be in h:mm format (e.g. 2:30) and between 0:01 and 24:00.", + ); + return; + } + if (!projectId) { + setError("Please select a project."); + return; + } + + setSaving(true); + try { + if (editEntry) { + await api.timeEntries.update(editEntry.id, { + projectId: Number(projectId), + entryDate, + durationMinutes: minutes, + }); + } else { + await api.timeEntries.create({ + userId, + projectId: Number(projectId), + entryDate, + durationMinutes: minutes, + }); + } + setDuration(""); + onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save entry."); + } finally { + setSaving(false); + } + }; + + return ( +
+

+ {editEntry ? "Edit Entry" : "Log Time"} +

+ + {error &&

{error}

} + +
+
+ + setEntryDate(e.target.value)} + required + className="border rounded px-2 py-1.5 text-sm" + /> +
+ +
+ + +
+ +
+ + setDuration(e.target.value)} + placeholder="e.g. 2:30" + required + className="border rounded px-2 py-1.5 text-sm" + /> +
+
+ +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx new file mode 100644 index 0000000..82f864c --- /dev/null +++ b/frontend/src/components/TopBar.tsx @@ -0,0 +1,65 @@ +import { NavLink } from "react-router-dom"; +import { useUser } from "../state/UserContext"; + +export function TopBar() { + const { users, selectedUserId, setSelectedUserId } = useUser(); + + return ( +
+
+ TimeTracker + + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/WeeklyView.tsx b/frontend/src/components/WeeklyView.tsx new file mode 100644 index 0000000..fade1fc --- /dev/null +++ b/frontend/src/components/WeeklyView.tsx @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useState } from "react"; +import { type TimeEntry, type WeeklyReport, api } from "../lib/api"; +import { + centsToEur, + currentIsoWeek, + formatDate, + isoWeekLabel, + isoWeekToMonday, + minutesToHmm, + stepIsoWeek, +} from "../lib/format"; +import { useUser } from "../state/UserContext"; +import { TimeEntryForm } from "./TimeEntryForm"; + +function dateString(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function WeeklyView() { + const { selectedUserId } = useUser(); + const [week, setWeek] = useState(currentIsoWeek()); + const [report, setReport] = useState(null); + const [entries, setEntries] = useState([]); + const [addingForDate, setAddingForDate] = useState(null); + const [editEntry, setEditEntry] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!selectedUserId) return; + setLoading(true); + setError(null); + try { + const monday = isoWeekToMonday(week); + const from = dateString(monday); + const sundayDate = new Date(monday); + sundayDate.setDate(monday.getDate() + 6); + const to = dateString(sundayDate); + + const [r, e] = await Promise.all([ + api.reports.weekly(selectedUserId, week), + api.timeEntries.list(selectedUserId, from, to), + ]); + setReport(r); + setEntries(e); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load data."); + } finally { + setLoading(false); + } + }, [selectedUserId, week]); + + useEffect(() => { + load(); + }, [load]); + + const handleDelete = async (id: number) => { + if (!confirm("Delete this entry?")) return; + await api.timeEntries.delete(id); + load(); + }; + + const entriesByDate: Record = {}; + for (const e of entries) { + const key = e.entryDate; + const existing = entriesByDate[key]; + if (existing === undefined) { + entriesByDate[key] = [e]; + } else { + existing.push(e); + } + } + + return ( +
+ {/* Week navigation */} +
+ + + {isoWeekLabel(week)} + + + +
+ + {error &&

{error}

} + {loading &&

Loading…

} + + {/* Day rows */} + {report?.days.map((day) => { + const dayEntries = entriesByDate[day.date] ?? []; + const isAddingHere = addingForDate === day.date; + + return ( +
+ {/* Day header */} +
+ + {formatDate(day.date)} + +
+ {minutesToHmm(day.totalMinutes)} + {centsToEur(day.totalCostCents)} + +
+
+ + {/* Entry list */} + {dayEntries.length > 0 && ( +
    + {dayEntries.map((e) => + editEntry?.id === e.id ? ( +
  • + { + setEditEntry(null); + load(); + }} + onCancel={() => setEditEntry(null)} + /> +
  • + ) : ( +
  • + {e.projectName} +
    + {minutesToHmm(e.durationMinutes)} + + +
    +
  • + ), + )} +
+ )} + + {/* Inline add form */} + {isAddingHere && !editEntry && selectedUserId !== null && ( +
+ { + setAddingForDate(null); + load(); + }} + onCancel={() => setAddingForDate(null)} + /> +
+ )} +
+ ); + })} + + {/* Week totals */} + {report && ( +
+ Week total: {minutesToHmm(report.weekTotalMinutes)} + {centsToEur(report.weekTotalCostCents)} +
+ )} +
+ ); +}