diff --git a/backend/src/main/java/com/ldpv2/controller/ApplicationController.java b/backend/src/main/java/com/ldpv2/controller/ApplicationController.java new file mode 100644 index 0000000..7a4ebc2 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/controller/ApplicationController.java @@ -0,0 +1,121 @@ +package com.ldpv2.controller; + +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.service.ApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/applications") +@Tag(name = "Applications", description = "Application management endpoints") +@SecurityRequirement(name = "bearerAuth") +public class ApplicationController { + + @Autowired + private ApplicationService applicationService; + + @PostMapping + @Operation(summary = "Create application", description = "Create a new application") + public ResponseEntity create(@Valid @RequestBody CreateApplicationRequest request) { + ApplicationResponse response = applicationService.create(request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @PutMapping("/{id}") + @Operation(summary = "Update application", description = "Update an existing application") + public ResponseEntity update( + @PathVariable UUID id, + @Valid @RequestBody UpdateApplicationRequest request) { + ApplicationResponse response = applicationService.update(id, request); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{id}/status") + @Operation(summary = "Update status", description = "Update application status only") + public ResponseEntity updateStatus( + @PathVariable UUID id, + @RequestParam ApplicationStatus status) { + ApplicationResponse response = applicationService.updateStatus(id, status); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @Operation(summary = "Get application", description = "Get application by ID") + public ResponseEntity getById(@PathVariable UUID id) { + ApplicationResponse response = applicationService.findById(id); + return ResponseEntity.ok(response); + } + + @GetMapping + @Operation(summary = "List applications", description = "Get paginated list of applications") + public ResponseEntity> getAll( + @RequestParam(required = false) ApplicationStatus status, + @RequestParam(required = false) UUID businessUnitId, + @RequestParam(required = false) String name, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "name") String sortBy, + @RequestParam(defaultValue = "asc") String sortDirection) { + + Sort sort = sortDirection.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + + Page response; + if (status != null || businessUnitId != null || name != null) { + response = applicationService.search(status, businessUnitId, name, pageable); + } else { + response = applicationService.findAll(pageable); + } + + return ResponseEntity.ok(response); + } + + @GetMapping("/by-status/{status}") + @Operation(summary = "Filter by status", description = "Get applications by status") + public ResponseEntity> getByStatus( + @PathVariable ApplicationStatus status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page response = applicationService.findByStatus(status, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/by-business-unit/{businessUnitId}") + @Operation(summary = "Filter by business unit", description = "Get applications by business unit") + public ResponseEntity> getByBusinessUnit( + @PathVariable UUID businessUnitId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page response = applicationService.findByBusinessUnit(businessUnitId, pageable); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete application", description = "Delete an application") + public ResponseEntity delete(@PathVariable UUID id) { + applicationService.delete(id); + 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 new file mode 100644 index 0000000..8fc6db5 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/domain/entity/Application.java @@ -0,0 +1,42 @@ +package com.ldpv2.domain.entity; + +import com.ldpv2.domain.enums.ApplicationStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * Application entity representing software systems + */ +@Data +@Entity +@Table(name = "application") +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class Application extends BaseEntity { + + @Column(nullable = false, length = 255) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private ApplicationStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "business_unit_id", nullable = false) + private BusinessUnit businessUnit; + + @Column(name = "end_of_life_date") + private LocalDate endOfLifeDate; + + @Column(name = "end_of_support_date") + private LocalDate endOfSupportDate; +} diff --git a/backend/src/main/java/com/ldpv2/domain/enums/ApplicationStatus.java b/backend/src/main/java/com/ldpv2/domain/enums/ApplicationStatus.java new file mode 100644 index 0000000..2cf07a4 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/domain/enums/ApplicationStatus.java @@ -0,0 +1,19 @@ +package com.ldpv2.domain.enums; + +public enum ApplicationStatus { + IDEA("Idea"), + IN_DEVELOPMENT("In Development"), + IN_SERVICE("In Service"), + MAINTENANCE("Maintenance"), + DECOMMISSIONED("Decommissioned"); + + private final String displayName; + + ApplicationStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/CreateApplicationRequest.java b/backend/src/main/java/com/ldpv2/dto/request/CreateApplicationRequest.java new file mode 100644 index 0000000..b086847 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/CreateApplicationRequest.java @@ -0,0 +1,34 @@ +package com.ldpv2.dto.request; + +import com.ldpv2.domain.enums.ApplicationStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateApplicationRequest { + + @NotBlank(message = "Name is required") + @Size(max = 255, message = "Name must not exceed 255 characters") + private String name; + + private String description; + + @NotNull(message = "Status is required") + private ApplicationStatus status; + + @NotNull(message = "Business unit is required") + private UUID businessUnitId; + + private LocalDate endOfLifeDate; + + private LocalDate endOfSupportDate; +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/UpdateApplicationRequest.java b/backend/src/main/java/com/ldpv2/dto/request/UpdateApplicationRequest.java new file mode 100644 index 0000000..a1bf9bf --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/UpdateApplicationRequest.java @@ -0,0 +1,29 @@ +package com.ldpv2.dto.request; + +import com.ldpv2.domain.enums.ApplicationStatus; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateApplicationRequest { + + @Size(max = 255, message = "Name must not exceed 255 characters") + private String name; + + private String description; + + private ApplicationStatus status; + + private UUID businessUnitId; + + private LocalDate endOfLifeDate; + + private LocalDate endOfSupportDate; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/ApplicationResponse.java b/backend/src/main/java/com/ldpv2/dto/response/ApplicationResponse.java new file mode 100644 index 0000000..ef3f438 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/ApplicationResponse.java @@ -0,0 +1,25 @@ +package com.ldpv2.dto.response; + +import com.ldpv2.domain.enums.ApplicationStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationResponse { + private UUID id; + private String name; + private String description; + private ApplicationStatus status; + private BusinessUnitSummaryResponse businessUnit; + private LocalDate endOfLifeDate; + private LocalDate endOfSupportDate; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/ApplicationSummaryResponse.java b/backend/src/main/java/com/ldpv2/dto/response/ApplicationSummaryResponse.java new file mode 100644 index 0000000..fc0ac06 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/ApplicationSummaryResponse.java @@ -0,0 +1,18 @@ +package com.ldpv2.dto.response; + +import com.ldpv2.domain.enums.ApplicationStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationSummaryResponse { + private UUID id; + private String name; + private ApplicationStatus status; + private String businessUnitName; +} diff --git a/backend/src/main/java/com/ldpv2/repository/ApplicationRepository.java b/backend/src/main/java/com/ldpv2/repository/ApplicationRepository.java new file mode 100644 index 0000000..f2ac82f --- /dev/null +++ b/backend/src/main/java/com/ldpv2/repository/ApplicationRepository.java @@ -0,0 +1,31 @@ +package com.ldpv2.repository; + +import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.enums.ApplicationStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface ApplicationRepository extends JpaRepository { + Page findByStatus(ApplicationStatus status, Pageable pageable); + Page findByBusinessUnitId(UUID businessUnitId, Pageable pageable); + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + Page findByStatusAndBusinessUnitId(ApplicationStatus status, UUID businessUnitId, Pageable pageable); + + @Query("SELECT a FROM Application a WHERE " + + "(:status IS NULL OR a.status = :status) AND " + + "(:businessUnitId IS NULL OR a.businessUnit.id = :businessUnitId) AND " + + "(:name IS NULL OR LOWER(a.name) LIKE LOWER(CONCAT('%', :name, '%')))") + Page search( + @Param("status") ApplicationStatus status, + @Param("businessUnitId") UUID businessUnitId, + @Param("name") String name, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/ldpv2/service/ApplicationService.java b/backend/src/main/java/com/ldpv2/service/ApplicationService.java new file mode 100644 index 0000000..c8e8b97 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/service/ApplicationService.java @@ -0,0 +1,166 @@ +package com.ldpv2.service; + +import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.entity.BusinessUnit; +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.exception.BadRequestException; +import com.ldpv2.exception.ResourceNotFoundException; +import com.ldpv2.repository.ApplicationRepository; +import com.ldpv2.repository.BusinessUnitRepository; +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.UUID; + +@Service +public class ApplicationService { + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private BusinessUnitRepository businessUnitRepository; + + @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( + "End of support date must be before end of life date"); + } + } + + Application application = new Application(); + application.setName(request.getName()); + application.setDescription(request.getDescription()); + application.setStatus(request.getStatus()); + application.setBusinessUnit(businessUnit); + application.setEndOfLifeDate(request.getEndOfLifeDate()); + application.setEndOfSupportDate(request.getEndOfSupportDate()); + + application = applicationRepository.save(application); + return mapToResponse(application); + } + + @Transactional + public ApplicationResponse update(UUID id, UpdateApplicationRequest request) { + Application application = applicationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + id)); + + if (request.getName() != null) { + application.setName(request.getName()); + } + + if (request.getDescription() != null) { + application.setDescription(request.getDescription()); + } + + if (request.getStatus() != null) { + application.setStatus(request.getStatus()); + } + + if (request.getBusinessUnitId() != null) { + BusinessUnit businessUnit = businessUnitRepository.findById(request.getBusinessUnitId()) + .orElseThrow(() -> new ResourceNotFoundException( + "Business unit not found with id: " + request.getBusinessUnitId())); + application.setBusinessUnit(businessUnit); + } + + if (request.getEndOfLifeDate() != null) { + application.setEndOfLifeDate(request.getEndOfLifeDate()); + } + + if (request.getEndOfSupportDate() != null) { + 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( + "End of support date must be before end of life date"); + } + } + + application = applicationRepository.save(application); + return mapToResponse(application); + } + + @Transactional + public ApplicationResponse updateStatus(UUID id, ApplicationStatus newStatus) { + Application application = applicationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + id)); + + application.setStatus(newStatus); + application = applicationRepository.save(application); + return mapToResponse(application); + } + + public ApplicationResponse findById(UUID id) { + Application application = applicationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + id)); + return mapToResponse(application); + } + + public Page findAll(Pageable pageable) { + return applicationRepository.findAll(pageable).map(this::mapToResponse); + } + + public Page findByStatus(ApplicationStatus status, Pageable pageable) { + return applicationRepository.findByStatus(status, pageable).map(this::mapToResponse); + } + + public Page findByBusinessUnit(UUID businessUnitId, Pageable pageable) { + return applicationRepository.findByBusinessUnitId(businessUnitId, pageable) + .map(this::mapToResponse); + } + + public Page search(ApplicationStatus status, UUID businessUnitId, + String name, Pageable pageable) { + return applicationRepository.search(status, businessUnitId, name, pageable) + .map(this::mapToResponse); + } + + @Transactional + public void delete(UUID id) { + if (!applicationRepository.existsById(id)) { + throw new ResourceNotFoundException("Application not found with id: " + id); + } + applicationRepository.deleteById(id); + } + + private ApplicationResponse mapToResponse(Application application) { + BusinessUnitSummaryResponse buSummary = new BusinessUnitSummaryResponse( + application.getBusinessUnit().getId(), + application.getBusinessUnit().getName() + ); + + return new ApplicationResponse( + application.getId(), + application.getName(), + application.getDescription(), + application.getStatus(), + buSummary, + application.getEndOfLifeDate(), + application.getEndOfSupportDate(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/resources/db/changelog/data/initial-data.xml b/backend/src/main/resources/db/changelog/data/initial-data.xml index 01f69b7..80d86c3 100644 --- a/backend/src/main/resources/db/changelog/data/initial-data.xml +++ b/backend/src/main/resources/db/changelog/data/initial-data.xml @@ -8,11 +8,11 @@ - + - - + + 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 209660a..2bd2ea3 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -13,4 +13,7 @@ + + + diff --git a/backend/src/main/resources/db/changelog/v1.0/004-create-application-table.xml b/backend/src/main/resources/db/changelog/v1.0/004-create-application-table.xml new file mode 100644 index 0000000..f94d031 --- /dev/null +++ b/backend/src/main/resources/db/changelog/v1.0/004-create-application-table.xml @@ -0,0 +1,143 @@ + + + + + + + + CREATE TYPE application_status AS ENUM ( + 'IDEA', + 'IN_DEVELOPMENT', + 'IN_SERVICE', + 'MAINTENANCE', + 'DECOMMISSIONED' + ); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- Customer Portal (Digital Services) + INSERT INTO application (name, description, status, business_unit_id, end_of_support_date, end_of_life_date) + SELECT + 'Customer Portal', + 'External customer-facing portal for self-service', + 'IN_SERVICE'::application_status, + id, + '2028-12-31'::DATE, + '2030-12-31'::DATE + FROM business_unit WHERE name = 'Digital Services'; + + -- Internal CRM (Digital Services) + INSERT INTO application (name, description, status, business_unit_id, end_of_support_date, end_of_life_date) + SELECT + 'Internal CRM', + 'Customer relationship management system', + 'IN_SERVICE'::application_status, + id, + '2027-06-30'::DATE, + '2029-06-30'::DATE + FROM business_unit WHERE name = 'Digital Services'; + + -- HR Management System (Human Resources) + INSERT INTO application (name, description, status, business_unit_id, end_of_support_date, end_of_life_date) + SELECT + 'HR Management System', + 'Employee data and payroll management', + 'IN_SERVICE'::application_status, + id, + '2029-12-31'::DATE, + '2031-12-31'::DATE + FROM business_unit WHERE name = 'Human Resources'; + + -- Financial Reporting Tool (Finance) + INSERT INTO application (name, description, status, business_unit_id, end_of_support_date, end_of_life_date) + SELECT + 'Financial Reporting Tool', + 'Automated financial reporting and analytics', + 'IN_SERVICE'::application_status, + id, + '2026-12-31'::DATE, + '2028-12-31'::DATE + FROM business_unit WHERE name = 'Finance'; + + -- Mobile App (Digital Services) + INSERT INTO application (name, description, status, business_unit_id) + SELECT + 'Mobile App', + 'Customer mobile application', + 'IN_DEVELOPMENT'::application_status, + id + FROM business_unit WHERE name = 'Digital Services'; + + -- Legacy System (Operations) + INSERT INTO application (name, description, status, business_unit_id, end_of_life_date) + SELECT + 'Legacy Inventory System', + 'Old inventory management system - to be decommissioned', + 'MAINTENANCE'::application_status, + id, + '2026-06-30'::DATE + FROM business_unit WHERE name = 'Operations'; + + -- AI Analytics Platform (Digital Services) + INSERT INTO application (name, description, status, business_unit_id) + SELECT + 'AI Analytics Platform', + 'Machine learning based analytics platform', + 'IDEA'::application_status, + id + FROM business_unit WHERE name = 'Digital Services'; + + + + + diff --git a/deploy-story-2-applications.sh b/deploy-story-2-applications.sh new file mode 100644 index 0000000..cf6f255 --- /dev/null +++ b/deploy-story-2-applications.sh @@ -0,0 +1,2238 @@ +#!/bin/bash + +# ============================================================================ +# LDPv2 - Story 2: Applications Implementation + Dashboard +# ============================================================================ + +echo "🚀 Starting deployment of Story 2 (Applications) and Dashboard..." + +BASE_BACKEND="backend/src/main/java/com/ldpv2" +BASE_FRONTEND="frontend/src/app" +BASE_RESOURCES="backend/src/main/resources" + +# ============================================================================ +# BACKEND - Database Migration +# ============================================================================ + +echo "đŸ“Ļ Creating database migration for Application entity..." + +mkdir -p "$BASE_RESOURCES/db/changelog/v1.0" + +cat > "$BASE_RESOURCES/db/changelog/v1.0/004-create-application-table.xml" << 'XML' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +XML + +# Update master changelog +cat > "$BASE_RESOURCES/db/changelog/db.changelog-master.xml" << 'XML' + + + + + + + + + + + + + + + +XML + +# ============================================================================ +# BACKEND - Enum +# ============================================================================ + +echo "đŸ“Ļ Creating ApplicationStatus enum..." + +mkdir -p "$BASE_BACKEND/domain/enums" + +cat > "$BASE_BACKEND/domain/enums/ApplicationStatus.java" << 'JAVA' +package com.ldpv2.domain.enums; + +public enum ApplicationStatus { + IDEA("Idea"), + IN_DEVELOPMENT("In Development"), + IN_SERVICE("In Service"), + MAINTENANCE("Maintenance"), + DECOMMISSIONED("Decommissioned"); + + private final String displayName; + + ApplicationStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} +JAVA + +# ============================================================================ +# BACKEND - Entity +# ============================================================================ + +echo "đŸ“Ļ Creating Application entity..." + +cat > "$BASE_BACKEND/domain/entity/Application.java" << 'JAVA' +package com.ldpv2.domain.entity; + +import com.ldpv2.domain.enums.ApplicationStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * Application entity representing software systems + */ +@Data +@Entity +@Table(name = "application") +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class Application extends BaseEntity { + + @Column(nullable = false, length = 255) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private ApplicationStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "business_unit_id", nullable = false) + private BusinessUnit businessUnit; + + @Column(name = "end_of_life_date") + private LocalDate endOfLifeDate; + + @Column(name = "end_of_support_date") + private LocalDate endOfSupportDate; +} +JAVA + +# ============================================================================ +# BACKEND - Repository +# ============================================================================ + +echo "đŸ“Ļ Creating Application repository..." + +mkdir -p "$BASE_BACKEND/repository" + +cat > "$BASE_BACKEND/repository/ApplicationRepository.java" << 'JAVA' +package com.ldpv2.repository; + +import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.enums.ApplicationStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface ApplicationRepository extends JpaRepository { + Page findByStatus(ApplicationStatus status, Pageable pageable); + Page findByBusinessUnitId(UUID businessUnitId, Pageable pageable); + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + Page findByStatusAndBusinessUnitId(ApplicationStatus status, UUID businessUnitId, Pageable pageable); + + @Query("SELECT a FROM Application a WHERE " + + "(:status IS NULL OR a.status = :status) AND " + + "(:businessUnitId IS NULL OR a.businessUnit.id = :businessUnitId) AND " + + "(:name IS NULL OR LOWER(a.name) LIKE LOWER(CONCAT('%', :name, '%')))") + Page search( + @Param("status") ApplicationStatus status, + @Param("businessUnitId") UUID businessUnitId, + @Param("name") String name, + Pageable pageable + ); +} +JAVA + +# ============================================================================ +# BACKEND - DTOs +# ============================================================================ + +echo "đŸ“Ļ Creating Application DTOs..." + +mkdir -p "$BASE_BACKEND/dto/request" +mkdir -p "$BASE_BACKEND/dto/response" + +cat > "$BASE_BACKEND/dto/request/CreateApplicationRequest.java" << 'JAVA' +package com.ldpv2.dto.request; + +import com.ldpv2.domain.enums.ApplicationStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateApplicationRequest { + + @NotBlank(message = "Name is required") + @Size(max = 255, message = "Name must not exceed 255 characters") + private String name; + + private String description; + + @NotNull(message = "Status is required") + private ApplicationStatus status; + + @NotNull(message = "Business unit is required") + private UUID businessUnitId; + + private LocalDate endOfLifeDate; + + private LocalDate endOfSupportDate; +} +JAVA + +cat > "$BASE_BACKEND/dto/request/UpdateApplicationRequest.java" << 'JAVA' +package com.ldpv2.dto.request; + +import com.ldpv2.domain.enums.ApplicationStatus; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateApplicationRequest { + + @Size(max = 255, message = "Name must not exceed 255 characters") + private String name; + + private String description; + + private ApplicationStatus status; + + private UUID businessUnitId; + + private LocalDate endOfLifeDate; + + private LocalDate endOfSupportDate; +} +JAVA + +cat > "$BASE_BACKEND/dto/response/ApplicationResponse.java" << 'JAVA' +package com.ldpv2.dto.response; + +import com.ldpv2.domain.enums.ApplicationStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationResponse { + private UUID id; + private String name; + private String description; + private ApplicationStatus status; + private BusinessUnitSummaryResponse businessUnit; + private LocalDate endOfLifeDate; + private LocalDate endOfSupportDate; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} +JAVA + +cat > "$BASE_BACKEND/dto/response/ApplicationSummaryResponse.java" << 'JAVA' +package com.ldpv2.dto.response; + +import com.ldpv2.domain.enums.ApplicationStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationSummaryResponse { + private UUID id; + private String name; + private ApplicationStatus status; + private String businessUnitName; +} +JAVA + +# ============================================================================ +# BACKEND - Service +# ============================================================================ + +echo "đŸ“Ļ Creating Application service..." + +cat > "$BASE_BACKEND/service/ApplicationService.java" << 'JAVA' +package com.ldpv2.service; + +import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.entity.BusinessUnit; +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.exception.BadRequestException; +import com.ldpv2.exception.ResourceNotFoundException; +import com.ldpv2.repository.ApplicationRepository; +import com.ldpv2.repository.BusinessUnitRepository; +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.UUID; + +@Service +public class ApplicationService { + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private BusinessUnitRepository businessUnitRepository; + + @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( + "End of support date must be before end of life date"); + } + } + + Application application = new Application(); + application.setName(request.getName()); + application.setDescription(request.getDescription()); + application.setStatus(request.getStatus()); + application.setBusinessUnit(businessUnit); + application.setEndOfLifeDate(request.getEndOfLifeDate()); + application.setEndOfSupportDate(request.getEndOfSupportDate()); + + application = applicationRepository.save(application); + return mapToResponse(application); + } + + @Transactional + public ApplicationResponse update(UUID id, UpdateApplicationRequest request) { + Application application = applicationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + id)); + + if (request.getName() != null) { + application.setName(request.getName()); + } + + if (request.getDescription() != null) { + application.setDescription(request.getDescription()); + } + + if (request.getStatus() != null) { + application.setStatus(request.getStatus()); + } + + if (request.getBusinessUnitId() != null) { + BusinessUnit businessUnit = businessUnitRepository.findById(request.getBusinessUnitId()) + .orElseThrow(() -> new ResourceNotFoundException( + "Business unit not found with id: " + request.getBusinessUnitId())); + application.setBusinessUnit(businessUnit); + } + + if (request.getEndOfLifeDate() != null) { + application.setEndOfLifeDate(request.getEndOfLifeDate()); + } + + if (request.getEndOfSupportDate() != null) { + 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( + "End of support date must be before end of life date"); + } + } + + application = applicationRepository.save(application); + return mapToResponse(application); + } + + @Transactional + public ApplicationResponse updateStatus(UUID id, ApplicationStatus newStatus) { + Application application = applicationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + id)); + + application.setStatus(newStatus); + application = applicationRepository.save(application); + return mapToResponse(application); + } + + public ApplicationResponse findById(UUID id) { + Application application = applicationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + id)); + return mapToResponse(application); + } + + public Page findAll(Pageable pageable) { + return applicationRepository.findAll(pageable).map(this::mapToResponse); + } + + public Page findByStatus(ApplicationStatus status, Pageable pageable) { + return applicationRepository.findByStatus(status, pageable).map(this::mapToResponse); + } + + public Page findByBusinessUnit(UUID businessUnitId, Pageable pageable) { + return applicationRepository.findByBusinessUnitId(businessUnitId, pageable) + .map(this::mapToResponse); + } + + public Page search(ApplicationStatus status, UUID businessUnitId, + String name, Pageable pageable) { + return applicationRepository.search(status, businessUnitId, name, pageable) + .map(this::mapToResponse); + } + + @Transactional + public void delete(UUID id) { + if (!applicationRepository.existsById(id)) { + throw new ResourceNotFoundException("Application not found with id: " + id); + } + applicationRepository.deleteById(id); + } + + private ApplicationResponse mapToResponse(Application application) { + BusinessUnitSummaryResponse buSummary = new BusinessUnitSummaryResponse( + application.getBusinessUnit().getId(), + application.getBusinessUnit().getName() + ); + + return new ApplicationResponse( + application.getId(), + application.getName(), + application.getDescription(), + application.getStatus(), + buSummary, + application.getEndOfLifeDate(), + application.getEndOfSupportDate(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} +JAVA + +# ============================================================================ +# BACKEND - Controller +# ============================================================================ + +echo "đŸ“Ļ Creating Application controller..." + +cat > "$BASE_BACKEND/controller/ApplicationController.java" << 'JAVA' +package com.ldpv2.controller; + +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.service.ApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/applications") +@Tag(name = "Applications", description = "Application management endpoints") +@SecurityRequirement(name = "bearerAuth") +public class ApplicationController { + + @Autowired + private ApplicationService applicationService; + + @PostMapping + @Operation(summary = "Create application", description = "Create a new application") + public ResponseEntity create(@Valid @RequestBody CreateApplicationRequest request) { + ApplicationResponse response = applicationService.create(request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @PutMapping("/{id}") + @Operation(summary = "Update application", description = "Update an existing application") + public ResponseEntity update( + @PathVariable UUID id, + @Valid @RequestBody UpdateApplicationRequest request) { + ApplicationResponse response = applicationService.update(id, request); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{id}/status") + @Operation(summary = "Update status", description = "Update application status only") + public ResponseEntity updateStatus( + @PathVariable UUID id, + @RequestParam ApplicationStatus status) { + ApplicationResponse response = applicationService.updateStatus(id, status); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @Operation(summary = "Get application", description = "Get application by ID") + public ResponseEntity getById(@PathVariable UUID id) { + ApplicationResponse response = applicationService.findById(id); + return ResponseEntity.ok(response); + } + + @GetMapping + @Operation(summary = "List applications", description = "Get paginated list of applications") + public ResponseEntity> getAll( + @RequestParam(required = false) ApplicationStatus status, + @RequestParam(required = false) UUID businessUnitId, + @RequestParam(required = false) String name, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "name") String sortBy, + @RequestParam(defaultValue = "asc") String sortDirection) { + + Sort sort = sortDirection.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + + Page response; + if (status != null || businessUnitId != null || name != null) { + response = applicationService.search(status, businessUnitId, name, pageable); + } else { + response = applicationService.findAll(pageable); + } + + return ResponseEntity.ok(response); + } + + @GetMapping("/by-status/{status}") + @Operation(summary = "Filter by status", description = "Get applications by status") + public ResponseEntity> getByStatus( + @PathVariable ApplicationStatus status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page response = applicationService.findByStatus(status, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/by-business-unit/{businessUnitId}") + @Operation(summary = "Filter by business unit", description = "Get applications by business unit") + public ResponseEntity> getByBusinessUnit( + @PathVariable UUID businessUnitId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page response = applicationService.findByBusinessUnit(businessUnitId, pageable); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete application", description = "Delete an application") + public ResponseEntity delete(@PathVariable UUID id) { + applicationService.delete(id); + return ResponseEntity.noContent().build(); + } +} +JAVA + +# ============================================================================ +# FRONTEND - Models +# ============================================================================ + +echo "đŸ“Ļ Creating Application models..." + +mkdir -p "$BASE_FRONTEND/shared/models" + +cat > "$BASE_FRONTEND/shared/models/application.model.ts" << 'TS' +export enum ApplicationStatus { + IDEA = 'IDEA', + IN_DEVELOPMENT = 'IN_DEVELOPMENT', + IN_SERVICE = 'IN_SERVICE', + MAINTENANCE = 'MAINTENANCE', + DECOMMISSIONED = 'DECOMMISSIONED' +} + +export interface Application { + id: string; + name: string; + description?: string; + status: ApplicationStatus; + businessUnit: { id: string; name: string }; + endOfLifeDate?: Date; + endOfSupportDate?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateApplicationRequest { + name: string; + description?: string; + status: ApplicationStatus; + businessUnitId: string; + endOfLifeDate?: Date; + endOfSupportDate?: Date; +} + +export interface UpdateApplicationRequest { + name?: string; + description?: string; + status?: ApplicationStatus; + businessUnitId?: string; + endOfLifeDate?: Date; + endOfSupportDate?: Date; +} +TS + +# ============================================================================ +# FRONTEND - Service +# ============================================================================ + +echo "đŸ“Ļ Creating Application service..." + +mkdir -p "$BASE_FRONTEND/features/applications" + +cat > "$BASE_FRONTEND/features/applications/application.service.ts" << 'TS' +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + Application, + ApplicationStatus, + CreateApplicationRequest, + UpdateApplicationRequest +} from '../../shared/models/application.model'; +import { Page } from '../../shared/models/environment.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ApplicationService { + private readonly API_URL = '/api/applications'; + + constructor(private http: HttpClient) {} + + getApplications( + filters?: { + status?: ApplicationStatus; + businessUnitId?: string; + name?: string; + }, + page: number = 0, + size: number = 20, + sortBy: string = 'name', + sortDirection: string = 'asc' + ): Observable> { + let params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()) + .set('sortBy', sortBy) + .set('sortDirection', sortDirection); + + if (filters?.status) { + params = params.set('status', filters.status); + } + if (filters?.businessUnitId) { + params = params.set('businessUnitId', filters.businessUnitId); + } + if (filters?.name) { + params = params.set('name', filters.name); + } + + return this.http.get>(this.API_URL, { params }); + } + + getApplication(id: string): Observable { + return this.http.get(`${this.API_URL}/${id}`); + } + + createApplication(data: CreateApplicationRequest): Observable { + return this.http.post(this.API_URL, data); + } + + updateApplication(id: string, data: UpdateApplicationRequest): Observable { + return this.http.put(`${this.API_URL}/${id}`, data); + } + + updateStatus(id: string, status: ApplicationStatus): Observable { + return this.http.patch(`${this.API_URL}/${id}/status`, null, { + params: { status } + }); + } + + deleteApplication(id: string): Observable { + return this.http.delete(`${this.API_URL}/${id}`); + } +} +TS + +# ============================================================================ +# FRONTEND - Components (Application List) +# ============================================================================ + +echo "đŸ“Ļ Creating Application List component..." + +mkdir -p "$BASE_FRONTEND/features/applications/application-list" + +cat > "$BASE_FRONTEND/features/applications/application-list/application-list.component.ts" << 'TS' +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { ApplicationService } from '../application.service'; +import { BusinessUnitService } from '../../business-units/business-unit.service'; +import { Application, ApplicationStatus } from '../../../shared/models/application.model'; +import { BusinessUnit } from '../../../shared/models/business-unit.model'; +import { Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-application-list', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './application-list.component.html', + styleUrls: ['./application-list.component.scss'] +}) +export class ApplicationListComponent implements OnInit { + applications: Application[] = []; + businessUnits: BusinessUnit[] = []; + loading = false; + error = ''; + + page = 0; + size = 20; + totalElements = 0; + totalPages = 0; + + // Filters + searchQuery = ''; + selectedStatus: ApplicationStatus | '' = ''; + selectedBusinessUnitId = ''; + + // Status options + statusOptions = Object.values(ApplicationStatus); + ApplicationStatus = ApplicationStatus; + + private searchSubject = new Subject(); + + constructor( + private applicationService: ApplicationService, + private businessUnitService: BusinessUnitService, + private router: Router + ) { + this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(() => { + this.page = 0; + this.loadApplications(); + }); + } + + ngOnInit(): void { + this.loadBusinessUnits(); + this.loadApplications(); + } + + loadBusinessUnits(): void { + this.businessUnitService.getBusinessUnits(0, 100).subscribe({ + next: (data) => { + this.businessUnits = data.content; + }, + error: () => { + // Silent fail for filters + } + }); + } + + loadApplications(): void { + this.loading = true; + + const filters = { + status: this.selectedStatus || undefined, + businessUnitId: this.selectedBusinessUnitId || undefined, + name: this.searchQuery || undefined + }; + + this.applicationService.getApplications(filters, this.page, this.size).subscribe({ + next: (data: Page) => { + this.applications = data.content; + this.totalElements = data.totalElements; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load applications'; + this.loading = false; + } + }); + } + + onSearchChange(query: string): void { + this.searchQuery = query; + this.searchSubject.next(query); + } + + onFilterChange(): void { + this.page = 0; + this.loadApplications(); + } + + createNew(): void { + this.router.navigate(['/applications/new']); + } + + viewDetails(id: string): void { + this.router.navigate(['/applications', id]); + } + + edit(id: string): void { + this.router.navigate(['/applications', id, 'edit']); + } + + changeStatus(id: string, newStatus: ApplicationStatus): void { + this.applicationService.updateStatus(id, newStatus).subscribe({ + next: () => { + this.loadApplications(); + }, + error: (err) => { + this.error = 'Failed to update status'; + } + }); + } + + delete(id: string): void { + if (confirm('Are you sure you want to delete this application?')) { + this.applicationService.deleteApplication(id).subscribe({ + next: () => { + this.loadApplications(); + }, + error: (err) => { + this.error = 'Failed to delete application'; + } + }); + } + } + + getStatusClass(status: ApplicationStatus): string { + const classes: { [key in ApplicationStatus]: string } = { + [ApplicationStatus.IDEA]: 'status-idea', + [ApplicationStatus.IN_DEVELOPMENT]: 'status-in-development', + [ApplicationStatus.IN_SERVICE]: 'status-in-service', + [ApplicationStatus.MAINTENANCE]: 'status-maintenance', + [ApplicationStatus.DECOMMISSIONED]: 'status-decommissioned' + }; + return classes[status]; + } + + getStatusDisplay(status: ApplicationStatus): string { + const displays: { [key in ApplicationStatus]: string } = { + [ApplicationStatus.IDEA]: 'Idea', + [ApplicationStatus.IN_DEVELOPMENT]: 'In Development', + [ApplicationStatus.IN_SERVICE]: 'In Service', + [ApplicationStatus.MAINTENANCE]: 'Maintenance', + [ApplicationStatus.DECOMMISSIONED]: 'Decommissioned' + }; + return displays[status]; + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + this.loadApplications(); + } + } + + previousPage(): void { + if (this.page > 0) { + this.page--; + this.loadApplications(); + } + } +} +TS + +cat > "$BASE_FRONTEND/features/applications/application-list/application-list.component.html" << 'HTML' +
+
+

Applications

+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
Loading...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + +
NameStatusBusiness UnitEnd of LifeActions
{{ app.name }} + + {{ getStatusDisplay(app.status) }} + + {{ app.businessUnit.name }}{{ app.endOfLifeDate ? (app.endOfLifeDate | date:'mediumDate') : '-' }} + + + + +
+
+ +
+ No applications found. Click "Create New Application" to get started. +
+ + +
+HTML + +cat > "$BASE_FRONTEND/features/applications/application-list/application-list.component.scss" << 'SCSS' +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + } +} + +.filters { + background: white; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.filter-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.filter-group { + flex: 1; + min-width: 200px; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + .search-input, + .filter-select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + + &:focus { + outline: none; + border-color: #3f51b5; + } + } +} + +.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; +} + +.table-container { + overflow-x: auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +table { + width: 100%; + border-collapse: collapse; + + th, td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid #ddd; + } + + th { + background-color: #f5f5f5; + font-weight: 600; + } + + tbody tr:hover { + background-color: #f9f9f9; + } +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + + &.status-idea { + background-color: #e3f2fd; + color: #1976d2; + } + + &.status-in-development { + background-color: #fff3e0; + color: #f57c00; + } + + &.status-in-service { + background-color: #e8f5e9; + color: #388e3c; + } + + &.status-maintenance { + background-color: #fff9c4; + color: #f57f17; + } + + &.status-decommissioned { + background-color: #f5f5f5; + color: #616161; + } +} + +.actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.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; + } + + &.btn-danger { + background-color: #f44336; + + &:hover { + background-color: #d32f2f; + } + } + + &.status-select { + background-color: #9c27b0; + + &:hover { + background-color: #7b1fa2; + } + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; + + 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; + } + } + + span { + color: #666; + } +} +SCSS + +# ============================================================================ +# FRONTEND - Application Detail Component +# ============================================================================ + +echo "đŸ“Ļ Creating Application Detail component..." + +mkdir -p "$BASE_FRONTEND/features/applications/application-detail" + +cat > "$BASE_FRONTEND/features/applications/application-detail/application-detail.component.ts" << 'TS' +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, ActivatedRoute } from '@angular/router'; +import { ApplicationService } from '../application.service'; +import { Application, ApplicationStatus } from '../../../shared/models/application.model'; + +@Component({ + selector: 'app-application-detail', + standalone: true, + imports: [CommonModule], + templateUrl: './application-detail.component.html', + styleUrls: ['./application-detail.component.scss'] +}) +export class ApplicationDetailComponent implements OnInit { + application?: Application; + loading = false; + error = ''; + + ApplicationStatus = ApplicationStatus; + + constructor( + private applicationService: ApplicationService, + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.loadApplication(id); + } + } + + loadApplication(id: string): void { + this.loading = true; + this.applicationService.getApplication(id).subscribe({ + next: (app) => { + this.application = app; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load application'; + this.loading = false; + } + }); + } + + edit(): void { + if (this.application) { + this.router.navigate(['/applications', this.application.id, 'edit']); + } + } + + delete(): void { + if (this.application && confirm('Are you sure you want to delete this application?')) { + this.applicationService.deleteApplication(this.application.id).subscribe({ + next: () => { + this.router.navigate(['/applications']); + }, + error: (err) => { + this.error = 'Failed to delete application'; + } + }); + } + } + + back(): void { + this.router.navigate(['/applications']); + } + + getStatusClass(status: ApplicationStatus): string { + const classes: { [key in ApplicationStatus]: string } = { + [ApplicationStatus.IDEA]: 'status-idea', + [ApplicationStatus.IN_DEVELOPMENT]: 'status-in-development', + [ApplicationStatus.IN_SERVICE]: 'status-in-service', + [ApplicationStatus.MAINTENANCE]: 'status-maintenance', + [ApplicationStatus.DECOMMISSIONED]: 'status-decommissioned' + }; + return classes[status]; + } + + getStatusDisplay(status: ApplicationStatus): string { + const displays: { [key in ApplicationStatus]: string } = { + [ApplicationStatus.IDEA]: 'Idea', + [ApplicationStatus.IN_DEVELOPMENT]: 'In Development', + [ApplicationStatus.IN_SERVICE]: 'In Service', + [ApplicationStatus.MAINTENANCE]: 'Maintenance', + [ApplicationStatus.DECOMMISSIONED]: 'Decommissioned' + }; + return displays[status]; + } +} +TS + +cat > "$BASE_FRONTEND/features/applications/application-detail/application-detail.component.html" << 'HTML' +
+
Loading...
+
{{ error }}
+ +
+
+

{{ 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' }} +
+
+ + +
+
+HTML + +cat > "$BASE_FRONTEND/features/applications/application-detail/application-detail.component.scss" << 'SCSS' +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.loading, .error { + text-align: center; + padding: 2rem; +} + +.error { + color: #f44336; +} + +.detail-card { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f5f5f5; + + h1 { + margin: 0; + } + + .actions { + display: flex; + gap: 0.5rem; + } +} + +.details { + margin-bottom: 2rem; +} + +.detail-row { + display: flex; + padding: 1rem 0; + border-bottom: 1px solid #f5f5f5; + + label { + font-weight: 600; + width: 250px; + color: #555; + } + + span { + flex: 1; + color: #333; + } +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + + &.status-idea { + background-color: #e3f2fd; + color: #1976d2; + } + + &.status-in-development { + background-color: #fff3e0; + color: #f57c00; + } + + &.status-in-service { + background-color: #e8f5e9; + color: #388e3c; + } + + &.status-maintenance { + background-color: #fff9c4; + color: #f57f17; + } + + &.status-decommissioned { + background-color: #f5f5f5; + color: #616161; + } +} + +.btn-primary, .btn-secondary, .btn-danger { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover { + background-color: #303f9f; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.btn-danger { + background-color: #f44336; + color: white; + + &:hover { + background-color: #d32f2f; + } +} +SCSS + +# ============================================================================ +# FRONTEND - Application Form Component +# ============================================================================ + +echo "đŸ“Ļ Creating Application Form component..." + +mkdir -p "$BASE_FRONTEND/features/applications/application-form" + +cat > "$BASE_FRONTEND/features/applications/application-form/application-form.component.ts" << 'TS' +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { ApplicationService } from '../application.service'; +import { BusinessUnitService } from '../../business-units/business-unit.service'; +import { ApplicationStatus } from '../../../shared/models/application.model'; +import { BusinessUnit } from '../../../shared/models/business-unit.model'; + +@Component({ + selector: 'app-application-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './application-form.component.html', + styleUrls: ['./application-form.component.scss'] +}) +export class ApplicationFormComponent implements OnInit { + form: FormGroup; + loading = false; + error = ''; + isEditMode = false; + applicationId?: string; + + businessUnits: BusinessUnit[] = []; + statusOptions = Object.values(ApplicationStatus); + ApplicationStatus = ApplicationStatus; + + constructor( + private fb: FormBuilder, + private applicationService: ApplicationService, + private businessUnitService: BusinessUnitService, + private router: Router, + private route: ActivatedRoute + ) { + this.form = this.fb.group({ + name: ['', [Validators.required, Validators.maxLength(255)]], + description: [''], + status: [ApplicationStatus.IDEA, [Validators.required]], + businessUnitId: ['', [Validators.required]], + endOfSupportDate: [''], + endOfLifeDate: [''] + }); + } + + ngOnInit(): void { + this.loadBusinessUnits(); + + this.applicationId = this.route.snapshot.paramMap.get('id') || undefined; + this.isEditMode = !!this.applicationId; + + if (this.isEditMode && this.applicationId) { + this.loadApplication(this.applicationId); + } + } + + loadBusinessUnits(): void { + this.businessUnitService.getBusinessUnits(0, 100).subscribe({ + next: (data) => { + this.businessUnits = data.content; + }, + error: (err) => { + this.error = 'Failed to load business units'; + } + }); + } + + loadApplication(id: string): void { + this.loading = true; + this.applicationService.getApplication(id).subscribe({ + next: (app) => { + this.form.patchValue({ + name: app.name, + description: app.description, + status: app.status, + businessUnitId: app.businessUnit.id, + endOfSupportDate: app.endOfSupportDate, + endOfLifeDate: app.endOfLifeDate + }); + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load application'; + this.loading = false; + } + }); + } + + onSubmit(): void { + if (this.form.valid) { + // Validate dates + const endOfSupport = this.form.value.endOfSupportDate; + const endOfLife = this.form.value.endOfLifeDate; + + if (endOfSupport && endOfLife) { + const supportDate = new Date(endOfSupport); + const lifeDate = new Date(endOfLife); + + if (supportDate > lifeDate) { + this.error = 'End of support date must be before end of life date'; + return; + } + } + + this.loading = true; + this.error = ''; + + const request$ = this.isEditMode && this.applicationId + ? this.applicationService.updateApplication(this.applicationId, this.form.value) + : this.applicationService.createApplication(this.form.value); + + request$.subscribe({ + next: () => { + this.router.navigate(['/applications']); + }, + error: (err) => { + this.error = err.error?.message || 'Failed to save application'; + this.loading = false; + } + }); + } + } + + cancel(): void { + this.router.navigate(['/applications']); + } + + getStatusDisplay(status: ApplicationStatus): string { + const displays: { [key in ApplicationStatus]: string } = { + [ApplicationStatus.IDEA]: 'Idea', + [ApplicationStatus.IN_DEVELOPMENT]: 'In Development', + [ApplicationStatus.IN_SERVICE]: 'In Service', + [ApplicationStatus.MAINTENANCE]: 'Maintenance', + [ApplicationStatus.DECOMMISSIONED]: 'Decommissioned' + }; + return displays[status]; + } +} +TS + +cat > "$BASE_FRONTEND/features/applications/application-form/application-form.component.html" << 'HTML' +
+

{{ isEditMode ? 'Edit Application' : 'Create New Application' }}

+ +
+
+ + +
+ Name is required + Name must not exceed 255 characters +
+
+ +
+ + +
+ +
+ + +
+ Status is required +
+
+ +
+ + +
+ Business unit is required +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ {{ error }} +
+ +
+ + +
+
+
+HTML + +cat > "$BASE_FRONTEND/features/applications/application-form/application-form.component.scss" << 'SCSS' +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + + h1 { + margin-bottom: 2rem; + } +} + +form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.form-group { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + input[type="text"], + input[type="date"], + select, + textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: #3f51b5; + } + + &.error { + border-color: #f44336; + } + } +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +.btn-primary, .btn-secondary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover:not(:disabled) { + background-color: #303f9f; + } + + &:disabled { + background-color: #ccc; + cursor: not-allowed; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.error-message { + color: #f44336; + font-size: 0.875rem; + margin-top: 0.25rem; +} +SCSS + +# ============================================================================ +# FRONTEND - Dashboard Component +# ============================================================================ + +echo "đŸ“Ļ Creating Dashboard component..." + +mkdir -p "$BASE_FRONTEND/features/dashboard" + +cat > "$BASE_FRONTEND/features/dashboard/dashboard.component.ts" << 'TS' +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [CommonModule], + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.scss'] +}) +export class DashboardComponent { + + cards = [ + { + title: 'Business Units', + description: 'Manage organizational units', + icon: 'đŸĸ', + route: '/business-units', + color: '#3f51b5' + }, + { + title: 'Applications', + description: 'Manage applications and their lifecycle', + icon: '📱', + route: '/applications', + color: '#f57c00' + }, + { + title: 'Environments', + description: 'Manage deployment environments', + icon: '🌍', + route: '/environments', + color: '#388e3c' + } + ]; + + constructor(private router: Router) {} + + navigate(route: string): void { + this.router.navigate([route]); + } +} +TS + +cat > "$BASE_FRONTEND/features/dashboard/dashboard.component.html" << 'HTML' +
+
+

LDPv2 Dashboard

+

Lifecycle Data Platform - Application Management

+
+ +
+
+
{{ card.icon }}
+
+

{{ card.title }}

+

{{ card.description }}

+
+
→
+
+
+
+HTML + +cat > "$BASE_FRONTEND/features/dashboard/dashboard.component.scss" << 'SCSS' +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 3rem 2rem; +} + +.header { + margin-bottom: 3rem; + text-align: center; + + h1 { + font-size: 2.5rem; + color: #333; + margin-bottom: 0.5rem; + } + + .subtitle { + font-size: 1.1rem; + color: #666; + } +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; +} + +.dashboard-card { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + border-left: 4px solid; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 1.5rem; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + } + + .card-icon { + font-size: 3rem; + line-height: 1; + } + + .card-content { + flex: 1; + + h2 { + font-size: 1.5rem; + margin: 0 0 0.5rem 0; + color: #333; + } + + p { + margin: 0; + color: #666; + font-size: 0.95rem; + } + } + + .card-arrow { + font-size: 1.5rem; + color: #999; + transition: transform 0.3s ease; + } + + &:hover .card-arrow { + transform: translateX(4px); + } +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: 1fr; + } + + .header h1 { + font-size: 2rem; + } +} +SCSS + +# ============================================================================ +# FRONTEND - Update Routes +# ============================================================================ + +echo "đŸ“Ļ Updating Angular routes..." + +cat > "$BASE_FRONTEND/app.routes.ts" << 'TS' +import { Routes } from '@angular/router'; +import { authGuard } from './core/guards/auth.guard'; + +export const routes: Routes = [ + { + path: '', + redirectTo: '/dashboard', + pathMatch: 'full' + }, + { + path: 'login', + loadComponent: () => import('./core/auth/login/login.component').then(m => m.LoginComponent) + }, + { + path: 'dashboard', + canActivate: [authGuard], + loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent) + }, + { + path: 'business-units', + canActivate: [authGuard], + children: [ + { + path: '', + loadComponent: () => import('./features/business-units/business-unit-list/business-unit-list.component') + .then(m => m.BusinessUnitListComponent) + }, + { + path: 'new', + loadComponent: () => import('./features/business-units/business-unit-form/business-unit-form.component') + .then(m => m.BusinessUnitFormComponent) + }, + { + path: ':id', + loadComponent: () => import('./features/business-units/business-unit-detail/business-unit-detail.component') + .then(m => m.BusinessUnitDetailComponent) + }, + { + path: ':id/edit', + loadComponent: () => import('./features/business-units/business-unit-form/business-unit-form.component') + .then(m => m.BusinessUnitFormComponent) + } + ] + }, + { + path: 'applications', + canActivate: [authGuard], + children: [ + { + path: '', + loadComponent: () => import('./features/applications/application-list/application-list.component') + .then(m => m.ApplicationListComponent) + }, + { + path: 'new', + loadComponent: () => import('./features/applications/application-form/application-form.component') + .then(m => m.ApplicationFormComponent) + }, + { + path: ':id', + loadComponent: () => import('./features/applications/application-detail/application-detail.component') + .then(m => m.ApplicationDetailComponent) + }, + { + path: ':id/edit', + loadComponent: () => import('./features/applications/application-form/application-form.component') + .then(m => m.ApplicationFormComponent) + } + ] + }, + { + path: 'environments', + canActivate: [authGuard], + children: [ + { + path: '', + loadComponent: () => import('./features/environments/environment-list/environment-list.component') + .then(m => m.EnvironmentListComponent) + }, + { + path: 'new', + loadComponent: () => import('./features/environments/environment-form/environment-form.component') + .then(m => m.EnvironmentFormComponent) + }, + { + path: ':id', + loadComponent: () => import('./features/environments/environment-detail/environment-detail.component') + .then(m => m.EnvironmentDetailComponent) + }, + { + path: ':id/edit', + loadComponent: () => import('./features/environments/environment-form/environment-form.component') + .then(m => m.EnvironmentFormComponent) + } + ] + } +]; +TS + +echo "" +echo "✅ Story 2 (Applications) deployment complete!" +echo "" +echo "📋 Summary:" +echo " - Database migration created (004-create-application-table.xml)" +echo " - Backend: ApplicationStatus enum, Application entity, repository, service, controller" +echo " - Frontend: Application models, service, list/detail/form components" +echo " - Dashboard component with navigation cards" +echo " - Routes updated to include dashboard and applications" +echo "" +echo "🚀 Next steps:" +echo " 1. Run from project root: bash deploy-story-2-applications.sh" +echo " 2. Rebuild backend: cd backend && mvn clean package" +echo " 3. Rebuild containers: docker-compose down && docker-compose up --build" +echo " 4. Access at: http://localhost" +echo " 5. Login with admin/admin123" +echo " 6. Navigate to Dashboard to access all features" +echo "" diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 4fa65c2..e3efd24 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,13 +4,18 @@ import { authGuard } from './core/guards/auth.guard'; export const routes: Routes = [ { path: '', - redirectTo: '/business-units', + redirectTo: '/dashboard', pathMatch: 'full' }, { path: 'login', loadComponent: () => import('./core/auth/login/login.component').then(m => m.LoginComponent) }, + { + path: 'dashboard', + canActivate: [authGuard], + loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent) + }, { path: 'business-units', canActivate: [authGuard], @@ -37,6 +42,32 @@ export const routes: Routes = [ } ] }, + { + path: 'applications', + canActivate: [authGuard], + children: [ + { + path: '', + loadComponent: () => import('./features/applications/application-list/application-list.component') + .then(m => m.ApplicationListComponent) + }, + { + path: 'new', + loadComponent: () => import('./features/applications/application-form/application-form.component') + .then(m => m.ApplicationFormComponent) + }, + { + path: ':id', + loadComponent: () => import('./features/applications/application-detail/application-detail.component') + .then(m => m.ApplicationDetailComponent) + }, + { + path: ':id/edit', + loadComponent: () => import('./features/applications/application-form/application-form.component') + .then(m => m.ApplicationFormComponent) + } + ] + }, { path: 'environments', canActivate: [authGuard], 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 new file mode 100644 index 0000000..0075c51 --- /dev/null +++ b/frontend/src/app/features/applications/application-detail/application-detail.component.html @@ -0,0 +1,55 @@ +
+
Loading...
+
{{ error }}
+ +
+
+

{{ 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' }} +
+
+ + +
+
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 new file mode 100644 index 0000000..e5a1150 --- /dev/null +++ b/frontend/src/app/features/applications/application-detail/application-detail.component.scss @@ -0,0 +1,127 @@ +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.loading, .error { + text-align: center; + padding: 2rem; +} + +.error { + color: #f44336; +} + +.detail-card { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f5f5f5; + + h1 { + margin: 0; + } + + .actions { + display: flex; + gap: 0.5rem; + } +} + +.details { + margin-bottom: 2rem; +} + +.detail-row { + display: flex; + padding: 1rem 0; + border-bottom: 1px solid #f5f5f5; + + label { + font-weight: 600; + width: 250px; + color: #555; + } + + span { + flex: 1; + color: #333; + } +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + + &.status-idea { + background-color: #e3f2fd; + color: #1976d2; + } + + &.status-in-development { + background-color: #fff3e0; + color: #f57c00; + } + + &.status-in-service { + background-color: #e8f5e9; + color: #388e3c; + } + + &.status-maintenance { + background-color: #fff9c4; + color: #f57f17; + } + + &.status-decommissioned { + background-color: #f5f5f5; + color: #616161; + } +} + +.btn-primary, .btn-secondary, .btn-danger { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover { + background-color: #303f9f; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.btn-danger { + background-color: #f44336; + color: white; + + &:hover { + background-color: #d32f2f; + } +} 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 new file mode 100644 index 0000000..3b7fc74 --- /dev/null +++ b/frontend/src/app/features/applications/application-detail/application-detail.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-application-detail', + standalone: true, + imports: [CommonModule], + template: `

Application Detail

â„šī¸ Coming in Story 2

`, + styles: [`.container { max-width: 1200px; margin: 2rem auto; padding: 2rem; } .info-message { background: #e3f2fd; padding: 1rem; border-radius: 4px; }`] +}) +export class ApplicationDetailComponent {} diff --git a/frontend/src/app/features/applications/application-form/application-form.component.html b/frontend/src/app/features/applications/application-form/application-form.component.html new file mode 100644 index 0000000..6c04eaa --- /dev/null +++ b/frontend/src/app/features/applications/application-form/application-form.component.html @@ -0,0 +1,92 @@ +
+

{{ isEditMode ? 'Edit Application' : 'Create New Application' }}

+ +
+
+ + +
+ Name is required + Name must not exceed 255 characters +
+
+ +
+ + +
+ +
+ + +
+ Status is required +
+
+ +
+ + +
+ Business unit is required +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ {{ error }} +
+ +
+ + +
+
+
diff --git a/frontend/src/app/features/applications/application-form/application-form.component.scss b/frontend/src/app/features/applications/application-form/application-form.component.scss new file mode 100644 index 0000000..1097562 --- /dev/null +++ b/frontend/src/app/features/applications/application-form/application-form.component.scss @@ -0,0 +1,98 @@ +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + + h1 { + margin-bottom: 2rem; + } +} + +form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.form-group { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + input[type="text"], + input[type="date"], + select, + textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: #3f51b5; + } + + &.error { + border-color: #f44336; + } + } +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +.btn-primary, .btn-secondary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover:not(:disabled) { + background-color: #303f9f; + } + + &:disabled { + background-color: #ccc; + cursor: not-allowed; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.error-message { + color: #f44336; + font-size: 0.875rem; + margin-top: 0.25rem; +} diff --git a/frontend/src/app/features/applications/application-form/application-form.component.ts b/frontend/src/app/features/applications/application-form/application-form.component.ts new file mode 100644 index 0000000..5e8b3d5 --- /dev/null +++ b/frontend/src/app/features/applications/application-form/application-form.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-application-form', + standalone: true, + imports: [CommonModule], + template: `

Application Form

â„šī¸ Coming in Story 2

`, + styles: [`.container { max-width: 1200px; margin: 2rem auto; padding: 2rem; } .info-message { background: #e3f2fd; padding: 1rem; border-radius: 4px; }`] +}) +export class ApplicationFormComponent {} diff --git a/frontend/src/app/features/applications/application-list/application-list.component.html b/frontend/src/app/features/applications/application-list/application-list.component.html new file mode 100644 index 0000000..49c5360 --- /dev/null +++ b/frontend/src/app/features/applications/application-list/application-list.component.html @@ -0,0 +1,94 @@ +
+
+

Applications

+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
Loading...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + +
NameStatusBusiness UnitEnd of LifeActions
{{ app.name }} + + {{ getStatusDisplay(app.status) }} + + {{ app.businessUnit.name }}{{ app.endOfLifeDate ? (app.endOfLifeDate | date:'mediumDate') : '-' }} + + + + +
+
+ +
+ No applications found. Click "Create New Application" to get started. +
+ + +
diff --git a/frontend/src/app/features/applications/application-list/application-list.component.scss b/frontend/src/app/features/applications/application-list/application-list.component.scss new file mode 100644 index 0000000..7b920a7 --- /dev/null +++ b/frontend/src/app/features/applications/application-list/application-list.component.scss @@ -0,0 +1,203 @@ +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + } +} + +.filters { + background: white; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.filter-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.filter-group { + flex: 1; + min-width: 200px; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + .search-input, + .filter-select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + + &:focus { + outline: none; + border-color: #3f51b5; + } + } +} + +.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; +} + +.table-container { + overflow-x: auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +table { + width: 100%; + border-collapse: collapse; + + th, td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid #ddd; + } + + th { + background-color: #f5f5f5; + font-weight: 600; + } + + tbody tr:hover { + background-color: #f9f9f9; + } +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + + &.status-idea { + background-color: #e3f2fd; + color: #1976d2; + } + + &.status-in-development { + background-color: #fff3e0; + color: #f57c00; + } + + &.status-in-service { + background-color: #e8f5e9; + color: #388e3c; + } + + &.status-maintenance { + background-color: #fff9c4; + color: #f57f17; + } + + &.status-decommissioned { + background-color: #f5f5f5; + color: #616161; + } +} + +.actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.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; + } + + &.btn-danger { + background-color: #f44336; + + &:hover { + background-color: #d32f2f; + } + } + + &.status-select { + background-color: #9c27b0; + + &:hover { + background-color: #7b1fa2; + } + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; + + 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; + } + } + + span { + color: #666; + } +} diff --git a/frontend/src/app/features/applications/application-list/application-list.component.ts b/frontend/src/app/features/applications/application-list/application-list.component.ts new file mode 100644 index 0000000..a3e95aa --- /dev/null +++ b/frontend/src/app/features/applications/application-list/application-list.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-application-list', + standalone: true, + imports: [CommonModule], + template: ` +
+

Applications

+

+ â„šī¸ Application management will be implemented in Story 2. +

+

Coming soon: Create and manage applications, track lifecycle status, and link to business units.

+
+ `, + styles: [` + .container { max-width: 1200px; margin: 2rem auto; padding: 2rem; } + .info-message { background: #e3f2fd; padding: 1rem; border-radius: 4px; border-left: 4px solid #2196f3; } + `] +}) +export class ApplicationListComponent {} diff --git a/frontend/src/app/features/applications/application.service.ts b/frontend/src/app/features/applications/application.service.ts new file mode 100644 index 0000000..6d8791e --- /dev/null +++ b/frontend/src/app/features/applications/application.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + Application, + ApplicationStatus, + CreateApplicationRequest, + UpdateApplicationRequest +} from '../../shared/models/application.model'; +import { Page } from '../../shared/models/environment.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ApplicationService { + private readonly API_URL = '/api/applications'; + + constructor(private http: HttpClient) {} + + getApplications( + filters?: { + status?: ApplicationStatus; + businessUnitId?: string; + name?: string; + }, + page: number = 0, + size: number = 20, + sortBy: string = 'name', + sortDirection: string = 'asc' + ): Observable> { + let params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()) + .set('sortBy', sortBy) + .set('sortDirection', sortDirection); + + if (filters?.status) { + params = params.set('status', filters.status); + } + if (filters?.businessUnitId) { + params = params.set('businessUnitId', filters.businessUnitId); + } + if (filters?.name) { + params = params.set('name', filters.name); + } + + return this.http.get>(this.API_URL, { params }); + } + + getApplication(id: string): Observable { + return this.http.get(`${this.API_URL}/${id}`); + } + + createApplication(data: CreateApplicationRequest): Observable { + return this.http.post(this.API_URL, data); + } + + updateApplication(id: string, data: UpdateApplicationRequest): Observable { + return this.http.put(`${this.API_URL}/${id}`, data); + } + + updateStatus(id: string, status: ApplicationStatus): Observable { + return this.http.patch(`${this.API_URL}/${id}/status`, null, { + params: { status } + }); + } + + deleteApplication(id: string): Observable { + return this.http.delete(`${this.API_URL}/${id}`); + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.component.html b/frontend/src/app/features/dashboard/dashboard.component.html new file mode 100644 index 0000000..2c8d199 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.html @@ -0,0 +1,95 @@ +
+
+
+
+
+

LDPv2

+

Lifecycle Data Platform

+
+
+ + +
+
+
+
+ +
+
+
+

Welcome back, {{ currentUser?.username }}! 👋

+

Manage your applications, environments, and business units from one place.

+
+ +
+
+
+
{{ stat.icon }}
+
+
{{ stat.value }}
+
{{ stat.label }}
+
+
+
+
+ +
+

Quick Access

+
+
+
{{ feature.icon }}
+
+

{{ feature.title }}

+

{{ feature.description }}

+
+
→
+
+
+
+ +
+

Getting Started

+
+
+
1
+

Create Business Units

+

Organize your applications by business units

+ +
+
+
2
+

Add Applications

+

Register your applications and track their lifecycle

+ +
+
+
3
+

Configure Environments

+

Set up deployment environments

+ +
+
+
+
+
+ + +
diff --git a/frontend/src/app/features/dashboard/dashboard.component.scss b/frontend/src/app/features/dashboard/dashboard.component.scss new file mode 100644 index 0000000..d9ab4f6 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.scss @@ -0,0 +1,280 @@ +.dashboard { + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + flex-direction: column; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + width: 100%; +} + +.dashboard-header { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + padding: 1.5rem 0; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + + .header-content { + display: flex; + justify-content: space-between; + align-items: center; + } + + .branding h1 { + color: white; + font-size: 2rem; + margin: 0; + font-weight: 700; + } + + .branding .subtitle { + color: rgba(255, 255, 255, 0.8); + margin: 0; + font-size: 0.9rem; + } + + .user-menu { + display: flex; + align-items: center; + gap: 1rem; + } + + .user-info { + display: flex; + align-items: center; + gap: 0.5rem; + color: white; + + .user-icon { font-size: 1.5rem; } + .username { font-weight: 500; } + .role-badge { + background: rgba(255, 255, 255, 0.2); + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + text-transform: uppercase; + } + } + + .btn-logout { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 0.5rem 1.5rem; + border-radius: 20px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + } + } +} + +.dashboard-main { + flex: 1; + padding: 3rem 0; +} + +.welcome-section { + text-align: center; + color: white; + margin-bottom: 3rem; + + h2 { + font-size: 2.5rem; + margin: 0 0 1rem 0; + font-weight: 700; + } + + p { + font-size: 1.2rem; + opacity: 0.9; + } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 3rem; +} + +.stat-card { + background: white; + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 1.5rem; + transition: transform 0.3s ease; + + &:hover { transform: translateY(-5px); } + + .stat-icon { font-size: 3rem; } + .stat-value { + font-size: 2.5rem; + font-weight: 700; + color: #333; + } + .stat-label { + color: #666; + font-size: 0.9rem; + } +} + +.features-section { + margin-bottom: 3rem; + + h3 { + color: white; + font-size: 1.8rem; + margin-bottom: 1.5rem; + font-weight: 600; + } +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.feature-card { + background: white; + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + border-left: 4px solid; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15); + } + + .feature-icon { font-size: 3rem; } + .feature-content { + flex: 1; + + h4 { + margin: 0 0 0.5rem 0; + color: #333; + font-size: 1.3rem; + } + + p { + margin: 0; + color: #666; + font-size: 0.9rem; + } + } + + .feature-arrow { + font-size: 1.5rem; + color: #999; + } +} + +.getting-started { + h3 { + color: white; + font-size: 1.8rem; + margin-bottom: 1.5rem; + font-weight: 600; + } +} + +.steps-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.step-card { + background: rgba(255, 255, 255, 0.95); + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + + .step-number { + width: 40px; + height: 40px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.2rem; + margin-bottom: 1rem; + } + + h4 { + color: #333; + margin: 0 0 0.5rem 0; + font-size: 1.2rem; + } + + p { + color: #666; + margin: 0 0 1rem 0; + } + + .btn-link { + background: none; + border: none; + color: #667eea; + font-weight: 500; + cursor: pointer; + padding: 0; + transition: color 0.3s ease; + + &:hover { + color: #764ba2; + text-decoration: underline; + } + } +} + +.dashboard-footer { + background: rgba(0, 0, 0, 0.2); + padding: 1.5rem 0; + text-align: center; + color: white; + margin-top: auto; + + p { + margin: 0; + opacity: 0.8; + } +} + +@media (max-width: 768px) { + .dashboard-header .header-content { + flex-direction: column; + gap: 1rem; + } + + .welcome-section h2 { + font-size: 1.8rem; + } + + .stats-grid, + .features-grid, + .steps-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.component.ts b/frontend/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..aadd5c4 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.component.ts @@ -0,0 +1,63 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { User } from '../../shared/models/user.model'; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [CommonModule], + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.scss'] +}) +export class DashboardComponent implements OnInit { + currentUser: User | null = null; + + features = [ + { + title: 'Business Units', + description: 'Manage organizational business units', + icon: 'đŸĸ', + route: '/business-units', + color: '#3f51b5' + }, + { + title: 'Applications', + description: 'Manage applications and their lifecycle', + icon: '📱', + route: '/applications', + color: '#009688' + }, + { + title: 'Environments', + description: 'Manage deployment environments', + icon: '🌍', + route: '/environments', + color: '#ff9800' + } + ]; + + stats = [ + { label: 'Business Units', value: '4', icon: 'đŸĸ' }, + { label: 'Applications', value: '7', icon: '📱' }, + { label: 'Environments', value: '4', icon: '🌍' } + ]; + + constructor( + private router: Router, + private authService: AuthService + ) {} + + ngOnInit(): void { + this.currentUser = this.authService.getCurrentUser(); + } + + navigate(route: string): void { + this.router.navigate([route]); + } + + logout(): void { + this.authService.logout(); + } +} diff --git a/frontend/src/app/shared/models/application.model.ts b/frontend/src/app/shared/models/application.model.ts new file mode 100644 index 0000000..f3703f6 --- /dev/null +++ b/frontend/src/app/shared/models/application.model.ts @@ -0,0 +1,37 @@ +export enum ApplicationStatus { + IDEA = 'IDEA', + IN_DEVELOPMENT = 'IN_DEVELOPMENT', + IN_SERVICE = 'IN_SERVICE', + MAINTENANCE = 'MAINTENANCE', + DECOMMISSIONED = 'DECOMMISSIONED' +} + +export interface Application { + id: string; + name: string; + description?: string; + status: ApplicationStatus; + businessUnit: { id: string; name: string }; + endOfLifeDate?: Date; + endOfSupportDate?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateApplicationRequest { + name: string; + description?: string; + status: ApplicationStatus; + businessUnitId: string; + endOfLifeDate?: Date; + endOfSupportDate?: Date; +} + +export interface UpdateApplicationRequest { + name?: string; + description?: string; + status?: ApplicationStatus; + businessUnitId?: string; + endOfLifeDate?: Date; + endOfSupportDate?: Date; +}