Add user and project rate editing across backend and frontend
This commit is contained in:
@@ -2,6 +2,7 @@ package com.example.app.project;
|
|||||||
|
|
||||||
import com.example.app.project.ProjectDtos.CreateProjectRequest;
|
import com.example.app.project.ProjectDtos.CreateProjectRequest;
|
||||||
import com.example.app.project.ProjectDtos.ProjectResponse;
|
import com.example.app.project.ProjectDtos.ProjectResponse;
|
||||||
|
import com.example.app.project.ProjectDtos.UpdateProjectRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -27,4 +28,10 @@ public class ProjectController {
|
|||||||
public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) {
|
public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) {
|
||||||
return projectService.create(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 int costBudgetCents,
|
||||||
@PositiveOrZero Integer hourlyRateCents) {}
|
@PositiveOrZero Integer hourlyRateCents) {}
|
||||||
|
|
||||||
|
public record UpdateProjectRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
@Min(1) int timeBudgetMinutes,
|
||||||
|
@PositiveOrZero int costBudgetCents,
|
||||||
|
@PositiveOrZero Integer hourlyRateCents) {}
|
||||||
|
|
||||||
public record ProjectResponse(
|
public record ProjectResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String name,
|
String name,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.example.app.project;
|
|||||||
|
|
||||||
import com.example.app.project.ProjectDtos.CreateProjectRequest;
|
import com.example.app.project.ProjectDtos.CreateProjectRequest;
|
||||||
import com.example.app.project.ProjectDtos.ProjectResponse;
|
import com.example.app.project.ProjectDtos.ProjectResponse;
|
||||||
|
import com.example.app.project.ProjectDtos.UpdateProjectRequest;
|
||||||
import com.example.app.time.TimeEntryRepository;
|
import com.example.app.time.TimeEntryRepository;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -53,4 +55,29 @@ public class ProjectService {
|
|||||||
Project saved = projectRepository.save(p);
|
Project saved = projectRepository.save(p);
|
||||||
return ProjectResponse.from(saved, 0, 0);
|
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;
|
package com.example.app.user;
|
||||||
|
|
||||||
|
import com.example.app.user.UserDtos.UpdateUserRequest;
|
||||||
import com.example.app.user.UserDtos.UserResponse;
|
import com.example.app.user.UserDtos.UserResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -20,4 +25,9 @@ public class UserController {
|
|||||||
public List<UserResponse> list() {
|
public List<UserResponse> list() {
|
||||||
return userService.findAll();
|
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;
|
package com.example.app.user;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.PositiveOrZero;
|
||||||
|
|
||||||
public class UserDtos {
|
public class UserDtos {
|
||||||
|
|
||||||
|
public record UpdateUserRequest(
|
||||||
|
@NotBlank String name, @PositiveOrZero int defaultHourlyRateCents) {}
|
||||||
|
|
||||||
public record UserResponse(Long id, String name, int defaultHourlyRateCents) {
|
public record UserResponse(Long id, String name, int defaultHourlyRateCents) {
|
||||||
|
|
||||||
public static UserResponse from(User user) {
|
public static UserResponse from(User user) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.example.app.user;
|
package com.example.app.user;
|
||||||
|
|
||||||
|
import com.example.app.user.UserDtos.UpdateUserRequest;
|
||||||
import com.example.app.user.UserDtos.UserResponse;
|
import com.example.app.user.UserDtos.UserResponse;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -18,4 +20,16 @@ public class UserService {
|
|||||||
public List<UserResponse> findAll() {
|
public List<UserResponse> findAll() {
|
||||||
return userRepository.findAll().stream().map(UserResponse::from).toList();
|
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));
|
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) {
|
private TimeEntry entry(User user, Project project, LocalDate date, int minutes) {
|
||||||
TimeEntry e = new TimeEntry();
|
TimeEntry e = new TimeEntry();
|
||||||
e.setUser(user);
|
e.setUser(user);
|
||||||
|
|||||||
@@ -15,27 +15,96 @@ function ProgressBar({ pct, exceeded }: { pct: number; exceeded: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateFormState {
|
interface ProjectFormState {
|
||||||
name: string;
|
name: string;
|
||||||
timeBudgetHours: string;
|
timeBudgetHours: string;
|
||||||
costBudget: string;
|
costBudget: string;
|
||||||
hourlyRate: string;
|
hourlyRate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm = (): CreateFormState => ({
|
const emptyForm = (): ProjectFormState => ({
|
||||||
name: "",
|
name: "",
|
||||||
timeBudgetHours: "",
|
timeBudgetHours: "",
|
||||||
costBudget: "",
|
costBudget: "",
|
||||||
hourlyRate: "",
|
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() {
|
export function ProjectsView() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [showForm, setShowForm] = useState(false);
|
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 [formError, setFormError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
const load = useCallback(() => {
|
||||||
api.projects.list().then(setProjects);
|
api.projects.list().then(setProjects);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -48,41 +117,15 @@ export function ProjectsView() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
const hours = Number.parseFloat(form.timeBudgetHours);
|
const parsed = parseForm(form);
|
||||||
const cost = Number.parseFloat(form.costBudget);
|
if (!parsed.ok) {
|
||||||
const rateStr = form.hourlyRate.trim();
|
setFormError(parsed.error);
|
||||||
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
setFormError("Name is required.");
|
|
||||||
return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.projects.create({
|
await api.projects.create(parsed.body);
|
||||||
name: form.name.trim(),
|
|
||||||
timeBudgetMinutes: Math.round(hours * 60),
|
|
||||||
costBudgetCents: Math.round(cost * 100),
|
|
||||||
hourlyRateCents,
|
|
||||||
});
|
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
load();
|
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(
|
const breachedProjects = projects.filter(
|
||||||
(p) => p.timeExceeded || p.costExceeded,
|
(p) => p.timeExceeded || p.costExceeded,
|
||||||
);
|
);
|
||||||
@@ -112,7 +186,6 @@ export function ProjectsView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Budget warnings */}
|
|
||||||
{breachedProjects.map((p) => (
|
{breachedProjects.map((p) => (
|
||||||
<BudgetBanner
|
<BudgetBanner
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -122,7 +195,6 @@ export function ProjectsView() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Create form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
@@ -228,7 +300,79 @@ export function ProjectsView() {
|
|||||||
</form>
|
</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 ? (
|
{projects.length > 0 ? (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<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="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">Time</th>
|
||||||
<th className="px-4 py-2 w-32">Cost</th>
|
<th className="px-4 py-2 w-32">Cost</th>
|
||||||
|
<th className="text-right px-4 py-2">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
@@ -292,6 +437,15 @@ export function ProjectsView() {
|
|||||||
<ProgressBar pct={costPct} exceeded={p.costExceeded} />
|
<ProgressBar pct={costPct} exceeded={p.costExceeded} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,8 +1,58 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { centsToEur } from "../lib/format";
|
||||||
import { useUser } from "../state/UserContext";
|
import { useUser } from "../state/UserContext";
|
||||||
|
|
||||||
export function TopBar() {
|
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 (
|
return (
|
||||||
<header className="bg-indigo-700 text-white shadow">
|
<header className="bg-indigo-700 text-white shadow">
|
||||||
@@ -58,8 +108,51 @@ export function TopBar() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ async function sendJson<T>(
|
|||||||
export const api = {
|
export const api = {
|
||||||
users: {
|
users: {
|
||||||
list: () => fetchJson(z.array(UserSchema), "/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: {
|
projects: {
|
||||||
@@ -114,6 +118,15 @@ export const api = {
|
|||||||
costBudgetCents: number;
|
costBudgetCents: number;
|
||||||
hourlyRateCents: number | null;
|
hourlyRateCents: number | null;
|
||||||
}) => sendJson(ProjectSchema, "POST", "/api/projects", body),
|
}) => 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: {
|
timeEntries: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
@@ -12,6 +13,11 @@ interface UserContextValue {
|
|||||||
selectedUserId: number | null;
|
selectedUserId: number | null;
|
||||||
setSelectedUserId: (id: number) => void;
|
setSelectedUserId: (id: number) => void;
|
||||||
selectedUser: User | null;
|
selectedUser: User | null;
|
||||||
|
reloadUsers: () => Promise<void>;
|
||||||
|
updateSelectedUser: (body: {
|
||||||
|
name: string;
|
||||||
|
defaultHourlyRateCents: number;
|
||||||
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserContext = createContext<UserContextValue | null>(null);
|
const UserContext = createContext<UserContextValue | null>(null);
|
||||||
@@ -27,10 +33,15 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const reloadUsers = useCallback(async () => {
|
||||||
api.users.list().then(setUsers);
|
const nextUsers = await api.users.list();
|
||||||
|
setUsers(nextUsers);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadUsers();
|
||||||
|
}, [reloadUsers]);
|
||||||
|
|
||||||
// set default user once users are loaded and none is selected
|
// set default user once users are loaded and none is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const first = users[0];
|
const first = users[0];
|
||||||
@@ -44,11 +55,29 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem(STORAGE_KEY, String(id));
|
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;
|
const selectedUser = users.find((u) => u.id === selectedUserId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider
|
<UserContext.Provider
|
||||||
value={{ users, selectedUserId, setSelectedUserId, selectedUser }}
|
value={{
|
||||||
|
users,
|
||||||
|
selectedUserId,
|
||||||
|
setSelectedUserId,
|
||||||
|
selectedUser,
|
||||||
|
reloadUsers,
|
||||||
|
updateSelectedUser,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user