From 23d652688704aaef8eb7dc5adefd44b141ca7918 Mon Sep 17 00:00:00 2001 From: Ariel Sousa Date: Fri, 13 Nov 2026 13:34:31 -0300 Subject: [PATCH] feat: listagem/exclusao Web (#3) --- README.md | 19 ++- .../records/PackageController.java | 20 ++- .../records/PackageDataAccess.java | 16 +- .../com/findmypacks/web/RequestIdFilter.java | 6 + .../PackageListingApiIntegrationTest.java | 112 ++++++++++++++ .../records/PackageListingDataAccessTest.java | 72 +++++++++ frontend/src/App.tsx | 13 +- frontend/src/PackageList.test.tsx | 105 +++++++++++++ frontend/src/PackageList.tsx | 145 ++++++++++++++++++ frontend/src/styles.css | 24 +++ 10 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 backend/src/test/java/com/findmypacks/records/PackageListingApiIntegrationTest.java create mode 100644 backend/src/test/java/com/findmypacks/records/PackageListingDataAccessTest.java create mode 100644 frontend/src/PackageList.test.tsx create mode 100644 frontend/src/PackageList.tsx diff --git a/README.md b/README.md index 9642a00..361d54b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,23 @@ To inspect the stored data: docker compose exec db psql -U findmypacks -d findmypacks -c "SELECT * FROM packages;" ``` +## Package listing and deletion (issue #3) + +After signing in, the home page loads packages from the database and displays them in a table. The table shows the tracking code, customer, destination, and posting date. It displays five packages per page and includes previous/next controls and the total number of records. Use the column headings for tracking code, customer, destination, or posting date to sort the results; clicking the active heading again reverses the sort direction. + +Select individual rows with their checkboxes, or select all rows on the current page with the checkbox in the table heading. Click **Excluir selecionados** and confirm to delete the selected packages. The list reloads after deletion. Click **Editar** on a row to load that package in the registration form above. + +The list endpoint supports Spring pagination and sorting parameters. The Web page requests five rows at a time: + +| Operation | Method and path | Success | +|---|---|---| +| List packages | `GET /api/web/records?page=0&size=5&sort=postedAt,desc` | `200` with a paginated JSON response | +| Delete a package | `DELETE /api/web/records/{id}` | `204` with no response body | + +Both endpoints require an authenticated session. The delete request also sends the CSRF token in the `X-XSRF-TOKEN` header. A list request returns HTTP 401 without a valid session; deleting an unknown ID returns HTTP 404. List and delete requests are recorded in the API logs, and their database reads/writes are logged with the same request ID. + +The frontend behavior is covered by `PackageList.test.tsx`. Backend coverage is in `PackageListingApiIntegrationTest` and `PackageListingDataAccessTest`. + ### Endpoints Both endpoints require an authenticated session and a CSRF token (`GET /api/web/csrf`, sent in the `X-XSRF-TOKEN` header). Requests without a valid session receive HTTP 401. The JSON body uses the camelCase field names from the table above. @@ -238,4 +255,4 @@ Use corresponding distinct directory and branch names for the other contributors ## Deployment -No deployment workflow is configured yet because a deployment target has not been selected. The pull-request CI runs through Docker Compose and verifies both tests and the complete application image. \ No newline at end of file +No deployment workflow is configured yet because a deployment target has not been selected. The pull-request CI runs through Docker Compose and verifies both tests and the complete application image. diff --git a/backend/src/main/java/com/findmypacks/records/PackageController.java b/backend/src/main/java/com/findmypacks/records/PackageController.java index 8979bf4..564bf08 100644 --- a/backend/src/main/java/com/findmypacks/records/PackageController.java +++ b/backend/src/main/java/com/findmypacks/records/PackageController.java @@ -6,6 +6,10 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -20,6 +24,20 @@ public PackageController(PackageDataAccess packages) { this.packages = packages; } + @GetMapping + public Page list(Pageable pageable) { + return packages.findAll(pageable).map(PackageResponse::from); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (packages.findById(id).isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Package not found"); + } + packages.deleteById(id); + return ResponseEntity.noContent().build(); + } + @PostMapping public ResponseEntity create(@Valid @RequestBody PackageRequest request) { if (packages.existsByTrackingCode(request.trackingCode())) { @@ -42,4 +60,4 @@ public PackageResponse update(@PathVariable Long id, @Valid @RequestBody Package request.carrier(), request.status(), request.postedAt()); return PackageResponse.from(packages.update(record)); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/findmypacks/records/PackageDataAccess.java b/backend/src/main/java/com/findmypacks/records/PackageDataAccess.java index 2597651..60ac774 100644 --- a/backend/src/main/java/com/findmypacks/records/PackageDataAccess.java +++ b/backend/src/main/java/com/findmypacks/records/PackageDataAccess.java @@ -8,6 +8,8 @@ import java.util.Optional; import java.util.stream.Collectors; import java.util.function.Supplier; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -35,6 +37,18 @@ public Optional findById(Long id) { () -> packages.findById(id)); } + public Page findAll(Pageable pageable) { + return run("list", "Package records listed", "Package records list failed", + () -> packages.findAll(pageable)); + } + + public void deleteById(Long id) { + run("delete", "Package record deleted", "Package record delete failed", () -> { + packages.deleteById(id); + return null; + }); + } + public boolean existsByTrackingCode(String trackingCode) { return run("check_tracking_code", "Tracking code checked", "Tracking code check failed", () -> packages.existsByTrackingCode(trackingCode)); @@ -70,4 +84,4 @@ private T run(String operation, String successMessage, String errorMessage, throw exception; } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/findmypacks/web/RequestIdFilter.java b/backend/src/main/java/com/findmypacks/web/RequestIdFilter.java index 3520149..936533e 100644 --- a/backend/src/main/java/com/findmypacks/web/RequestIdFilter.java +++ b/backend/src/main/java/com/findmypacks/web/RequestIdFilter.java @@ -63,6 +63,12 @@ private String operationFor(HttpServletRequest request) { if (uri.startsWith("/api/web/records/") && "PUT".equals(method)) { return "update"; } + if ("/api/web/records".equals(uri) && "GET".equals(method)) { + return "list"; + } + if (uri.startsWith("/api/web/records/") && "DELETE".equals(method)) { + return "delete"; + } return switch (request.getRequestURI()) { case "/api/web/health" -> "health"; diff --git a/backend/src/test/java/com/findmypacks/records/PackageListingApiIntegrationTest.java b/backend/src/test/java/com/findmypacks/records/PackageListingApiIntegrationTest.java new file mode 100644 index 0000000..1550572 --- /dev/null +++ b/backend/src/test/java/com/findmypacks/records/PackageListingApiIntegrationTest.java @@ -0,0 +1,112 @@ +package com.findmypacks.records; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class PackageListingApiIntegrationTest { + private static final String URL = "/api/web/records"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PackageRecordRepository packages; + + @BeforeEach + void cleanTable() { + packages.deleteAll(); + } + + private static RequestPostProcessor admin() { + return user("findmypacksadmin").roles("ADMIN"); + } + + private PackageRecord savePackage(String trackingCode, String customer) { + return packages.save(new PackageRecord( + trackingCode, customer, "Fortaleza", "CORREIOS", "POSTED", LocalDate.now().minusDays(1))); + } + + @Test + void listsPackagesFromDatabaseWithFiveItemsPerPage() throws Exception { + for (int index = 1; index <= 7; index++) { + savePackage("CODE%05d".formatted(index), "Cliente %d".formatted(index)); + } + + mockMvc.perform(get(URL).param("page", "0").param("size", "5").with(admin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(5)) + .andExpect(jsonPath("$.totalElements").value(7)) + .andExpect(jsonPath("$.totalPages").value(2)) + .andExpect(jsonPath("$.content[0].trackingCode").exists()) + .andExpect(jsonPath("$.content[0].customer").exists()) + .andExpect(jsonPath("$.content[0].destination").exists()) + .andExpect(jsonPath("$.content[0].postedAt").exists()); + + mockMvc.perform(get(URL).param("page", "1").param("size", "5").with(admin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.number").value(1)); + } + + @Test + void listsPackagesInRequestedSortOrder() throws Exception { + savePackage("CODE00001", "Zelda"); + savePackage("CODE00002", "Ana"); + + mockMvc.perform(get(URL).param("sort", "customer,asc").with(admin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].customer").value("Ana")) + .andExpect(jsonPath("$.content[1].customer").value("Zelda")); + } + + @Test + void listingWithoutAuthenticationReturns401() throws Exception { + mockMvc.perform(get(URL)).andExpect(status().isUnauthorized()); + } + + @Test + void deletesSelectedPackageFromDatabase() throws Exception { + PackageRecord record = savePackage("CODE00001", "Ana"); + + mockMvc.perform(delete(URL + "/" + record.getId()).with(admin()).with(csrf())) + .andExpect(status().isNoContent()); + + assertThat(packages.findById(record.getId())).isEmpty(); + } + + @Test + void deletingUnknownPackageReturns404() throws Exception { + mockMvc.perform(delete(URL + "/999999").with(admin()).with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Package not found")); + } + + @Test + void deletingWithoutAuthenticationReturns401AndKeepsPackage() throws Exception { + PackageRecord record = savePackage("CODE00001", "Ana"); + + mockMvc.perform(delete(URL + "/" + record.getId()).with(csrf())) + .andExpect(status().isUnauthorized()); + + assertThat(packages.findById(record.getId())).isPresent(); + } +} diff --git a/backend/src/test/java/com/findmypacks/records/PackageListingDataAccessTest.java b/backend/src/test/java/com/findmypacks/records/PackageListingDataAccessTest.java new file mode 100644 index 0000000..0896887 --- /dev/null +++ b/backend/src/test/java/com/findmypacks/records/PackageListingDataAccessTest.java @@ -0,0 +1,72 @@ +package com.findmypacks.records; + +import com.findmypacks.logging.StructuredOperationLogger; +import com.findmypacks.logging.StructuredOperationLogger.Level; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PackageListingDataAccessTest { + private final PackageRecordRepository repository = mock(PackageRecordRepository.class); + private final StructuredOperationLogger operationLogger = mock(StructuredOperationLogger.class); + private final PackageDataAccess dataAccess = new PackageDataAccess(repository, operationLogger); + private final PackageRecord record = mock(PackageRecord.class); + + @BeforeEach + void setLoggingContext() { + MDC.put("request_id", "package-listing-data-access-test"); + MDC.put("client", "web"); + } + + @AfterEach + void clearLoggingContext() { + MDC.clear(); + } + + @Test + void listsPackagesAndLogsSuccessfulDatabaseRead() { + var pageable = PageRequest.of(0, 5); + var page = new PageImpl<>(List.of(record), pageable, 1); + when(repository.findAll(pageable)).thenReturn(page); + + assertThat(dataAccess.findAll(pageable)).isSameAs(page); + verify(operationLogger).log(Level.INFO, "db", "list", "success", "Package records listed"); + } + + @Test + void logsListingFailureAndRethrowsIt() { + var pageable = PageRequest.of(0, 5); + IllegalStateException failure = new IllegalStateException("database unavailable"); + when(repository.findAll(pageable)).thenThrow(failure); + + assertThatThrownBy(() -> dataAccess.findAll(pageable)).isSameAs(failure); + verify(operationLogger).log(Level.ERROR, "db", "list", "error", "Package records list failed"); + } + + @Test + void deletesPackageAndLogsSuccessfulDatabaseWrite() { + dataAccess.deleteById(7L); + + verify(repository).deleteById(7L); + verify(operationLogger).log(Level.INFO, "db", "delete", "success", "Package record deleted"); + } + + @Test + void logsDeleteFailureAndRethrowsIt() { + IllegalStateException failure = new IllegalStateException("database unavailable"); + org.mockito.Mockito.doThrow(failure).when(repository).deleteById(7L); + + assertThatThrownBy(() -> dataAccess.deleteById(7L)).isSameAs(failure); + verify(operationLogger).log(Level.ERROR, "db", "delete", "error", "Package record delete failed"); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0f9ee9..2b0d9d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import type { FormEvent } from "react"; import Dashboard from "./Dashboard"; import PackageForm from "./PackageForm"; +import PackageList from "./PackageList"; +import type { PackageRecord } from "./PackageForm"; type LoginResult = { username: string; @@ -19,6 +21,8 @@ export default function App() { const [signedInAs, setSignedInAs] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [dashboardRefreshKey, setDashboardRefreshKey] = useState(0); + const [editingRecord, setEditingRecord] = useState(null); + const [refreshPackages, setRefreshPackages] = useState(0); const [isCheckingSession, setIsCheckingSession] = useState(true); useEffect(() => { @@ -118,7 +122,14 @@ export default function App() { Bem-vindo, {signedInAs}.

- setDashboardRefreshKey((current) => current + 1)} /> + { + setDashboardRefreshKey((current) => current + 1); + setRefreshPackages((current) => current + 1); + }} + /> + ) : isCheckingSession ? (
diff --git a/frontend/src/PackageList.test.tsx b/frontend/src/PackageList.test.tsx new file mode 100644 index 0000000..d5b7694 --- /dev/null +++ b/frontend/src/PackageList.test.tsx @@ -0,0 +1,105 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import PackageList from "./PackageList"; +import type { PackageRecord } from "./PackageForm"; + +const records: PackageRecord[] = Array.from({ length: 6 }, (_, index) => ({ + id: index + 1, + trackingCode: `BR1234567${index}`, + customer: `Cliente ${index + 1}`, + destination: "Fortaleza", + carrier: "CORREIOS", + status: "POSTED", + postedAt: `2026-01-0${index + 1}`, +})); + +function pageResponse(content: PackageRecord[], number = 0, totalElements = content.length) { + return new Response(JSON.stringify({ + content, + number, + size: 5, + totalElements, + totalPages: Math.ceil(totalElements / 5), + }), { status: 200 }); +} + +describe("PackageList", () => { + afterEach(() => cleanup()); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("loads five packages on the first page and displays the required fields", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(pageResponse(records.slice(0, 5), 0, 6)); + render(); + + expect(await screen.findByText("BR12345670")).toBeInTheDocument(); + expect(screen.getByText("Cliente 1")).toBeInTheDocument(); + expect(screen.getAllByText("Fortaleza")).toHaveLength(5); + expect(screen.getByText("2026-01-01")).toBeInTheDocument(); + expect(screen.getAllByRole("row")).toHaveLength(6); + expect(fetchMock).toHaveBeenCalledWith("/api/web/records?page=0&size=5&sort=postedAt,desc"); + expect(screen.getByText("Página 1 de 2")).toBeInTheDocument(); + }); + + it("changes sort order and loads the next page", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.includes("page=1")) return pageResponse([records[5]], 1, 6); + return pageResponse(records.slice(0, 5), 0, 6); + }); + render(); + await screen.findByText("BR12345670"); + + fireEvent.click(screen.getByRole("button", { name: "Cliente" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/web/records?page=0&size=5&sort=customer,asc")); + + fireEvent.click(screen.getByRole("button", { name: "Próxima" })); + expect(await screen.findByText("BR12345675")).toBeInTheDocument(); + expect(screen.getByText("Página 2 de 2")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenLastCalledWith("/api/web/records?page=1&size=5&sort=customer,asc"); + }); + + it("passes a chosen package to the edit action", async () => { + const onEdit = vi.fn(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(pageResponse(records.slice(0, 5), 0, 6)); + render(); + await screen.findByText("BR12345670"); + + fireEvent.click(screen.getAllByRole("button", { name: "Editar" })[0]); + + expect(onEdit).toHaveBeenCalledWith(records[0]); + }); + + it("deletes selected packages with a CSRF token and reloads the list", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(pageResponse(records.slice(0, 5), 0, 6)) + .mockResolvedValueOnce(new Response(JSON.stringify({ token: "csrf-token" }), { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(pageResponse(records.slice(1, 5), 0, 5)); + render(); + await screen.findByText("BR12345670"); + + fireEvent.click(screen.getByRole("checkbox", { name: "Selecionar pacote BR12345670" })); + fireEvent.click(screen.getByRole("button", { name: "Excluir selecionados (1)" })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + expect(window.confirm).toHaveBeenCalledWith("Excluir 1 pacote(s) selecionado(s)?"); + expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/web/csrf"); + expect(fetchMock).toHaveBeenNthCalledWith(3, "/api/web/records/1", { + method: "DELETE", + headers: { "X-XSRF-TOKEN": "csrf-token" }, + }); + expect(await screen.findByRole("status")).toHaveTextContent("Pacote(s) excluído(s)."); + expect(screen.queryByText("BR12345670")).not.toBeInTheDocument(); + }); + + it("shows an authentication message when listing requires a new login", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 401 })); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Sua sessão expirou. Entre novamente."); + }); +}); diff --git a/frontend/src/PackageList.tsx b/frontend/src/PackageList.tsx new file mode 100644 index 0000000..ac34c2f --- /dev/null +++ b/frontend/src/PackageList.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from "react"; +import type { PackageRecord } from "./PackageForm"; + +type PageResult = { + content: PackageRecord[]; + number: number; + totalPages: number; + totalElements: number; +}; + +const PAGE_SIZE = 5; +const SORT_FIELDS = [ + ["trackingCode", "Código"], + ["customer", "Cliente"], + ["destination", "Destino"], + ["postedAt", "Data de postagem"], +] as const; + +type PackageListProps = { + onEdit: (record: PackageRecord) => void; + refreshKey: number; +}; + +export default function PackageList({ onEdit, refreshKey }: PackageListProps) { + const [page, setPage] = useState(0); + const [sort, setSort] = useState("postedAt,desc"); + const [result, setResult] = useState(null); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + const loadPackages = useCallback(async () => { + setLoading(true); + setError(""); + try { + const response = await fetch(`/api/web/records?page=${page}&size=${PAGE_SIZE}&sort=${sort}`); + if (response.status === 401) throw new Error("Sua sessão expirou. Entre novamente."); + if (!response.ok) throw new Error("Não foi possível carregar os pacotes."); + const data = (await response.json()) as PageResult; + setResult(data); + setSelected([]); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Não foi possível carregar os pacotes."); + } finally { + setLoading(false); + } + }, [page, sort]); + + useEffect(() => { void loadPackages(); }, [loadPackages, refreshKey]); + + function changeSort(field: string) { + const [currentField, direction] = sort.split(","); + setSort(`${field},${currentField === field && direction === "asc" ? "desc" : "asc"}`); + setPage(0); + } + + function toggleSelected(id: number) { + setSelected((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]); + } + + function togglePageSelection() { + const ids = result?.content.map((item) => item.id) ?? []; + const allSelected = ids.length > 0 && ids.every((id) => selected.includes(id)); + setSelected((current) => allSelected ? current.filter((id) => !ids.includes(id)) : [...new Set([...current, ...ids])]); + } + + async function deleteSelected() { + if (selected.length === 0 || !window.confirm(`Excluir ${selected.length} pacote(s) selecionado(s)?`)) return; + setDeleting(true); + setError(""); + setNotice(""); + try { + const csrfResponse = await fetch("/api/web/csrf"); + if (!csrfResponse.ok) throw new Error("Não foi possível iniciar a sessão segura."); + const { token } = (await csrfResponse.json()) as { token: string }; + for (const id of selected) { + const response = await fetch(`/api/web/records/${id}`, { + method: "DELETE", + headers: { "X-XSRF-TOKEN": token }, + }); + if (!response.ok) { + if (response.status === 401) throw new Error("Sua sessão expirou. Entre novamente."); + throw new Error("Não foi possível excluir todos os pacotes selecionados."); + } + } + setNotice("Pacote(s) excluído(s). "); + if (result && result.content.length === selected.length && page > 0) setPage((current) => current - 1); + else await loadPackages(); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "Não foi possível excluir os pacotes."); + await loadPackages(); + } finally { + setDeleting(false); + } + } + + const pageIds = result?.content.map((item) => item.id) ?? []; + const allSelected = pageIds.length > 0 && pageIds.every((id) => selected.includes(id)); + + return ( +
+
+
+

Pacotes cadastrados

+

Selecione registros para excluir ou abra um pacote para editar.

+
+ +
+ {error &&

{error}

} + {notice &&

{notice}

} +
+ + + + {SORT_FIELDS.map(([field, label]) => )} + + + + {loading ? : result?.content.length ? result.content.map((record) => ( + + + + + + )) : } + +
+ + Ações
Carregando pacotes...
toggleSelected(record.id)} type="checkbox" />{record.trackingCode}{record.customer}{record.destination}{record.postedAt}
Nenhum pacote cadastrado.
+
+
+ {result?.totalElements ?? 0} pacote(s) +
+ + Página {result ? result.number + 1 : 1} de {Math.max(result?.totalPages ?? 0, 1)} + +
+
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 92725b2..eee3029 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -531,6 +531,30 @@ select[aria-invalid="true"] { margin: 0; } +.package-list { margin-top: 24px; } +.list-heading, .pagination, .pagination > div { align-items: center; display: flex; justify-content: space-between; gap: 12px; } +.list-heading .form-description { margin-bottom: 0; } +.danger-button { background: #bd3344; margin-top: 0; white-space: nowrap; } +.danger-button:hover { background: #a92737; } +.danger-button:disabled { cursor: not-allowed; opacity: .5; } +.table-scroll { overflow-x: auto; } +table { border-collapse: collapse; margin-top: 24px; min-width: 680px; text-align: left; width: 100%; } +th, td { border-bottom: 1px solid #e8eaf1; color: #414a5d; padding: 13px 10px; } +th { color: #737b8f; font-size: 12px; } +td { font-size: 13px; } +th input, td input { margin: 0; } +.sort-button, .table-action { background: transparent; color: #414a5d; display: inline; margin: 0; padding: 0; } +.sort-button:hover, .table-action:hover { background: transparent; color: #5146d3; } +.table-action { color: #6459e8; font-weight: 700; } +.pagination { color: #737b8f; font-size: 12px; margin-top: 18px; } +.pagination > div { gap: 14px; } +.pagination .secondary-button { margin: 0; padding: 9px 12px; } +.pagination .secondary-button:disabled { cursor: not-allowed; opacity: .5; } +@media (max-width: 760px) { + .list-heading, .pagination { align-items: flex-start; flex-direction: column; } + .pagination > div { flex-wrap: wrap; } +} + .welcome-note { color: #687186; font-size: 14px;