diff --git a/backend/src/main/java/com/ldpv2/controller/ApplicationController.java b/backend/src/main/java/com/ldpv2/controller/ApplicationController.java index 7a4ebc2..7714b01 100644 --- a/backend/src/main/java/com/ldpv2/controller/ApplicationController.java +++ b/backend/src/main/java/com/ldpv2/controller/ApplicationController.java @@ -1,8 +1,10 @@ package com.ldpv2.controller; import com.ldpv2.domain.enums.ApplicationStatus; +import com.ldpv2.dto.request.AddContactToApplicationRequest; import com.ldpv2.dto.request.CreateApplicationRequest; import com.ldpv2.dto.request.UpdateApplicationRequest; +import com.ldpv2.dto.response.ApplicationContactResponse; import com.ldpv2.dto.response.ApplicationResponse; import com.ldpv2.service.ApplicationService; import io.swagger.v3.oas.annotations.Operation; @@ -18,6 +20,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.UUID; @RestController @@ -118,4 +121,31 @@ public class ApplicationController { applicationService.delete(id); return ResponseEntity.noContent().build(); } + + // ========== CONTACTS MANAGEMENT ========== + + @GetMapping("/{applicationId}/contacts") + @Operation(summary = "Get application contacts", description = "Get all contacts for an application") + public ResponseEntity> getContacts(@PathVariable UUID applicationId) { + List response = applicationService.getApplicationContacts(applicationId); + return ResponseEntity.ok(response); + } + + @PostMapping("/{applicationId}/contacts") + @Operation(summary = "Add contact to application", description = "Associate a contact with an application") + public ResponseEntity addContact( + @PathVariable UUID applicationId, + @Valid @RequestBody AddContactToApplicationRequest request) { + ApplicationContactResponse response = applicationService.addContact(applicationId, request.getContactId()); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @DeleteMapping("/{applicationId}/contacts/{contactId}") + @Operation(summary = "Remove contact from application", description = "Remove a contact from an application") + public ResponseEntity removeContact( + @PathVariable UUID applicationId, + @PathVariable UUID contactId) { + applicationService.removeContact(applicationId, contactId); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/ldpv2/domain/entity/Application.java b/backend/src/main/java/com/ldpv2/domain/entity/Application.java index 8fc6db5..4a3b9e9 100644 --- a/backend/src/main/java/com/ldpv2/domain/entity/Application.java +++ b/backend/src/main/java/com/ldpv2/domain/entity/Application.java @@ -8,6 +8,8 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; /** * Application entity representing software systems @@ -17,7 +19,7 @@ import java.time.LocalDate; @Table(name = "application") @NoArgsConstructor @AllArgsConstructor -@EqualsAndHashCode(callSuper = true) +@EqualsAndHashCode(callSuper = true, exclude = {"applicationContacts"}) public class Application extends BaseEntity { @Column(nullable = false, length = 255) @@ -39,4 +41,18 @@ public class Application extends BaseEntity { @Column(name = "end_of_support_date") private LocalDate endOfSupportDate; + + @OneToMany(mappedBy = "application", cascade = CascadeType.ALL, orphanRemoval = true) + private Set applicationContacts = new HashSet<>(); + + public void addContact(Contact contact) { + ApplicationContact appContact = new ApplicationContact(); + appContact.setApplication(this); + appContact.setContact(contact); + applicationContacts.add(appContact); + } + + public void removeContact(Contact contact) { + applicationContacts.removeIf(ac -> ac.getContact().equals(contact)); + } } diff --git a/backend/src/main/java/com/ldpv2/domain/entity/ApplicationContact.java b/backend/src/main/java/com/ldpv2/domain/entity/ApplicationContact.java new file mode 100644 index 0000000..e9078c1 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/domain/entity/ApplicationContact.java @@ -0,0 +1,44 @@ +package com.ldpv2.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * Junction entity linking Applications to Contacts + */ +@Data +@Entity +@Table(name = "application_contact") +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationContact implements Serializable { + + @EmbeddedId + private ApplicationContactId id = new ApplicationContactId(); + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("applicationId") + @JoinColumn(name = "application_id") + private Application application; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("contactId") + @JoinColumn(name = "contact_id") + private Contact contact; + + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ApplicationContactId implements Serializable { + @Column(name = "application_id") + private java.util.UUID applicationId; + + @Column(name = "contact_id") + private java.util.UUID contactId; + } +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/AddContactToApplicationRequest.java b/backend/src/main/java/com/ldpv2/dto/request/AddContactToApplicationRequest.java new file mode 100644 index 0000000..a475418 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/AddContactToApplicationRequest.java @@ -0,0 +1,16 @@ +package com.ldpv2.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AddContactToApplicationRequest { + @NotNull(message = "Contact ID is required") + private UUID contactId; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/ApplicationContactResponse.java b/backend/src/main/java/com/ldpv2/dto/response/ApplicationContactResponse.java new file mode 100644 index 0000000..3562e1d --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/ApplicationContactResponse.java @@ -0,0 +1,15 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationContactResponse { + private UUID applicationId; + private ContactResponse contact; +} diff --git a/backend/src/main/java/com/ldpv2/repository/ApplicationContactRepository.java b/backend/src/main/java/com/ldpv2/repository/ApplicationContactRepository.java new file mode 100644 index 0000000..5fb825d --- /dev/null +++ b/backend/src/main/java/com/ldpv2/repository/ApplicationContactRepository.java @@ -0,0 +1,21 @@ +package com.ldpv2.repository; + +import com.ldpv2.domain.entity.ApplicationContact; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface ApplicationContactRepository extends JpaRepository { + + @Query("SELECT ac FROM ApplicationContact ac " + + "JOIN FETCH ac.contact c " + + "JOIN FETCH c.contactRole " + + "LEFT JOIN FETCH c.contactPersons cp " + + "LEFT JOIN FETCH cp.person " + + "WHERE ac.application.id = :applicationId") + List findByApplicationIdWithDetails(UUID applicationId); +} diff --git a/backend/src/main/java/com/ldpv2/service/ApplicationService.java b/backend/src/main/java/com/ldpv2/service/ApplicationService.java index c8e8b97..feaa84a 100644 --- a/backend/src/main/java/com/ldpv2/service/ApplicationService.java +++ b/backend/src/main/java/com/ldpv2/service/ApplicationService.java @@ -1,23 +1,28 @@ package com.ldpv2.service; import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.entity.ApplicationContact; import com.ldpv2.domain.entity.BusinessUnit; +import com.ldpv2.domain.entity.Contact; import com.ldpv2.domain.enums.ApplicationStatus; import com.ldpv2.dto.request.CreateApplicationRequest; import com.ldpv2.dto.request.UpdateApplicationRequest; -import com.ldpv2.dto.response.ApplicationResponse; -import com.ldpv2.dto.response.BusinessUnitSummaryResponse; +import com.ldpv2.dto.response.*; import com.ldpv2.exception.BadRequestException; import com.ldpv2.exception.ResourceNotFoundException; +import com.ldpv2.repository.ApplicationContactRepository; import com.ldpv2.repository.ApplicationRepository; import com.ldpv2.repository.BusinessUnitRepository; +import com.ldpv2.repository.ContactRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service public class ApplicationService { @@ -28,14 +33,18 @@ public class ApplicationService { @Autowired private BusinessUnitRepository businessUnitRepository; + @Autowired + private ContactRepository contactRepository; + + @Autowired + private ApplicationContactRepository applicationContactRepository; + @Transactional public ApplicationResponse create(CreateApplicationRequest request) { - // Validate business unit exists BusinessUnit businessUnit = businessUnitRepository.findById(request.getBusinessUnitId()) .orElseThrow(() -> new ResourceNotFoundException( "Business unit not found with id: " + request.getBusinessUnitId())); - // Validate dates if both are provided if (request.getEndOfSupportDate() != null && request.getEndOfLifeDate() != null) { if (request.getEndOfSupportDate().isAfter(request.getEndOfLifeDate())) { throw new BadRequestException( @@ -88,7 +97,6 @@ public class ApplicationService { application.setEndOfSupportDate(request.getEndOfSupportDate()); } - // Validate dates if both are set if (application.getEndOfSupportDate() != null && application.getEndOfLifeDate() != null) { if (application.getEndOfSupportDate().isAfter(application.getEndOfLifeDate())) { throw new BadRequestException( @@ -145,6 +153,52 @@ public class ApplicationService { applicationRepository.deleteById(id); } + @Transactional + public ApplicationContactResponse addContact(UUID applicationId, UUID contactId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + applicationId)); + + Contact contact = contactRepository.findById(contactId) + .orElseThrow(() -> new ResourceNotFoundException( + "Contact not found with id: " + contactId)); + + application.addContact(contact); + applicationRepository.save(application); + + return new ApplicationContactResponse(applicationId, mapContactToResponse(contact)); + } + + @Transactional + public void removeContact(UUID applicationId, UUID contactId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + applicationId)); + + Contact contact = contactRepository.findById(contactId) + .orElseThrow(() -> new ResourceNotFoundException( + "Contact not found with id: " + contactId)); + + application.removeContact(contact); + applicationRepository.save(application); + } + + public List getApplicationContacts(UUID applicationId) { + if (!applicationRepository.existsById(applicationId)) { + throw new ResourceNotFoundException("Application not found with id: " + applicationId); + } + + List appContacts = applicationContactRepository + .findByApplicationIdWithDetails(applicationId); + + return appContacts.stream() + .map(ac -> new ApplicationContactResponse( + applicationId, + mapContactToResponse(ac.getContact()) + )) + .collect(Collectors.toList()); + } + private ApplicationResponse mapToResponse(Application application) { BusinessUnitSummaryResponse buSummary = new BusinessUnitSummaryResponse( application.getBusinessUnit().getId(), @@ -163,4 +217,37 @@ public class ApplicationService { application.getUpdatedAt() ); } + + private ContactResponse mapContactToResponse(Contact contact) { + ContactRoleResponse roleResponse = new ContactRoleResponse( + contact.getContactRole().getId(), + contact.getContactRole().getRoleName(), + contact.getContactRole().getDescription(), + contact.getContactRole().getCreatedAt(), + contact.getContactRole().getUpdatedAt() + ); + + List personsResponse = contact.getContactPersons().stream() + .map(cp -> { + PersonResponse personResponse = new PersonResponse( + cp.getPerson().getId(), + cp.getPerson().getFirstName(), + cp.getPerson().getLastName(), + cp.getPerson().getEmail(), + cp.getPerson().getPhone(), + cp.getPerson().getCreatedAt(), + cp.getPerson().getUpdatedAt() + ); + return new PersonInContactResponse(personResponse, cp.isPrimary()); + }) + .collect(Collectors.toList()); + + return new ContactResponse( + contact.getId(), + roleResponse, + personsResponse, + contact.getCreatedAt(), + contact.getUpdatedAt() + ); + } } diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 7f2e29a..c2966f2 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -13,5 +13,6 @@ + diff --git a/backend/src/main/resources/db/changelog/v1.0/008-create-application-contact-table.xml b/backend/src/main/resources/db/changelog/v1.0/008-create-application-contact-table.xml new file mode 100644 index 0000000..28f6abf --- /dev/null +++ b/backend/src/main/resources/db/changelog/v1.0/008-create-application-contact-table.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/features/applications/application-contacts/application-contacts.component.html b/frontend/src/app/features/applications/application-contacts/application-contacts.component.html new file mode 100644 index 0000000..87033c6 --- /dev/null +++ b/frontend/src/app/features/applications/application-contacts/application-contacts.component.html @@ -0,0 +1,69 @@ +
+
+

Application Contacts

+ +
+ +
Loading contacts...
+
{{ error }}
+ +
+
+
+

{{ appContact.contact.contactRole.roleName }}

+ +
+
+
+ Primary: {{ getPrimaryPerson(appContact.contact) }} +
+
+ {{ appContact.contact.persons.length }} person(s) +
+
+
+
+ +
+ No contacts assigned to this application yet. +
+ + +
+
+
+

Add Contact

+ +
+ +
+
+ + +
+ +
+
+ No available contacts to add. +
+ +
+
+ {{ contact.contactRole.roleName }} +
{{ getPrimaryPerson(contact) }}
+
+ +
+
+
+
+
+
diff --git a/frontend/src/app/features/applications/application-contacts/application-contacts.component.scss b/frontend/src/app/features/applications/application-contacts/application-contacts.component.scss new file mode 100644 index 0000000..8ab4149 --- /dev/null +++ b/frontend/src/app/features/applications/application-contacts/application-contacts.component.scss @@ -0,0 +1,216 @@ +.contacts-container { + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h3 { + margin: 0; + } + } +} + +.btn-primary { + background-color: #3f51b5; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: #303f9f; + } +} + +.loading, .error, .empty { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { + color: #f44336; +} + +.contacts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.contact-card { + background: #f9f9f9; + border-radius: 8px; + overflow: hidden; + + .contact-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + + h4 { + margin: 0; + } + + .btn-remove { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + } + + .contact-body { + padding: 1rem; + + .primary-person { + margin-bottom: 0.5rem; + } + + .person-count { + color: #666; + font-size: 0.875rem; + } + } +} + +// Dialog styles +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.dialog { + background: white; + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + + .dialog-header { + padding: 1.5rem; + border-bottom: 1px solid #f5f5f5; + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + margin: 0; + } + + .btn-close { + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: #666; + line-height: 1; + width: 32px; + height: 32px; + + &:hover { + color: #333; + } + } + } + + .dialog-body { + padding: 1.5rem; + overflow-y: auto; + + .filter-section { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + } + + .filter-select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + } + } + + .contacts-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .no-contacts { + text-align: center; + padding: 2rem; + color: #666; + } + + .contact-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: #f9f9f9; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #f0f0f0; + } + + .contact-info { + flex: 1; + + strong { + display: block; + margin-bottom: 0.25rem; + } + + .contact-primary { + color: #666; + font-size: 0.875rem; + } + } + + .btn-add { + background-color: #4caf50; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: #45a049; + } + } + } + } + } +} diff --git a/frontend/src/app/features/applications/application-contacts/application-contacts.component.ts b/frontend/src/app/features/applications/application-contacts/application-contacts.component.ts new file mode 100644 index 0000000..ca7d085 --- /dev/null +++ b/frontend/src/app/features/applications/application-contacts/application-contacts.component.ts @@ -0,0 +1,130 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ApplicationService } from '../application.service'; +import { ContactService } from '../../contacts/contact.service'; +import { ApplicationContactResponse } from '../../../shared/models/application.model'; +import { ContactRole } from '../../../shared/models/contact.model'; + +@Component({ + selector: 'app-application-contacts', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './application-contacts.component.html', + styleUrls: ['./application-contacts.component.scss'] +}) +export class ApplicationContactsComponent implements OnInit { + @Input() applicationId!: string; + @Input() applicationName!: string; + + contacts: ApplicationContactResponse[] = []; + availableRoles: ContactRole[] = []; + loading = false; + error = ''; + + showAddDialog = false; + selectedContactRole = ''; + availableContacts: any[] = []; + + constructor( + private applicationService: ApplicationService, + private contactService: ContactService + ) {} + + ngOnInit(): void { + if (this.applicationId) { + this.loadContacts(); + this.loadContactRoles(); + } + } + + loadContacts(): void { + this.loading = true; + this.applicationService.getApplicationContacts(this.applicationId).subscribe({ + next: (contacts) => { + this.contacts = contacts; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load contacts'; + this.loading = false; + } + }); + } + + loadContactRoles(): void { + this.contactService.getContactRoles().subscribe({ + next: (roles) => { + this.availableRoles = roles; + }, + error: (err) => { + console.error('Failed to load contact roles', err); + } + }); + } + + openAddDialog(): void { + this.showAddDialog = true; + this.loadAllContacts(); + } + + closeAddDialog(): void { + this.showAddDialog = false; + this.selectedContactRole = ''; + } + + loadAllContacts(): void { + this.contactService.getContacts().subscribe({ + next: (contacts) => { + this.availableContacts = contacts.filter(c => + !this.contacts.some(ac => ac.contact.id === c.id) + ); + }, + error: (err) => { + console.error('Failed to load contacts', err); + } + }); + } + + addContact(contactId: string): void { + this.applicationService.addContactToApplication(this.applicationId, contactId).subscribe({ + next: () => { + this.loadContacts(); + this.closeAddDialog(); + }, + error: (err) => { + this.error = 'Failed to add contact'; + } + }); + } + + removeContact(contactId: string): void { + if (confirm('Remove this contact from the application?')) { + this.applicationService.removeContactFromApplication(this.applicationId, contactId).subscribe({ + next: () => { + this.loadContacts(); + }, + error: (err) => { + this.error = 'Failed to remove contact'; + } + }); + } + } + + getPrimaryPerson(contact: any): string { + const primary = contact.persons.find((p: any) => p.isPrimary); + if (primary) { + return `${primary.person.firstName} ${primary.person.lastName}`; + } + return 'No primary contact'; + } + + getFilteredContacts(): any[] { + if (!this.selectedContactRole) { + return this.availableContacts; + } + return this.availableContacts.filter(c => + c.contactRole.id === this.selectedContactRole + ); + } +} diff --git a/frontend/src/app/features/applications/application-deployments/application-deployments.component.html b/frontend/src/app/features/applications/application-deployments/application-deployments.component.html new file mode 100644 index 0000000..176c144 --- /dev/null +++ b/frontend/src/app/features/applications/application-deployments/application-deployments.component.html @@ -0,0 +1,45 @@ +
+
+

Deployment History

+ +
+ +
Loading deployments...
+
{{ error }}
+ +
+
+
+
+ Version {{ deployment.version.versionIdentifier }} +
+
+ + {{ deployment.environment.name }} + +
+
+
+
{{ deployment.deploymentDate | date:'medium' }}
+
{{ getDaysAgo(deployment.deploymentDate) }} days ago
+
by {{ deployment.deployedBy }}
+
+
+ +
+
+
+ +
+ No deployments recorded for this application yet. +
+ + +
diff --git a/frontend/src/app/features/applications/application-deployments/application-deployments.component.scss b/frontend/src/app/features/applications/application-deployments/application-deployments.component.scss new file mode 100644 index 0000000..35ec5d2 --- /dev/null +++ b/frontend/src/app/features/applications/application-deployments/application-deployments.component.scss @@ -0,0 +1,125 @@ +.deployments-container { + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h3 { + margin: 0; + } + } +} + +.btn-primary { + background-color: #3f51b5; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: #303f9f; + } +} + +.loading, .error, .empty { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { + color: #f44336; +} + +.deployments-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.deployment-item { + background: #f9f9f9; + padding: 1.5rem; + border-radius: 8px; + display: grid; + grid-template-columns: 1fr auto auto; + gap: 1rem; + align-items: center; + + .deployment-main { + .deployment-version { + margin-bottom: 0.5rem; + color: #333; + } + + .env-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + + &.env-prod { + background-color: #ffebee; + color: #c62828; + } + + &.env-non-prod { + background-color: #e3f2fd; + color: #1565c0; + } + } + } + + .deployment-meta { + color: #666; + font-size: 0.875rem; + text-align: right; + + .time-ago { + color: #999; + font-size: 0.75rem; + } + } +} + +.btn-sm { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + background-color: #2196f3; + color: white; + font-size: 0.875rem; + + &:hover { + background-color: #1976d2; + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; + + button { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #f5f5f5; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/frontend/src/app/features/applications/application-deployments/application-deployments.component.ts b/frontend/src/app/features/applications/application-deployments/application-deployments.component.ts new file mode 100644 index 0000000..cf79571 --- /dev/null +++ b/frontend/src/app/features/applications/application-deployments/application-deployments.component.ts @@ -0,0 +1,83 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { DeploymentService } from '../../deployments/deployment.service'; +import { Deployment } from '../../../shared/models/deployment.model'; +import { Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-application-deployments', + standalone: true, + imports: [CommonModule], + templateUrl: './application-deployments.component.html', + styleUrls: ['./application-deployments.component.scss'] +}) +export class ApplicationDeploymentsComponent implements OnInit { + @Input() applicationId!: string; + @Input() applicationName!: string; + + deployments: Deployment[] = []; + loading = false; + error = ''; + + page = 0; + size = 10; + totalPages = 0; + + constructor( + private deploymentService: DeploymentService, + private router: Router + ) {} + + ngOnInit(): void { + if (this.applicationId) { + this.loadDeployments(); + } + } + + loadDeployments(): void { + this.loading = true; + this.deploymentService.getDeploymentsByApplication(this.applicationId, this.page, this.size).subscribe({ + next: (data: Page) => { + this.deployments = data.content; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load deployments'; + this.loading = false; + } + }); + } + + recordDeployment(): void { + this.router.navigate(['/deployments/new'], { + queryParams: { applicationId: this.applicationId } + }); + } + + viewDetails(id: string): void { + this.router.navigate(['/deployments', id]); + } + + getDaysAgo(date: Date): number { + const now = new Date(); + const deployDate = new Date(date); + const diff = now.getTime() - deployDate.getTime(); + return Math.floor(diff / (1000 * 60 * 60 * 24)); + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + this.loadDeployments(); + } + } + + previousPage(): void { + if (this.page > 0) { + this.page--; + this.loadDeployments(); + } + } +} diff --git a/frontend/src/app/features/applications/application-detail/application-detail.component.html b/frontend/src/app/features/applications/application-detail/application-detail.component.html index 0075c51..dd7b87b 100644 --- a/frontend/src/app/features/applications/application-detail/application-detail.component.html +++ b/frontend/src/app/features/applications/application-detail/application-detail.component.html @@ -2,54 +2,117 @@
Loading...
{{ error }}
-
+
+
-

{{ application.name }}

-
- - -
-
- -
-
- +
+

{{ application.name }}

{{ getStatusDisplay(application.status) }}
- -
- - {{ application.description || '-' }} -
- -
- - {{ application.businessUnit.name }} -
- -
- - {{ application.endOfSupportDate ? (application.endOfSupportDate | date:'mediumDate') : '-' }} -
- -
- - {{ application.endOfLifeDate ? (application.endOfLifeDate | date:'mediumDate') : '-' }} -
- -
- - {{ application.createdAt | date:'medium' }} -
- -
- - {{ application.updatedAt | date:'medium' }} +
+ + +
- + +
+ + + + +
+ + +
+ +
+
+
+ + {{ application.description || '-' }} +
+ +
+ + {{ application.businessUnit.name }} +
+ +
+ + + {{ getStatusDisplay(application.status) }} + +
+ +
+ + {{ application.endOfSupportDate ? (application.endOfSupportDate | date:'mediumDate') : '-' }} +
+ +
+ + {{ application.endOfLifeDate ? (application.endOfLifeDate | date:'mediumDate') : '-' }} +
+ +
+ + {{ application.createdAt | date:'medium' }} +
+ +
+ + {{ application.updatedAt | date:'medium' }} +
+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
diff --git a/frontend/src/app/features/applications/application-detail/application-detail.component.scss b/frontend/src/app/features/applications/application-detail/application-detail.component.scss index e5a1150..e005e10 100644 --- a/frontend/src/app/features/applications/application-detail/application-detail.component.scss +++ b/frontend/src/app/features/applications/application-detail/application-detail.component.scss @@ -1,5 +1,5 @@ .container { - max-width: 800px; + max-width: 1200px; margin: 0 auto; padding: 2rem; } @@ -13,9 +13,8 @@ color: #f44336; } -.detail-card { +.detail-page { background: white; - padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } @@ -24,38 +23,75 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 2rem; - padding-bottom: 1rem; - border-bottom: 2px solid #f5f5f5; + padding: 2rem; + border-bottom: 1px solid #f5f5f5; - h1 { - margin: 0; + .header-left { + display: flex; + align-items: center; + gap: 1rem; + + h1 { + margin: 0; + } } - .actions { + .header-actions { display: flex; gap: 0.5rem; } } -.details { - margin-bottom: 2rem; +.tabs { + display: flex; + border-bottom: 2px solid #f5f5f5; + background: #fafafa; + + .tab-button { + padding: 1rem 2rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + color: #666; + transition: all 0.3s ease; + + &:hover { + background: #f5f5f5; + color: #333; + } + + &.active { + color: #3f51b5; + border-bottom-color: #3f51b5; + background: white; + } + } } -.detail-row { - display: flex; - padding: 1rem 0; - border-bottom: 1px solid #f5f5f5; +.tab-content { + padding: 2rem; + min-height: 400px; +} - label { - font-weight: 600; - width: 250px; - color: #555; - } +.detail-card { + .detail-row { + display: flex; + padding: 1rem 0; + border-bottom: 1px solid #f5f5f5; - span { - flex: 1; - color: #333; + label { + font-weight: 600; + width: 250px; + color: #555; + } + + span { + flex: 1; + color: #333; + } } } diff --git a/frontend/src/app/features/applications/application-detail/application-detail.component.ts b/frontend/src/app/features/applications/application-detail/application-detail.component.ts index 32aaf48..010f30b 100644 --- a/frontend/src/app/features/applications/application-detail/application-detail.component.ts +++ b/frontend/src/app/features/applications/application-detail/application-detail.component.ts @@ -3,11 +3,19 @@ import { CommonModule } from '@angular/common'; import { Router, ActivatedRoute } from '@angular/router'; import { ApplicationService } from '../application.service'; import { Application, ApplicationStatus } from '../../../shared/models/application.model'; +import { VersionListComponent } from '../../versions/version-list/version-list.component'; +import { ApplicationDeploymentsComponent } from '../application-deployments/application-deployments.component'; +import { ApplicationContactsComponent } from '../application-contacts/application-contacts.component'; @Component({ selector: 'app-application-detail', standalone: true, - imports: [CommonModule], + imports: [ + CommonModule, + VersionListComponent, + ApplicationDeploymentsComponent, + ApplicationContactsComponent + ], templateUrl: './application-detail.component.html', styleUrls: ['./application-detail.component.scss'] }) @@ -15,6 +23,7 @@ export class ApplicationDetailComponent implements OnInit { application?: Application; loading = false; error = ''; + activeTab: 'overview' | 'versions' | 'deployments' | 'contacts' = 'overview'; constructor( private applicationService: ApplicationService, @@ -24,6 +33,11 @@ export class ApplicationDetailComponent implements OnInit { ngOnInit(): void { const id = this.route.snapshot.paramMap.get('id'); + const tab = this.route.snapshot.queryParamMap.get('tab'); + if (tab && ['overview', 'versions', 'deployments', 'contacts'].includes(tab)) { + this.activeTab = tab as any; + } + if (id) { this.loadApplication(id); } @@ -43,6 +57,15 @@ export class ApplicationDetailComponent implements OnInit { }); } + setActiveTab(tab: 'overview' | 'versions' | 'deployments' | 'contacts'): void { + this.activeTab = tab; + this.router.navigate([], { + relativeTo: this.route, + queryParams: { tab }, + queryParamsHandling: 'merge' + }); + } + edit(): void { if (this.application) { this.router.navigate(['/applications', this.application.id, 'edit']); diff --git a/frontend/src/app/features/applications/application.service.ts b/frontend/src/app/features/applications/application.service.ts index 6d8791e..e49fee3 100644 --- a/frontend/src/app/features/applications/application.service.ts +++ b/frontend/src/app/features/applications/application.service.ts @@ -5,7 +5,9 @@ import { Application, ApplicationStatus, CreateApplicationRequest, - UpdateApplicationRequest + UpdateApplicationRequest, + ApplicationContactResponse, + AddContactToApplicationRequest } from '../../shared/models/application.model'; import { Page } from '../../shared/models/environment.model'; @@ -68,4 +70,25 @@ export class ApplicationService { deleteApplication(id: string): Observable { return this.http.delete(`${this.API_URL}/${id}`); } + + // Contact management + getApplicationContacts(applicationId: string): Observable { + return this.http.get( + `${this.API_URL}/${applicationId}/contacts` + ); + } + + addContactToApplication(applicationId: string, contactId: string): Observable { + const request: AddContactToApplicationRequest = { contactId }; + return this.http.post( + `${this.API_URL}/${applicationId}/contacts`, + request + ); + } + + removeContactFromApplication(applicationId: string, contactId: string): Observable { + return this.http.delete( + `${this.API_URL}/${applicationId}/contacts/${contactId}` + ); + } } diff --git a/frontend/src/app/shared/models/application.model.ts b/frontend/src/app/shared/models/application.model.ts index f3703f6..3670362 100644 --- a/frontend/src/app/shared/models/application.model.ts +++ b/frontend/src/app/shared/models/application.model.ts @@ -1,3 +1,5 @@ +import { ContactResponse } from './contact.model'; + export enum ApplicationStatus { IDEA = 'IDEA', IN_DEVELOPMENT = 'IN_DEVELOPMENT', @@ -35,3 +37,12 @@ export interface UpdateApplicationRequest { endOfLifeDate?: Date; endOfSupportDate?: Date; } + +export interface ApplicationContactResponse { + applicationId: string; + contact: ContactResponse; +} + +export interface AddContactToApplicationRequest { + contactId: string; +}