diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java index 68f3732c..dc6abd60 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java @@ -9,6 +9,7 @@ public class SilvaOracleConstants { public static final String ORG_UNIT = "orgUnit"; public static final String CATEGORY = "category"; public static final String STATUS_LIST = "statusList"; + public static final String OPENING_IDS = "openingIds"; public static final String MY_OPENINGS = "myOpenings"; public static final String SUBMITTED_TO_FRPA = "submittedToFrpa"; public static final String DISTURBANCE_DATE_START = "disturbanceDateStart"; diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java index 62fc0296..487e59c8 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java @@ -35,6 +35,7 @@ public class OpeningSearchFiltersDto { @Setter private String requestUserId; + private List openingIds; /** Creates an instance of the search opening filter dto. */ public OpeningSearchFiltersDto( @@ -58,6 +59,7 @@ public OpeningSearchFiltersDto( this.orgUnit = Objects.isNull(orgUnit) ? null : orgUnit.toUpperCase().trim(); this.category = Objects.isNull(category) ? null : category.toUpperCase().trim(); this.statusList = new ArrayList<>(); + this.openingIds = new ArrayList<>(); if (!Objects.isNull(statusList)) { this.statusList.addAll(statusList.stream().map(s -> String.format("'%s'", s)).toList()); } @@ -82,6 +84,28 @@ public OpeningSearchFiltersDto( Objects.isNull(mainSearchTerm) ? null : mainSearchTerm.toUpperCase().trim(); } + // Create a constructor with only the List openingIds + public OpeningSearchFiltersDto( + List openingIds) { + this.orgUnit = null; + this.category = null; + this.statusList = new ArrayList<>(); + this.openingIds = openingIds; + this.myOpenings = null; + this.submittedToFrpa = false; + this.disturbanceDateStart = null; + this.disturbanceDateEnd = null; + this.regenDelayDateStart = null; + this.regenDelayDateEnd = null; + this.freeGrowingDateStart = null; + this.freeGrowingDateEnd = null; + this.updateDateStart = null; + this.updateDateEnd = null; + this.cuttingPermitId = null; + this.cutBlockId = null; + this.timberMark = null; + this.mainSearchTerm = null; + } /** * Define if a property has value. * @@ -93,6 +117,7 @@ public boolean hasValue(String prop) { case SilvaOracleConstants.ORG_UNIT -> !Objects.isNull(this.orgUnit); case SilvaOracleConstants.CATEGORY -> !Objects.isNull(this.category); case SilvaOracleConstants.STATUS_LIST -> !this.statusList.isEmpty(); + case SilvaOracleConstants.OPENING_IDS -> !this.openingIds.isEmpty(); case SilvaOracleConstants.MY_OPENINGS -> !Objects.isNull(this.myOpenings); case SilvaOracleConstants.SUBMITTED_TO_FRPA -> !Objects.isNull(this.submittedToFrpa); case SilvaOracleConstants.DISTURBANCE_DATE_START -> diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java index b658b5da..394fff72 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java @@ -43,4 +43,5 @@ public class OpeningSearchResponseDto { private Boolean submittedToFrpa; private String forestFileId; private Long silvaReliefAppId; + private LocalDateTime lastViewDate; } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningSearchRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningSearchRepository.java index 4c79aed8..0a6feb71 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningSearchRepository.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningSearchRepository.java @@ -68,8 +68,7 @@ public PaginatedResult searchOpeningQuery( int startIndex = PaginationUtil.getStartIndex(pagination.page(), pagination.perPage()); int endIndex = PaginationUtil.getEndIndex(startIndex, pagination.perPage(), result.size()); - List resultList = - buildResultListDto(result.subList(startIndex, endIndex)); + List resultList = buildResultListDto(result.subList(startIndex, endIndex)); paginatedResult.setData(resultList); paginatedResult.setPerPage(resultList.size()); @@ -124,8 +123,7 @@ private List buildResultListDto(List result) { } if (row.length > index) { - BigDecimal openingGrossAreaHa = - getValue(BigDecimal.class, row[index++], "openingGrossAreaHa"); + BigDecimal openingGrossAreaHa = getValue(BigDecimal.class, row[index++], "openingGrossAreaHa"); searchOpeningDto.setOpeningGrossAreaHa(openingGrossAreaHa); } @@ -193,8 +191,7 @@ private List buildResultListDto(List result) { } if (row.length > index) { - BigDecimal silvaReliefAppId = - getValue(BigDecimal.class, row[index++], "submittedToFrpa108"); + BigDecimal silvaReliefAppId = getValue(BigDecimal.class, row[index++], "submittedToFrpa108"); boolean submittedApp = silvaReliefAppId.compareTo(BigDecimal.ZERO) > 0; searchOpeningDto.setSubmittedToFrpa(submittedApp); if (submittedApp) { @@ -246,7 +243,7 @@ private Query setQueryParameters(OpeningSearchFiltersDto filtersDto, String nati boolean itsNumeric = filtersDto.getMainSearchTerm().replaceAll("[0-9]", "").isEmpty(); if (itsNumeric) { log.info("Setting mainSearchTerm as numeric filter value"); - // Opening id or File id + // Opening id or File id query.setParameter("openingOrFile", filtersDto.getMainSearchTerm()); } else { log.info("Setting mainSearchTerm as non-numeric filter value"); @@ -269,7 +266,14 @@ private Query setQueryParameters(OpeningSearchFiltersDto filtersDto, String nati if (filtersDto.hasValue(SilvaOracleConstants.STATUS_LIST)) { log.info("Setting statusList filter values"); - // No need to set value since the query already dit it. Didn't work set through named param + // No need to set value since the query already dit it. Didn't work set through + // named param + } + // similarly for openingIds + if (filtersDto.hasValue(SilvaOracleConstants.OPENING_IDS)) { + log.info("Setting openingIds filter values"); + // No need to set value since the query already dit it. Didn't work set through + // named param } // 4. User entry id if (filtersDto.hasValue(SilvaOracleConstants.MY_OPENINGS)) { @@ -390,8 +394,17 @@ private String createNativeSqlQuery(OpeningSearchFiltersDto filtersDto) { builder.append("WHERE 1=1 "); /* Filters */ + + // List of openings from the openingIds of the filterDto object for the recent openings + if (filtersDto.hasValue(SilvaOracleConstants.OPENING_IDS)) { + String openingIds = String.join(",", filtersDto.getOpeningIds()); + log.info("Filter for openingIds detected! openingIds={}", openingIds); + builder.append(String.format("AND o.OPENING_ID IN (%s) ", openingIds)); + } + // 0. Main number filter [opening_id, opening_number, timber_mark, file_id] - // if it's a number, filter by openingId or fileId, otherwise filter by timber mark and opening + // if it's a number, filter by openingId or fileId, otherwise filter by timber + // mark and opening // number if (filtersDto.hasValue(SilvaOracleConstants.MAIN_SEARCH_TERM)) { log.info("Filter mainSearchTerm detected! mainSearchTerm={}", filtersDto.getMainSearchTerm()); @@ -428,6 +441,7 @@ private String createNativeSqlQuery(OpeningSearchFiltersDto filtersDto) { log.info("Filter statusList detected! statusList={}", statuses); builder.append(String.format("AND o.OPENING_STATUS_CODE IN (%s) ", statuses)); } + // 4. My openings if (filtersDto.hasValue(SilvaOracleConstants.MY_OPENINGS)) { log.info("Filter myOpenings detected! entryUserId={}", filtersDto.getRequestUserId()); diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/dto/UserRecentOpeningDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/dto/UserRecentOpeningDto.java new file mode 100644 index 00000000..3592a5cb --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/dto/UserRecentOpeningDto.java @@ -0,0 +1,19 @@ +package ca.bc.gov.restapi.results.postgres.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.With; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@With +@Builder +public class UserRecentOpeningDto { + private final String userId; + private final String openingId; + private final LocalDateTime lastViewed; +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserRecentOpeningEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserRecentOpeningEndpoint.java new file mode 100644 index 00000000..99a7f92f --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserRecentOpeningEndpoint.java @@ -0,0 +1,49 @@ +package ca.bc.gov.restapi.results.postgres.endpoint; + +import ca.bc.gov.restapi.results.common.pagination.PaginatedResult; +import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; +import ca.bc.gov.restapi.results.postgres.dto.UserRecentOpeningDto; +import ca.bc.gov.restapi.results.postgres.service.UserRecentOpeningService; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class UserRecentOpeningEndpoint { + + private final UserRecentOpeningService userRecentOpeningService; + + /** + * Records the opening viewed by the user based on the provided opening ID. + * + * @param openingId The ID of the opening viewed by the user. + * @return A simple confirmation message or the HTTP code 204-No Content. + */ + @PostMapping("api/users/recent/{openingId}") + public ResponseEntity recordUserViewedOpening(@PathVariable String openingId) { + // Store the opening and return the DTO + UserRecentOpeningDto recentOpeningDto = userRecentOpeningService.storeViewedOpening(openingId); + return ResponseEntity.ok(recentOpeningDto); + } + + /** + * Retrieves a list of recent openings viewed by the user, limited by the number of results. + * + * @param limit The maximum number of results to return. + * @return A list of opening IDs viewed by the user. + */ + @GetMapping("api/user/recent-openings") + public ResponseEntity> getUserRecentOpenings(@RequestParam(defaultValue = "10") int limit) { + // Fetch recent openings for the logged-in user with the specified limit + PaginatedResult recentOpenings = userRecentOpeningService.getAllRecentOpeningsForUser(limit); + return ResponseEntity.ok(recentOpenings); + } +} \ No newline at end of file diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java new file mode 100644 index 00000000..733a8619 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java @@ -0,0 +1,38 @@ +package ca.bc.gov.restapi.results.postgres.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.With; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@With +@Builder +@Entity +@Table(schema = "silva", name = "user_recent_openings") +public class UserRecentOpeningEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "opening_id", nullable = false) + private String openingId; + + @Column(name = "last_viewed", nullable = false) + private LocalDateTime lastViewed; +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserRecentOpeningRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserRecentOpeningRepository.java new file mode 100644 index 00000000..78b2483c --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserRecentOpeningRepository.java @@ -0,0 +1,16 @@ +package ca.bc.gov.restapi.results.postgres.repository; + +import ca.bc.gov.restapi.results.postgres.entity.UserRecentOpeningEntity; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRecentOpeningRepository extends JpaRepository { + UserRecentOpeningEntity findByUserIdAndOpeningId(String userId, String openingId); + // Add a method to fetch recent openings for a user with a limit and sorting by last_viewed in descending order + Page findByUserIdOrderByLastViewedDesc(String userId, Pageable pageable); +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserRecentOpeningService.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserRecentOpeningService.java new file mode 100644 index 00000000..3068401f --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserRecentOpeningService.java @@ -0,0 +1,107 @@ +package ca.bc.gov.restapi.results.postgres.service; + +import ca.bc.gov.restapi.results.common.pagination.PaginatedResult; +import ca.bc.gov.restapi.results.common.pagination.PaginationParameters; +import ca.bc.gov.restapi.results.common.security.LoggedUserService; +import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchFiltersDto; +import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; +import ca.bc.gov.restapi.results.oracle.service.OpeningService; +import ca.bc.gov.restapi.results.postgres.dto.UserRecentOpeningDto; +import ca.bc.gov.restapi.results.postgres.entity.UserRecentOpeningEntity; +import ca.bc.gov.restapi.results.postgres.repository.UserRecentOpeningRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserRecentOpeningService { + + private final LoggedUserService loggedUserService; + private final UserRecentOpeningRepository userRecentOpeningRepository; + private final OpeningService openingService; + + /** + * Stores the opening viewed by the user and returns the DTO. + * + * @param openingId The ID of the opening viewed by the user. + * @return A DTO with userId, openingId, and lastViewed timestamp. + */ + public UserRecentOpeningDto storeViewedOpening(String openingId) { + String userId = loggedUserService.getLoggedUserId(); + LocalDateTime lastViewed = LocalDateTime.now(); + + // Verify that the openingId String contains numbers only and no spaces + if (!openingId.matches("^[0-9]*$")) { + throw new IllegalArgumentException("Opening ID must contain numbers only!"); + } + + // Check if the user has already viewed this opening + UserRecentOpeningEntity existingEntity = userRecentOpeningRepository.findByUserIdAndOpeningId(userId, openingId); + + if (existingEntity != null) { + // Update the last viewed timestamp for the existing record + existingEntity.setLastViewed(lastViewed); + userRecentOpeningRepository.save(existingEntity); // Save the updated entity + } else { + // Create a new entity if this openingId is being viewed for the first time + UserRecentOpeningEntity newEntity = new UserRecentOpeningEntity(null, userId, openingId, lastViewed); + userRecentOpeningRepository.save(newEntity); // Save the new entity + } + + // Return the DTO + return new UserRecentOpeningDto(userId, openingId, lastViewed); + } + + /** + * Retrieves the recent openings viewed by the logged-in user, limited by the provided limit. + * + * @param limit The maximum number of recent openings to retrieve. + * @return A list of opening IDs the user has viewed, sorted by last viewed in descending order. + */ + public PaginatedResult getAllRecentOpeningsForUser(int limit) { + String userId = loggedUserService.getLoggedUserId(); + Pageable pageable = PageRequest.of(0, limit); // PageRequest object to apply limit + + // Fetch recent openings for the user + Page recentOpenings = userRecentOpeningRepository + .findByUserIdOrderByLastViewedDesc(userId, pageable); + + // Extract opening IDs as String + Map openingIds = recentOpenings.getContent().stream() + //.map(opening -> String.valueOf(opening.getOpeningId())) // Convert Integer to String + //.collect(Collectors.toList()); + .collect(Collectors.toMap(UserRecentOpeningEntity::getOpeningId, UserRecentOpeningEntity::getLastViewed)); + log.info("User with the userId {} has the following openindIds {}", userId, openingIds); + if (openingIds.isEmpty()) { + return new PaginatedResult<>(); + } + // Call the oracle service method to fetch opening details for the given opening IDs + //convert the openingIds to a list of strings and pass it to the OpeningSearchFiltersDto constructor + OpeningSearchFiltersDto filtersDto = new OpeningSearchFiltersDto(new ArrayList<>(openingIds.keySet())); + PaginationParameters paginationParameters = new PaginationParameters(0, 10); + PaginatedResult pageResult = openingService.openingSearch(filtersDto, paginationParameters); + // perform the sorting and set the lastViewDate to the OpeningSearchResponseDto + pageResult.setData( + pageResult + .getData() + .stream() + .peek(result -> result.setLastViewDate(openingIds.get(result.getOpeningId().toString()))) + .sorted(Comparator.comparing(OpeningSearchResponseDto::getLastViewDate).reversed()) + .collect(Collectors.toList()) + ); + return pageResult; + } + +} diff --git a/backend/src/main/resources/db/migration/V1__create_schema.sql b/backend/src/main/resources/db/migration/V1__create_schema.sql index d40f9e76..d3e22f45 100644 --- a/backend/src/main/resources/db/migration/V1__create_schema.sql +++ b/backend/src/main/resources/db/migration/V1__create_schema.sql @@ -44,3 +44,19 @@ CREATE TABLE IF NOT EXISTS silva.oracle_extraction_logs ( CONSTRAINT oracle_extraction_logs_pk PRIMARY KEY(id) ); + +-- Create sequence if it doesn't exist +CREATE SEQUENCE IF NOT EXISTS silva.user_recent_openings_seq +START WITH 1 +INCREMENT BY 1 +MINVALUE 1 +NO MAXVALUE +CACHE 30; + +-- Use the sequence in your table creation or insert statements +CREATE TABLE IF NOT EXISTS silva.user_recent_openings ( + id BIGINT PRIMARY KEY DEFAULT nextval('silva.user_recent_openings_seq'), + opening_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + last_viewed TIMESTAMP DEFAULT NOW() +); diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserRecentOpeningServiceTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserRecentOpeningServiceTest.java new file mode 100644 index 00000000..250817ae --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserRecentOpeningServiceTest.java @@ -0,0 +1,150 @@ +package ca.bc.gov.restapi.results.postgres.service; + +import ca.bc.gov.restapi.results.common.pagination.PaginatedResult; +import ca.bc.gov.restapi.results.common.pagination.PaginationParameters; +import ca.bc.gov.restapi.results.common.security.LoggedUserService; +import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchFiltersDto; +import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; +import ca.bc.gov.restapi.results.oracle.service.OpeningService; +import ca.bc.gov.restapi.results.postgres.dto.UserRecentOpeningDto; +import ca.bc.gov.restapi.results.postgres.entity.UserRecentOpeningEntity; +import ca.bc.gov.restapi.results.postgres.repository.UserRecentOpeningRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class UserRecentOpeningServiceTest { + + @Mock + private LoggedUserService loggedUserService; + + @Mock + private UserRecentOpeningRepository userRecentOpeningRepository; + + @Mock + private OpeningService openingService; + + @InjectMocks + private UserRecentOpeningService userRecentOpeningService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void storeViewedOpening_newOpening_savesEntity() { + String userId = "user123"; + String openingId = "123"; + LocalDateTime lastViewed = LocalDateTime.now(); + + when(loggedUserService.getLoggedUserId()).thenReturn(userId); + when(userRecentOpeningRepository.findByUserIdAndOpeningId(userId, openingId)).thenReturn(null); + + UserRecentOpeningDto result = userRecentOpeningService.storeViewedOpening(openingId); + + assertNotNull(result); + assertEquals(userId, result.getUserId()); + assertEquals(openingId, result.getOpeningId()); + + verify(userRecentOpeningRepository, times(1)).save(any(UserRecentOpeningEntity.class)); + } + + @Test + void storeViewedOpening_existingOpening_updatesEntity() { + String userId = "user123"; + String openingId = "123"; + LocalDateTime lastViewed = LocalDateTime.now(); + UserRecentOpeningEntity existingEntity = new UserRecentOpeningEntity(1L, userId, openingId, lastViewed.minusDays(1)); + + when(loggedUserService.getLoggedUserId()).thenReturn(userId); + when(userRecentOpeningRepository.findByUserIdAndOpeningId(userId, openingId)).thenReturn(existingEntity); + + UserRecentOpeningDto result = userRecentOpeningService.storeViewedOpening(openingId); + + assertNotNull(result); + assertEquals(userId, result.getUserId()); + assertEquals(openingId, result.getOpeningId()); + + verify(userRecentOpeningRepository, times(1)).save(existingEntity); + } + + @Test + void storeViewedOpening_invalidOpeningId_throwsException() { + String invalidOpeningId = "abc"; + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + userRecentOpeningService.storeViewedOpening(invalidOpeningId); + }); + + assertEquals("Opening ID must contain numbers only!", exception.getMessage()); + } + + @Test + void getAllRecentOpeningsForUser_noRecentOpenings_returnsEmptyResult() { + String userId = "idir@jasgrewa"; + int limit = 10; + + // Arrange + when(loggedUserService.getLoggedUserId()).thenReturn(userId); + when(userRecentOpeningRepository.findByUserIdOrderByLastViewedDesc(eq(userId), any(PageRequest.class))) + .thenReturn(Page.empty()); // Mocking an empty page of recent openings + + // Act + PaginatedResult result = userRecentOpeningService.getAllRecentOpeningsForUser(limit); + + // Assert + assertNotNull(result); + + // Check if data is null and assert empty + assertTrue(result.getData() == null || result.getData().isEmpty(), "Data should be empty or null"); + } + + + @Test + void getAllRecentOpeningsForUser_withRecentOpenings_returnsSortedResult() { + String userId = "user123"; + int limit = 10; + LocalDateTime now = LocalDateTime.now(); + + UserRecentOpeningEntity opening1 = new UserRecentOpeningEntity(1L, userId, "123", now.minusDays(2)); + UserRecentOpeningEntity opening2 = new UserRecentOpeningEntity(2L, userId, "456", now.minusDays(1)); + + List openings = List.of(opening1, opening2); + when(loggedUserService.getLoggedUserId()).thenReturn(userId); + when(userRecentOpeningRepository.findByUserIdOrderByLastViewedDesc(eq(userId), any(PageRequest.class))) + .thenReturn(new PageImpl<>(openings)); + + OpeningSearchResponseDto dto1 = new OpeningSearchResponseDto(); + dto1.setOpeningId(123); + + OpeningSearchResponseDto dto2 = new OpeningSearchResponseDto(); + dto2.setOpeningId(456); + + PaginatedResult pageResult = new PaginatedResult<>(); + pageResult.setData(List.of(dto1, dto2)); + + when(openingService.openingSearch(any(OpeningSearchFiltersDto.class), any(PaginationParameters.class))) + .thenReturn(pageResult); + + PaginatedResult result = userRecentOpeningService.getAllRecentOpeningsForUser(limit); + + assertNotNull(result); + assertEquals(2, result.getData().size()); + assertEquals((long) 456L, (long) result.getData().get(0).getOpeningId()); // Most recent first + assertEquals((long) 123L, (long) result.getData().get(1).getOpeningId()); // Least recent last + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cedbbd10..4150ff3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@testing-library/dom": "^10.2.0", - "@testing-library/jest-dom": "^6.4.5", + "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", @@ -4963,11 +4963,10 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", - "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.2.tgz", + "integrity": "sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw==", "dev": true, - "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", diff --git a/frontend/package.json b/frontend/package.json index 8e667992..e641aa3d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ }, "devDependencies": { "@testing-library/dom": "^10.2.0", - "@testing-library/jest-dom": "^6.4.5", + "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d44cba2..ade6cec8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import './custom.scss'; import Landing from "./screens/Landing"; import Help from "./screens/Help"; -import Reports from './screens/Reports'; import SideLayout from './layouts/SideLayout'; import PostLoginRoute from './routes/PostLoginRoute'; import ProtectedRoute from './routes/ProtectedRoute'; @@ -38,11 +37,6 @@ const App: React.FC = () => { } /> } /> - - } /> - - } /> } />} /> diff --git a/frontend/src/__test__/components/BarChartGrouped.test.tsx b/frontend/src/__test__/components/BarChartGrouped.test.tsx index 16255636..3c3c63a6 100644 --- a/frontend/src/__test__/components/BarChartGrouped.test.tsx +++ b/frontend/src/__test__/components/BarChartGrouped.test.tsx @@ -1,26 +1,40 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import BarChartGrouped from '../../components/BarChartGrouped'; -import { fetchOpeningsPerYear } from '../../services/OpeningService'; - -vi.mock('../../services/OpeningService', () => ({ - fetchOpeningsPerYear: vi.fn(() => Promise.resolve([ - { group: '2022', key: 'Openings', value: 10 }, - { group: '2023', key: 'Openings', value: 15 }, - ])), +import { useDistrictListQuery, useFetchOpeningsPerYear } from '../../services/queries/dashboard/dashboardQueries'; +import { describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; +import '@testing-library/jest-dom'; +// Mock the hook +vi.mock('../../services/queries/dashboard/dashboardQueries', () => ({ + useFetchOpeningsPerYear: vi.fn(), + useDistrictListQuery: vi.fn(), })); -describe('BarChartGrouped component tests', () => { - it('should render loading state while fetching data and clean it after', async () => { - render(); +const queryClient = new QueryClient(); - const element = await waitFor(() => screen.getByText('Loading...')); +describe('BarChartGrouped component', () => { + it('should display loading state when data is fetching', () => { + // Mock loading state for openings data + (useFetchOpeningsPerYear as any).mockReturnValue({ + data: [], + isLoading: true, + }); - expect(element).toBeDefined(); - - expect(fetchOpeningsPerYear).toHaveBeenCalled(); - expect(screen.queryByTestId('bar-chart')).toBeDefined(); - }); + // If you're using useDistrictListQuery, mock it too + (useDistrictListQuery as any).mockReturnValue({ + data: [], + isLoading: false, + }); + render( + + + + ); + + // Check if loading text is displayed + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/__test__/screens/Opening.test.tsx b/frontend/src/__test__/screens/Opening.test.tsx index 0535dc88..4ac7b4a3 100644 --- a/frontend/src/__test__/screens/Opening.test.tsx +++ b/frontend/src/__test__/screens/Opening.test.tsx @@ -6,21 +6,21 @@ import PaginationContext from '../../contexts/PaginationContext'; import { BrowserRouter } from 'react-router-dom'; import * as redux from 'react-redux'; import { RecentOpening } from '../../types/RecentOpening'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +// Mock data and services const data = { - "activityType": "Update", - "openingId": "1541297", - "statusCode": "APP", - "statusDescription": "Approved", - "lastUpdatedLabel": "1 minute ago", - "lastUpdated": "2024-05-16T19:59:21.635Z" + activityType: "Update", + openingId: "1541297", + statusCode: "APP", + statusDescription: "Approved", + lastUpdatedLabel: "1 minute ago", + lastUpdated: "2024-05-16T19:59:21.635Z" }; vi.mock('../../services/SecretsService', () => ({ getWmsLayersWhitelistUsers: vi.fn(() => [ - { - userName: 'TEST' - } + { userName: 'TEST' } ]) })); @@ -46,10 +46,7 @@ vi.mock('../../services/OpeningService', () => ({ { group: '2023', key: 'Openings', value: 15 }, ])), fetchFreeGrowingMilestones: vi.fn(() => Promise.resolve([ - { - group: '1-5', - value: 11 - } + { group: '1-5', value: 11 } ])), fetchRecentActions: vi.fn(() => [ { @@ -73,10 +70,11 @@ const state = { vi.spyOn(redux, 'useSelector') .mockImplementation((callback) => callback(state)); +// Pagination context mock const rows: RecentOpening[] = [{ id: '123', openingId: '123', - fileId: '1', + forestFileId: '1', cuttingPermit: '1', timberMark: '1', cutBlock: '1', @@ -87,7 +85,7 @@ const rows: RecentOpening[] = [{ entryTimestamp: '1', updateTimestamp: '1', }]; - + const paginationValueMock = { getCurrentData: () => rows, currentPage: 0, @@ -99,20 +97,30 @@ const paginationValueMock = { setInitialItemsPerPage: vi.fn(), }; +// Create a query client for testing +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, // Disable retries for test stability + }, + }, +}); + describe('Opening screen test cases', () => { - it('should renders Opening Page Title component', async () => { + it('should render Opening Page Title component', async () => { + const queryClient = createTestQueryClient(); + const { getByTestId } = render( - - - - - + + + + + + + ); const pageTitleComp = await waitFor(() => getByTestId('opening-pagetitle')); expect(pageTitleComp).toBeDefined(); - - //const subtitle = 'Create, manage or check opening information'; - //expect(screen.getByText(subtitle)).toBeDefined(); }); }); diff --git a/frontend/src/__test__/screens/Reports.test.tsx b/frontend/src/__test__/screens/Reports.test.tsx deleted file mode 100644 index 6996653a..00000000 --- a/frontend/src/__test__/screens/Reports.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import Reports from '../../screens/Reports'; - -describe('Reports', () => { - it('should render the reports page title', () => { - render(); - const titleElement = screen.getByText(/Reports Page/i); - expect(titleElement).toBeDefined(); - }); - - it('should render the form sample title', () => { - render(); - const formTitleElement = screen.getByText(/Form Sample/i); - expect(formTitleElement).toBeDefined(); - }); - - it('should render the date picker component', () => { - render(); - const datePickerElement = screen.getByLabelText(/Start date/i); - expect(datePickerElement).toBeDefined(); - }); - - it('should renders the dropdown component', () => { - render(); - const dropdownElements = screen.getAllByLabelText(/Select Fruit from Dropdown/i); - expect(dropdownElements.length).toBe(8); - }); - - it('renders the modal button', () => { - render(); - const modalButtonElement = screen.getByText(/Launch modal/i); - expect(modalButtonElement).toBeDefined(); - }); - - it('renders the table headers', () => { - render(); - const tableHeaders = ['Name', 'Rule', 'Status', 'Other', 'Example']; - tableHeaders.forEach((header) => { - const headerElement = screen.getByText(header); - expect(headerElement).toBeDefined(); - }); - }); - - it('renders the table rows and cells', () => { - render(); - const tableRows = screen.getAllByRole('row'); - // Excluding the header row - expect(tableRows.length).toBe(8); - - // Example: Check for specific cell content - const cellContent = screen.getByText('Load Balancer 1'); - expect(cellContent).toBeDefined(); - }); -}); diff --git a/frontend/src/components/BarChartGrouped/index.tsx b/frontend/src/components/BarChartGrouped/index.tsx index 2acd8ee9..c395c2f8 100644 --- a/frontend/src/components/BarChartGrouped/index.tsx +++ b/frontend/src/components/BarChartGrouped/index.tsx @@ -1,79 +1,62 @@ -import React, { useState, useEffect } from "react"; +// components/BarChartGrouped.tsx +import React, { useState } from "react"; import { GroupedBarChart, ScaleTypes } from "@carbon/charts-react"; import { Dropdown, DatePicker, DatePickerInput } from "@carbon/react"; -import { fetchOpeningsPerYear } from "../../services/OpeningService"; -import { OpeningPerYearChart } from "../../types/OpeningPerYearChart"; +import { useDistrictListQuery, useFetchOpeningsPerYear } from "../../services/queries/dashboard/dashboardQueries"; +import { IOpeningPerYear } from "../../types/IOpeningPerYear"; + import "@carbon/charts/styles.css"; import "./BarChartGrouped.scss"; -interface IDropdownItem { - value: string; - text: string; -} - -/** - * Renders an Bar Chart Grouped component. - * - * @returns {JSX.Element} The rendered BarChartGrouped component. - */ function BarChartGrouped(): JSX.Element { - const [windowWidth, setWindowWidth] = useState(window.innerWidth); - const [chartData, setChartData] = useState([]); - const [isLoading, setIsLoading] = useState(true); const [orgUnitCode, setOrgUnitCode] = useState(null); const [statusCode, setStatusCode] = useState(null); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); + + const formatDateToString = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const formattedStartDate = startDate ? formatDateToString(startDate) : null; + const formattedEndDate = endDate ? formatDateToString(endDate) : null; - const handleResize = () => { - setWindowWidth(window.innerWidth); + const queryProps: IOpeningPerYear = { + orgUnitCode, + statusCode, + entryDateStart: formattedStartDate, + entryDateEnd: formattedEndDate, }; - useEffect(() => { - const fetchChartData = async () => { - try { - setIsLoading(true); - let formattedStartDate: string | null = null; - let formattedEndDate: string | null = null; + // Fetch the openings submission trends data + const { data: chartData = [], isLoading } = useFetchOpeningsPerYear(queryProps); + // Fetch the org units (district list) data + const { data: orgunitsData = [], isLoading: isOrgUnitsLoading } = useDistrictListQuery(); - if (startDate) { - formattedStartDate = formatDateToString(startDate); - } - if (endDate) { - formattedEndDate = formatDateToString(endDate); - } - - const data: OpeningPerYearChart[] = await fetchOpeningsPerYear({ - orgUnitCode, - statusCode, - entryDateStart: formattedStartDate, - entryDateEnd: formattedEndDate, - }); - setChartData(data); - setIsLoading(false); - } catch (error) { - console.error("Error fetching chart data:", error); - setIsLoading(false); - } - }; + // Map the orgunitsData to create orgUnitItems for the Dropdown + const orgUnitItems = orgunitsData?.map((item: any) => ({ + text: item.orgUnitCode, + value: item.orgUnitCode, + })) || []; - fetchChartData(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [orgUnitCode, statusCode, startDate, endDate]); + const statusItems = [ + { value: "APP", text: "Approved" }, + { value: "NAN", text: "Not Approved" }, + ]; - const formatDateToString = (dateToFormat: Date) => { - if (!dateToFormat) return null; - const year = dateToFormat.getFullYear(); - const month = String(dateToFormat.getMonth() + 1).padStart(2, "0"); - const day = String(dateToFormat.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; + const setOrgUnitCodeSelected = ({ selectedItem }: { selectedItem: { value: string } }) => { + setOrgUnitCode(selectedItem.value); }; - const colors = { - Openings: "#1192E8", + const setStatusCodeSelected = ({ selectedItem }: { selectedItem: { value: string } }) => { + setStatusCode(selectedItem.value); }; + + const options = { axes: { left: { @@ -84,62 +67,13 @@ function BarChartGrouped(): JSX.Element { mapsTo: "key", }, }, - color: { - scale: colors, - }, + color: { scale: { Openings: "#1192E8" } }, height: "18.5rem", grid: { - x: { - enabled: false, - color: "#d3d3d3", - strokeDashArray: "2,2", - }, - y: { - enabled: true, - color: "#d3d3d3", - strokeDashArray: "2,2", - }, - }, - toolbar: { - enabled: false, - numberOfIcons: 2, - controls: [ - { - type: "Make fullscreen", - }, - { - type: "Make fullscreen", - }, - ], + x: { enabled: false, color: "#d3d3d3", strokeDashArray: "2,2" }, + y: { enabled: true, color: "#d3d3d3", strokeDashArray: "2,2" }, }, - }; - - const orgUnitItems = [ - { value: "DCR", text: "DCR" }, - { value: "XYZ", text: "District 2" }, - // Add more options as needed - ]; - - const statusItems = [ - { value: "APP", text: "Approved" }, - { value: "NAN", text: "Not Approved" }, - // Add more options as needed - ]; - - const setOrgUnitCodeSelected = ({ - selectedItem, - }: { - selectedItem: IDropdownItem; - }) => { - setOrgUnitCode(selectedItem.value); - }; - - const setStatusCodeSelected = ({ - selectedItem, - }: { - selectedItem: IDropdownItem; - }) => { - setStatusCode(selectedItem.value); + toolbar: { enabled: false }, }; return ( @@ -150,7 +84,7 @@ function BarChartGrouped(): JSX.Element { id="district-dropdown" titleText="District" items={orgUnitItems} - itemToString={(item: IDropdownItem) => (item ? item.text : "")} + itemToString={(item:any) => (item ? item.text : "")} onChange={setOrgUnitCodeSelected} label="District" /> @@ -160,35 +94,19 @@ function BarChartGrouped(): JSX.Element { id="status-dropdown" titleText="Status" items={statusItems} - itemToString={(item: IDropdownItem) => (item ? item.text : "")} + itemToString={(item:any) => (item ? item.text : "")} onChange={setStatusCodeSelected} label="Status" />
- setStartDate(dates[0])} - > - + setStartDate(dates[0])}> +
- setEndDate(dates[0])} - > - + setEndDate(dates[0])}> +
@@ -201,6 +119,6 @@ function BarChartGrouped(): JSX.Element { )} ); -}; +} export default BarChartGrouped; diff --git a/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/index.tsx b/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/index.tsx new file mode 100644 index 00000000..7cef2986 --- /dev/null +++ b/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/index.tsx @@ -0,0 +1,330 @@ +import React, { useContext, useEffect, useState } from "react"; +import { + TableToolbar, + TableToolbarAction, + TableToolbarContent, + TableToolbarMenu, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Button, + Pagination, + OverflowMenu, + OverflowMenuItem, + Popover, + PopoverContent, + Checkbox, + CheckboxGroup, + Modal, + ActionableNotification +} from "@carbon/react"; +import * as Icons from "@carbon/icons-react"; +import StatusTag from "../../../StatusTag"; +import "./styles.scss"; +import EmptySection from "../../../EmptySection"; +import PaginationContext from "../../../../contexts/PaginationContext"; +import { OpeningsSearch } from "../../../../types/OpeningsSearch"; +import { ITableHeader } from "../../../../types/TableHeader"; +import { MenuItem } from "@carbon/react"; +import { + convertToCSV, + downloadCSV, + downloadPDF, + downloadXLSX, +} from "../../../../utils/fileConversions"; +import { Tooltip } from "@carbon/react"; +import { useNavigate } from "react-router-dom"; + +interface IRecentOpeningsDataTable { + rows: OpeningsSearch[]; + headers: ITableHeader[]; + defaultColumns: ITableHeader[]; + handleCheckboxChange: Function; + setOpeningId: Function; + toggleSpatial: Function; + showSpatial: boolean; + totalItems: number; +} + +const RecentOpeningsDataTable: React.FC = ({ + rows, + headers, + defaultColumns, + showSpatial, + totalItems, +}) => { + const { + handlePageChange, + handleItemsPerPageChange, + itemsPerPage, + setInitialItemsPerPage, + currentPage, + } = useContext(PaginationContext); + const alignTwo = document?.dir === "rtl" ? "bottom-left" : "bottom-right"; + const [openDownload, setOpenDownload] = useState(false); + const [selectedRows, setSelectedRows] = useState([]); // State to store selected rows + const [toastText, setToastText] = useState(null); + const [openingDetails, setOpeningDetails] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + setInitialItemsPerPage(itemsPerPage); + }, [rows, totalItems]); + + // Function to handle row selection changes + const handleRowSelectionChanged = (rowId: string) => { + setSelectedRows((prevSelectedRows) => { + if (prevSelectedRows.includes(rowId)) { + // If the row is already selected, remove it from the selected rows + return prevSelectedRows.filter((id) => id !== rowId); + } else { + // If the row is not selected, add it to the selected rows + return [...prevSelectedRows, rowId]; + } + }); + }; + + //Function to handle the favourite feature of the opening for a user + const handleFavouriteOpening = (rowId: string) => { + //make a call to the api for the favourite opening when ready + setToastText(`Following "OpeningID ${rowId}"`); + } + + return ( + <> + + + +
+

+ Total Search Results: {totalItems} +

+
+ + console.log("Download Click")}> + Print + + { + console.log("Clicked print"); + }} + > + Download + + +
+
+
+ setOpenDownload(false)} + > + + + { + downloadPDF(headers, rows); + }} + /> + { + const csvData = convertToCSV(headers, rows); + downloadCSV(csvData, "openings-data.csv"); + }} + /> + downloadXLSX(headers, rows)} + /> + + +
+
+
+ + + + {headers.map((header) => + header.selected ? ( + {header.header} + ) : null + )} + + + + {rows && + rows.map((row: any, i: number) => ( + { + //add the api call to send the viewed opening + // await handleRowClick(row.openingId); + setOpeningDetails(true); + }} + > + {headers.map((header) => + header.selected ? ( + + {header.key === "statusDescription" ? ( + + ) : header.key === "actions" ? ( + <> + <> +
+
+ + {rows.length <= 0 ? ( + + ) : null} + + {rows.length > 0 && ( + { + handlePageChange(page); + handleItemsPerPageChange(page, pageSize); + }} + /> + )} + {toastText != null ? ( + setToastText(null)} + actionButtonLabel="Go to track openings" + onActionButtonClick={() => + navigate("/opening?tab=metrics&scrollTo=trackOpenings") + } + /> + ) : null} + + setOpeningDetails(false)} + passiveModal + modalHeading="We are working hard to get this feature asap, unfortunately you cannot view the opening details from SILVA atm." + modalLabel="Opening Details" + /> + + ); +}; + +export default RecentOpeningsDataTable; diff --git a/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/styles.scss b/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/styles.scss new file mode 100644 index 00000000..badbfad9 --- /dev/null +++ b/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/styles.scss @@ -0,0 +1,165 @@ +@use '@bcgov-nr/nr-theme/design-tokens/colors.scss' as colors; +@use '@bcgov-nr/nr-theme/design-tokens/variables.scss' as vars; +@use '@carbon/type'; + + +.search-data-table{ + // nested elements for the search data table only + .table-toolbar{ + border-top: 1px solid var(--#{vars.$bcgov-prefix}-border-subtle-01); + } + .divider{ + width: 1px; + height: 48px; + background-color: var(--#{vars.$bcgov-prefix}-border-subtle-01); + } + .total-results-container{ + height: 100%; + align-items: center; + width: 100%; + display: flex; + padding: 0px 16px 0px 32px; + } + .total-search-results { + @include type.type-style('body-compact-02'); + font-weight: 400; + font-size: 14px; + line-height: 18px; + letter-spacing: 0.16px; + color: var(--bx-text-secondary); + } + .bx--btn--ghost{ + min-height: 48px; + align-items: center; + } + + +} + + +.edit-column-content{ + width: 400px; + .dropdown-label { + padding:16px; + p { + @include type.type-style('label-02'); + font-size: 12px; + } + } + .dropdown-container{ + padding: 16px; + padding-top: 0px; + } + .menu-item{ + font-size: 12px; + } + .checkbox-item .bx--checkbox-label-text{ + font-size: 14px; + line-height: 18px; + letter-spacing: 0.16px; + font-weight: 400; + } + +} +.download-column-content{ + width: 240px; + .menu-item{ + padding: 16px; + } + +} + +.checkbox-tip span{ + @include type.type-style('body-compact-02'); + max-width: 205px; + font-size: 14px; + line-height: 18px; + letter-spacing: 0.16px; +} + +.fav-toast{ + position: fixed; + top: 64px; + right: 16px; + z-index:2; +} + +//Need to find selector for specific screen +.bx--overflow-menu-options{ + width:260px !important +} +.bx--overflow-menu-options__option-content { + overflow:visible; +} + +.activity-table { + margin-bottom: 2.5rem; + + tr > th:first-child, + tr > td:first-child { + padding-left: 2.5rem; + } + + tr > th:last-child, + tr > td:last-child { + padding-right: 2.5rem; + } + + tr > th:last-child div, + tr > td:last-child { + text-align: center; + } + + .activities-table-cell svg { + position: relative; + margin-right: 0.5rem; + top: 0.1875rem; + } +} + +.#{vars.$bcgov-prefix}--data-table thead tr th#blank { + min-width:50px; +} + +.#{vars.$bcgov-prefix}--data-table thead tr th { + background-color: #F3F3F5; + border-top: 1px solid; + border-color: var(--#{vars.$bcgov-prefix}-border-subtle-01); + background-color: var(--#{vars.$bcgov-prefix}-layer-accent-01) !important; +} + +.#{vars.$bcgov-prefix}--data-table thead tr th { + min-width:158px; +} + +.#{vars.$bcgov-prefix}--data-table tr:nth-child(even) td { + background-color: var(--#{vars.$bcgov-prefix}-layer-01) !important; + height: 64px; +} +.#{vars.$bcgov-prefix}--data-table tr:nth-child(odd) td { + background-color: var(--#{vars.$bcgov-prefix}-layer-02) !important; + height: 64px; +} +.#{vars.$bcgov-prefix}--data-table tr:hover td { + background-color: var(--#{vars.$bcgov-prefix}-layer-accent-02) !important; + cursor: pointer; +} +.#{vars.$bcgov-prefix}--pagination { + background-color: var(--#{vars.$bcgov-prefix}-layer-02) !important; +} + +.table-toolbar{ + background-color: var(--#{vars.$bcgov-prefix}-layer-02); + +} + +@media only screen and (max-width: 672px) { + .#{vars.$bcgov-prefix}--data-table-content { + width: 100%; + overflow-x: scroll; + } + + .activity-table { + width: 56.25rem; + } +} diff --git a/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/testData.ts b/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/testData.ts new file mode 100644 index 00000000..a3e5d814 --- /dev/null +++ b/frontend/src/components/Dashboard/Opening/RecentOpeningsDataTable/testData.ts @@ -0,0 +1,284 @@ +import { ITableHeader } from "../../../../types/TableHeader"; + +export const columns: ITableHeader[] = [ + { + key: 'openingId', + header: 'Opening Id', + selected: true + }, + { + key: 'forestFileId', + header: 'File Id', + selected: true + }, + { + key: 'cuttingPermitId', + header: 'Cutting permit', + selected: true + }, + { + key: 'timberMark', + header: 'Timber mark', + selected: true + }, + { + key: 'cutBlockId', + header: 'Cut block', + selected: true + }, + { + key: 'openingGrossAreaHa', + header: 'Gross Area', + selected: true + }, + + { + key: 'statusDescription', + header: 'Status', + selected: true + }, + { + key: 'categoryDescription', + header: 'Category', + selected: true + }, + { + key: 'disturbanceStartDate', + header: 'Disturbance Date', + selected: false + }, + { + key: 'actions', + header: 'Actions', + selected: true + } +]; + + +export const rows:any = [ + { + id: '114207', + openingId: '114207', + fileId: 'TFL47', + cuttingPermit: '12S', + timberMark: '47/12S', + cutBlock: '12-69', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2022-10-27', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-27' + }, + { + id: '114206', + openingId: '114206', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-69', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2022-09-04', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-27' + }, + { + id: '114205', + openingId: '114205', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2022-09-04', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-27' + }, + { + id: '114204', + openingId: '114204', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Active', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2022-01-16', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-26' + }, + { + id: '114203', + openingId: '114203', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Active', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-12-08', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-26' + }, + { + id: '114202', + openingId: '114202', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-11-15', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-25' + }, + { + id: '114201', + openingId: '114201', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-11-15', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-25' + }, + { + id: '114200', + openingId: '114200', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Active', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-10-20', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-24' + }, + { + id: '114199', + openingId: '114199', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Active', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-10-20', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-24' + }, + { + id: '114198', + openingId: '114198', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-09-12', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-23' + }, + { + id: '114197', + openingId: '114197', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-09-12', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-23' + }, + { + id: '114196', + openingId: '114196', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-08-05', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-22' + }, + { + id: '114195', + openingId: '114195', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Free growing', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-08-05', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-22' + }, + { + id: '114194', + openingId: '114194', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Active', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-07-10', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-21' + }, + { + id: '114193', + openingId: '114193', + fileId: 'TFL47', + cuttingPermit: '12T', + timberMark: '47/12S', + cutBlock: '12-44A', + grossAreaHa: '12.9', + status: 'Active', + category: 'FTML', + disturbanceStart: '-', + createdAt: '2021-07-10', + orgUnit: 'DCC - Cariboo chilcotin natural resources', + lastViewed: '2022-10-21' + } +]; diff --git a/frontend/src/components/OpeningsTab/index.tsx b/frontend/src/components/OpeningsTab/index.tsx index 26f3a8f4..3aa9ea34 100644 --- a/frontend/src/components/OpeningsTab/index.tsx +++ b/frontend/src/components/OpeningsTab/index.tsx @@ -4,7 +4,7 @@ import './styles.scss' import { Location } from '@carbon/icons-react'; import OpeningsMap from '../OpeningsMap'; import OpeningScreenDataTable from '../OpeningScreenDataTable/index'; -import { headers } from '../OpeningScreenDataTable/testData'; +import { columns } from '../Dashboard/Opening/RecentOpeningsDataTable/testData'; import { fetchRecentOpenings } from '../../services/OpeningService'; import SectionTitle from '../SectionTitle'; import TableSkeleton from '../TableSkeleton'; @@ -14,6 +14,9 @@ import { useSelector } from 'react-redux'; import { RootState } from '../../store'; import { generateHtmlFile } from './layersGenerator'; import { getWmsLayersWhitelistUsers, WmsLayersWhitelistUser } from '../../services/SecretsService'; +import { useUserRecentOpeningQuery } from '../../services/queries/search/openingQueries'; +import RecentOpeningsDataTable from '../Dashboard/Opening/RecentOpeningsDataTable'; +import { ITableHeader } from '../../types/TableHeader'; interface Props { showSpatial: boolean; @@ -28,6 +31,8 @@ const OpeningsTab: React.FC = ({ showSpatial, setShowSpatial }) => { const [openingPolygonNotFound, setOpeningPolygonNotFound] = useState(false); const [wmsUsersWhitelist, setWmsUsersWhitelist] = useState([]); const userDetails = useSelector((state: RootState) => state.userDetails); + const { data, isFetching } = useUserRecentOpeningQuery(10); + const [headers, setHeaders] = useState(columns); useEffect(() => { const fetchData = async () => { @@ -73,6 +78,31 @@ const OpeningsTab: React.FC = ({ showSpatial, setShowSpatial }) => { } }; + const handleCheckboxChange = (columnKey: string) => { + if(columnKey === "select-default"){ + //set to the deafult + setHeaders(columns) + } + else if(columnKey === "select-all"){ + setHeaders((prevHeaders) => + prevHeaders.map((header) => ({ + ...header, + selected: true, // Select all headers + })) + ); + } + else{ + setHeaders((prevHeaders) => + prevHeaders.map((header) => + header.key === columnKey + ? { ...header, selected: !header.selected } + : header + ) + ); + } + + }; + return ( <>
@@ -116,12 +146,16 @@ const OpeningsTab: React.FC = ({ showSpatial, setShowSpatial }) => { {loading ? ( ) : ( - + )}
diff --git a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx index 3821b73e..8822d0ee 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -340,7 +340,7 @@ const AdvancedSearchDropdown: React.FC = ({
= ({ }; const handleSearchClick = () => { + //set the Advanced Filter Dropsdown visibility to false + setIsOpen(false); onSearchClick(); }; + // this function calls handleSearchClick when the enter key is pressed + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearchClick(); + } + }; const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; @@ -62,7 +70,7 @@ const OpeningsSearchBar: React.FC = ({ closeButtonLabelText="Clear search input" id={`search-1`} onChange={handleInputChange} // Handle input change - onKeyDown={() => {}} + onKeyDown={handleKeyDown} // Handle enter key press value={searchTerm} /> diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index fd3b43c3..4d927a38 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -22,7 +22,7 @@ import { Row, Column, MenuItemDivider, - ToastNotification, + Modal, ActionableNotification } from "@carbon/react"; import * as Icons from "@carbon/icons-react"; @@ -42,6 +42,7 @@ import { } from "../../../../utils/fileConversions"; import { Tooltip } from "@carbon/react"; import { useNavigate } from "react-router-dom"; +import { usePostViewedOpening } from "../../../../services/queries/dashboard/dashboardQueries"; interface ISearchScreenDataTable { rows: OpeningsSearch[]; @@ -76,6 +77,8 @@ const SearchScreenDataTable: React.FC = ({ const [openDownload, setOpenDownload] = useState(false); const [selectedRows, setSelectedRows] = useState([]); // State to store selected rows const [toastText, setToastText] = useState(null); + const [openingDetails, setOpeningDetails] = useState(false); + const { mutate: markAsViewedOpening, isError, error } = usePostViewedOpening(); const navigate = useNavigate(); useEffect(() => { @@ -95,9 +98,22 @@ const SearchScreenDataTable: React.FC = ({ }); }; + const handleRowClick = (openingId: string) => { + // Call the mutation to mark as viewed + markAsViewedOpening(openingId, { + onSuccess: () => { + // setToastText(`Successfully marked opening ${openingId} as viewed.`); + console.log(`Successfully marked opening ${openingId} as viewed.`); + }, + onError: (err: any) => { + // setToastText(`Failed to mark as viewed: ${err.message}`); + console.log(`Failed to mark as viewed: ${err.message}`); + } + }); + }; + //Function to handle the favourite feature of the opening for a user const handleFavouriteOpening = (rowId: string) => { - console.log(rowId + " has been added as a favourite for the user") //make a call to the api for the favourite opening when ready setToastText(`Following "OpeningID ${rowId}"`); } @@ -272,7 +288,15 @@ const SearchScreenDataTable: React.FC = ({ {rows && rows.map((row: any, i: number) => ( - + { + //add the api call to send the viewed opening + await handleRowClick(row.openingId); + setOpeningDetails(true) + } + } + > {headers.map((header) => header.selected ? ( = ({
)} - + e.stopPropagation()} // Stop row onClick from triggering + > - handleFavouriteOpening(row.openingId) - } + onClick={(e: any) => { + e.stopPropagation(); // Stop row onClick from triggering + handleFavouriteOpening(row.openingId); + }} /> - downloadPDF(defaultColumns, [row]) - } + onClick={(e: any) => { + e.stopPropagation(); // Stop row onClick from triggering + downloadPDF(defaultColumns, [row]); + }} /> { + onClick={(e: any) => { + e.stopPropagation(); // Stop row onClick from triggering const csvData = convertToCSV(defaultColumns, [ row, ]); @@ -382,7 +413,7 @@ const SearchScreenDataTable: React.FC = ({ }} /> )} - {toastText!=null ? ( + {toastText != null ? ( = ({ closeOnEscape onClose={() => setToastText(null)} actionButtonLabel="Go to track openings" - onActionButtonClick = {() => navigate('/opening?tab=metrics&scrollTo=trackOpenings')} - + onActionButtonClick={() => + navigate("/opening?tab=metrics&scrollTo=trackOpenings") + } /> ) : null} + + setOpeningDetails(false)} + passiveModal + modalHeading="We are working hard to get this feature asap, unfortunately you cannot view the opening details from SILVA atm." + modalLabel="Opening Details" + /> ); }; diff --git a/frontend/src/screens/Reports/Reports.scss b/frontend/src/screens/Reports/Reports.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/screens/Reports/index.tsx b/frontend/src/screens/Reports/index.tsx deleted file mode 100644 index 38c092d2..00000000 --- a/frontend/src/screens/Reports/index.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import React from 'react'; -import { - DatePicker, - DatePickerInput, - Dropdown, - Table, - TableHead, - TableHeader, - TableRow, - TableBody, - TableCell, - ModalWrapper - } from '@carbon/react'; -import './Reports.scss'; - -/** - * Reports component. - * - * This component renders a page with a sample form and a table. - * - * @returns {JSX.Element} The Reports component. - */ -function Reports(): JSX.Element { - const items: string[] = ["Apple", "Mango", "Orange", "Peach"]; - - const rows:any[] = [ - { - id: 'load-balancer-1', - name: 'Load Balancer 1', - rule: 'Round robin', - Status: 'Starting', - other: 'Test', - example: '22', - }, - { - id: 'load-balancer-2', - name: 'Load Balancer 2', - rule: 'DNS delegation', - status: 'Active', - other: 'Test', - example: '22', - }, - { - id: 'load-balancer-3', - name: 'Load Balancer 3', - rule: 'Round robin', - status: 'Disabled', - other: 'Test', - example: '22', - }, - { - id: 'load-balancer-4', - name: 'Load Balancer 4', - rule: 'Round robin', - status: 'Disabled', - other: 'Test', - example: '22', - }, - { - id: 'load-balancer-5', - name: 'Load Balancer 5', - rule: 'Round robin', - status: 'Disabled', - other: 'Test', - example: '22', - }, - { - id: 'load-balancer-6', - name: 'Load Balancer 6', - rule: 'Round robin', - status: 'Disabled', - other: 'Test', - example: '22', - }, - { - id: 'load-balancer-7', - name: 'Load Balancer 7', - rule: 'Round robin', - status: 'Disabled', - other: 'Test', - example: '22', - }, - ]; - - const headers:any[] = ['Name', 'Rule', 'Status', 'Other', 'Example']; - - return ( -
-
-
-
Reports Page
-
-
- -
Form Sample
- -
-
- - - - -
-
- (item ? item : '')} - /> -
-
- -
-
- (item ? item : '')} - /> -
-
- (item ? item : '')} - /> -
-
- (item ? item : '')} - /> -
-
- -
- {}} - > -

Modal content here

-
-
- -
-
- - - - {headers.map((header) => ( - - {header} - - ))} - - - - {rows.map((row) => ( - - {Object.keys(row) - .filter((key) => key !== 'id') - .map((key) => { - return {row[key]}; - })} - - ))} - -
-
-
-
- ); -}; - -export default Reports; diff --git a/frontend/src/services/OpeningService.ts b/frontend/src/services/OpeningService.ts index 81cda2e7..08be9011 100644 --- a/frontend/src/services/OpeningService.ts +++ b/frontend/src/services/OpeningService.ts @@ -4,6 +4,7 @@ import { env } from '../env'; import { RecentAction } from '../types/RecentAction'; import { OpeningPerYearChart } from '../types/OpeningPerYearChart'; import { RecentOpening } from '../types/RecentOpening'; +import { IOpeningPerYear } from '../types/IOpeningPerYear'; const backendUrl = env.VITE_BACKEND_URL; @@ -70,13 +71,6 @@ export async function fetchRecentOpenings(): Promise { } } -interface IOpeningPerYear { - orgUnitCode: string | null; - statusCode: string | null; - entryDateStart: string | null; - entryDateEnd: string | null; -} - /** * Fetch openings per year data from backend. * @@ -122,6 +116,39 @@ export async function fetchOpeningsPerYear(props: IOpeningPerYear): Promise => { + const authToken = getAuthIdToken(); + + try { + let url = `${backendUrl}/api/dashboard-metrics/submission-trends`; + if (props.orgUnitCode || props.statusCode || props.entryDateStart || props.entryDateEnd) { + url += "?"; + if (props.orgUnitCode) url += `orgUnitCode=${props.orgUnitCode}&`; + if (props.statusCode) url += `statusCode=${props.statusCode}&`; + if (props.entryDateStart) url += `entryDateStart=${props.entryDateStart}&`; + if (props.entryDateEnd) url += `entryDateEnd=${props.entryDateEnd}&`; + url = url.replace(/&$/, ""); + } + + const response = await axios.get(url, { + headers: { Authorization: `Bearer ${authToken}` } + }); + + if (response.data && Array.isArray(response.data)) { + return response.data.map(item => ({ + group: "Openings", + key: item.monthName, + value: item.amount + })); + } + + return []; + } catch (error) { + console.error("Error fetching openings per year:", error); + throw error; + } +}; + interface IFreeGrowingProps { orgUnitCode: string; clientNumber: string; diff --git a/frontend/src/services/queries/dashboard/dashboardQueries.ts b/frontend/src/services/queries/dashboard/dashboardQueries.ts new file mode 100644 index 00000000..9a3d2dfb --- /dev/null +++ b/frontend/src/services/queries/dashboard/dashboardQueries.ts @@ -0,0 +1,53 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { getAuthIdToken } from "../../AuthService"; +import { fetchOpeningsPerYearAPI } from "../../OpeningService"; +import { IOpeningPerYear } from "../../../types/IOpeningPerYear"; +import { fetchOrgUnits } from "../../search/openings"; +import { env } from "../../../env"; + +const backendUrl = env.VITE_BACKEND_URL; + +// Function to send the POST request +export const postViewedOpening = async (openingId: string): Promise => { + const authToken = getAuthIdToken(); + try { + const response = await axios.post(`${backendUrl}/api/users/recent/${openingId}`, null, { + headers: { + Authorization: `Bearer ${authToken}` + } + }); + return response.data; + } catch (error:any) { + if (error.response?.status === 403) { + throw new Error("Forbidden: You don't have permission to view this opening."); + } else { + throw new Error(error.response.data.message); + } + } + }; + + // Hook for using the mutation + export const usePostViewedOpening = () => { + return useMutation({ + mutationFn: (openingId: string) => postViewedOpening(openingId) + }); + }; + +// Custom hook to use in your component +export const useFetchOpeningsPerYear = (props: IOpeningPerYear) => { + return useQuery({ + queryKey: ['openingsPerYear', props], // Cache key including props + queryFn: () => fetchOpeningsPerYearAPI(props), // Fetch function + enabled: true, // For Conditional fetch we can use !!props.orgUnitCode || !!props.statusCode || !!props.entryDateStart || !!props.entryDateEnd + staleTime: 5 * 60 * 1000 // Cache duration (optional) + }); +}; + +export const useDistrictListQuery = () => { + return useQuery({ + queryKey: ["districtList"], + queryFn: fetchOrgUnits + }); +}; + diff --git a/frontend/src/services/queries/search/openingQueries.ts b/frontend/src/services/queries/search/openingQueries.ts index 4f5a6ebe..dd966796 100644 --- a/frontend/src/services/queries/search/openingQueries.ts +++ b/frontend/src/services/queries/search/openingQueries.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { fetchOpeningFilters, fetchOpenings, OpeningFilters } from "../../search/openings"; +import { fetchOpeningFilters, fetchOpenings, fetchUserRecentOpenings, OpeningFilters } from "../../search/openings"; export const useOpeningsQuery = (filters: OpeningFilters, enabled: boolean) => { return useQuery({ @@ -9,6 +9,15 @@ export const useOpeningsQuery = (filters: OpeningFilters, enabled: boolean) => { }); }; +export const useUserRecentOpeningQuery = (limit:number) => { + return useQuery({ + queryKey: ["userRecentOpenings"], + queryFn: () => fetchUserRecentOpenings(limit), + enabled: true, + refetchOnMount: "always" + }); +}; + export const useOpeningFiltersQuery = () => { return useQuery({ queryKey: ["openingFilters"], diff --git a/frontend/src/services/search/openings.ts b/frontend/src/services/search/openings.ts index 59c87b9f..58fe71a3 100644 --- a/frontend/src/services/search/openings.ts +++ b/frontend/src/services/search/openings.ts @@ -67,7 +67,7 @@ export const fetchOpenings = async (filters: OpeningFilters): Promise => { statusList: filters.status, // Keep it as an array entryUserId: filters.clientAcronym, cutBlockId: filters.cutBlock, - cuttinPermitId:filters.cuttingPermit, + cuttingPermitId:filters.cuttingPermit, timbermark:filters.timberMark, myOpenings: filters.openingFilters?.includes("Openings created by me") || undefined, @@ -118,6 +118,36 @@ export const fetchOpenings = async (filters: OpeningFilters): Promise => { }; }; +// Used to fetch the recent openings for a user based on a limit value +export const fetchUserRecentOpenings = async (limit: number): Promise => { + + // Retrieve the auth token + const authToken = getAuthIdToken(); + + // Make the API request with the Authorization header + const response = await axios.get(`${backendUrl}/api/user/recent-openings`, { + headers: { + Authorization: `Bearer ${authToken}` + } + }); + + // Flatten the data part of the response + const flattenedData = response.data.data.map((item: OpeningItem) => ({ + ...item, + statusCode: item.status?.code, + statusDescription: item.status?.description, + categoryCode: item.category?.code, + categoryDescription: item.category?.description, + status: undefined, // Remove the old nested status object + category: undefined // Remove the old nested category object + })); + + // Returning the modified response data with the flattened structure + return { + ...response.data, + data: flattenedData + }; +}; export const fetchCategories = async (): Promise => { // Retrieve the auth token diff --git a/frontend/src/types/IOpeningPerYear.ts b/frontend/src/types/IOpeningPerYear.ts new file mode 100644 index 00000000..4add109d --- /dev/null +++ b/frontend/src/types/IOpeningPerYear.ts @@ -0,0 +1,6 @@ +export interface IOpeningPerYear { + orgUnitCode: string | null; + statusCode: string | null; + entryDateStart: string | null; + entryDateEnd: string | null; + } \ No newline at end of file diff --git a/frontend/src/utils/DateUtils.ts b/frontend/src/utils/DateUtils.ts index a5b416ce..44b66e3d 100644 --- a/frontend/src/utils/DateUtils.ts +++ b/frontend/src/utils/DateUtils.ts @@ -12,3 +12,11 @@ export const dateStringToISO = (date: string): string => { } return ''; }; + +export const formatDateToString = (dateToFormat: Date) => { + if (!dateToFormat) return null; + const year = dateToFormat.getFullYear(); + const month = String(dateToFormat.getMonth() + 1).padStart(2, "0"); + const day = String(dateToFormat.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; diff --git a/stub/__files/forestclient/findByClientNumber_00149081.json b/stub/__files/forestclient/findByClientNumber_00149081.json new file mode 100644 index 00000000..cbec2a7f --- /dev/null +++ b/stub/__files/forestclient/findByClientNumber_00149081.json @@ -0,0 +1,7 @@ +{ + "clientNumber": "00149081", + "clientName": "PAULO CORPORATION OF MARS", + "clientStatusCode": "ACT", + "clientTypeCode": "F", + "acronym": "PGCJ" +} \ No newline at end of file diff --git a/stub/mappings/forestclient_mapping.json b/stub/mappings/forestclient_mapping.json index 47cfb36b..e1f20145 100644 --- a/stub/mappings/forestclient_mapping.json +++ b/stub/mappings/forestclient_mapping.json @@ -8,6 +8,9 @@ }, "response": { "status": 200, + "headers": { + "Content-Type": "application/json" + }, "transformers": [ "response-template" ],