diff --git a/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml b/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml index 69366db..3cdd5da 100644 --- a/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml +++ b/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml @@ -34,6 +34,9 @@ + + + @@ -73,4 +76,4 @@ - + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 618a305..fff753e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,8 @@ services: ports: - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data + #- postgres_data:/var/lib/postgresql/data + - /srv/ldpv2_db:/var/lib/postgresql/data networks: - ldpv2-network healthcheck: @@ -52,8 +53,9 @@ services: restart: unless-stopped # Optional: Mount logs directory for easier debugging volumes: - - app_logs:/var/log/supervisor - + #- app_logs:/var/log/supervisor + - /srv/ldpv2_logs:/var/log/supervisor + volumes: postgres_data: driver: local diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 67af473..4a69d15 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -67,6 +67,16 @@ export const routes: Routes = [ path: ':id/edit', loadComponent: () => import('./features/applications/application-form/application-form.component') .then(m => m.ApplicationFormComponent) + }, + { + path: ':applicationId/versions/new', + loadComponent: () => import('./features/versions/version-form/version-form.component') + .then(m => m.VersionFormComponent) + }, + { + path: ':applicationId/versions/:versionId/edit', + loadComponent: () => import('./features/versions/version-form/version-form.component') + .then(m => m.VersionFormComponent) } ] }, @@ -95,6 +105,26 @@ export const routes: Routes = [ } ] }, + { + path: 'deployments', + children: [ + { + path: '', + loadComponent: () => import('./features/deployments/deployment-list/deployment-list.component') + .then(m => m.DeploymentListComponent) + }, + { + path: 'new', + loadComponent: () => import('./features/deployments/deployment-form/deployment-form.component') + .then(m => m.DeploymentFormComponent) + }, + { + path: 'current', + loadComponent: () => import('./features/deployments/deployment-dashboard/deployment-dashboard.component') + .then(m => m.DeploymentDashboardComponent) + } + ] + }, { path: 'persons', children: [ diff --git a/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.html b/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.html new file mode 100644 index 0000000..53c74df --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.html @@ -0,0 +1,60 @@ +
+
+

Current Deployment State

+ +
+ +
Loading deployment state...
+
{{ error }}
+ +
+
+ + Recent (< 30 days) + + + Moderate (30-90 days) + + + Old (> 90 days) + + + Not deployed + +
+ +
+ + + + + + + + + + + + + +
Application + {{ environmentNames.get(envId) }} +
{{ matrix[appId].applicationName }} +
+
{{ deployment.version.versionIdentifier }}
+
{{ deployment.deploymentDate | date:'shortDate' }}
+
{{ getDaysAgo(deployment.deploymentDate) }}d ago
+
+
+ - +
+
+
+
+ +
+ No deployments recorded yet. +
+
diff --git a/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.scss b/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.scss new file mode 100644 index 0000000..0a844f4 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.scss @@ -0,0 +1,144 @@ +.container { + max-width: 1600px; + margin: 0 auto; + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h1 { margin: 0; } +} + +.btn-primary { + background-color: #3f51b5; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + + &:hover { background-color: #303f9f; } +} + +.loading, .error, .empty { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { color: #f44336; } + +.legend { + display: flex; + gap: 1.5rem; + margin-bottom: 1rem; + padding: 1rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + .legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + } + + .legend-color { + width: 20px; + height: 20px; + border-radius: 4px; + border: 1px solid #ddd; + } +} + +.matrix-scroll { + overflow-x: auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.deployment-matrix { + width: 100%; + border-collapse: collapse; + min-width: 800px; + + th, td { + padding: 1rem; + border: 1px solid #ddd; + } + + thead { + background-color: #f5f5f5; + position: sticky; + top: 0; + z-index: 10; + + th { + font-weight: 600; + text-align: center; + } + } + + .app-header { + text-align: left; + min-width: 200px; + } + + .app-name { + font-weight: 600; + background-color: #fafafa; + position: sticky; + left: 0; + z-index: 5; + } + + .deployment-cell { + text-align: center; + min-width: 150px; + + &.cell-recent { + background-color: #e8f5e9; + } + + &.cell-moderate { + background-color: #fff9c4; + } + + &.cell-old { + background-color: #ffebee; + } + + &.cell-empty { + background-color: #f5f5f5; + } + } + + .cell-content { + .version { + font-weight: 600; + color: #333; + margin-bottom: 0.25rem; + } + + .date { + font-size: 0.875rem; + color: #666; + } + + .days-ago { + font-size: 0.75rem; + color: #999; + margin-top: 0.25rem; + } + + &.empty span { + color: #999; + } + } +} diff --git a/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.ts b/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.ts new file mode 100644 index 0000000..b559c67 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-dashboard/deployment-dashboard.component.ts @@ -0,0 +1,106 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { DeploymentService } from '../deployment.service'; +import { CurrentDeploymentState } from '../../../shared/models/deployment.model'; + +interface DeploymentMatrix { + [applicationId: string]: { + applicationName: string; + environments: { + [environmentId: string]: CurrentDeploymentState; + }; + }; +} + +@Component({ + selector: 'app-deployment-dashboard', + standalone: true, + imports: [CommonModule], + templateUrl: './deployment-dashboard.component.html', + styleUrls: ['./deployment-dashboard.component.scss'] +}) +export class DeploymentDashboardComponent implements OnInit { + deploymentStates: CurrentDeploymentState[] = []; + matrix: DeploymentMatrix = {}; + environmentIds: string[] = []; + environmentNames: Map = new Map(); + loading = false; + error = ''; + + constructor( + private deploymentService: DeploymentService, + private router: Router + ) {} + + ngOnInit(): void { + this.loadCurrentState(); + } + + loadCurrentState(): void { + this.loading = true; + this.deploymentService.getCurrentState().subscribe({ + next: (states) => { + this.deploymentStates = states; + this.buildMatrix(); + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load deployment state'; + this.loading = false; + } + }); + } + + buildMatrix(): void { + this.matrix = {}; + const envSet = new Set(); + + this.deploymentStates.forEach(state => { + const appId = state.application.id; + const envId = state.environment.id; + + if (!this.matrix[appId]) { + this.matrix[appId] = { + applicationName: state.application.name, + environments: {} + }; + } + + this.matrix[appId].environments[envId] = state; + envSet.add(envId); + this.environmentNames.set(envId, state.environment.name); + }); + + this.environmentIds = Array.from(envSet).sort(); + } + + getApplicationIds(): string[] { + return Object.keys(this.matrix); + } + + getDeploymentForCell(appId: string, envId: string): CurrentDeploymentState | undefined { + return this.matrix[appId]?.environments[envId]; + } + + getCellClass(deployment?: CurrentDeploymentState): string { + if (!deployment) return 'cell-empty'; + + const daysAgo = this.getDaysAgo(deployment.deploymentDate); + + if (daysAgo < 30) return 'cell-recent'; + if (daysAgo < 90) return 'cell-moderate'; + return 'cell-old'; + } + + getDaysAgo(date: Date): number { + const now = new Date(); + const deployDate = new Date(date); + const diff = now.getTime() - deployDate.getTime(); + return Math.floor(diff / (1000 * 60 * 60 * 24)); + } + + recordDeployment(): void { + this.router.navigate(['/deployments/new']); + } +} diff --git a/frontend/src/app/features/deployments/deployment-form/deployment-form.component.html b/frontend/src/app/features/deployments/deployment-form/deployment-form.component.html new file mode 100644 index 0000000..7925d08 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-form/deployment-form.component.html @@ -0,0 +1,100 @@ +
+

Record New Deployment

+ +
+
+ + +
+ Application is required +
+
+ +
+ + +
+ Version is required +
+
+ +
+ + +
+ Environment is required +
+
+ +
+
+ + +
+ Deployment date is required +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ {{ error }} +
+ +
+ + +
+
+
diff --git a/frontend/src/app/features/deployments/deployment-form/deployment-form.component.scss b/frontend/src/app/features/deployments/deployment-form/deployment-form.component.scss new file mode 100644 index 0000000..d843204 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-form/deployment-form.component.scss @@ -0,0 +1,92 @@ +.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, + 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; + } + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + } + + &.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/deployments/deployment-form/deployment-form.component.ts b/frontend/src/app/features/deployments/deployment-form/deployment-form.component.ts new file mode 100644 index 0000000..928bd29 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-form/deployment-form.component.ts @@ -0,0 +1,134 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DeploymentService } from '../deployment.service'; +import { ApplicationService } from '../../applications/application.service'; +import { EnvironmentService } from '../../environments/environment.service'; +import { VersionService } from '../../versions/version.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { Application } from '../../../shared/models/application.model'; +import { Environment } from '../../../shared/models/environment.model'; +import { Version } from '../../../shared/models/version.model'; +import { Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-deployment-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './deployment-form.component.html', + styleUrls: ['./deployment-form.component.scss'] +}) +export class DeploymentFormComponent implements OnInit { + form: FormGroup; + loading = false; + error = ''; + + applications: Application[] = []; + environments: Environment[] = []; + versions: Version[] = []; + + constructor( + private fb: FormBuilder, + private deploymentService: DeploymentService, + private applicationService: ApplicationService, + private environmentService: EnvironmentService, + private versionService: VersionService, + private authService: AuthService, + private router: Router + ) { + const now = new Date(); + const currentDateTime = now.toISOString().slice(0, 16); + const currentUser = this.authService.getCurrentUser(); + + this.form = this.fb.group({ + applicationId: ['', [Validators.required]], + versionId: ['', [Validators.required]], + environmentId: ['', [Validators.required]], + deploymentDate: [currentDateTime, [Validators.required]], + deployedBy: [currentUser?.username || '', []], + notes: [''] + }); + } + + ngOnInit(): void { + this.loadApplications(); + this.loadEnvironments(); + + this.form.get('applicationId')?.valueChanges.subscribe(appId => { + if (appId) { + this.loadVersionsForApplication(appId); + } else { + this.versions = []; + this.form.patchValue({ versionId: '' }); + } + }); + } + + loadApplications(): void { + this.applicationService.getApplications({}, 0, 100).subscribe({ + next: (data: Page) => { + this.applications = data.content; + }, + error: (err) => { + this.error = 'Failed to load applications'; + } + }); + } + + loadEnvironments(): void { + this.environmentService.getEnvironments(0, 100).subscribe({ + next: (data: Page) => { + this.environments = data.content; + }, + error: (err) => { + this.error = 'Failed to load environments'; + } + }); + } + + loadVersionsForApplication(applicationId: string): void { + this.versionService.getVersions(applicationId, 0, 100).subscribe({ + next: (data: Page) => { + this.versions = data.content; + }, + error: (err) => { + this.error = 'Failed to load versions'; + } + }); + } + + onSubmit(): void { + if (this.form.valid) { + const deploymentDate = new Date(this.form.value.deploymentDate); + const now = new Date(); + + if (deploymentDate > now) { + this.error = 'Deployment date cannot be in the future'; + return; + } + + this.loading = true; + this.error = ''; + + const formData = { + ...this.form.value, + deploymentDate: deploymentDate.toISOString() + }; + + this.deploymentService.recordDeployment(formData).subscribe({ + next: () => { + this.router.navigate(['/deployments']); + }, + error: (err) => { + this.error = err.error?.message || 'Failed to record deployment'; + this.loading = false; + } + }); + } + } + + cancel(): void { + this.router.navigate(['/deployments']); + } +} diff --git a/frontend/src/app/features/deployments/deployment-list/deployment-list.component.html b/frontend/src/app/features/deployments/deployment-list/deployment-list.component.html new file mode 100644 index 0000000..389e9a2 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-list/deployment-list.component.html @@ -0,0 +1,89 @@ +
+
+

Deployment History

+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
Loading...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
ApplicationVersionEnvironmentDeployment DateDeployed ByNotesActions
{{ deployment.application.name }}{{ deployment.version.versionIdentifier }} + + {{ deployment.environment.name }} + + + {{ deployment.deploymentDate | date:'medium' }} +
+ {{ getDaysAgo(deployment.deploymentDate) }} days ago +
{{ deployment.deployedBy || '-' }}{{ deployment.notes || '-' }} + +
+
+ +
+ No deployments found matching your criteria. +
+ + +
diff --git a/frontend/src/app/features/deployments/deployment-list/deployment-list.component.scss b/frontend/src/app/features/deployments/deployment-list/deployment-list.component.scss new file mode 100644 index 0000000..bc222c1 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-list/deployment-list.component.scss @@ -0,0 +1,153 @@ +.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: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.filter-group { + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + .filter-select, + .filter-input { + 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; } +} + +.env-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; + + &.env-prod { + background-color: #ffebee; + color: #c62828; + } + + &.env-non-prod { + background-color: #e3f2fd; + color: #1565c0; + } +} + +.time-ago { + color: #999; + font-size: 0.75rem; +} + +.btn-sm { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + background-color: #2196f3; + color: white; + font-size: 0.875rem; + + &:hover { background-color: #1976d2; } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 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/deployments/deployment-list/deployment-list.component.ts b/frontend/src/app/features/deployments/deployment-list/deployment-list.component.ts new file mode 100644 index 0000000..f957bf5 --- /dev/null +++ b/frontend/src/app/features/deployments/deployment-list/deployment-list.component.ts @@ -0,0 +1,133 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { DeploymentService } from '../deployment.service'; +import { ApplicationService } from '../../applications/application.service'; +import { EnvironmentService } from '../../environments/environment.service'; +import { Deployment } from '../../../shared/models/deployment.model'; +import { Application } from '../../../shared/models/application.model'; +import { Environment } from '../../../shared/models/environment.model'; +import { Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-deployment-list', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './deployment-list.component.html', + styleUrls: ['./deployment-list.component.scss'] +}) +export class DeploymentListComponent implements OnInit { + deployments: Deployment[] = []; + applications: Application[] = []; + environments: Environment[] = []; + + loading = false; + error = ''; + + page = 0; + size = 20; + totalElements = 0; + totalPages = 0; + + selectedApplicationId = ''; + selectedEnvironmentId = ''; + dateFrom = ''; + dateTo = ''; + + constructor( + private deploymentService: DeploymentService, + private applicationService: ApplicationService, + private environmentService: EnvironmentService, + private router: Router + ) {} + + ngOnInit(): void { + this.loadApplications(); + this.loadEnvironments(); + this.loadDeployments(); + } + + loadApplications(): void { + this.applicationService.getApplications({}, 0, 100).subscribe({ + next: (data: Page) => { + this.applications = data.content; + }, + error: (err) => console.error('Failed to load applications', err) + }); + } + + loadEnvironments(): void { + this.environmentService.getEnvironments(0, 100).subscribe({ + next: (data: Page) => { + this.environments = data.content; + }, + error: (err) => console.error('Failed to load environments', err) + }); + } + + loadDeployments(): void { + this.loading = true; + const filters: any = {}; + + if (this.selectedApplicationId) { + filters.applicationId = this.selectedApplicationId; + } + if (this.selectedEnvironmentId) { + filters.environmentId = this.selectedEnvironmentId; + } + if (this.dateFrom) { + filters.dateFrom = new Date(this.dateFrom); + } + if (this.dateTo) { + filters.dateTo = new Date(this.dateTo); + } + + this.deploymentService.getDeployments(filters, this.page, this.size).subscribe({ + next: (data: Page) => { + this.deployments = data.content; + this.totalElements = data.totalElements; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load deployments'; + this.loading = false; + } + }); + } + + onFilterChange(): void { + this.page = 0; + this.loadDeployments(); + } + + recordNew(): void { + this.router.navigate(['/deployments/new']); + } + + viewDetails(id: string): void { + this.router.navigate(['/deployments', id]); + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + this.loadDeployments(); + } + } + + previousPage(): void { + if (this.page > 0) { + this.page--; + this.loadDeployments(); + } + } + + getDaysAgo(date: Date): number { + const now = new Date(); + const deployDate = new Date(date); + const diff = now.getTime() - deployDate.getTime(); + return Math.floor(diff / (1000 * 60 * 60 * 24)); + } +} diff --git a/frontend/src/app/features/versions/version-form/version-form.component.html b/frontend/src/app/features/versions/version-form/version-form.component.html new file mode 100644 index 0000000..6c3c3e7 --- /dev/null +++ b/frontend/src/app/features/versions/version-form/version-form.component.html @@ -0,0 +1,70 @@ +
+

{{ isEditMode ? 'Edit Version' : 'Add New Version' }}

+ +
+
+ + +
+ Version identifier is required + Max 100 characters +
+
+ +
+ + + Git tag, JIRA release link, etc. +
+ Max 500 characters +
+
+ +
+
+ + +
+ Release date is required +
+
+ +
+ + +
+
+ +
+ {{ error }} +
+ +
+ + +
+
+
diff --git a/frontend/src/app/features/versions/version-form/version-form.component.scss b/frontend/src/app/features/versions/version-form/version-form.component.scss new file mode 100644 index 0000000..bcaaea6 --- /dev/null +++ b/frontend/src/app/features/versions/version-form/version-form.component.scss @@ -0,0 +1,91 @@ +.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 { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + + &:focus { + outline: none; + border-color: #3f51b5; + } + + &.error { border-color: #f44336; } + } + + small { + display: block; + margin-top: 0.25rem; + color: #666; + font-size: 0.875rem; + } +} + +.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/versions/version-form/version-form.component.ts b/frontend/src/app/features/versions/version-form/version-form.component.ts new file mode 100644 index 0000000..0492ef9 --- /dev/null +++ b/frontend/src/app/features/versions/version-form/version-form.component.ts @@ -0,0 +1,128 @@ +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 { VersionService } from '../version.service'; + +@Component({ + selector: 'app-version-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './version-form.component.html', + styleUrls: ['./version-form.component.scss'] +}) +export class VersionFormComponent implements OnInit { + form: FormGroup; + loading = false; + error = ''; + isEditMode = false; + applicationId!: string; + versionId?: string; + + constructor( + private fb: FormBuilder, + private versionService: VersionService, + private router: Router, + private route: ActivatedRoute + ) { + this.form = this.fb.group({ + versionIdentifier: ['', [Validators.required, Validators.maxLength(100)]], + externalReference: ['', [Validators.maxLength(500)]], + releaseDate: ['', [Validators.required]], + endOfLifeDate: [''] + }); + } + + ngOnInit(): void { + this.applicationId = this.route.snapshot.paramMap.get('applicationId')!; + this.versionId = this.route.snapshot.paramMap.get('versionId') || undefined; + this.isEditMode = !!this.versionId; + + if (this.isEditMode && this.versionId) { + this.loadVersion(); + } else { + // Default release date to today + const today = new Date().toISOString().split('T')[0]; + this.form.patchValue({ releaseDate: today }); + } + } + + loadVersion(): void { + if (!this.versionId) return; + + this.loading = true; + this.versionService.getVersion(this.applicationId, this.versionId).subscribe({ + next: (version) => { + this.form.patchValue({ + versionIdentifier: version.versionIdentifier, + externalReference: version.externalReference, + releaseDate: this.formatDateForInput(version.releaseDate), + endOfLifeDate: version.endOfLifeDate ? this.formatDateForInput(version.endOfLifeDate) : '' + }); + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load version'; + this.loading = false; + } + }); + } + + formatDateForInput(date: Date): string { + const d = new Date(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + onSubmit(): void { + if (this.form.valid) { + const releaseDate = new Date(this.form.value.releaseDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (releaseDate > today) { + this.error = 'Release date cannot be in the future'; + return; + } + + if (this.form.value.endOfLifeDate) { + const eolDate = new Date(this.form.value.endOfLifeDate); + if (eolDate <= releaseDate) { + this.error = 'End of life date must be after release date'; + return; + } + } + + this.loading = true; + this.error = ''; + + const formData = { ...this.form.value }; + if (!formData.endOfLifeDate) { + formData.endOfLifeDate = null; + } + if (!formData.externalReference) { + formData.externalReference = null; + } + + const request$ = this.isEditMode && this.versionId + ? this.versionService.updateVersion(this.applicationId, this.versionId, formData) + : this.versionService.createVersion(this.applicationId, formData); + + request$.subscribe({ + next: () => { + this.router.navigate(['/applications', this.applicationId]); + }, + error: (err) => { + this.error = err.error?.message || 'Failed to save version'; + this.loading = false; + } + }); + } + } + + cancel(): void { + this.router.navigate(['/applications', this.applicationId]); + } +} diff --git a/frontend/src/app/features/versions/version-list/version-list.component.html b/frontend/src/app/features/versions/version-list/version-list.component.html new file mode 100644 index 0000000..97252eb --- /dev/null +++ b/frontend/src/app/features/versions/version-list/version-list.component.html @@ -0,0 +1,53 @@ +
+
+

Versions for {{ applicationName }}

+ +
+ +
Loading versions...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + +
VersionRelease DateEnd of LifeExternal ReferenceActions
{{ version.versionIdentifier }}{{ version.releaseDate | date:'mediumDate' }}{{ version.endOfLifeDate ? (version.endOfLifeDate | date:'mediumDate') : '-' }} + + View Reference + + - + + + +
+
+ +
+ No versions found. Click "Add New Version" to create one. +
+ + +
diff --git a/frontend/src/app/features/versions/version-list/version-list.component.scss b/frontend/src/app/features/versions/version-list/version-list.component.scss new file mode 100644 index 0000000..ae15757 --- /dev/null +++ b/frontend/src/app/features/versions/version-list/version-list.component.scss @@ -0,0 +1,106 @@ +.version-list-container { + padding: 1.5rem; + background: white; + border-radius: 8px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h2 { margin: 0; } +} + +.btn-primary { + background-color: #3f51b5; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + + &:hover { background-color: #303f9f; } +} + +.loading, .error, .empty { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { color: #f44336; } + +.table-container { + overflow-x: auto; +} + +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; } +} + +.external-link { + color: #2196f3; + text-decoration: none; + + &:hover { text-decoration: underline; } +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.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; } + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; + + button { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + cursor: pointer; + + &:hover:not(:disabled) { background-color: #f5f5f5; } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/frontend/src/app/features/versions/version-list/version-list.component.ts b/frontend/src/app/features/versions/version-list/version-list.component.ts new file mode 100644 index 0000000..40856fe --- /dev/null +++ b/frontend/src/app/features/versions/version-list/version-list.component.ts @@ -0,0 +1,87 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { VersionService } from '../version.service'; +import { Version } from '../../../shared/models/version.model'; +import { Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-version-list', + standalone: true, + imports: [CommonModule], + templateUrl: './version-list.component.html', + styleUrls: ['./version-list.component.scss'] +}) +export class VersionListComponent implements OnInit { + @Input() applicationId!: string; + @Input() applicationName!: string; + + versions: Version[] = []; + loading = false; + error = ''; + + page = 0; + size = 20; + totalPages = 0; + + constructor( + private versionService: VersionService, + private router: Router + ) {} + + ngOnInit(): void { + if (this.applicationId) { + this.loadVersions(); + } + } + + loadVersions(): void { + this.loading = true; + this.versionService.getVersions(this.applicationId, this.page, this.size).subscribe({ + next: (data: Page) => { + this.versions = data.content; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load versions'; + this.loading = false; + } + }); + } + + addVersion(): void { + this.router.navigate(['/applications', this.applicationId, 'versions', 'new']); + } + + editVersion(versionId: string): void { + this.router.navigate(['/applications', this.applicationId, 'versions', versionId, 'edit']); + } + + deleteVersion(versionId: string): void { + if (confirm('Are you sure you want to delete this version?')) { + this.versionService.deleteVersion(this.applicationId, versionId).subscribe({ + next: () => { + this.loadVersions(); + }, + error: (err) => { + this.error = 'Failed to delete version'; + } + }); + } + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + this.loadVersions(); + } + } + + previousPage(): void { + if (this.page > 0) { + this.page--; + this.loadVersions(); + } + } +}