Add user and project rate editing across backend and frontend

This commit is contained in:
2026-05-21 13:43:17 +02:00
parent 38f1461d37
commit 1b420271c7
13 changed files with 623 additions and 40 deletions
@@ -2,6 +2,7 @@ package com.example.app.project;
import com.example.app.project.ProjectDtos.CreateProjectRequest;
import com.example.app.project.ProjectDtos.ProjectResponse;
import com.example.app.project.ProjectDtos.UpdateProjectRequest;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
@@ -27,4 +28,10 @@ public class ProjectController {
public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) {
return projectService.create(req);
}
@PutMapping("/{id}")
public ProjectResponse update(
@PathVariable Long id, @Valid @RequestBody UpdateProjectRequest req) {
return projectService.update(id, req);
}
}
@@ -12,6 +12,12 @@ public class ProjectDtos {
@PositiveOrZero int costBudgetCents,
@PositiveOrZero Integer hourlyRateCents) {}
public record UpdateProjectRequest(
@NotBlank String name,
@Min(1) int timeBudgetMinutes,
@PositiveOrZero int costBudgetCents,
@PositiveOrZero Integer hourlyRateCents) {}
public record ProjectResponse(
Long id,
String name,
@@ -2,7 +2,9 @@ package com.example.app.project;
import com.example.app.project.ProjectDtos.CreateProjectRequest;
import com.example.app.project.ProjectDtos.ProjectResponse;
import com.example.app.project.ProjectDtos.UpdateProjectRequest;
import com.example.app.time.TimeEntryRepository;
import jakarta.persistence.EntityNotFoundException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -53,4 +55,29 @@ public class ProjectService {
Project saved = projectRepository.save(p);
return ProjectResponse.from(saved, 0, 0);
}
@Transactional
public ProjectResponse update(Long id, UpdateProjectRequest req) {
Project project =
projectRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Project not found: " + id));
project.setName(req.name());
project.setTimeBudgetMinutes(req.timeBudgetMinutes());
project.setCostBudgetCents(req.costBudgetCents());
project.setHourlyRateCents(req.hourlyRateCents());
Project saved = projectRepository.save(project);
long[] totals = new long[] {0, 0};
for (Object[] row : timeEntryRepository.aggregateAllByProject()) {
long projectId = ((Number) row[0]).longValue();
if (projectId == saved.getId()) {
totals[0] = ((Number) row[1]).longValue();
totals[1] = ((Number) row[2]).longValue();
break;
}
}
return ProjectResponse.from(saved, totals[0], totals[1]);
}
}
@@ -1,8 +1,13 @@
package com.example.app.user;
import com.example.app.user.UserDtos.UpdateUserRequest;
import com.example.app.user.UserDtos.UserResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -20,4 +25,9 @@ public class UserController {
public List<UserResponse> list() {
return userService.findAll();
}
@PutMapping("/{id}")
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest req) {
return userService.update(id, req);
}
}
@@ -1,7 +1,13 @@
package com.example.app.user;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
public class UserDtos {
public record UpdateUserRequest(
@NotBlank String name, @PositiveOrZero int defaultHourlyRateCents) {}
public record UserResponse(Long id, String name, int defaultHourlyRateCents) {
public static UserResponse from(User user) {
@@ -1,6 +1,8 @@
package com.example.app.user;
import com.example.app.user.UserDtos.UpdateUserRequest;
import com.example.app.user.UserDtos.UserResponse;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,4 +20,16 @@ public class UserService {
public List<UserResponse> findAll() {
return userRepository.findAll().stream().map(UserResponse::from).toList();
}
@Transactional
public UserResponse update(Long id, UpdateUserRequest req) {
User user =
userRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + id));
user.setName(req.name());
user.setDefaultHourlyRateCents(req.defaultHourlyRateCents());
User saved = userRepository.save(user);
return UserResponse.from(saved);
}
}
@@ -0,0 +1,123 @@
package com.example.app;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.example.app.project.Project;
import com.example.app.project.ProjectRepository;
import com.example.app.user.User;
import com.example.app.user.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class RateUpdateEndpointsTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@Autowired UserRepository userRepository;
@Autowired ProjectRepository projectRepository;
@Test
void putUser_updatesNameAndDefaultRate() throws Exception {
User user = userRepository.findAll().get(0);
String originalName = user.getName();
int originalRate = user.getDefaultHourlyRateCents();
try {
String body =
objectMapper.writeValueAsString(
Map.of("name", "Updated User", "defaultHourlyRateCents", 12345));
mockMvc
.perform(
put("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated User"))
.andExpect(jsonPath("$.defaultHourlyRateCents").value(12345));
} finally {
user.setName(originalName);
user.setDefaultHourlyRateCents(originalRate);
userRepository.save(user);
}
}
@Test
void putUser_rejectsNegativeRate() throws Exception {
User user = userRepository.findAll().get(0);
String body =
objectMapper.writeValueAsString(Map.of("name", "X", "defaultHourlyRateCents", -1));
mockMvc
.perform(
put("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void putProject_updatesFieldsIncludingRate() throws Exception {
Project project = projectRepository.findAll().get(0);
String originalName = project.getName();
int originalTime = project.getTimeBudgetMinutes();
int originalCost = project.getCostBudgetCents();
Integer originalRate = project.getHourlyRateCents();
try {
String body =
objectMapper.writeValueAsString(
Map.of(
"name", "Updated Project",
"timeBudgetMinutes", 600,
"costBudgetCents", 99999,
"hourlyRateCents", 15000));
mockMvc
.perform(
put("/api/projects/{id}", project.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated Project"))
.andExpect(jsonPath("$.timeBudgetMinutes").value(600))
.andExpect(jsonPath("$.costBudgetCents").value(99999))
.andExpect(jsonPath("$.hourlyRateCents").value(15000));
} finally {
project.setName(originalName);
project.setTimeBudgetMinutes(originalTime);
project.setCostBudgetCents(originalCost);
project.setHourlyRateCents(originalRate);
projectRepository.save(project);
}
}
@Test
void putProject_rejectsBlankName() throws Exception {
Project project = projectRepository.findAll().get(0);
String body =
objectMapper.writeValueAsString(
Map.of(
"name", "",
"timeBudgetMinutes", 600,
"costBudgetCents", 99999,
"hourlyRateCents", 15000));
mockMvc
.perform(
put("/api/projects/{id}", project.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
}
@@ -45,6 +45,46 @@ class TimeEntryRepositoryTest {
timeEntryRepository.deleteAll(List.of(inRange, outOfRange));
}
@Test
void aggregateByDay_reflectsUpdatedRatesRetroactively() {
User user = new User();
user.setName("Rate Test User");
user.setDefaultHourlyRateCents(6000);
user = userRepository.save(user);
Project project = new Project();
project.setName("Rate Test Project");
project.setTimeBudgetMinutes(600);
project.setCostBudgetCents(100_000);
project.setHourlyRateCents(null);
project = projectRepository.save(project);
LocalDate day = LocalDate.of(2026, 5, 20);
TimeEntry entry = entry(user, project, day, 60);
entry = timeEntryRepository.save(entry);
try {
List<Object[]> initial = timeEntryRepository.aggregateByDay(user.getId(), day, day);
assertThat(initial).hasSize(1);
assertThat(((Number) initial.get(0)[2]).longValue()).isEqualTo(6000);
user.setDefaultHourlyRateCents(9000);
userRepository.save(user);
List<Object[]> updatedUserRate = timeEntryRepository.aggregateByDay(user.getId(), day, day);
assertThat(((Number) updatedUserRate.get(0)[2]).longValue()).isEqualTo(9000);
project.setHourlyRateCents(12000);
projectRepository.save(project);
List<Object[]> updatedProjectRate =
timeEntryRepository.aggregateByDay(user.getId(), day, day);
assertThat(((Number) updatedProjectRate.get(0)[2]).longValue()).isEqualTo(12000);
} finally {
timeEntryRepository.delete(entry);
projectRepository.delete(project);
userRepository.delete(user);
}
}
private TimeEntry entry(User user, Project project, LocalDate date, int minutes) {
TimeEntry e = new TimeEntry();
e.setUser(user);
+190 -36
View File
@@ -15,27 +15,96 @@ function ProgressBar({ pct, exceeded }: { pct: number; exceeded: boolean }) {
);
}
interface CreateFormState {
interface ProjectFormState {
name: string;
timeBudgetHours: string;
costBudget: string;
hourlyRate: string;
}
const emptyForm = (): CreateFormState => ({
const emptyForm = (): ProjectFormState => ({
name: "",
timeBudgetHours: "",
costBudget: "",
hourlyRate: "",
});
function toForm(project: Project): ProjectFormState {
return {
name: project.name,
timeBudgetHours: (project.timeBudgetMinutes / 60).toString(),
costBudget: centsToEur(project.costBudgetCents),
hourlyRate:
project.hourlyRateCents === null
? ""
: centsToEur(project.hourlyRateCents),
};
}
function parseForm(form: ProjectFormState):
| {
ok: true;
body: {
name: string;
timeBudgetMinutes: number;
costBudgetCents: number;
hourlyRateCents: number | null;
};
}
| { ok: false; error: string } {
const hours = Number.parseFloat(form.timeBudgetHours);
const cost = Number.parseFloat(form.costBudget);
const rateStr = form.hourlyRate.trim();
if (!form.name.trim()) return { ok: false, error: "Name is required." };
if (Number.isNaN(hours) || hours <= 0) {
return {
ok: false,
error: "Time budget must be a positive number of hours.",
};
}
if (Number.isNaN(cost) || cost < 0) {
return {
ok: false,
error: "Cost budget must be a non-negative number in EUR.",
};
}
let hourlyRateCents: number | null = null;
if (rateStr) {
const rate = Math.round(Number.parseFloat(rateStr) * 100);
if (Number.isNaN(rate) || rate < 0) {
return {
ok: false,
error: "Hourly rate must be a non-negative number in EUR.",
};
}
hourlyRateCents = rate;
}
return {
ok: true,
body: {
name: form.name.trim(),
timeBudgetMinutes: Math.round(hours * 60),
costBudgetCents: Math.round(cost * 100),
hourlyRateCents,
},
};
}
export function ProjectsView() {
const [projects, setProjects] = useState<Project[]>([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<CreateFormState>(emptyForm());
const [form, setForm] = useState<ProjectFormState>(emptyForm());
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<ProjectFormState>(emptyForm());
const [editError, setEditError] = useState<string | null>(null);
const [savingEdit, setSavingEdit] = useState(false);
const load = useCallback(() => {
api.projects.list().then(setProjects);
}, []);
@@ -48,41 +117,15 @@ export function ProjectsView() {
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.");
const parsed = parseForm(form);
if (!parsed.ok) {
setFormError(parsed.error);
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,
});
await api.projects.create(parsed.body);
setForm(emptyForm());
setShowForm(false);
load();
@@ -95,6 +138,37 @@ export function ProjectsView() {
}
};
const startEdit = (project: Project) => {
setEditingProjectId(project.id);
setEditForm(toForm(project));
setEditError(null);
};
const handleSaveEdit = async () => {
if (editingProjectId === null) return;
setEditError(null);
const parsed = parseForm(editForm);
if (!parsed.ok) {
setEditError(parsed.error);
return;
}
setSavingEdit(true);
try {
await api.projects.update(editingProjectId, parsed.body);
setEditingProjectId(null);
setEditForm(emptyForm());
load();
} catch (err) {
setEditError(
err instanceof Error ? err.message : "Failed to update project.",
);
} finally {
setSavingEdit(false);
}
};
const breachedProjects = projects.filter(
(p) => p.timeExceeded || p.costExceeded,
);
@@ -112,7 +186,6 @@ export function ProjectsView() {
</button>
</div>
{/* Budget warnings */}
{breachedProjects.map((p) => (
<BudgetBanner
key={p.id}
@@ -122,7 +195,6 @@ export function ProjectsView() {
/>
))}
{/* Create form */}
{showForm && (
<form
onSubmit={handleCreate}
@@ -228,7 +300,79 @@ export function ProjectsView() {
</form>
)}
{/* Project list */}
{editingProjectId !== null && (
<div className="bg-white border rounded-lg p-4 flex flex-col gap-3">
<h3 className="font-semibold text-gray-800">Edit Project</h3>
{editError && <p className="text-red-600 text-sm">{editError}</p>}
<div className="grid grid-cols-2 gap-3">
<input
type="text"
value={editForm.name}
onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
placeholder="Name"
className="border rounded px-2 py-1.5 text-sm col-span-2"
/>
<input
type="number"
value={editForm.timeBudgetHours}
onChange={(e) =>
setEditForm({ ...editForm, timeBudgetHours: e.target.value })
}
placeholder="Time budget hours"
min="0.1"
step="0.5"
className="border rounded px-2 py-1.5 text-sm"
/>
<input
type="number"
value={editForm.costBudget}
onChange={(e) =>
setEditForm({ ...editForm, costBudget: e.target.value })
}
placeholder="Cost budget EUR"
min="0"
step="100"
className="border rounded px-2 py-1.5 text-sm"
/>
<input
type="number"
value={editForm.hourlyRate}
onChange={(e) =>
setEditForm({ ...editForm, hourlyRate: e.target.value })
}
placeholder="Hourly rate EUR (blank = per user)"
min="0"
step="5"
className="border rounded px-2 py-1.5 text-sm col-span-2"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setEditingProjectId(null);
setEditError(null);
}}
className="px-4 py-1.5 text-sm rounded border"
>
Cancel
</button>
<button
type="button"
disabled={savingEdit}
onClick={handleSaveEdit}
className="px-4 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
>
{savingEdit ? "Saving…" : "Save changes"}
</button>
</div>
</div>
)}
{projects.length > 0 ? (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
@@ -240,6 +384,7 @@ export function ProjectsView() {
<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>
<th className="text-right px-4 py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -292,6 +437,15 @@ export function ProjectsView() {
<ProgressBar pct={costPct} exceeded={p.costExceeded} />
</div>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => startEdit(p)}
className="text-indigo-600 hover:text-indigo-800"
>
Edit
</button>
</td>
</tr>
);
})}
+94 -1
View File
@@ -1,8 +1,58 @@
import { useEffect, useState } from "react";
import { NavLink } from "react-router-dom";
import { centsToEur } from "../lib/format";
import { useUser } from "../state/UserContext";
export function TopBar() {
const { users, selectedUserId, setSelectedUserId } = useUser();
const {
users,
selectedUserId,
setSelectedUserId,
selectedUser,
updateSelectedUser,
} = useUser();
const [editingUser, setEditingUser] = useState(false);
const [name, setName] = useState("");
const [rateEur, setRateEur] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!selectedUser) return;
setName(selectedUser.name);
setRateEur(centsToEur(selectedUser.defaultHourlyRateCents));
setError(null);
}, [selectedUser]);
const handleSave = async () => {
if (!selectedUser) return;
setError(null);
const nextName = name.trim();
if (!nextName) {
setError("Name is required.");
return;
}
const rate = Math.round(Number.parseFloat(rateEur) * 100);
if (Number.isNaN(rate) || rate < 0) {
setError("Default hourly rate must be a non-negative EUR value.");
return;
}
setSaving(true);
try {
await updateSelectedUser({
name: nextName,
defaultHourlyRateCents: rate,
});
setEditingUser(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update user.");
} finally {
setSaving(false);
}
};
return (
<header className="bg-indigo-700 text-white shadow">
@@ -58,8 +108,51 @@ export function TopBar() {
</option>
))}
</select>
<button
type="button"
onClick={() => setEditingUser((prev) => !prev)}
className="bg-indigo-800 rounded px-2 py-1 text-xs hover:bg-indigo-900"
>
{editingUser ? "Close" : "Edit user"}
</button>
</div>
</div>
{editingUser && selectedUser && (
<div className="mx-auto max-w-5xl px-4 pb-3">
<div className="bg-indigo-800 rounded p-3 grid grid-cols-1 md:grid-cols-[1fr_160px_auto] gap-2 items-end">
<label className="text-xs">
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full mt-1 rounded px-2 py-1 text-gray-900"
/>
</label>
<label className="text-xs">
Default rate (EUR/h)
<input
type="number"
min="0"
step="1"
value={rateEur}
onChange={(e) => setRateEur(e.target.value)}
className="w-full mt-1 rounded px-2 py-1 text-gray-900"
/>
</label>
<button
type="button"
disabled={saving}
onClick={handleSave}
className="h-8 px-3 rounded bg-white text-indigo-800 text-sm font-medium disabled:opacity-60"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
{error && <p className="text-xs text-red-200 mt-1">{error}</p>}
</div>
)}
</header>
);
}
+13
View File
@@ -104,6 +104,10 @@ async function sendJson<T>(
export const api = {
users: {
list: () => fetchJson(z.array(UserSchema), "/api/users"),
update: (
id: number,
body: { name: string; defaultHourlyRateCents: number },
) => sendJson(UserSchema, "PUT", `/api/users/${id}`, body),
},
projects: {
@@ -114,6 +118,15 @@ export const api = {
costBudgetCents: number;
hourlyRateCents: number | null;
}) => sendJson(ProjectSchema, "POST", "/api/projects", body),
update: (
id: number,
body: {
name: string;
timeBudgetMinutes: number;
costBudgetCents: number;
hourlyRateCents: number | null;
},
) => sendJson(ProjectSchema, "PUT", `/api/projects/${id}`, body),
},
timeEntries: {
+32 -3
View File
@@ -1,6 +1,7 @@
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
@@ -12,6 +13,11 @@ interface UserContextValue {
selectedUserId: number | null;
setSelectedUserId: (id: number) => void;
selectedUser: User | null;
reloadUsers: () => Promise<void>;
updateSelectedUser: (body: {
name: string;
defaultHourlyRateCents: number;
}) => Promise<void>;
}
const UserContext = createContext<UserContextValue | null>(null);
@@ -27,10 +33,15 @@ export function UserProvider({ children }: { children: ReactNode }) {
},
);
useEffect(() => {
api.users.list().then(setUsers);
const reloadUsers = useCallback(async () => {
const nextUsers = await api.users.list();
setUsers(nextUsers);
}, []);
useEffect(() => {
reloadUsers();
}, [reloadUsers]);
// set default user once users are loaded and none is selected
useEffect(() => {
const first = users[0];
@@ -44,11 +55,29 @@ export function UserProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, String(id));
};
const updateSelectedUser = useCallback(
async (body: { name: string; defaultHourlyRateCents: number }) => {
if (selectedUserId === null) {
throw new Error("No selected user.");
}
const updated = await api.users.update(selectedUserId, body);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
},
[selectedUserId],
);
const selectedUser = users.find((u) => u.id === selectedUserId) ?? null;
return (
<UserContext.Provider
value={{ users, selectedUserId, setSelectedUserId, selectedUser }}
value={{
users,
selectedUserId,
setSelectedUserId,
selectedUser,
reloadUsers,
updateSelectedUser,
}}
>
{children}
</UserContext.Provider>
+61
View File
@@ -0,0 +1,61 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import App from "../src/App";
const { usersList, usersUpdate } = vi.hoisted(() => ({
usersList: vi
.fn()
.mockResolvedValue([
{ id: 1, name: "Alice", defaultHourlyRateCents: 9500 },
]),
usersUpdate: vi.fn().mockResolvedValue({
id: 1,
name: "Alice Updated",
defaultHourlyRateCents: 12000,
}),
}));
vi.mock("../src/lib/api", () => ({
api: {
users: { list: usersList, update: usersUpdate },
projects: { list: vi.fn().mockResolvedValue([]) },
reports: {
weekly: vi.fn().mockResolvedValue({
days: [],
weekTotalMinutes: 0,
weekTotalCostCents: 0,
}),
},
timeEntries: {
list: vi.fn().mockResolvedValue([]),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
describe("TopBar", () => {
it("edits selected user default rate", async () => {
render(
<MemoryRouter initialEntries={["/weekly"]}>
<App />
</MemoryRouter>,
);
await waitFor(() => expect(usersList).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Edit user" }));
const rateInput = screen.getByLabelText("Default rate (EUR/h)");
fireEvent.change(rateInput, { target: { value: "120" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(usersUpdate).toHaveBeenCalledWith(1, {
name: "Alice",
defaultHourlyRateCents: 12000,
}),
);
});
});