autocomit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user