diff --git a/TODOS b/TODOS
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts
index e3efd24..e61dc2a 100644
--- a/frontend/src/app/app.routes.ts
+++ b/frontend/src/app/app.routes.ts
@@ -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)
}
];
diff --git a/frontend/src/app/features/contacts/contact-detail/contact-detail.component.html b/frontend/src/app/features/contacts/contact-detail/contact-detail.component.html
new file mode 100644
index 0000000..fe027cf
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-detail/contact-detail.component.html
@@ -0,0 +1,57 @@
+
+
Loading...
+
{{ error }}
+
+
+
+
+
+
{{ contact.contactRole.description }}
+
+
+
+
Associated Persons ({{ contact.persons.length }})
+
+
0">
+
+
+
+ {{ personInContact.person.firstName }} {{ personInContact.person.lastName }}
+ PRIMARY
+
+
{{ personInContact.person.email }}
+
{{ personInContact.person.phone }}
+
+
+
+
+
+
+
+
+
+
+ No persons associated with this contact.
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/features/contacts/contact-detail/contact-detail.component.scss b/frontend/src/app/features/contacts/contact-detail/contact-detail.component.scss
new file mode 100644
index 0000000..1d20c55
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-detail/contact-detail.component.scss
@@ -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; }
+}
diff --git a/frontend/src/app/features/contacts/contact-detail/contact-detail.component.ts b/frontend/src/app/features/contacts/contact-detail/contact-detail.component.ts
new file mode 100644
index 0000000..9df87a5
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-detail/contact-detail.component.ts
@@ -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']);
+ }
+}
diff --git a/frontend/src/app/features/contacts/contact-form/contact-form.component.html b/frontend/src/app/features/contacts/contact-form/contact-form.component.html
new file mode 100644
index 0000000..7faa93b
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-form/contact-form.component.html
@@ -0,0 +1,85 @@
+
+
Create New Contact
+
+
+
diff --git a/frontend/src/app/features/contacts/contact-form/contact-form.component.scss b/frontend/src/app/features/contacts/contact-form/contact-form.component.scss
new file mode 100644
index 0000000..393c63a
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-form/contact-form.component.scss
@@ -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;
+}
diff --git a/frontend/src/app/features/contacts/contact-form/contact-form.component.ts b/frontend/src/app/features/contacts/contact-form/contact-form.component.ts
new file mode 100644
index 0000000..35a8302
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-form/contact-form.component.ts
@@ -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 = 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) => {
+ 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']);
+ }
+}
diff --git a/frontend/src/app/features/contacts/contact-list/contact-list.component.html b/frontend/src/app/features/contacts/contact-list/contact-list.component.html
new file mode 100644
index 0000000..94db4e7
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-list/contact-list.component.html
@@ -0,0 +1,43 @@
+
+
+
+
Loading...
+
{{ error }}
+
+
0" class="contacts-grid">
+
+
+
+
+ No contacts found. Click "Create New Contact" to get started.
+
+
diff --git a/frontend/src/app/features/contacts/contact-list/contact-list.component.scss b/frontend/src/app/features/contacts/contact-list/contact-list.component.scss
new file mode 100644
index 0000000..4fd3393
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-list/contact-list.component.scss
@@ -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; }
+ }
+}
diff --git a/frontend/src/app/features/contacts/contact-list/contact-list.component.ts b/frontend/src/app/features/contacts/contact-list/contact-list.component.ts
new file mode 100644
index 0000000..586f728
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-list/contact-list.component.ts
@@ -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;
+ }
+}
diff --git a/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.html b/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.html
new file mode 100644
index 0000000..6b4fa37
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.html
@@ -0,0 +1,21 @@
+
+
+
+
Loading...
+
{{ error }}
+
+
0" class="roles-grid">
+
+
👤
+
{{ role.roleName }}
+
{{ role.description || 'No description' }}
+
+
+
+
+ No contact roles found.
+
+
diff --git a/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.scss b/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.scss
new file mode 100644
index 0000000..2d31711
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.scss
@@ -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;
+ }
+}
diff --git a/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.ts b/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.ts
new file mode 100644
index 0000000..4935ace
--- /dev/null
+++ b/frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.ts
@@ -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;
+ }
+ });
+ }
+}
diff --git a/frontend/src/app/features/dashboard/dashboard.component.ts b/frontend/src/app/features/dashboard/dashboard.component.ts
index aadd5c4..cf61aef 100644
--- a/frontend/src/app/features/dashboard/dashboard.component.ts
+++ b/frontend/src/app/features/dashboard/dashboard.component.ts
@@ -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(
diff --git a/frontend/src/app/features/persons/person-detail/person-detail.component.html b/frontend/src/app/features/persons/person-detail/person-detail.component.html
new file mode 100644
index 0000000..891a379
--- /dev/null
+++ b/frontend/src/app/features/persons/person-detail/person-detail.component.html
@@ -0,0 +1,48 @@
+
+
Loading...
+
{{ error }}
+
+
+
+
+
+
+
+ {{ person.firstName }}
+
+
+
+
+ {{ person.lastName }}
+
+
+
+
+ {{ person.email }}
+
+
+
+
+ {{ person.phone || '-' }}
+
+
+
+
+ {{ person.createdAt | date:'medium' }}
+
+
+
+
+ {{ person.updatedAt | date:'medium' }}
+
+
+
+
+
+
diff --git a/frontend/src/app/features/persons/person-detail/person-detail.component.scss b/frontend/src/app/features/persons/person-detail/person-detail.component.scss
new file mode 100644
index 0000000..4c7f46e
--- /dev/null
+++ b/frontend/src/app/features/persons/person-detail/person-detail.component.scss
@@ -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; }
+}
diff --git a/frontend/src/app/features/persons/person-detail/person-detail.component.ts b/frontend/src/app/features/persons/person-detail/person-detail.component.ts
new file mode 100644
index 0000000..0782117
--- /dev/null
+++ b/frontend/src/app/features/persons/person-detail/person-detail.component.ts
@@ -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']);
+ }
+}
diff --git a/frontend/src/app/features/persons/person-form/person-form.component.html b/frontend/src/app/features/persons/person-form/person-form.component.html
new file mode 100644
index 0000000..57f041f
--- /dev/null
+++ b/frontend/src/app/features/persons/person-form/person-form.component.html
@@ -0,0 +1,71 @@
+
+
{{ isEditMode ? 'Edit Person' : 'Add New Person' }}
+
+
+
diff --git a/frontend/src/app/features/persons/person-form/person-form.component.scss b/frontend/src/app/features/persons/person-form/person-form.component.scss
new file mode 100644
index 0000000..6ebf607
--- /dev/null
+++ b/frontend/src/app/features/persons/person-form/person-form.component.scss
@@ -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;
+}
diff --git a/frontend/src/app/features/persons/person-form/person-form.component.ts b/frontend/src/app/features/persons/person-form/person-form.component.ts
new file mode 100644
index 0000000..71eab84
--- /dev/null
+++ b/frontend/src/app/features/persons/person-form/person-form.component.ts
@@ -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']);
+ }
+}
diff --git a/frontend/src/app/features/persons/person-list/person-list.component.html b/frontend/src/app/features/persons/person-list/person-list.component.html
new file mode 100644
index 0000000..81e0fdd
--- /dev/null
+++ b/frontend/src/app/features/persons/person-list/person-list.component.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
Loading...
+
{{ error }}
+
+
0" class="table-container">
+
+
+
+ | First Name |
+ Last Name |
+ Email |
+ Phone |
+ Actions |
+
+
+
+
+ | {{ person.firstName }} |
+ {{ person.lastName }} |
+ {{ person.email }} |
+ {{ person.phone || '-' }} |
+
+
+
+
+ |
+
+
+
+
+
+
+ No persons found. Click "Add New Person" to get started.
+
+
+
+ No persons match your search "{{ searchQuery }}".
+
+
+
1" class="pagination">
+
+ Page {{ page + 1 }} of {{ totalPages }} ({{ totalElements }} total)
+
+
+
diff --git a/frontend/src/app/features/persons/person-list/person-list.component.scss b/frontend/src/app/features/persons/person-list/person-list.component.scss
new file mode 100644
index 0000000..6d3c13b
--- /dev/null
+++ b/frontend/src/app/features/persons/person-list/person-list.component.scss
@@ -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; }
+}
diff --git a/frontend/src/app/features/persons/person-list/person-list.component.ts b/frontend/src/app/features/persons/person-list/person-list.component.ts
new file mode 100644
index 0000000..2adcb8a
--- /dev/null
+++ b/frontend/src/app/features/persons/person-list/person-list.component.ts
@@ -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();
+
+ 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) => {
+ 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();
+ }
+ }
+}