From 1aeadb9c993a45cea94b92479ddfc8dce612ac71 Mon Sep 17 00:00:00 2001 From: "laurent.deleers@gmail.com" Date: Sun, 8 Feb 2026 16:09:01 +0100 Subject: [PATCH] autocomit --- TODOS | 0 frontend/src/app/app.routes.ts | 53 ++++++ .../contact-detail.component.html | 57 ++++++ .../contact-detail.component.scss | 148 ++++++++++++++ .../contact-detail.component.ts | 88 +++++++++ .../contact-form/contact-form.component.html | 85 +++++++++ .../contact-form/contact-form.component.scss | 180 ++++++++++++++++++ .../contact-form/contact-form.component.ts | 125 ++++++++++++ .../contact-list/contact-list.component.html | 43 +++++ .../contact-list/contact-list.component.scss | 128 +++++++++++++ .../contact-list/contact-list.component.ts | 74 +++++++ .../contact-role-list.component.html | 21 ++ .../contact-role-list.component.scss | 60 ++++++ .../contact-role-list.component.ts | 37 ++++ .../features/dashboard/dashboard.component.ts | 24 ++- .../person-detail.component.html | 48 +++++ .../person-detail.component.scss | 80 ++++++++ .../person-detail/person-detail.component.ts | 68 +++++++ .../person-form/person-form.component.html | 71 +++++++ .../person-form/person-form.component.scss | 78 ++++++++ .../person-form/person-form.component.ts | 82 ++++++++ .../person-list/person-list.component.html | 60 ++++++ .../person-list/person-list.component.scss | 122 ++++++++++++ .../person-list/person-list.component.ts | 108 +++++++++++ 24 files changed, 1839 insertions(+), 1 deletion(-) create mode 100644 TODOS create mode 100644 frontend/src/app/features/contacts/contact-detail/contact-detail.component.html create mode 100644 frontend/src/app/features/contacts/contact-detail/contact-detail.component.scss create mode 100644 frontend/src/app/features/contacts/contact-detail/contact-detail.component.ts create mode 100644 frontend/src/app/features/contacts/contact-form/contact-form.component.html create mode 100644 frontend/src/app/features/contacts/contact-form/contact-form.component.scss create mode 100644 frontend/src/app/features/contacts/contact-form/contact-form.component.ts create mode 100644 frontend/src/app/features/contacts/contact-list/contact-list.component.html create mode 100644 frontend/src/app/features/contacts/contact-list/contact-list.component.scss create mode 100644 frontend/src/app/features/contacts/contact-list/contact-list.component.ts create mode 100644 frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.html create mode 100644 frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.scss create mode 100644 frontend/src/app/features/contacts/contact-role-list/contact-role-list.component.ts create mode 100644 frontend/src/app/features/persons/person-detail/person-detail.component.html create mode 100644 frontend/src/app/features/persons/person-detail/person-detail.component.scss create mode 100644 frontend/src/app/features/persons/person-detail/person-detail.component.ts create mode 100644 frontend/src/app/features/persons/person-form/person-form.component.html create mode 100644 frontend/src/app/features/persons/person-form/person-form.component.scss create mode 100644 frontend/src/app/features/persons/person-form/person-form.component.ts create mode 100644 frontend/src/app/features/persons/person-list/person-list.component.html create mode 100644 frontend/src/app/features/persons/person-list/person-list.component.scss create mode 100644 frontend/src/app/features/persons/person-list/person-list.component.ts 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.roleName }}

+ +
+ +
+

{{ contact.contactRole.description }}

+
+ +
+

Associated Persons ({{ contact.persons.length }})

+ +
+
+
+

+ {{ 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

+ +
+
+ + +
+ Contact role is required +
+
+ +
+

Select Persons *

+

Select one or more persons and designate one as primary

+ +
+
+ +
+ +
+ + + +
+ +
+
+
+ +
+ No persons available. Please create persons first. +
+ +
+ Selected: {{ selectedPersons.size }} person(s) + | Primary set ✓ + | Please designate a primary person +
+
+ +
+ {{ error }} +
+ +
+ + +
+
+
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 @@ +
+
+

Contacts

+ +
+ +
Loading...
+
{{ error }}
+ +
+
+
+

{{ contact.contactRole.roleName }}

+ {{ getPersonCount(contact) }} person(s) +
+ +
+
+ Primary: {{ getPrimaryPerson(contact) }} +
+ +
+ All persons: +
    +
  • + {{ personInContact.person.firstName }} {{ personInContact.person.lastName }} + PRIMARY +
  • +
+
+
+ +
+ + +
+
+
+ +
+ 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 @@ +
+
+

Contact Roles

+

Predefined roles for contacts (Admin can add new roles via API)

+
+ +
Loading...
+
{{ error }}
+ +
+
+
👤
+

{{ 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.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' }}

+ +
+
+ + +
+ First name is required + Max 100 characters +
+
+ +
+ + +
+ Last name is required + Max 100 characters +
+
+ +
+ + +
+ Email is required + Valid email is required +
+
+ +
+ + +
+ Max 50 characters +
+
+ +
+ {{ error }} +
+ +
+ + +
+
+
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 @@ +
+
+

Persons

+ +
+ + + +
Loading...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + + + +
First NameLast NameEmailPhoneActions
{{ person.firstName }}{{ person.lastName }}{{ person.email }}{{ person.phone || '-' }} + + + +
+
+ +
+ No persons found. Click "Add New Person" to get started. +
+ +
+ No persons match your search "{{ searchQuery }}". +
+ + +
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(); + } + } +}