feat(frontend): implement all views and components
- TopBar: brand + nav links (NavLink active styling) + user dropdown
- TimeEntryForm: create + edit mode; date/project/h:mm inputs; pre-fills
on edit; posts to /api/time-entries or PUTs /{id}
- WeeklyView: ISO week navigation (← →); 7-day list with entry rows,
inline TimeEntryForm per day, delete; week total in header
- MonthlyView: year-month navigation; per-project table with time and
cost progress bars; BudgetBanner for breached projects
- ProjectsView: project list with budget bars + create-project form
- BudgetBanner: red banner displayed when any project is over budget
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-800 text-sm rounded px-3 py-2">
|
||||||
|
<strong>{projectName}</strong> has exceeded its {parts.join(" and ")}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full ${exceeded ? "bg-red-500" : "bg-indigo-500"}`}
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyView() {
|
||||||
|
const { selectedUserId } = useUser();
|
||||||
|
const [month, setMonth] = useState(currentYearMonth());
|
||||||
|
const [report, setReport] = useState<MonthlyReport | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonth(stepYearMonth(month, -1))}
|
||||||
|
className="px-3 py-1 rounded border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold text-gray-700">{monthLabel(month)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonth(stepYearMonth(month, 1))}
|
||||||
|
className="px-3 py-1 rounded border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonth(currentYearMonth())}
|
||||||
|
className="px-3 py-1 rounded border text-sm text-indigo-600 hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
This month
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||||
|
{loading && <p className="text-gray-400 text-sm">Loading…</p>}
|
||||||
|
|
||||||
|
{/* Budget warnings */}
|
||||||
|
{report?.projects
|
||||||
|
.filter((p) => p.breached)
|
||||||
|
.map((p) => (
|
||||||
|
<BudgetBanner
|
||||||
|
key={p.projectId}
|
||||||
|
projectName={p.name}
|
||||||
|
timeExceeded={p.minutes > p.timeBudgetMinutes}
|
||||||
|
costExceeded={p.costCents > p.costBudgetCents}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Per-project table */}
|
||||||
|
{report && report.projects.length > 0 && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">Project</th>
|
||||||
|
<th className="text-right px-4 py-2">Hours</th>
|
||||||
|
<th className="text-right px-4 py-2">Cost</th>
|
||||||
|
<th className="px-4 py-2 w-32">Time budget</th>
|
||||||
|
<th className="px-4 py-2 w-32">Cost budget</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{report.projects.map((p) => (
|
||||||
|
<tr key={p.projectId} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-800">
|
||||||
|
{p.name}
|
||||||
|
{p.breached && (
|
||||||
|
<span className="ml-2 text-xs text-red-600 font-normal">
|
||||||
|
⚠ budget exceeded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{minutesToHmm(p.minutes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{centsToEur(p.costCents)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-gray-500 text-right">
|
||||||
|
{Math.round(p.timeBudgetPct)}%
|
||||||
|
</span>
|
||||||
|
<ProgressBar
|
||||||
|
pct={p.timeBudgetPct}
|
||||||
|
exceeded={p.minutes > p.timeBudgetMinutes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-gray-500 text-right">
|
||||||
|
{Math.round(p.costBudgetPct)}%
|
||||||
|
</span>
|
||||||
|
<ProgressBar
|
||||||
|
pct={p.costBudgetPct}
|
||||||
|
exceeded={p.costCents > p.costBudgetCents}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && report.projects.length === 0 && !loading && (
|
||||||
|
<p className="text-gray-400 text-sm text-center py-8">
|
||||||
|
No time entries for this month.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Month totals */}
|
||||||
|
{report && report.projects.length > 0 && (
|
||||||
|
<div className="flex justify-end gap-6 text-sm font-semibold text-gray-700 border-t pt-3">
|
||||||
|
<span>Month total: {minutesToHmm(report.monthTotalMinutes)}</span>
|
||||||
|
<span>{centsToEur(report.monthTotalCostCents)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full ${exceeded ? "bg-red-500" : "bg-indigo-500"}`}
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateFormState {
|
||||||
|
name: string;
|
||||||
|
timeBudgetHours: string;
|
||||||
|
costBudget: string;
|
||||||
|
hourlyRate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = (): CreateFormState => ({
|
||||||
|
name: "",
|
||||||
|
timeBudgetHours: "",
|
||||||
|
costBudget: "",
|
||||||
|
hourlyRate: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ProjectsView() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState<CreateFormState>(emptyForm());
|
||||||
|
const [formError, setFormError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">Projects</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "+ New Project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget warnings */}
|
||||||
|
{breachedProjects.map((p) => (
|
||||||
|
<BudgetBanner
|
||||||
|
key={p.id}
|
||||||
|
projectName={p.name}
|
||||||
|
timeExceeded={p.timeExceeded}
|
||||||
|
costExceeded={p.costExceeded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
className="bg-white border rounded-lg p-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-gray-800">New Project</h3>
|
||||||
|
{formError && <p className="text-red-600 text-sm">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1 col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="project-name"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-name"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Project name"
|
||||||
|
required
|
||||||
|
className="border rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="project-time-budget"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Time budget (hours) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-time-budget"
|
||||||
|
type="number"
|
||||||
|
value={form.timeBudgetHours}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="project-cost-budget"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Cost budget (EUR) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-cost-budget"
|
||||||
|
type="number"
|
||||||
|
value={form.costBudget}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="project-hourly-rate"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Hourly rate (EUR) — leave empty to use each user's rate
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-hourly-rate"
|
||||||
|
type="number"
|
||||||
|
value={form.hourlyRate}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Creating…" : "Create Project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project list */}
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2">Project</th>
|
||||||
|
<th className="text-right px-4 py-2">Rate</th>
|
||||||
|
<th className="text-right px-4 py-2">Logged</th>
|
||||||
|
<th className="text-right px-4 py-2">Cost logged</th>
|
||||||
|
<th className="px-4 py-2 w-32">Time</th>
|
||||||
|
<th className="px-4 py-2 w-32">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{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 (
|
||||||
|
<tr key={p.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-800">
|
||||||
|
{p.name}
|
||||||
|
{(p.timeExceeded || p.costExceeded) && (
|
||||||
|
<span className="ml-2 text-xs text-red-600 font-normal">
|
||||||
|
⚠ exceeded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-500 text-xs">
|
||||||
|
{p.hourlyRateCents != null
|
||||||
|
? `${centsToEur(p.hourlyRateCents)}/h`
|
||||||
|
: "per user"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{minutesToHmm(p.minutesLogged)} /{" "}
|
||||||
|
{minutesToHmm(p.timeBudgetMinutes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{centsToEur(p.costLoggedCents)} /{" "}
|
||||||
|
{centsToEur(p.costBudgetCents)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-gray-500 text-right">
|
||||||
|
{Math.round(timePct)}%
|
||||||
|
</span>
|
||||||
|
<ProgressBar pct={timePct} exceeded={p.timeExceeded} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-gray-500 text-right">
|
||||||
|
{Math.round(costPct)}%
|
||||||
|
</span>
|
||||||
|
<ProgressBar pct={costPct} exceeded={p.costExceeded} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400 text-sm text-center py-8">
|
||||||
|
No projects yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Project[]>([]);
|
||||||
|
const [projectId, setProjectId] = useState<string>("");
|
||||||
|
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<string | null>(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 (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-white border rounded-lg p-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-gray-800">
|
||||||
|
{editEntry ? "Edit Entry" : "Log Time"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="entry-date"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entry-date"
|
||||||
|
type="date"
|
||||||
|
value={entryDate}
|
||||||
|
onChange={(e) => setEntryDate(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="entry-project"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Project
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="entry-project"
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="entry-duration"
|
||||||
|
className="text-xs text-gray-500 font-medium"
|
||||||
|
>
|
||||||
|
Duration (h:mm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entry-duration"
|
||||||
|
type="text"
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
|
placeholder="e.g. 2:30"
|
||||||
|
required
|
||||||
|
className="border rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : editEntry ? "Update" : "Log"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useUser } from "../state/UserContext";
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const { users, selectedUserId, setSelectedUserId } = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-indigo-700 text-white shadow">
|
||||||
|
<div className="mx-auto max-w-5xl flex items-center gap-6 px-4 h-14">
|
||||||
|
<span className="font-bold text-lg tracking-tight">TimeTracker</span>
|
||||||
|
|
||||||
|
<nav className="flex gap-4 text-sm font-medium">
|
||||||
|
<NavLink
|
||||||
|
to="/weekly"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive
|
||||||
|
? "underline underline-offset-4"
|
||||||
|
: "opacity-80 hover:opacity-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Weekly
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/monthly"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive
|
||||||
|
? "underline underline-offset-4"
|
||||||
|
: "opacity-80 hover:opacity-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/projects"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive
|
||||||
|
? "underline underline-offset-4"
|
||||||
|
: "opacity-80 hover:opacity-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
<label htmlFor="user-select" className="opacity-80">
|
||||||
|
Logged in as
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="user-select"
|
||||||
|
value={selectedUserId ?? ""}
|
||||||
|
onChange={(e) => setSelectedUserId(Number(e.target.value))}
|
||||||
|
className="bg-indigo-800 text-white rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<WeeklyReport | null>(null);
|
||||||
|
const [entries, setEntries] = useState<TimeEntry[]>([]);
|
||||||
|
const [addingForDate, setAddingForDate] = useState<string | null>(null);
|
||||||
|
const [editEntry, setEditEntry] = useState<TimeEntry | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<string, TimeEntry[]> = {};
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = e.entryDate;
|
||||||
|
const existing = entriesByDate[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
entriesByDate[key] = [e];
|
||||||
|
} else {
|
||||||
|
existing.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Week navigation */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWeek(stepIsoWeek(week, -1))}
|
||||||
|
className="px-3 py-1 rounded border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold text-gray-700">
|
||||||
|
{isoWeekLabel(week)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWeek(stepIsoWeek(week, 1))}
|
||||||
|
className="px-3 py-1 rounded border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWeek(currentIsoWeek())}
|
||||||
|
className="px-3 py-1 rounded border text-sm text-indigo-600 hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||||
|
{loading && <p className="text-gray-400 text-sm">Loading…</p>}
|
||||||
|
|
||||||
|
{/* Day rows */}
|
||||||
|
{report?.days.map((day) => {
|
||||||
|
const dayEntries = entriesByDate[day.date] ?? [];
|
||||||
|
const isAddingHere = addingForDate === day.date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={day.date} className="border rounded-lg overflow-hidden">
|
||||||
|
{/* Day header */}
|
||||||
|
<div className="bg-gray-50 px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{formatDate(day.date)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span>{minutesToHmm(day.totalMinutes)}</span>
|
||||||
|
<span>{centsToEur(day.totalCostCents)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAddingForDate(isAddingHere ? null : day.date);
|
||||||
|
setEditEntry(null);
|
||||||
|
}}
|
||||||
|
className="text-indigo-600 hover:text-indigo-800 font-semibold"
|
||||||
|
title="Add entry"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entry list */}
|
||||||
|
{dayEntries.length > 0 && (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{dayEntries.map((e) =>
|
||||||
|
editEntry?.id === e.id ? (
|
||||||
|
<li key={e.id} className="p-3">
|
||||||
|
<TimeEntryForm
|
||||||
|
userId={e.userId}
|
||||||
|
editEntry={editEntry}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditEntry(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditEntry(null)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li
|
||||||
|
key={e.id}
|
||||||
|
className="px-4 py-2 flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-gray-700">{e.projectName}</span>
|
||||||
|
<div className="flex items-center gap-4 text-gray-500">
|
||||||
|
<span>{minutesToHmm(e.durationMinutes)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditEntry(e);
|
||||||
|
setAddingForDate(null);
|
||||||
|
}}
|
||||||
|
className="text-indigo-500 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(e.id)}
|
||||||
|
className="text-red-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline add form */}
|
||||||
|
{isAddingHere && !editEntry && selectedUserId !== null && (
|
||||||
|
<div className="p-3 border-t bg-gray-50">
|
||||||
|
<TimeEntryForm
|
||||||
|
userId={selectedUserId}
|
||||||
|
defaultDate={day.date}
|
||||||
|
onSaved={() => {
|
||||||
|
setAddingForDate(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
onCancel={() => setAddingForDate(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Week totals */}
|
||||||
|
{report && (
|
||||||
|
<div className="flex justify-end gap-6 text-sm font-semibold text-gray-700 border-t pt-3">
|
||||||
|
<span>Week total: {minutesToHmm(report.weekTotalMinutes)}</span>
|
||||||
|
<span>{centsToEur(report.weekTotalCostCents)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user