autocomit

This commit is contained in:
2026-02-08 16:09:01 +01:00
parent e3d9ea1050
commit 1aeadb9c99
24 changed files with 1839 additions and 1 deletions
+53
View File
@@ -93,5 +93,58 @@ export const routes: Routes = [
.then(m => m.EnvironmentFormComponent)
}
]
},
{
path: 'persons',
canActivate: [authGuard],
children: [
{
path: '',
loadComponent: () => import('./features/persons/person-list/person-list.component')
.then(m => m.PersonListComponent)
},
{
path: 'new',
loadComponent: () => import('./features/persons/person-form/person-form.component')
.then(m => m.PersonFormComponent)
},
{
path: ':id',
loadComponent: () => import('./features/persons/person-detail/person-detail.component')
.then(m => m.PersonDetailComponent)
},
{
path: ':id/edit',
loadComponent: () => import('./features/persons/person-form/person-form.component')
.then(m => m.PersonFormComponent)
}
]
},
{
path: 'contacts',
canActivate: [authGuard],
children: [
{
path: '',
loadComponent: () => import('./features/contacts/contact-list/contact-list.component')
.then(m => m.ContactListComponent)
},
{
path: 'new',
loadComponent: () => import('./features/contacts/contact-form/contact-form.component')
.then(m => m.ContactFormComponent)
},
{
path: ':id',
loadComponent: () => import('./features/contacts/contact-detail/contact-detail.component')
.then(m => m.ContactDetailComponent)
}
]
},
{
path: 'contact-roles',
canActivate: [authGuard],
loadComponent: () => import('./features/contacts/contact-role-list/contact-role-list.component')
.then(m => m.ContactRoleListComponent)
}
];
@@ -0,0 +1,57 @@
<div class="container">
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="contact && !loading" class="detail-card">
<div class="header">
<h1>{{ contact.contactRole.roleName }}</h1>
<button (click)="delete()" class="btn-danger">Delete Contact</button>
</div>
<div class="role-description" *ngIf="contact.contactRole.description">
<p>{{ contact.contactRole.description }}</p>
</div>
<div class="persons-section">
<h2>Associated Persons ({{ contact.persons.length }})</h2>
<div class="persons-list" *ngIf="contact.persons.length > 0">
<div class="person-item" *ngFor="let personInContact of contact.persons">
<div class="person-info">
<h3>
{{ personInContact.person.firstName }} {{ personInContact.person.lastName }}
<span class="primary-badge" *ngIf="personInContact.isPrimary">PRIMARY</span>
</h3>
<p>{{ personInContact.person.email }}</p>
<p *ngIf="personInContact.person.phone">{{ personInContact.person.phone }}</p>
</div>
<div class="person-actions">
<button
(click)="setPrimary(personInContact.person.id)"
class="btn-sm btn-primary"
*ngIf="!personInContact.isPrimary">
Set as Primary
</button>
<button
(click)="removePerson(personInContact.person.id)"
class="btn-sm btn-danger">
Remove
</button>
</div>
</div>
</div>
<div class="no-persons" *ngIf="contact.persons.length === 0">
No persons associated with this contact.
</div>
</div>
<div class="metadata">
<p><strong>Created:</strong> {{ contact.createdAt | date:'medium' }}</p>
<p><strong>Updated:</strong> {{ contact.updatedAt | date:'medium' }}</p>
</div>
<button (click)="back()" class="btn-secondary">Back to List</button>
</div>
</div>
@@ -0,0 +1,148 @@
.container {
max-width: 900px;
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: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f5f5f5;
h1 { margin: 0; }
}
.role-description {
background: #f9f9f9;
padding: 1rem;
border-radius: 4px;
margin-bottom: 2rem;
p {
margin: 0;
color: #666;
}
}
.persons-section {
margin-bottom: 2rem;
h2 {
margin: 0 0 1rem 0;
color: #333;
}
}
.persons-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.person-item {
background: #f9f9f9;
padding: 1.5rem;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
.person-info {
flex: 1;
h3 {
margin: 0 0 0.5rem 0;
color: #333;
display: flex;
align-items: center;
gap: 0.5rem;
.primary-badge {
background: #4caf50;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
}
p {
margin: 0.25rem 0;
color: #666;
}
}
.person-actions {
display: flex;
gap: 0.5rem;
}
}
.no-persons {
text-align: center;
padding: 2rem;
color: #666;
background: #f9f9f9;
border-radius: 8px;
}
.metadata {
margin: 2rem 0;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
p {
margin: 0.5rem 0;
color: #666;
}
}
.btn-primary, .btn-secondary, .btn-danger, .btn-sm {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.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; }
}
@@ -0,0 +1,88 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { ContactService } from '../contact.service';
import { Contact } from '../../../shared/models/contact.model';
@Component({
selector: 'app-contact-detail',
standalone: true,
imports: [CommonModule],
templateUrl: './contact-detail.component.html',
styleUrls: ['./contact-detail.component.scss']
})
export class ContactDetailComponent implements OnInit {
contact?: Contact;
loading = false;
error = '';
constructor(
private contactService: ContactService,
private router: Router,
private route: ActivatedRoute
) {}
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.loadContact(id);
}
}
loadContact(id: string): void {
this.loading = true;
this.contactService.getContact(id).subscribe({
next: (contact) => {
this.contact = contact;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load contact';
this.loading = false;
}
});
}
setPrimary(personId: string): void {
if (this.contact) {
this.contactService.setPrimaryPerson(this.contact.id, personId).subscribe({
next: (updated) => {
this.contact = updated;
},
error: (err) => {
this.error = 'Failed to set primary person';
}
});
}
}
removePerson(personId: string): void {
if (this.contact && confirm('Remove this person from the contact?')) {
this.contactService.removePersonFromContact(this.contact.id, personId).subscribe({
next: (updated) => {
this.contact = updated;
},
error: (err) => {
this.error = 'Failed to remove person';
}
});
}
}
delete(): void {
if (this.contact && confirm('Delete this contact?')) {
this.contactService.deleteContact(this.contact.id).subscribe({
next: () => {
this.router.navigate(['/contacts']);
},
error: (err) => {
this.error = 'Failed to delete contact';
}
});
}
}
back(): void {
this.router.navigate(['/contacts']);
}
}
@@ -0,0 +1,85 @@
<div class="container">
<h1>Create New Contact</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="contactRoleId">Contact Role *</label>
<select
id="contactRoleId"
formControlName="contactRoleId"
[class.error]="form.get('contactRoleId')?.invalid && form.get('contactRoleId')?.touched"
>
<option value="">Select a role</option>
<option *ngFor="let role of roles" [value]="role.id">
{{ role.roleName }}
</option>
</select>
<div class="error-message" *ngIf="form.get('contactRoleId')?.invalid && form.get('contactRoleId')?.touched">
Contact role is required
</div>
</div>
<div class="persons-section">
<h2>Select Persons *</h2>
<p class="help-text">Select one or more persons and designate one as primary</p>
<div class="persons-list" *ngIf="persons.length > 0">
<div
class="person-item"
*ngFor="let person of persons"
[class.selected]="isSelected(person.id)"
[class.primary]="isPrimary(person.id)">
<div class="person-checkbox">
<input
type="checkbox"
[id]="'person-' + person.id"
[checked]="isSelected(person.id)"
(change)="togglePerson(person.id)"
/>
</div>
<label [for]="'person-' + person.id" class="person-info">
<strong>{{ person.firstName }} {{ person.lastName }}</strong>
<span>{{ person.email }}</span>
<span *ngIf="person.phone">{{ person.phone }}</span>
</label>
<div class="person-actions">
<button
type="button"
class="btn-sm btn-primary"
[disabled]="!isSelected(person.id)"
(click)="setPrimary(person.id)">
{{ isPrimary(person.id) ? '★ Primary' : 'Set Primary' }}
</button>
</div>
</div>
</div>
<div class="no-persons" *ngIf="persons.length === 0">
No persons available. Please create persons first.
</div>
<div class="selected-summary" *ngIf="selectedPersons.size > 0">
<strong>Selected:</strong> {{ selectedPersons.size }} person(s)
<span *ngIf="primaryPersonId"> | Primary set ✓</span>
<span *ngIf="!primaryPersonId" class="warning"> | Please designate a primary person</span>
</div>
</div>
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<div class="form-actions">
<button type="button" (click)="cancel()" class="btn-secondary">Cancel</button>
<button
type="submit"
[disabled]="form.invalid || selectedPersons.size === 0 || !primaryPersonId || loading"
class="btn-primary">
{{ loading ? 'Creating...' : 'Create Contact' }}
</button>
</div>
</form>
</div>
@@ -0,0 +1,180 @@
.container {
max-width: 900px;
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: 2rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
select {
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; }
}
}
.persons-section {
margin-bottom: 2rem;
h2 {
margin: 0 0 0.5rem 0;
color: #333;
}
.help-text {
color: #666;
font-size: 0.875rem;
margin: 0 0 1rem 0;
}
}
.persons-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
}
.person-item {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 0.5rem;
border: 2px solid transparent;
border-radius: 4px;
background: #f9f9f9;
transition: all 0.2s ease;
&.selected {
border-color: #3f51b5;
background: #e8eaf6;
}
&.primary {
border-color: #4caf50;
background: #e8f5e9;
}
.person-checkbox {
margin-right: 1rem;
input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
}
.person-info {
flex: 1;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.25rem;
strong {
color: #333;
}
span {
color: #666;
font-size: 0.875rem;
}
}
.person-actions {
margin-left: 1rem;
}
}
.no-persons {
text-align: center;
padding: 2rem;
color: #666;
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
}
.selected-summary {
margin-top: 1rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
text-align: center;
.warning {
color: #ff9800;
font-weight: 500;
}
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn-primary, .btn-secondary, .btn-sm {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.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;
}
@@ -0,0 +1,125 @@
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 { ContactService } from '../contact.service';
import { PersonService } from '../../persons/person.service';
import { ContactRole, Person } from '../../../shared/models/contact.model';
import { Page } from '../../../shared/models/environment.model';
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './contact-form.component.html',
styleUrls: ['./contact-form.component.scss']
})
export class ContactFormComponent implements OnInit {
form: FormGroup;
loading = false;
error = '';
roles: ContactRole[] = [];
persons: Person[] = [];
selectedPersons: Set<string> = new Set();
primaryPersonId: string = '';
constructor(
private fb: FormBuilder,
private contactService: ContactService,
private personService: PersonService,
private router: Router
) {
this.form = this.fb.group({
contactRoleId: ['', [Validators.required]]
});
}
ngOnInit(): void {
this.loadRoles();
this.loadPersons();
}
loadRoles(): void {
this.contactService.getContactRoles().subscribe({
next: (roles) => {
this.roles = roles;
},
error: (err) => {
this.error = 'Failed to load contact roles';
}
});
}
loadPersons(): void {
this.personService.getPersons(undefined, 0, 100).subscribe({
next: (data: Page<Person>) => {
this.persons = data.content;
},
error: (err) => {
this.error = 'Failed to load persons';
}
});
}
togglePerson(personId: string): void {
if (this.selectedPersons.has(personId)) {
this.selectedPersons.delete(personId);
if (this.primaryPersonId === personId) {
this.primaryPersonId = '';
}
} else {
this.selectedPersons.add(personId);
if (this.selectedPersons.size === 1) {
this.primaryPersonId = personId;
}
}
}
setPrimary(personId: string): void {
if (this.selectedPersons.has(personId)) {
this.primaryPersonId = personId;
}
}
isSelected(personId: string): boolean {
return this.selectedPersons.has(personId);
}
isPrimary(personId: string): boolean {
return this.primaryPersonId === personId;
}
onSubmit(): void {
if (this.form.valid && this.selectedPersons.size > 0 && this.primaryPersonId) {
this.loading = true;
this.error = '';
const request = {
contactRoleId: this.form.value.contactRoleId,
personIds: Array.from(this.selectedPersons),
primaryPersonId: this.primaryPersonId
};
this.contactService.createContact(request).subscribe({
next: () => {
this.router.navigate(['/contacts']);
},
error: (err) => {
this.error = err.error?.message || 'Failed to create contact';
this.loading = false;
}
});
} else {
if (this.selectedPersons.size === 0) {
this.error = 'Please select at least one person';
} else if (!this.primaryPersonId) {
this.error = 'Please designate a primary person';
}
}
}
cancel(): void {
this.router.navigate(['/contacts']);
}
}
@@ -0,0 +1,43 @@
<div class="container">
<div class="header">
<h1>Contacts</h1>
<button (click)="createNew()" class="btn-primary">Create New Contact</button>
</div>
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && contacts.length > 0" class="contacts-grid">
<div class="contact-card" *ngFor="let contact of contacts">
<div class="contact-header">
<h3>{{ contact.contactRole.roleName }}</h3>
<span class="person-count">{{ getPersonCount(contact) }} person(s)</span>
</div>
<div class="contact-body">
<div class="primary-person">
<strong>Primary:</strong> {{ getPrimaryPerson(contact) }}
</div>
<div class="all-persons" *ngIf="contact.persons.length > 0">
<strong>All persons:</strong>
<ul>
<li *ngFor="let personInContact of contact.persons">
{{ personInContact.person.firstName }} {{ personInContact.person.lastName }}
<span class="primary-badge" *ngIf="personInContact.isPrimary">PRIMARY</span>
</li>
</ul>
</div>
</div>
<div class="contact-actions">
<button (click)="viewDetails(contact.id)" class="btn-sm">View Details</button>
<button (click)="delete(contact.id)" class="btn-sm btn-danger">Delete</button>
</div>
</div>
</div>
<div *ngIf="!loading && contacts.length === 0" class="empty">
No contacts found. Click "Create New Contact" to get started.
</div>
</div>
@@ -0,0 +1,128 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
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; }
.contacts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.contact-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.contact-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
h3 { margin: 0; }
.person-count {
background: rgba(255,255,255,0.2);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
}
}
.contact-body {
padding: 1.5rem;
.primary-person {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #f5f5f5;
}
.all-persons {
ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
li {
padding: 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
.primary-badge {
background: #4caf50;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
}
}
}
}
.contact-actions {
padding: 1rem 1.5rem;
border-top: 1px solid #f5f5f5;
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; }
}
}
@@ -0,0 +1,74 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { ContactService } from '../contact.service';
import { Contact } from '../../../shared/models/contact.model';
@Component({
selector: 'app-contact-list',
standalone: true,
imports: [CommonModule],
templateUrl: './contact-list.component.html',
styleUrls: ['./contact-list.component.scss']
})
export class ContactListComponent implements OnInit {
contacts: Contact[] = [];
loading = false;
error = '';
constructor(
private contactService: ContactService,
private router: Router
) {}
ngOnInit(): void {
this.loadContacts();
}
loadContacts(): void {
this.loading = true;
this.contactService.getContacts().subscribe({
next: (contacts) => {
this.contacts = contacts;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load contacts';
this.loading = false;
}
});
}
createNew(): void {
this.router.navigate(['/contacts/new']);
}
viewDetails(id: string): void {
this.router.navigate(['/contacts', id]);
}
delete(id: string): void {
if (confirm('Are you sure you want to delete this contact?')) {
this.contactService.deleteContact(id).subscribe({
next: () => {
this.loadContacts();
},
error: (err) => {
this.error = 'Failed to delete contact';
}
});
}
}
getPrimaryPerson(contact: Contact): string {
const primary = contact.persons.find(p => p.isPrimary);
if (primary) {
return `${primary.person.firstName} ${primary.person.lastName}`;
}
return 'No primary contact';
}
getPersonCount(contact: Contact): number {
return contact.persons.length;
}
}
@@ -0,0 +1,21 @@
<div class="container">
<div class="header">
<h1>Contact Roles</h1>
<p class="subtitle">Predefined roles for contacts (Admin can add new roles via API)</p>
</div>
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && roles.length > 0" class="roles-grid">
<div class="role-card" *ngFor="let role of roles">
<div class="role-icon">👤</div>
<h3>{{ role.roleName }}</h3>
<p>{{ role.description || 'No description' }}</p>
</div>
</div>
<div *ngIf="!loading && roles.length === 0" class="empty">
No contact roles found.
</div>
</div>
@@ -0,0 +1,60 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
margin-bottom: 2rem;
h1 { margin: 0 0 0.5rem 0; }
.subtitle {
color: #666;
margin: 0;
}
}
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #666;
}
.error { color: #f44336; }
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.role-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.role-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
h3 {
margin: 0 0 0.5rem 0;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 0.9rem;
}
}
@@ -0,0 +1,37 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ContactService } from '../contact.service';
import { ContactRole } from '../../../shared/models/contact.model';
@Component({
selector: 'app-contact-role-list',
standalone: true,
imports: [CommonModule],
templateUrl: './contact-role-list.component.html',
styleUrls: ['./contact-role-list.component.scss']
})
export class ContactRoleListComponent implements OnInit {
roles: ContactRole[] = [];
loading = false;
error = '';
constructor(private contactService: ContactService) {}
ngOnInit(): void {
this.loadRoles();
}
loadRoles(): void {
this.loading = true;
this.contactService.getContactRoles().subscribe({
next: (roles) => {
this.roles = roles;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load contact roles';
this.loading = false;
}
});
}
}
@@ -35,13 +35,35 @@ export class DashboardComponent implements OnInit {
icon: '🌍',
route: '/environments',
color: '#ff9800'
},
{
title: 'Persons',
description: 'Manage individual contacts',
icon: '👤',
route: '/persons',
color: '#e91e63'
},
{
title: 'Contacts',
description: 'Manage functional contacts and roles',
icon: '👥',
route: '/contacts',
color: '#9c27b0'
},
{
title: 'Contact Roles',
description: 'View predefined contact roles',
icon: '🎭',
route: '/contact-roles',
color: '#607d8b'
}
];
stats = [
{ label: 'Business Units', value: '4', icon: '🏢' },
{ label: 'Applications', value: '7', icon: '📱' },
{ label: 'Environments', value: '4', icon: '🌍' }
{ label: 'Environments', value: '4', icon: '🌍' },
{ label: 'Persons', value: '4', icon: '👤' }
];
constructor(
@@ -0,0 +1,48 @@
<div class="container">
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="person && !loading" class="detail-card">
<div class="header">
<h1>{{ person.firstName }} {{ person.lastName }}</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>First Name:</label>
<span>{{ person.firstName }}</span>
</div>
<div class="detail-row">
<label>Last Name:</label>
<span>{{ person.lastName }}</span>
</div>
<div class="detail-row">
<label>Email:</label>
<span>{{ person.email }}</span>
</div>
<div class="detail-row">
<label>Phone:</label>
<span>{{ person.phone || '-' }}</span>
</div>
<div class="detail-row">
<label>Created:</label>
<span>{{ person.createdAt | date:'medium' }}</span>
</div>
<div class="detail-row">
<label>Last Updated:</label>
<span>{{ person.updatedAt | date:'medium' }}</span>
</div>
</div>
<button (click)="back()" class="btn-secondary">Back to List</button>
</div>
</div>
@@ -0,0 +1,80 @@
.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: 200px;
color: #555;
}
span {
flex: 1;
color: #333;
}
}
.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; }
}
@@ -0,0 +1,68 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { PersonService } from '../person.service';
import { Person } from '../../../shared/models/contact.model';
@Component({
selector: 'app-person-detail',
standalone: true,
imports: [CommonModule],
templateUrl: './person-detail.component.html',
styleUrls: ['./person-detail.component.scss']
})
export class PersonDetailComponent implements OnInit {
person?: Person;
loading = false;
error = '';
constructor(
private personService: PersonService,
private router: Router,
private route: ActivatedRoute
) {}
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.loadPerson(id);
}
}
loadPerson(id: string): void {
this.loading = true;
this.personService.getPerson(id).subscribe({
next: (person) => {
this.person = person;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load person';
this.loading = false;
}
});
}
edit(): void {
if (this.person) {
this.router.navigate(['/persons', this.person.id, 'edit']);
}
}
delete(): void {
if (this.person && confirm('Are you sure you want to delete this person?')) {
this.personService.deletePerson(this.person.id).subscribe({
next: () => {
this.router.navigate(['/persons']);
},
error: (err) => {
this.error = 'Failed to delete person';
}
});
}
}
back(): void {
this.router.navigate(['/persons']);
}
}
@@ -0,0 +1,71 @@
<div class="container">
<h1>{{ isEditMode ? 'Edit Person' : 'Add New Person' }}</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="firstName">First Name *</label>
<input
id="firstName"
type="text"
formControlName="firstName"
[class.error]="form.get('firstName')?.invalid && form.get('firstName')?.touched"
/>
<div class="error-message" *ngIf="form.get('firstName')?.invalid && form.get('firstName')?.touched">
<span *ngIf="form.get('firstName')?.errors?.['required']">First name is required</span>
<span *ngIf="form.get('firstName')?.errors?.['maxlength']">Max 100 characters</span>
</div>
</div>
<div class="form-group">
<label for="lastName">Last Name *</label>
<input
id="lastName"
type="text"
formControlName="lastName"
[class.error]="form.get('lastName')?.invalid && form.get('lastName')?.touched"
/>
<div class="error-message" *ngIf="form.get('lastName')?.invalid && form.get('lastName')?.touched">
<span *ngIf="form.get('lastName')?.errors?.['required']">Last name is required</span>
<span *ngIf="form.get('lastName')?.errors?.['maxlength']">Max 100 characters</span>
</div>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input
id="email"
type="email"
formControlName="email"
[class.error]="form.get('email')?.invalid && form.get('email')?.touched"
/>
<div class="error-message" *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
<span *ngIf="form.get('email')?.errors?.['required']">Email is required</span>
<span *ngIf="form.get('email')?.errors?.['email']">Valid email is required</span>
</div>
</div>
<div class="form-group">
<label for="phone">Phone</label>
<input
id="phone"
type="tel"
formControlName="phone"
[class.error]="form.get('phone')?.invalid && form.get('phone')?.touched"
/>
<div class="error-message" *ngIf="form.get('phone')?.invalid && form.get('phone')?.touched">
<span *ngIf="form.get('phone')?.errors?.['maxlength']">Max 50 characters</span>
</div>
</div>
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<div class="form-actions">
<button type="button" (click)="cancel()" class="btn-secondary">Cancel</button>
<button type="submit" [disabled]="form.invalid || loading" class="btn-primary">
{{ loading ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
@@ -0,0 +1,78 @@
.container {
max-width: 600px;
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; }
}
}
.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;
}
@@ -0,0 +1,82 @@
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 { PersonService } from '../person.service';
@Component({
selector: 'app-person-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './person-form.component.html',
styleUrls: ['./person-form.component.scss']
})
export class PersonFormComponent implements OnInit {
form: FormGroup;
loading = false;
error = '';
isEditMode = false;
personId?: string;
constructor(
private fb: FormBuilder,
private personService: PersonService,
private router: Router,
private route: ActivatedRoute
) {
this.form = this.fb.group({
firstName: ['', [Validators.required, Validators.maxLength(100)]],
lastName: ['', [Validators.required, Validators.maxLength(100)]],
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.maxLength(50)]]
});
}
ngOnInit(): void {
this.personId = this.route.snapshot.paramMap.get('id') || undefined;
this.isEditMode = !!this.personId;
if (this.isEditMode && this.personId) {
this.loadPerson(this.personId);
}
}
loadPerson(id: string): void {
this.loading = true;
this.personService.getPerson(id).subscribe({
next: (person) => {
this.form.patchValue(person);
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load person';
this.loading = false;
}
});
}
onSubmit(): void {
if (this.form.valid) {
this.loading = true;
this.error = '';
const request$ = this.isEditMode && this.personId
? this.personService.updatePerson(this.personId, this.form.value)
: this.personService.createPerson(this.form.value);
request$.subscribe({
next: () => {
this.router.navigate(['/persons']);
},
error: (err) => {
this.error = err.error?.message || 'Failed to save person';
this.loading = false;
}
});
}
}
cancel(): void {
this.router.navigate(['/persons']);
}
}
@@ -0,0 +1,60 @@
<div class="container">
<div class="header">
<h1>Persons</h1>
<button (click)="createNew()" class="btn-primary">Add New Person</button>
</div>
<div class="search-bar">
<input
type="text"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
placeholder="Search by name..."
class="search-input"
/>
</div>
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && persons.length > 0" class="table-container">
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Phone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let person of persons">
<td>{{ person.firstName }}</td>
<td>{{ person.lastName }}</td>
<td>{{ person.email }}</td>
<td>{{ person.phone || '-' }}</td>
<td class="actions">
<button (click)="viewDetails(person.id)" class="btn-sm">View</button>
<button (click)="edit(person.id)" class="btn-sm">Edit</button>
<button (click)="delete(person.id)" class="btn-sm btn-danger">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="!loading && persons.length === 0 && !searchQuery" class="empty">
No persons found. Click "Add New Person" to get started.
</div>
<div *ngIf="!loading && persons.length === 0 && searchQuery" class="empty">
No persons match your search "{{ searchQuery }}".
</div>
<div *ngIf="totalPages > 1" class="pagination">
<button (click)="previousPage()" [disabled]="page === 0">Previous</button>
<span>Page {{ page + 1 }} of {{ totalPages }} ({{ totalElements }} total)</span>
<button (click)="nextPage()" [disabled]="page >= totalPages - 1">Next</button>
</div>
</div>
@@ -0,0 +1,122 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h1 { margin: 0; }
}
.search-bar {
margin-bottom: 1.5rem;
.search-input {
width: 100%;
max-width: 400px;
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; }
}
.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: 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; }
}
@@ -0,0 +1,108 @@
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 { PersonService } from '../person.service';
import { Person } from '../../../shared/models/contact.model';
import { Page } from '../../../shared/models/environment.model';
@Component({
selector: 'app-person-list',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './person-list.component.html',
styleUrls: ['./person-list.component.scss']
})
export class PersonListComponent implements OnInit {
persons: Person[] = [];
loading = false;
error = '';
page = 0;
size = 20;
totalElements = 0;
totalPages = 0;
searchQuery = '';
private searchSubject = new Subject<string>();
constructor(
private personService: PersonService,
private router: Router
) {
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(query => {
this.page = 0;
this.loadPersons();
});
}
ngOnInit(): void {
this.loadPersons();
}
loadPersons(): void {
this.loading = true;
const name = this.searchQuery.trim() || undefined;
this.personService.getPersons(name, this.page, this.size).subscribe({
next: (data: Page<Person>) => {
this.persons = data.content;
this.totalElements = data.totalElements;
this.totalPages = data.totalPages;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load persons';
this.loading = false;
}
});
}
onSearchChange(query: string): void {
this.searchQuery = query;
this.searchSubject.next(query);
}
createNew(): void {
this.router.navigate(['/persons/new']);
}
viewDetails(id: string): void {
this.router.navigate(['/persons', id]);
}
edit(id: string): void {
this.router.navigate(['/persons', id, 'edit']);
}
delete(id: string): void {
if (confirm('Are you sure you want to delete this person? This will remove them from all contacts.')) {
this.personService.deletePerson(id).subscribe({
next: () => {
this.loadPersons();
},
error: (err) => {
this.error = 'Failed to delete person';
}
});
}
}
nextPage(): void {
if (this.page < this.totalPages - 1) {
this.page++;
this.loadPersons();
}
}
previousPage(): void {
if (this.page > 0) {
this.page--;
this.loadPersons();
}
}
}