autocomit

This commit is contained in:
2026-02-08 21:19:21 +01:00
parent 6b588005c9
commit 9b76f7c967
20 changed files with 1164 additions and 72 deletions
@@ -0,0 +1,69 @@
<div class="contacts-container">
<div class="header">
<h3>Application Contacts</h3>
<button (click)="openAddDialog()" class="btn-primary">Add Contact</button>
</div>
<div *ngIf="loading" class="loading">Loading contacts...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && contacts.length > 0" class="contacts-grid">
<div class="contact-card" *ngFor="let appContact of contacts">
<div class="contact-header">
<h4>{{ appContact.contact.contactRole.roleName }}</h4>
<button (click)="removeContact(appContact.contact.id)" class="btn-remove">×</button>
</div>
<div class="contact-body">
<div class="primary-person">
<strong>Primary:</strong> {{ getPrimaryPerson(appContact.contact) }}
</div>
<div class="person-count">
{{ appContact.contact.persons.length }} person(s)
</div>
</div>
</div>
</div>
<div *ngIf="!loading && contacts.length === 0" class="empty">
No contacts assigned to this application yet.
</div>
<!-- Add Contact Dialog -->
<div *ngIf="showAddDialog" class="dialog-overlay" (click)="closeAddDialog()">
<div class="dialog" (click)="$event.stopPropagation()">
<div class="dialog-header">
<h3>Add Contact</h3>
<button (click)="closeAddDialog()" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="filter-section">
<label>Filter by role:</label>
<select [(ngModel)]="selectedContactRole" class="filter-select">
<option value="">All Roles</option>
<option *ngFor="let role of availableRoles" [value]="role.id">
{{ role.roleName }}
</option>
</select>
</div>
<div class="contacts-list">
<div *ngIf="getFilteredContacts().length === 0" class="no-contacts">
No available contacts to add.
</div>
<div
class="contact-item"
*ngFor="let contact of getFilteredContacts()"
(click)="addContact(contact.id)">
<div class="contact-info">
<strong>{{ contact.contactRole.roleName }}</strong>
<div class="contact-primary">{{ getPrimaryPerson(contact) }}</div>
</div>
<button class="btn-add">Add</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,216 @@
.contacts-container {
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h3 {
margin: 0;
}
}
}
.btn-primary {
background-color: #3f51b5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #303f9f;
}
}
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #666;
}
.error {
color: #f44336;
}
.contacts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.contact-card {
background: #f9f9f9;
border-radius: 8px;
overflow: hidden;
.contact-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
h4 {
margin: 0;
}
.btn-remove {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
}
.contact-body {
padding: 1rem;
.primary-person {
margin-bottom: 0.5rem;
}
.person-count {
color: #666;
font-size: 0.875rem;
}
}
}
// Dialog styles
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
.dialog-header {
padding: 1.5rem;
border-bottom: 1px solid #f5f5f5;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
.btn-close {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
line-height: 1;
width: 32px;
height: 32px;
&:hover {
color: #333;
}
}
}
.dialog-body {
padding: 1.5rem;
overflow-y: auto;
.filter-section {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.filter-select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
}
.contacts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
.no-contacts {
text-align: center;
padding: 2rem;
color: #666;
}
.contact-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f0f0f0;
}
.contact-info {
flex: 1;
strong {
display: block;
margin-bottom: 0.25rem;
}
.contact-primary {
color: #666;
font-size: 0.875rem;
}
}
.btn-add {
background-color: #4caf50;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #45a049;
}
}
}
}
}
}
@@ -0,0 +1,130 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApplicationService } from '../application.service';
import { ContactService } from '../../contacts/contact.service';
import { ApplicationContactResponse } from '../../../shared/models/application.model';
import { ContactRole } from '../../../shared/models/contact.model';
@Component({
selector: 'app-application-contacts',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './application-contacts.component.html',
styleUrls: ['./application-contacts.component.scss']
})
export class ApplicationContactsComponent implements OnInit {
@Input() applicationId!: string;
@Input() applicationName!: string;
contacts: ApplicationContactResponse[] = [];
availableRoles: ContactRole[] = [];
loading = false;
error = '';
showAddDialog = false;
selectedContactRole = '';
availableContacts: any[] = [];
constructor(
private applicationService: ApplicationService,
private contactService: ContactService
) {}
ngOnInit(): void {
if (this.applicationId) {
this.loadContacts();
this.loadContactRoles();
}
}
loadContacts(): void {
this.loading = true;
this.applicationService.getApplicationContacts(this.applicationId).subscribe({
next: (contacts) => {
this.contacts = contacts;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load contacts';
this.loading = false;
}
});
}
loadContactRoles(): void {
this.contactService.getContactRoles().subscribe({
next: (roles) => {
this.availableRoles = roles;
},
error: (err) => {
console.error('Failed to load contact roles', err);
}
});
}
openAddDialog(): void {
this.showAddDialog = true;
this.loadAllContacts();
}
closeAddDialog(): void {
this.showAddDialog = false;
this.selectedContactRole = '';
}
loadAllContacts(): void {
this.contactService.getContacts().subscribe({
next: (contacts) => {
this.availableContacts = contacts.filter(c =>
!this.contacts.some(ac => ac.contact.id === c.id)
);
},
error: (err) => {
console.error('Failed to load contacts', err);
}
});
}
addContact(contactId: string): void {
this.applicationService.addContactToApplication(this.applicationId, contactId).subscribe({
next: () => {
this.loadContacts();
this.closeAddDialog();
},
error: (err) => {
this.error = 'Failed to add contact';
}
});
}
removeContact(contactId: string): void {
if (confirm('Remove this contact from the application?')) {
this.applicationService.removeContactFromApplication(this.applicationId, contactId).subscribe({
next: () => {
this.loadContacts();
},
error: (err) => {
this.error = 'Failed to remove contact';
}
});
}
}
getPrimaryPerson(contact: any): string {
const primary = contact.persons.find((p: any) => p.isPrimary);
if (primary) {
return `${primary.person.firstName} ${primary.person.lastName}`;
}
return 'No primary contact';
}
getFilteredContacts(): any[] {
if (!this.selectedContactRole) {
return this.availableContacts;
}
return this.availableContacts.filter(c =>
c.contactRole.id === this.selectedContactRole
);
}
}
@@ -0,0 +1,45 @@
<div class="deployments-container">
<div class="header">
<h3>Deployment History</h3>
<button (click)="recordDeployment()" class="btn-primary">Record New Deployment</button>
</div>
<div *ngIf="loading" class="loading">Loading deployments...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && deployments.length > 0" class="deployments-list">
<div class="deployment-item" *ngFor="let deployment of deployments">
<div class="deployment-main">
<div class="deployment-version">
<strong>Version {{ deployment.version.versionIdentifier }}</strong>
</div>
<div class="deployment-environment">
<span
class="env-badge"
[class.env-prod]="deployment.environment.isProduction"
[class.env-non-prod]="!deployment.environment.isProduction">
{{ deployment.environment.name }}
</span>
</div>
</div>
<div class="deployment-meta">
<div>{{ deployment.deploymentDate | date:'medium' }}</div>
<div class="time-ago">{{ getDaysAgo(deployment.deploymentDate) }} days ago</div>
<div *ngIf="deployment.deployedBy">by {{ deployment.deployedBy }}</div>
</div>
<div class="deployment-actions">
<button (click)="viewDetails(deployment.id)" class="btn-sm">Details</button>
</div>
</div>
</div>
<div *ngIf="!loading && deployments.length === 0" class="empty">
No deployments recorded for this application yet.
</div>
<div *ngIf="totalPages > 1" class="pagination">
<button (click)="previousPage()" [disabled]="page === 0">Previous</button>
<span>Page {{ page + 1 }} of {{ totalPages }}</span>
<button (click)="nextPage()" [disabled]="page >= totalPages - 1">Next</button>
</div>
</div>
@@ -0,0 +1,125 @@
.deployments-container {
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h3 {
margin: 0;
}
}
}
.btn-primary {
background-color: #3f51b5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #303f9f;
}
}
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #666;
}
.error {
color: #f44336;
}
.deployments-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.deployment-item {
background: #f9f9f9;
padding: 1.5rem;
border-radius: 8px;
display: grid;
grid-template-columns: 1fr auto auto;
gap: 1rem;
align-items: center;
.deployment-main {
.deployment-version {
margin-bottom: 0.5rem;
color: #333;
}
.env-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
&.env-prod {
background-color: #ffebee;
color: #c62828;
}
&.env-non-prod {
background-color: #e3f2fd;
color: #1565c0;
}
}
}
.deployment-meta {
color: #666;
font-size: 0.875rem;
text-align: right;
.time-ago {
color: #999;
font-size: 0.75rem;
}
}
}
.btn-sm {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
background-color: #2196f3;
color: white;
font-size: 0.875rem;
&:hover {
background-color: #1976d2;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
&:hover:not(:disabled) {
background-color: #f5f5f5;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@@ -0,0 +1,83 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { DeploymentService } from '../../deployments/deployment.service';
import { Deployment } from '../../../shared/models/deployment.model';
import { Page } from '../../../shared/models/environment.model';
@Component({
selector: 'app-application-deployments',
standalone: true,
imports: [CommonModule],
templateUrl: './application-deployments.component.html',
styleUrls: ['./application-deployments.component.scss']
})
export class ApplicationDeploymentsComponent implements OnInit {
@Input() applicationId!: string;
@Input() applicationName!: string;
deployments: Deployment[] = [];
loading = false;
error = '';
page = 0;
size = 10;
totalPages = 0;
constructor(
private deploymentService: DeploymentService,
private router: Router
) {}
ngOnInit(): void {
if (this.applicationId) {
this.loadDeployments();
}
}
loadDeployments(): void {
this.loading = true;
this.deploymentService.getDeploymentsByApplication(this.applicationId, this.page, this.size).subscribe({
next: (data: Page<Deployment>) => {
this.deployments = data.content;
this.totalPages = data.totalPages;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load deployments';
this.loading = false;
}
});
}
recordDeployment(): void {
this.router.navigate(['/deployments/new'], {
queryParams: { applicationId: this.applicationId }
});
}
viewDetails(id: string): void {
this.router.navigate(['/deployments', id]);
}
getDaysAgo(date: Date): number {
const now = new Date();
const deployDate = new Date(date);
const diff = now.getTime() - deployDate.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
nextPage(): void {
if (this.page < this.totalPages - 1) {
this.page++;
this.loadDeployments();
}
}
previousPage(): void {
if (this.page > 0) {
this.page--;
this.loadDeployments();
}
}
}
@@ -2,54 +2,117 @@
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="application && !loading" class="detail-card">
<div *ngIf="application && !loading" class="detail-page">
<!-- Header -->
<div class="header">
<h1>{{ application.name }}</h1>
<div class="actions">
<button (click)="edit()" class="btn-primary">Edit</button>
<button (click)="delete()" class="btn-danger">Delete</button>
</div>
</div>
<div class="details">
<div class="detail-row">
<label>Status:</label>
<div class="header-left">
<h1>{{ application.name }}</h1>
<span class="status-badge" [ngClass]="getStatusClass(application.status)">
{{ getStatusDisplay(application.status) }}
</span>
</div>
<div class="detail-row">
<label>Description:</label>
<span>{{ application.description || '-' }}</span>
</div>
<div class="detail-row">
<label>Business Unit:</label>
<span>{{ application.businessUnit.name }}</span>
</div>
<div class="detail-row">
<label>End of Support Date:</label>
<span>{{ application.endOfSupportDate ? (application.endOfSupportDate | date:'mediumDate') : '-' }}</span>
</div>
<div class="detail-row">
<label>End of Life Date:</label>
<span>{{ application.endOfLifeDate ? (application.endOfLifeDate | date:'mediumDate') : '-' }}</span>
</div>
<div class="detail-row">
<label>Created:</label>
<span>{{ application.createdAt | date:'medium' }}</span>
</div>
<div class="detail-row">
<label>Last Updated:</label>
<span>{{ application.updatedAt | date:'medium' }}</span>
<div class="header-actions">
<button (click)="edit()" class="btn-primary">Edit</button>
<button (click)="delete()" class="btn-danger">Delete</button>
<button (click)="back()" class="btn-secondary">Back</button>
</div>
</div>
<button (click)="back()" class="btn-secondary">Back to List</button>
<!-- Tab Navigation -->
<div class="tabs">
<button
class="tab-button"
[class.active]="activeTab === 'overview'"
(click)="setActiveTab('overview')">
📋 Overview
</button>
<button
class="tab-button"
[class.active]="activeTab === 'versions'"
(click)="setActiveTab('versions')">
📦 Versions
</button>
<button
class="tab-button"
[class.active]="activeTab === 'deployments'"
(click)="setActiveTab('deployments')">
🚀 Deployments
</button>
<button
class="tab-button"
[class.active]="activeTab === 'contacts'"
(click)="setActiveTab('contacts')">
👥 Contacts
</button>
</div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Overview Tab -->
<div *ngIf="activeTab === 'overview'" class="overview-tab">
<div class="detail-card">
<div class="detail-row">
<label>Description:</label>
<span>{{ application.description || '-' }}</span>
</div>
<div class="detail-row">
<label>Business Unit:</label>
<span>{{ application.businessUnit.name }}</span>
</div>
<div class="detail-row">
<label>Status:</label>
<span class="status-badge" [ngClass]="getStatusClass(application.status)">
{{ getStatusDisplay(application.status) }}
</span>
</div>
<div class="detail-row">
<label>End of Support Date:</label>
<span>{{ application.endOfSupportDate ? (application.endOfSupportDate | date:'mediumDate') : '-' }}</span>
</div>
<div class="detail-row">
<label>End of Life Date:</label>
<span>{{ application.endOfLifeDate ? (application.endOfLifeDate | date:'mediumDate') : '-' }}</span>
</div>
<div class="detail-row">
<label>Created:</label>
<span>{{ application.createdAt | date:'medium' }}</span>
</div>
<div class="detail-row">
<label>Last Updated:</label>
<span>{{ application.updatedAt | date:'medium' }}</span>
</div>
</div>
</div>
<!-- Versions Tab -->
<div *ngIf="activeTab === 'versions'">
<app-version-list
[applicationId]="application.id"
[applicationName]="application.name">
</app-version-list>
</div>
<!-- Deployments Tab -->
<div *ngIf="activeTab === 'deployments'">
<app-application-deployments
[applicationId]="application.id"
[applicationName]="application.name">
</app-application-deployments>
</div>
<!-- Contacts Tab -->
<div *ngIf="activeTab === 'contacts'">
<app-application-contacts
[applicationId]="application.id"
[applicationName]="application.name">
</app-application-contacts>
</div>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
.container {
max-width: 800px;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
@@ -13,9 +13,8 @@
color: #f44336;
}
.detail-card {
.detail-page {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
@@ -24,38 +23,75 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f5f5f5;
padding: 2rem;
border-bottom: 1px solid #f5f5f5;
h1 {
margin: 0;
.header-left {
display: flex;
align-items: center;
gap: 1rem;
h1 {
margin: 0;
}
}
.actions {
.header-actions {
display: flex;
gap: 0.5rem;
}
}
.details {
margin-bottom: 2rem;
.tabs {
display: flex;
border-bottom: 2px solid #f5f5f5;
background: #fafafa;
.tab-button {
padding: 1rem 2rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: #666;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
color: #333;
}
&.active {
color: #3f51b5;
border-bottom-color: #3f51b5;
background: white;
}
}
}
.detail-row {
display: flex;
padding: 1rem 0;
border-bottom: 1px solid #f5f5f5;
.tab-content {
padding: 2rem;
min-height: 400px;
}
label {
font-weight: 600;
width: 250px;
color: #555;
}
.detail-card {
.detail-row {
display: flex;
padding: 1rem 0;
border-bottom: 1px solid #f5f5f5;
span {
flex: 1;
color: #333;
label {
font-weight: 600;
width: 250px;
color: #555;
}
span {
flex: 1;
color: #333;
}
}
}
@@ -3,11 +3,19 @@ import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { ApplicationService } from '../application.service';
import { Application, ApplicationStatus } from '../../../shared/models/application.model';
import { VersionListComponent } from '../../versions/version-list/version-list.component';
import { ApplicationDeploymentsComponent } from '../application-deployments/application-deployments.component';
import { ApplicationContactsComponent } from '../application-contacts/application-contacts.component';
@Component({
selector: 'app-application-detail',
standalone: true,
imports: [CommonModule],
imports: [
CommonModule,
VersionListComponent,
ApplicationDeploymentsComponent,
ApplicationContactsComponent
],
templateUrl: './application-detail.component.html',
styleUrls: ['./application-detail.component.scss']
})
@@ -15,6 +23,7 @@ export class ApplicationDetailComponent implements OnInit {
application?: Application;
loading = false;
error = '';
activeTab: 'overview' | 'versions' | 'deployments' | 'contacts' = 'overview';
constructor(
private applicationService: ApplicationService,
@@ -24,6 +33,11 @@ export class ApplicationDetailComponent implements OnInit {
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
const tab = this.route.snapshot.queryParamMap.get('tab');
if (tab && ['overview', 'versions', 'deployments', 'contacts'].includes(tab)) {
this.activeTab = tab as any;
}
if (id) {
this.loadApplication(id);
}
@@ -43,6 +57,15 @@ export class ApplicationDetailComponent implements OnInit {
});
}
setActiveTab(tab: 'overview' | 'versions' | 'deployments' | 'contacts'): void {
this.activeTab = tab;
this.router.navigate([], {
relativeTo: this.route,
queryParams: { tab },
queryParamsHandling: 'merge'
});
}
edit(): void {
if (this.application) {
this.router.navigate(['/applications', this.application.id, 'edit']);
@@ -5,7 +5,9 @@ import {
Application,
ApplicationStatus,
CreateApplicationRequest,
UpdateApplicationRequest
UpdateApplicationRequest,
ApplicationContactResponse,
AddContactToApplicationRequest
} from '../../shared/models/application.model';
import { Page } from '../../shared/models/environment.model';
@@ -68,4 +70,25 @@ export class ApplicationService {
deleteApplication(id: string): Observable<void> {
return this.http.delete<void>(`${this.API_URL}/${id}`);
}
// Contact management
getApplicationContacts(applicationId: string): Observable<ApplicationContactResponse[]> {
return this.http.get<ApplicationContactResponse[]>(
`${this.API_URL}/${applicationId}/contacts`
);
}
addContactToApplication(applicationId: string, contactId: string): Observable<ApplicationContactResponse> {
const request: AddContactToApplicationRequest = { contactId };
return this.http.post<ApplicationContactResponse>(
`${this.API_URL}/${applicationId}/contacts`,
request
);
}
removeContactFromApplication(applicationId: string, contactId: string): Observable<void> {
return this.http.delete<void>(
`${this.API_URL}/${applicationId}/contacts/${contactId}`
);
}
}
@@ -1,3 +1,5 @@
import { ContactResponse } from './contact.model';
export enum ApplicationStatus {
IDEA = 'IDEA',
IN_DEVELOPMENT = 'IN_DEVELOPMENT',
@@ -35,3 +37,12 @@ export interface UpdateApplicationRequest {
endOfLifeDate?: Date;
endOfSupportDate?: Date;
}
export interface ApplicationContactResponse {
applicationId: string;
contact: ContactResponse;
}
export interface AddContactToApplicationRequest {
contactId: string;
}