diff --git a/backend/src/main/java/quarano/occasion/Occasion.java b/backend/src/main/java/quarano/occasion/Occasion.java index b2ede195c..20c29d07d 100644 --- a/backend/src/main/java/quarano/occasion/Occasion.java +++ b/backend/src/main/java/quarano/occasion/Occasion.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.Setter; +import quarano.core.Address; import quarano.core.QuaranoAggregate; import quarano.department.TrackedCase.TrackedCaseIdentifier; import quarano.occasion.Occasion.OccasionIdentifier; @@ -36,16 +37,22 @@ public class Occasion extends QuaranoAggregate { private @Setter @Column(name = "start_date") LocalDateTime start; private @Setter @Column(name = "end_date") LocalDateTime end; private @Setter String title; + private @Setter String additionalInformation; + private @Setter String contactPerson; private OccasionCode occasionCode; private TrackedCaseIdentifier trackedCaseId; + private @Setter Address address; - Occasion(String title, LocalDateTime start, LocalDateTime end, OccasionCode eventCode, + Occasion(String title, LocalDateTime start, LocalDateTime end, Address address, String additionalInformation, String contactPerson, OccasionCode eventCode, TrackedCaseIdentifier trackedCaseId) { this.id = OccasionIdentifier.of(UUID.randomUUID()); this.start = start; this.end = end; this.title = title; + this.address = address; + this.additionalInformation = additionalInformation; + this.contactPerson = contactPerson; this.visitorGroups = new ArrayList<>(); this.occasionCode = eventCode; this.trackedCaseId = trackedCaseId; diff --git a/backend/src/main/java/quarano/occasion/OccasionDataInitializer.java b/backend/src/main/java/quarano/occasion/OccasionDataInitializer.java index 643654e93..8007089f6 100644 --- a/backend/src/main/java/quarano/occasion/OccasionDataInitializer.java +++ b/backend/src/main/java/quarano/occasion/OccasionDataInitializer.java @@ -2,6 +2,8 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +import quarano.core.Address; +import quarano.core.ZipCode; import quarano.core.DataInitializer; import quarano.department.TrackedCaseDataInitializer; @@ -28,8 +30,7 @@ public class OccasionDataInitializer implements DataInitializer { */ @Override public void initialize() { - occasions.save(new Occasion("Sample event", LocalDate.now().atStartOfDay(), - LocalDate.now().plusDays(1).atStartOfDay(), OCCASION_CODE_1, TrackedCaseDataInitializer.TRACKED_CASE_MARKUS)); + LocalDate.now().plusDays(1).atStartOfDay(), new Address("Musterstraße", Address.HouseNumber.of("2"), "Musterstadt", ZipCode.of("12345")),"","", OCCASION_CODE_1, TrackedCaseDataInitializer.TRACKED_CASE_MARKUS)); } } diff --git a/backend/src/main/java/quarano/occasion/OccasionManagement.java b/backend/src/main/java/quarano/occasion/OccasionManagement.java index 4d6822d31..2b9d4f904 100644 --- a/backend/src/main/java/quarano/occasion/OccasionManagement.java +++ b/backend/src/main/java/quarano/occasion/OccasionManagement.java @@ -1,6 +1,8 @@ package quarano.occasion; import lombok.RequiredArgsConstructor; +import quarano.core.Address; +import quarano.core.ZipCode; import quarano.department.TrackedCase.TrackedCaseIdentifier; import quarano.department.TrackedCaseRepository; @@ -32,16 +34,42 @@ public class OccasionManagement { * @param title must not be {@literal null} or empty. * @param start must not be {@literal null}. * @param end must not be {@literal null}. + * @param street + * @param s + * @param postalCode + * @param city + * @param contactPerson + * @param additionalInformation * @param trackedCaseId the {@link TrackedCaseIdentifier} for the case which the {@link Occasion} to be created shall * be associated with. Must not be {@literal null}. * @return will never be {@literal null}. */ public Optional createOccasion(String title, LocalDateTime start, LocalDateTime end, - TrackedCaseIdentifier trackedCaseId) { - + String street, String houseNumber, String zipCode, String city, String additionalInformation, String contactPerson, TrackedCaseIdentifier trackedCaseId) { + Address address = new Address(street, Address.HouseNumber.of(houseNumber), city, ZipCode.of(zipCode)); return !trackedCaseRepository.existsById(trackedCaseId) ? Optional.empty() - : Optional.of(occasions.save(new Occasion(title, start, end, findValidOccasionCode(), trackedCaseId))); + : Optional.of(occasions.save(new Occasion(title, start, end, address,additionalInformation, contactPerson, findValidOccasionCode(), trackedCaseId))); + } + + /** + * Updates the {@link Occasion} that has the given {@link OccasionCode} assigned. + * + * @param trackedCaseId + * @param occasion must not be {@literal null}. + * @param id + * @return will never be {@literal null}. + */ + public Occasion updateOccasionBy(String title, LocalDateTime start, LocalDateTime end, + String street, String houseNumber, String zipCode, String city, String additionalInformation, String contactPerson, Occasion existing) { + Address address = new Address(street, Address.HouseNumber.of(houseNumber), city, ZipCode.of(zipCode)); + existing.setTitle(title); + existing.setStart(start); + existing.setEnd(end); + existing.setAddress(address); + existing.setAdditionalInformation(additionalInformation); + existing.setContactPerson(contactPerson); + return occasions.save(existing); } /** @@ -86,4 +114,8 @@ private OccasionCode findValidOccasionCode() { ? occasionCode : findValidOccasionCode(); } + + public void deleteOccasion(Occasion occasion) { + occasions.delete(occasion); + } } diff --git a/backend/src/main/java/quarano/occasion/web/OccasionController.java b/backend/src/main/java/quarano/occasion/web/OccasionController.java index 6bbafdc50..2461162d5 100644 --- a/backend/src/main/java/quarano/occasion/web/OccasionController.java +++ b/backend/src/main/java/quarano/occasion/web/OccasionController.java @@ -18,11 +18,14 @@ import org.springframework.hateoas.mediatype.hal.HalModelBuilder; import org.springframework.hateoas.server.mvc.MvcLink; import org.springframework.http.HttpEntity; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.DeleteMapping; 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -68,6 +71,44 @@ HttpEntity getOccasion(@PathVariable OccasionCode occasionCode) { .orElseGet(() -> ResponseEntity.notFound().build()); } + /** + * Update the occasion registered for the given {@link OccasionCode}. + * + * @param occasionCode must not be {@literal null}. + * @param payload + * @param errors + * @return will never be {@literal null}. + */ + @PutMapping("/hd/occasions/{occasionCode:" + OccasionCode.REGEX + "}") + HttpEntity getOccasion(@PathVariable OccasionCode occasionCode, @RequestBody OccasionsDto payload, Errors errors) { + var existing = occasions.findOccasionBy(occasionCode).orElse(null); + return MappedPayloads.of(payload, errors) + .notFoundIf(existing == null) + .map(it -> occasions.updateOccasionBy(it.title, it.start, it.end, it.street, it.houseNumber, it.zipCode, it.city, it.additionalInformation, it.contactPerson, existing)) + .map(representations::toSummary) + .concludeIfValid(ResponseEntity::ok); + } + + + /** + * Delete the occasion registered for the given {@link OccasionCode}. + * + * @param occasionCode must not be {@literal null}. + * @return will never be {@literal null}. + */ + + @DeleteMapping("/hd/occasions/{occasionCode:" + OccasionCode.REGEX + "}") + HttpEntity deleteOccasion(@PathVariable OccasionCode occasionCode) { + + return occasions.findOccasionBy(occasionCode) + .map(it -> { + occasions.deleteOccasion(it); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .build(); + }).orElseGet(() -> ResponseEntity.badRequest().build()); + } + /** * Creates a new occasion associated with the {@link TrackedCase} identified by the given * {@link TrackedCaseIdentifier}. @@ -82,7 +123,7 @@ HttpEntity postOccasions(@PathVariable("id") TrackedCaseIdentifier trackedCas @RequestBody OccasionsDto payload, Errors errors) { return MappedPayloads.of(payload, errors) - .flatMap(it -> occasions.createOccasion(it.title, it.start, it.end, trackedCaseId)) + .flatMap(it -> occasions.createOccasion(it.title, it.start, it.end, it.street, it.street, it.zipCode, it.city, it.additionalInformation, it.contactPerson, trackedCaseId)) .map(representations::toSummary) .concludeIfValid(ResponseEntity::ok); } diff --git a/backend/src/main/java/quarano/occasion/web/OccasionRepresentions.java b/backend/src/main/java/quarano/occasion/web/OccasionRepresentions.java index 0468f19ef..82757a6e8 100644 --- a/backend/src/main/java/quarano/occasion/web/OccasionRepresentions.java +++ b/backend/src/main/java/quarano/occasion/web/OccasionRepresentions.java @@ -70,6 +70,18 @@ public static class OccasionsDto { * The end date and time of the occasion. */ LocalDateTime end; + + @Pattern(regexp = Strings.STREET) String street; + + @Pattern(regexp = Strings.HOUSE_NUMBER) String houseNumber; + + @Pattern(regexp = ZipCode.PATTERN) String zipCode; + + @Pattern(regexp = Strings.CITY) String city; + + @Textual String contactPerson; + + @Textual String additionalInformation; } interface OccasionSummary { @@ -80,6 +92,12 @@ interface OccasionSummary { LocalDateTime getEnd(); + OccasionAddress getAddress(); + + String getAdditionalInformation(); + + String getContactPerson(); + /** * An 8-digit occasion code to be handed to location owners or third-party software to report visitor groups. Note * the absence of characters that might be ambiguous when transmitted verbally or in hand writing (I, J, 1, O, 0). @@ -90,6 +108,18 @@ interface OccasionSummary { List getVisitorGroups(); + interface OccasionAddress{ + + String getStreet(); + + String getHouseNumber(); + + String getZipCode(); + + String getCity(); + + } + interface VisitorGroupSummary { List getVisitors(); diff --git a/backend/src/main/resources/db/migration/V1025__Extend_Occasions.sql b/backend/src/main/resources/db/migration/V1025__Extend_Occasions.sql new file mode 100644 index 000000000..e2d83f4af --- /dev/null +++ b/backend/src/main/resources/db/migration/V1025__Extend_Occasions.sql @@ -0,0 +1,7 @@ +ALTER TABLE occasions ADD city varchar(255) NULL; +ALTER TABLE occasions ADD house_number varchar(255) NULL; +ALTER TABLE occasions ADD street varchar(255) NULL; +ALTER TABLE occasions ADD zipcode varchar(255) NULL; +ALTER TABLE occasions ADD contact_person varchar(255) NULL; +ALTER TABLE occasions ADD additional_information varchar(255) NULL; + diff --git a/backend/src/test/java/quarano/occasion/GDPRDeleteJobIntegrationTests.java b/backend/src/test/java/quarano/occasion/GDPRDeleteJobIntegrationTests.java index 3750845ff..183008865 100644 --- a/backend/src/test/java/quarano/occasion/GDPRDeleteJobIntegrationTests.java +++ b/backend/src/test/java/quarano/occasion/GDPRDeleteJobIntegrationTests.java @@ -92,7 +92,7 @@ void testDeleteVisitors() { private void createOccasion(String title, String locationName, String... visitorNames) { - var event = occasions.createOccasion(title, today.atStartOfDay(), today.plusDays(1).atStartOfDay(), +/* TODO 614 var event = occasions.createOccasion(title, today.atStartOfDay(), today.plusDays(1).atStartOfDay(), TrackedCaseDataInitializer.TRACKED_CASE_MARKUS).orElseThrow(); var visitors = Arrays.stream(visitorNames) @@ -103,6 +103,6 @@ private void createOccasion(String title, String locationName, String... visitor .setLocationName(locationName) .setVisitors(visitors); - occasions.registerVisitorGroupForEvent(event.getOccasionCode(), visitorGroup); + occasions.registerVisitorGroupForEvent(event.getOccasionCode(), visitorGroup);*/ } } diff --git a/backend/src/test/java/quarano/occasion/OccasionManagementIntegrationTests.java b/backend/src/test/java/quarano/occasion/OccasionManagementIntegrationTests.java index d67104111..ce4fbb789 100644 --- a/backend/src/test/java/quarano/occasion/OccasionManagementIntegrationTests.java +++ b/backend/src/test/java/quarano/occasion/OccasionManagementIntegrationTests.java @@ -27,7 +27,7 @@ class OccasionManagementIntegrationTests { void testCreateEvent() { var event = occasions.createOccasion("TestEvent", today.atStartOfDay(), today.plusDays(1).atStartOfDay(), - TrackedCaseDataInitializer.TRACKED_CASE_MARKUS); + "Musterstraße", "2", "12345", "Musterstadt", "Event Location ftw", "Oma Gerda", TrackedCaseDataInitializer.TRACKED_CASE_MARKUS); assertThat(event).map(Occasion::getOccasionCode).isPresent(); } @@ -36,7 +36,7 @@ void testCreateEvent() { void testCreateEventAndAddGroup() { var event = occasions.createOccasion("TestEvent 2", today.atStartOfDay(), today.plusDays(1).atStartOfDay(), - TrackedCaseDataInitializer.TRACKED_CASE_MARKUS).orElseThrow(); + "Musterstraße", "2", "12345", "Musterstadt", "Event Location ftw", "Oma Gerda", TrackedCaseDataInitializer.TRACKED_CASE_MARKUS).orElseThrow(); var visitorGroup = new VisitorGroup(today.atStartOfDay(), event.getOccasionCode()) .setComment("Comment") diff --git a/backend/src/test/java/quarano/occasion/web/OccasionControllerWebIntegrationTests.java b/backend/src/test/java/quarano/occasion/web/OccasionControllerWebIntegrationTests.java index f837e9499..60de06442 100644 --- a/backend/src/test/java/quarano/occasion/web/OccasionControllerWebIntegrationTests.java +++ b/backend/src/test/java/quarano/occasion/web/OccasionControllerWebIntegrationTests.java @@ -11,6 +11,7 @@ import quarano.QuaranoWebIntegrationTest; import quarano.WithQuaranoUser; import quarano.department.TrackedCaseDataInitializer; +import quarano.occasion.OccasionCode; import quarano.occasion.web.OccasionRepresentions.OccasionsDto; import java.time.LocalDateTime; @@ -40,7 +41,7 @@ class OccasionControllerWebIntegrationTests extends AbstractDocumentation { void createOccasionTest() throws Exception { var now = LocalDateTime.now(); - var payload = new OccasionsDto("Omas 80. Geburtstag", now.minusDays(7), now.minusDays(6)); + var payload = new OccasionsDto("Omas 80. Geburtstag", now.minusDays(7), now.minusDays(6),"Musterstraße","2","12435", "Musterstadt", "Max Mustermann", "War eine nette Feier"); var respones = mvc.perform(post("/hd/cases/{id}/occasions", TrackedCaseDataInitializer.TRACKED_CASE_MARKUS) .content(objectMapper.writeValueAsString(payload)) @@ -54,4 +55,85 @@ void createOccasionTest() throws Exception { assertThat(parseDoc.read("$.occasionCode", String.class)).isNotBlank(); } + + @Test // CORE-613 + @WithQuaranoUser("admin") + void deleteOccasionTest() throws Exception { + + var respones = mvc.perform(get("/hd/occasions") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(flow.document("get-occasions", + responseFields().responseBodyAsType(OccasionRepresentions.OccasionSummary.class))) + .andReturn().getResponse().getContentAsString(); + + var parseDoc = JsonPath.parse(respones); + String occasionCode = parseDoc.read("$._embedded.occasions[0].occasionCode", String.class); + assertThat(occasionCode).isNotBlank(); + + mvc.perform(delete("/hd/occasions/{occasion-code}", occasionCode) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(flow.document("delete-occasion", + responseFields().responseBodyAsType(OccasionRepresentions.OccasionSummary.class))); + + } + + @Test // CORE-613 + @WithQuaranoUser("admin") + void updateOccasionTest() throws Exception { + + var respones = mvc.perform(get("/hd/occasions") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(flow.document("get-occasions", + responseFields().responseBodyAsType(OccasionRepresentions.OccasionSummary.class))) + .andReturn().getResponse().getContentAsString(); + + var parseDoc = JsonPath.parse(respones); + String occasionCode = parseDoc.read("$._embedded.occasions[0].occasionCode", String.class); + assertThat(occasionCode).isNotBlank(); + + var now = LocalDateTime.now(); + var dtoUpdate = new OccasionsDto("Omas 79. Geburtstag", now.minusDays(7), now.minusDays(6), "Musterstraße", "2", "54321", "Musterstadt", "Max Mustermann", "War eine nette Feier"); + + mvc.perform(put("/hd/occasions/{occasion-code}", occasionCode) + .content(objectMapper.writeValueAsString(dtoUpdate)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(flow.document("update-occasions", + responseFields().responseBodyAsType(OccasionRepresentions.OccasionSummary.class))); + + var responseUpdated = mvc.perform(get("/hd/occasions/{occasion-code}", occasionCode) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(flow.document("get-occasions", + responseFields().responseBodyAsType(OccasionRepresentions.OccasionSummary.class))) + .andReturn().getResponse().getContentAsString(); + + var parseDocUpdated = JsonPath.parse(responseUpdated); + String occasionCodeUpdated = parseDocUpdated.read("$.occasionCode", String.class); + String zipCodeUpdated = parseDocUpdated.read("$.address.zipCode", String.class); + String titleUpdated = parseDocUpdated.read("$.title", String.class); + + assertThat(occasionCodeUpdated).isEqualTo(occasionCode); + assertThat(zipCodeUpdated).isEqualTo("54321"); + assertThat(titleUpdated).isEqualTo("Omas 79. Geburtstag"); + } + + @Test // CORE-613 + @WithQuaranoUser("admin") + void updateOccasionWithInvalidOccasionCodeTest() throws Exception { + OccasionCode occasionCode = OccasionCode.of("INVALID"); + + var now = LocalDateTime.now(); + var payload = new OccasionsDto("Omas 80. Geburtstag", now.minusDays(7), now.minusDays(6), "Musterstraße", "2", "12435", "Musterstadt", "Max Mustermann", "War eine nette Feier"); + + mvc.perform(put("/hd/occasions/{occasion-code}", occasionCode) + .content(objectMapper.writeValueAsString(payload)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andDo(flow.document("update-occasions", + responseFields().responseBodyAsType(OccasionRepresentions.OccasionSummary.class))); + } } diff --git a/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s1.spec.ts b/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s1.spec.ts index 6a15f3003..c4d926a07 100644 --- a/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s1.spec.ts +++ b/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s1.spec.ts @@ -117,6 +117,7 @@ describe('S1 - Externe PLZ führt zu Status externe PLZ', { defaultCommandTimeou cy.wait('@getEmailText').its('response.statusCode').should('eq', 200); cy.get('qro-client-mail > div > pre') .should('exist') + .should('be.visible') .extractActivationCode(0, 'index') .then((extractedActivationCode) => { // 15 - Logout als GAMA @@ -296,6 +297,7 @@ describe('S1 - Externe PLZ führt zu Status externe PLZ', { defaultCommandTimeou cy.wait('@getEmailText').its('response.statusCode').should('eq', 200); cy.get('qro-client-mail > div > pre') .should('exist') + .should('be.visible') .extractActivationCode(0, 'contact') .then((extractedActivationCode) => { // 48 - Logout als GAMA diff --git a/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s2.spec.ts b/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s2.spec.ts index 6b6d2922a..c03e92782 100644 --- a/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s2.spec.ts +++ b/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s2.spec.ts @@ -81,7 +81,7 @@ describe( }); cy.get('[data-cy="start-tracking-button"]').should('be.enabled'); - cy.get('.mat-tab-links').children().should('have.length', 5); + cy.get('.mat-tab-links').children().should('have.length', 6); cy.get('[data-cy="start-tracking-button"]').click(); cy.wait('@registration').its('status').should('eq', 200); diff --git a/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s3.spec.ts b/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s3.spec.ts index 4189fd61f..416c9cf28 100644 --- a/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s3.spec.ts +++ b/frontend/apps/quarano-frontend-e2e/src/integration/scenario/s3.spec.ts @@ -93,7 +93,7 @@ describe( }); cy.get('[data-cy="start-tracking-button"]').should('be.enabled'); - cy.get('.mat-tab-links').children().should('have.length', 4); + cy.get('.mat-tab-links').children().should('have.length', 5); cy.get('[data-cy="start-tracking-button"]').click(); cy.wait('@registration').its('status').should('eq', 200); diff --git a/frontend/apps/quarano-frontend/src/assets/i18n/de.json b/frontend/apps/quarano-frontend/src/assets/i18n/de.json index c78f9e845..f5e01ba12 100644 --- a/frontend/apps/quarano-frontend/src/assets/i18n/de.json +++ b/frontend/apps/quarano-frontend/src/assets/i18n/de.json @@ -35,7 +35,8 @@ "ZURÜCK": "Zurück" }, "CASE_DETAIL": { - "DIARY": "Tagebuch" + "DIARY": "Tagebuch", + "EVENTS": "Ereignisse" }, "CHANGE_PASSWORD": { "BISHERIGES_PASSWORT": "Bisheriges Passwort", @@ -389,7 +390,8 @@ "USERNAME": "Bitte geben Sie einen gültigen Benutzernamen ein! Dieser kann Buchstaben, Zahlen, Binde- oder Unterstriche enthalten", "USERNAME_INVALID": "Der angegebene Benutzername kann nicht verwendet werden", "UUID": "Bitte geben Sie eine gültige UUID ein", - "ZIP": "Bitte geben Sie eine gültige Postleitzahl aus 5 Ziffern an" + "ZIP": "Bitte geben Sie eine gültige Postleitzahl aus 5 Ziffern an", + "TIMESTAMP": "Benötigtes Format hh:mm (20:15)" }, "WELCOME": { "INDEXFÄLLE": "Indexfälle", diff --git a/frontend/libs/health-department/domain/src/lib/data-access/health-department.service.ts b/frontend/libs/health-department/domain/src/lib/data-access/health-department.service.ts index 169e08d19..89a3e4afc 100644 --- a/frontend/libs/health-department/domain/src/lib/data-access/health-department.service.ts +++ b/frontend/libs/health-department/domain/src/lib/data-access/health-department.service.ts @@ -8,6 +8,7 @@ import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; import { AuthStore, CaseType, HealthDepartmentDto } from '@qro/auth/api'; import * as moment from 'moment'; import { Moment } from 'moment'; +import { OccasionDto } from '../model/occasion'; @Injectable({ providedIn: 'root', @@ -93,6 +94,24 @@ export class HealthDepartmentService { .pipe(shareReplay()); } + addOccasion(caseId: string, event: any): Observable { + return this.httpClient.post(`${this.apiUrl}/hd/cases/${caseId}/occasions`, event).pipe(shareReplay()); + } + + getOccasion(): Observable { + return this.httpClient.get(`${this.apiUrl}/hd/occasions`).pipe(shareReplay()); + } + + deleteOccasion(occasion: any): Observable { + return this.httpClient.delete(occasion._links.self.href).pipe(shareReplay()); + } + + editOccasion(occasionCode: string, occasion: OccasionDto): Observable { + return this.httpClient + .put(`${this.apiUrl}/hd/occasions/${occasionCode}`, occasion) + .pipe(shareReplay()); + } + public get healthDepartment$(): Observable { return this.authStore.user$.pipe( distinctUntilChanged(), diff --git a/frontend/libs/health-department/domain/src/lib/model/occasion.ts b/frontend/libs/health-department/domain/src/lib/model/occasion.ts new file mode 100644 index 000000000..eae030f5a --- /dev/null +++ b/frontend/libs/health-department/domain/src/lib/model/occasion.ts @@ -0,0 +1,27 @@ +export interface OccasionDto { + start: Date; + end: Date; + title: string; + address: Address; + additionalInformation: string; + contactPerson: string; + visitorGroups: VisitorGroup[]; + occasionCode: string; + trackedCaseId: string; +} + +interface Address { + street: string; + houseNumber: number; + city: string; + zipCode: number; +} + +interface VisitorGroup { + visitors: []; + start: Date; + end: Date; + occasionCode: string; + comment: string; + locationName: string; +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.html b/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.html index 911712ac2..9364461e5 100644 --- a/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.html +++ b/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.html @@ -176,6 +176,16 @@ > {{ 'CASE_DETAIL.DIARY' | translate }} + + {{ 'CASE_DETAIL.EVENTS' | translate }}{{ (occasions$ | async)?.length }} + diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.ts b/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.ts index 942bfa6a8..18cf31fbc 100644 --- a/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.ts +++ b/frontend/libs/health-department/feature-case-detail/src/lib/case-detail/case-detail.component.ts @@ -16,6 +16,8 @@ import { ConfirmationDialogComponent } from '@qro/shared/ui-confirmation-dialog' import { CloseCaseDialogComponent } from '../close-case-dialog/close-case-dialog.component'; import { ApiService, HalResponse } from '@qro/shared/util-data-access'; import { CaseType } from '@qro/auth/api'; +import { OccasionService } from '../occasion/occasion.service'; +import { OccasionDto } from '../../../../domain/src/lib/model/occasion'; @Component({ selector: 'qro-case-detail', @@ -31,6 +33,7 @@ export class CaseDetailComponent implements OnDestroy { caseLabel$: Observable; ClientType = CaseType; caseDetail$: Observable; + occasions$: Observable; constructor( private route: ActivatedRoute, @@ -39,7 +42,8 @@ export class CaseDetailComponent implements OnDestroy { private apiService: ApiService, private dialog: MatDialog, private entityService: CaseEntityService, - private router: Router + private router: Router, + private occasionService: OccasionService ) { this.initData(); this.setCaseLabel(); @@ -74,6 +78,8 @@ export class CaseDetailComponent implements OnDestroy { return this.entityService.emptyCase; }) ); + + this.occasions$ = this.occasionService.getOccasions(); } getStartTrackingTitle(caseDetail: CaseDto, buttonIsDisabled: boolean): string { diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/health-department-feature-case-detail.module.ts b/frontend/libs/health-department/feature-case-detail/src/lib/health-department-feature-case-detail.module.ts index 9da1e509f..a39867ce4 100644 --- a/frontend/libs/health-department/feature-case-detail/src/lib/health-department-feature-case-detail.module.ts +++ b/frontend/libs/health-department/feature-case-detail/src/lib/health-department-feature-case-detail.module.ts @@ -26,6 +26,10 @@ import { AnomalyComponent } from './anomaly/anomaly.component'; import { ActionComponent } from './action/action.component'; import { DiaryEntriesListComponent } from './diary-entries-list/diary-entries-list.component'; import { DiaryEntriesListItemComponent } from './diary-entries-list-item/diary-entries-list-item.component'; +import { OccasionListComponent } from './occasion/occasion-list/occasion-list.component'; +import { OccasionCardComponent } from './occasion/occasion-card/occasion-card.component'; +import { OccasionDetailDialogComponent } from './occasion/occasion-detail-dialog/occasion-detail-dialog.component'; +import { NgxMaterialTimepickerModule } from 'ngx-material-timepicker'; const routes: Routes = [ { @@ -88,6 +92,10 @@ const routes: Routes = [ path: 'diary', component: DiaryEntriesListComponent, }, + { + path: 'events', + component: OccasionListComponent, + }, ], }, ]; @@ -106,6 +114,9 @@ const routes: Routes = [ ContactListComponent, DiaryEntriesListComponent, DiaryEntriesListItemComponent, + OccasionListComponent, + OccasionCardComponent, + OccasionDetailDialogComponent, ], imports: [ CommonModule, @@ -120,6 +131,7 @@ const routes: Routes = [ SharedUiMultipleAutocompleteModule, SharedUtilTranslationModule, SharedUiAgGridModule, + NgxMaterialTimepickerModule, ], }) export class HealthDepartmentFeatureCaseDetailModule {} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.html b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.html new file mode 100644 index 000000000..2109f0c2c --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.html @@ -0,0 +1,24 @@ + +

{{ occasion.title }}

+ + +
Ereignis Code: {{ occasion?.occasionCode }}
+
Anzahl Teilnehmer: {{ occasion?.visitorGroups?.length }}
+
Start: {{ occasion?.start | date: 'EEEEEE, dd.MM.y' }}
+
Ende: {{ occasion?.end | date: 'EEEEEE, dd.MM.y' }}
+
+ + + + + +
diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.scss b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.scss new file mode 100644 index 000000000..10574e530 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.scss @@ -0,0 +1,4 @@ +mat-card-footer { + display: flex; + justify-content: flex-end; +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.spec.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.spec.ts new file mode 100644 index 000000000..a706eefb0 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OccasionCardComponent } from './occasion-card.component'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('occasionCardComponent', () => { + let component: OccasionCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OccasionCardComponent], + imports: [ + TranslateModule.forRoot(), + FormsModule, + ReactiveFormsModule, + MatDatepickerModule, + HttpClientTestingModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OccasionCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.ts new file mode 100644 index 000000000..b1b6ae0dc --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-card/occasion-card.component.ts @@ -0,0 +1,58 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { OccasionDetailDialogComponent } from '../occasion-detail-dialog/occasion-detail-dialog.component'; +import { filter, take } from 'rxjs/operators'; +import { MatDialog } from '@angular/material/dialog'; +import { OccasionDto } from '../../../../../domain/src/lib/model/occasion'; +import { ConfirmationDialogComponent } from '@qro/shared/ui-confirmation-dialog'; +import { ConfirmDialogData } from '@qro/client/ui-contact-person-detail'; + +@Component({ + selector: 'qro-occasion-card', + templateUrl: './occasion-card.component.html', + styleUrls: ['./occasion-card.component.scss'], +}) +export class OccasionCardComponent { + @Input() + occasion: OccasionDto; + + @Output() + saveOccasionEvent = new EventEmitter(); + + @Output() + deleteOccasionEvent = new EventEmitter(); + + constructor(private dialog: MatDialog) {} + + deleteOccasion() { + this.openConfirmDialog() + .afterClosed() + .pipe(filter((response) => !!response)) + .subscribe((_) => this.deleteOccasionEvent.emit(this.occasion)); + } + + editOccasion(occasion: OccasionDto) { + const dialogRef = this.dialog.open(OccasionDetailDialogComponent); + dialogRef.componentInstance.occasion(occasion); + + dialogRef + .afterClosed() + .pipe( + take(1), + filter((occasionData) => occasionData) + ) + .subscribe((occasionData) => { + this.saveOccasionEvent.emit(occasionData); + }); + } + + private openConfirmDialog() { + const dialogData: ConfirmDialogData = { + text: 'Möchten Sie dieses Ereignis löschen?', + title: 'Ereignis löschen', + abortButtonText: 'Abbrechen', + confirmButtonText: 'Löschen', + }; + + return this.dialog.open(ConfirmationDialogComponent, { data: dialogData }); + } +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.html b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.html new file mode 100644 index 000000000..6bdfb8d17 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.html @@ -0,0 +1,148 @@ +

Ereignis Details

+ + +
+ + Name + + Name + + + + Ansprechpartner + + + + + Startdatum + + + + Dies ist ein Pflichtfeld + + +
+ + Startdatum +
+ + +
+ + {{ validationErrorService.getErrorMessage(occasionFormGroup.controls.timeStart, error) | async }} + +
+ +
+ + + Enddatum + + + + Dies ist ein Pflichtfeld + + +
+ + Uhrzeit bis +
+ + +
+ Benötigtes Format hh:mm (20:15) + + {{ validationErrorService.getErrorMessage(occasionFormGroup.controls.timeEnd, error) | async }} + +
+ +
+ + + Straße + + + {{ validationErrorService.getErrorMessage(occasionFormGroup.controls.street, error) | async }} + + + + + Hausnummer + + + {{ validationErrorService.getErrorMessage(occasionFormGroup.controls.houseNumber, error) | async }} + + + + + PLZ + + Dies ist ein Pflichtfeld + + {{ validationErrorService.getErrorMessage(occasionFormGroup.controls.zipCode, error) | async }} + + + + + Stadt + + + {{ validationErrorService.getErrorMessage(occasionFormGroup.controls.city, error) | async }} + + + +
+ Zusätzliche Beschreibung + + + + +
+ + + Ereignis Code + + +
+
+ + + + + + + + + + + + diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.scss b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.scss new file mode 100644 index 000000000..7a1d9e1a9 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.scss @@ -0,0 +1,46 @@ +form { + display: flex; + justify-content: space-evenly; +} + +.event-sub-content { + display: flex; + flex-direction: column; + justify-content: space-evenly; +} + +mat-form-field { + margin: 20px; +} + +.new-event-form { + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(4, auto); +} + +.additional-info { + padding-right: 55px; + grid-column: 1 / 5; + grid-row: 6; +} + +.timepicker-input { + display: flex; + + .mat-icon-button { + line-height: 13px; + width: 14px; + height: 14px; + } + + .material-icons { + font-size: 14px; + } + + .mat-icon-button i, + .mat-icon-button .mat-icon { + line-height: 14px; + padding-right: 10px; + } +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.spec.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.spec.ts new file mode 100644 index 000000000..e039ff8b1 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OccasionDetailDialogComponent } from './occasion-detail-dialog.component'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('OccasionDetailDialogComponent', () => { + let component: OccasionDetailDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OccasionDetailDialogComponent], + imports: [ + TranslateModule.forRoot(), + FormsModule, + ReactiveFormsModule, + MatDatepickerModule, + HttpClientTestingModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OccasionDetailDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.ts new file mode 100644 index 000000000..9ec630db7 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-detail-dialog/occasion-detail-dialog.component.ts @@ -0,0 +1,171 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef } from '@angular/material/dialog'; +import { OccasionDto } from '../../../../../domain/src/lib/model/occasion'; +import { NgxMaterialTimepickerTheme } from 'ngx-material-timepicker'; +import { TrimmedPatternValidator, VALIDATION_PATTERNS, ValidationErrorService } from '@qro/shared/util-forms'; + +@Component({ + selector: 'qro-occasion-detail-dialog', + templateUrl: './occasion-detail-dialog.component.html', + styleUrls: ['./occasion-detail-dialog.component.scss'], +}) +export class OccasionDetailDialogComponent implements OnInit { + initialOccasion: OccasionDto; + occasionFormGroup: FormGroup; + timepickerTheme: NgxMaterialTimepickerTheme; + + @Input() + occasion(occasion: OccasionDto) { + this.initialOccasion = occasion; + } + + constructor( + public dialogRef: MatDialogRef, + private builder: FormBuilder, + public validationErrorService: ValidationErrorService + ) { + this.initialOccasion = { + additionalInformation: '', + address: undefined, + contactPerson: '', + end: undefined, + occasionCode: '', + start: undefined, + title: '', + trackedCaseId: '', + visitorGroups: [], + }; + this.occasionFormGroup = this.builder.group(this.mapOccasionToForm()); + this.timepickerTheme = { + container: { + buttonColor: '#5ce1e6', + }, + clockFace: { + clockHandColor: '#5ce1e6', + }, + dial: { + dialBackgroundColor: '#5ce1e6', + }, + }; + } + + ngOnInit() { + this.occasionFormGroup = this.mapOccasionToForm(); + this.initializeValidators(); + } + + private mapTimestampToString(timestamp: Date) { + if (!timestamp) { + return ''; + } + const timestampHour = this.mapZeros(timestamp?.getHours()); + const timestampMinute = this.mapZeros(timestamp?.getMinutes()); + return timestampHour.concat(':').concat(timestampMinute); + } + + private mapZeros(time: number) { + const t = time?.toString(); + if (t.length === 1) { + return '0'.concat(t); + } + return t; + } + + setPickedStartTime(event: string) { + this.occasionFormGroup.controls.timeStart.setValue(event); + } + + setPickedEndTime(event: string) { + this.occasionFormGroup.controls.timeEnd.setValue(event); + } + + close() { + this.dialogRef.close(); + } + + closeAndSubmit() { + console.log(this.mapFormToOccasion()); + this.dialogRef.close(this.mapFormToOccasion()); + } + + private mapPickedDateAndTime(time: string, date: Date): Date { + const splitted = time.split(':'); + const hour = splitted[0]; + const minute = splitted[1]; + + if (!(date instanceof Date)) { + // @ts-ignore + date = (date as unknown).toDate(); + } + + hour ? date.setHours(Number(hour)) : date.setHours(0); + minute ? date.setMinutes(Number(minute)) : date.setMinutes(0); + return date; + } + + private initializeValidators() { + this.occasionFormGroup.controls.title.setValidators([Validators.required]); + this.occasionFormGroup.controls.start.setValidators([Validators.required]); + this.occasionFormGroup.controls.end.setValidators([Validators.required]); + this.occasionFormGroup.controls.zipCode.setValidators([ + TrimmedPatternValidator.trimmedPattern(VALIDATION_PATTERNS.zip), + Validators.required, + ]); + this.occasionFormGroup.controls.timeStart.setValidators([ + TrimmedPatternValidator.trimmedPattern(VALIDATION_PATTERNS.timestamp), + ]); + this.occasionFormGroup.controls.timeEnd.setValidators([ + TrimmedPatternValidator.trimmedPattern(VALIDATION_PATTERNS.timestamp), + ]); + this.occasionFormGroup.controls.houseNumber.setValidators([ + TrimmedPatternValidator.trimmedPattern(VALIDATION_PATTERNS.houseNumber), + ]); + this.occasionFormGroup.controls.street.setValidators([ + TrimmedPatternValidator.trimmedPattern(VALIDATION_PATTERNS.street), + ]); + this.occasionFormGroup.controls.city.setValidators([ + TrimmedPatternValidator.trimmedPattern(VALIDATION_PATTERNS.city), + ]); + } + + private mapFormToOccasion() { + const startTime = this.occasionFormGroup.controls.timeStart.value; + const endTime = this.occasionFormGroup.controls.timeEnd.value; + return { + title: this.occasionFormGroup?.controls?.title?.value, + additionalInformation: this.occasionFormGroup?.controls?.additionalInformation?.value, + end: this.mapPickedDateAndTime(endTime, this.occasionFormGroup?.controls?.end?.value), + start: this.mapPickedDateAndTime(startTime, this.occasionFormGroup?.controls?.start?.value), + houseNumber: this.occasionFormGroup?.controls?.houseNumber?.value, + street: this.occasionFormGroup?.controls?.street?.value, + zipCode: this.occasionFormGroup?.controls?.zipCode?.value, + city: this.occasionFormGroup?.controls?.city?.value, + contactPerson: this.occasionFormGroup?.controls?.contactPerson?.value, + // properties set by backend + occasionCode: this.initialOccasion?.occasionCode, + trackedCaseId: this.initialOccasion?.trackedCaseId, + visitorGroups: this.initialOccasion?.visitorGroups, + }; + } + + private mapOccasionToForm(): FormGroup { + const initialData = { + additionalInformation: this.initialOccasion?.additionalInformation, + end: this.initialOccasion?.end, + occasionCode: this.initialOccasion?.occasionCode, + start: this.initialOccasion?.start, + title: this.initialOccasion?.title, + trackedCaseId: this.initialOccasion?.trackedCaseId, + street: this.initialOccasion?.address?.street, + houseNumber: this.initialOccasion?.address?.houseNumber, + city: this.initialOccasion?.address?.city, + zipCode: this.initialOccasion?.address?.zipCode, + visitorGroups: this.initialOccasion?.visitorGroups, + contactPerson: this.initialOccasion?.contactPerson, + timeStart: this.mapTimestampToString(this.initialOccasion?.start), + timeEnd: this.mapTimestampToString(this.initialOccasion?.end), + }; + return this.builder.group(initialData); + } +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.html b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.html new file mode 100644 index 000000000..768d6c271 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.html @@ -0,0 +1,17 @@ +
+ + +

Neues Ereignis anlegen

+
+
+ +
+
diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.scss b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.scss new file mode 100644 index 000000000..f594e5a24 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.scss @@ -0,0 +1,21 @@ +.events-grid { + display: grid; + grid-gap: 20px; + grid-template-columns: repeat(3, auto); +} + +.new-event-card { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.new-event-subtitle { + position: absolute; + top: 75%; +} + +mat-card { + min-height: 208px; +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.spec.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.spec.ts new file mode 100644 index 000000000..8ee6e9773 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OccasionListComponent } from './occasion-list.component'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('OccasionListComponent', () => { + let component: OccasionListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OccasionListComponent], + imports: [ + TranslateModule.forRoot(), + FormsModule, + ReactiveFormsModule, + MatDatepickerModule, + HttpClientTestingModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OccasionListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.ts new file mode 100644 index 000000000..4841f33c2 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion-list/occasion-list.component.ts @@ -0,0 +1,92 @@ +import { Component, OnDestroy } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { SubSink } from 'subsink'; +import { filter, take, tap } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; +import { OccasionDetailDialogComponent } from '../occasion-detail-dialog/occasion-detail-dialog.component'; +import { BehaviorSubject } from 'rxjs'; +import { OccasionDto } from '../../../../../domain/src/lib/model/occasion'; +import { OccasionService } from '../occasion.service'; +import { SnackbarService } from '@qro/shared/util-snackbar'; + +@Component({ + selector: 'qro-occasion-list', + templateUrl: './occasion-list.component.html', + styleUrls: ['./occasion-list.component.scss'], +}) +export class OccasionListComponent implements OnDestroy { + subs = new SubSink(); + caseId = null; + + $$occasions: BehaviorSubject = new BehaviorSubject(null); + + constructor( + private dialog: MatDialog, + private route: ActivatedRoute, + private occasionService: OccasionService, + private snackbarService: SnackbarService + ) { + this.route.parent.paramMap + .pipe( + take(1), + tap((params) => (this.caseId = params.get('id'))) + ) + .subscribe(); + this.loadOccasions(); + } + + get occasions() { + return this.$$occasions.asObservable(); + } + + openOccasionDetailDialog() { + this.subs.add( + this.dialog + .open(OccasionDetailDialogComponent) + .afterClosed() + .pipe(filter((occasionData) => occasionData)) + .subscribe((occasionData) => this.createNewOccasion(occasionData)) + ); + } + + editOccasion(occasion: OccasionDto) { + this.occasionService + .editOccasion(occasion.occasionCode, occasion) + .pipe(take(1)) + .subscribe((_) => { + this.snackbarService.success('Ereignis erfolgreich bearbeitet'); + this.loadOccasions(); + }); + } + + deleteOccasion(occasion: OccasionDto) { + this.occasionService + .deleteOccasion(occasion) + .pipe(take(1)) + .subscribe((_) => { + this.snackbarService.success('Ereignis erfolgreich gelöscht'); + this.loadOccasions(); + }); + } + + private loadOccasions() { + this.occasionService + .getOccasions() + .pipe(take(1)) + .subscribe((occasions) => this.$$occasions.next(occasions)); + } + + private createNewOccasion(newOccasion) { + this.occasionService + .saveOccasion(this.caseId, newOccasion) + .pipe(take(1)) + .subscribe((_) => { + this.snackbarService.success('Ereignis erfolgreich erstellt'); + this.loadOccasions(); + }); + } + + ngOnDestroy(): void { + this.subs.unsubscribe(); + } +} diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion.service.spec.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion.service.spec.ts new file mode 100644 index 000000000..6d7b55548 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion.service.spec.ts @@ -0,0 +1,25 @@ +import { TestBed } from '@angular/core/testing'; + +import { OccasionService } from './occasion.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { AuthStore } from '@qro/auth/domain'; +import { API_URL } from '@qro/shared/util-data-access'; + +describe('OccasionService', () => { + let service: OccasionService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: API_URL, useValue: '' }, + { provide: AuthStore, useValue: {} }, + ], + }); + service = TestBed.inject(OccasionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion.service.ts b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion.service.ts new file mode 100644 index 000000000..3032c90e6 --- /dev/null +++ b/frontend/libs/health-department/feature-case-detail/src/lib/occasion/occasion.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { OccasionDto } from '../../../../domain/src/lib/model/occasion'; +import { switchMap, take } from 'rxjs/operators'; +import { HealthDepartmentService } from '@qro/health-department/domain'; + +@Injectable({ + providedIn: 'root', +}) +export class OccasionService { + constructor(private healthDepartmentService: HealthDepartmentService) {} + + getOccasions(): Observable { + return this.healthDepartmentService + .getOccasion() + .pipe(switchMap((occasions) => of(occasions?._embedded?.occasions))); + } + + saveOccasion(occasionCode: string, occasion: OccasionDto): Observable { + return this.healthDepartmentService.addOccasion(occasionCode, occasion); + } + + editOccasion(occasionCode: string, occasion: OccasionDto): Observable { + return this.healthDepartmentService.editOccasion(occasionCode, occasion); + } + + deleteOccasion(occasion: OccasionDto) { + return this.healthDepartmentService.deleteOccasion(occasion); + } +} diff --git a/frontend/libs/shared/ui-styles/src/lib/_alignment.scss b/frontend/libs/shared/ui-styles/src/lib/_alignment.scss index e6d24d863..b3d5d368c 100644 --- a/frontend/libs/shared/ui-styles/src/lib/_alignment.scss +++ b/frontend/libs/shared/ui-styles/src/lib/_alignment.scss @@ -14,6 +14,10 @@ text-align: center !important; } +.text-left { + text-align: left; +} + .w-100 { width: 100%; } diff --git a/frontend/libs/shared/ui-styles/src/lib/styles.scss b/frontend/libs/shared/ui-styles/src/lib/styles.scss index 73727f1ea..3bf4d5926 100644 --- a/frontend/libs/shared/ui-styles/src/lib/styles.scss +++ b/frontend/libs/shared/ui-styles/src/lib/styles.scss @@ -53,3 +53,11 @@ body { .qro-tooltip { font-size: 14px; } + +.timepicker-overlay { + z-index: 10000 !important; +} + +.timepicker-backdrop-overlay { + z-index: 10000 !important; +} diff --git a/frontend/libs/shared/util-forms/src/lib/validators/validation-patterns.ts b/frontend/libs/shared/util-forms/src/lib/validators/validation-patterns.ts index c7dc2159e..35302228c 100644 --- a/frontend/libs/shared/util-forms/src/lib/validators/validation-patterns.ts +++ b/frontend/libs/shared/util-forms/src/lib/validators/validation-patterns.ts @@ -20,6 +20,7 @@ export enum VALIDATION_PATTERNS { email = 'email', city = 'city', extReferenceNumber = 'extReferenceNumber', + timestamp = 'timestamp', } export class ValidationPattern { @@ -92,5 +93,9 @@ export class ValidationPattern { pattern: `[${characterSet}0-9\\-\\_\\/]*`, errorMessage: 'VALIDATION.EXT_REFERENCE_NUMBER', }); + this._patterns.set(VALIDATION_PATTERNS.timestamp, { + pattern: '((?:(?:0|1)\\d|2[0-3])):([0-5]\\d)', + errorMessage: 'VALIDATION.TIMESTAMP', + }); } } diff --git a/frontend/package.json b/frontend/package.json index 3ae0a78a3..d0e9cc74c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,6 +77,7 @@ "jwt-decode": "3.1.1", "moment": "2.29.1", "nan": "2.14.2", + "ngx-material-timepicker": "^5.5.3", "rxjs": "6.6.3", "subsink": "1.0.1", "zone.js": "0.10.3"