Skip to content

Commit a8494f9

Browse files
authored
Merge pull request #2379 from bcgov/feature/2360
Start C&E frontend
2 parents 2beb0b6 + 0dc21de commit a8494f9

File tree

14 files changed

+678
-1
lines changed

14 files changed

+678
-1
lines changed

alcs-frontend/src/app/app-routing.module.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ const routes: Routes = [
7878
},
7979
loadChildren: () => import('./features/inquiry/inquiry.module').then((m) => m.InquiryModule),
8080
},
81+
{
82+
path: 'compliance-and-enforcement',
83+
canActivate: [HasRolesGuard],
84+
data: {
85+
roles: [ROLES.C_AND_E],
86+
},
87+
loadChildren: () =>
88+
import('./features/compliance-and-enforcement/compliance-and-enforcement.module').then(
89+
(m) => m.ComplianceAndEnforcementModule,
90+
),
91+
},
8192
{
8293
path: 'schedule',
8394
canActivate: [HasRolesGuard],
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { NgModule } from '@angular/core';
2+
import { RouterModule, Routes } from '@angular/router';
3+
import { SharedModule } from '../../shared/shared.module';
4+
import { OverviewComponent } from './overview/overview.component';
5+
import { MatMomentDateModule } from '@angular/material-moment-adapter';
6+
import { DraftComponent } from './draft/draft.component';
7+
8+
const routes: Routes = [
9+
{
10+
path: ':fileNumber/draft',
11+
component: DraftComponent,
12+
},
13+
];
14+
15+
@NgModule({
16+
declarations: [DraftComponent, OverviewComponent],
17+
imports: [SharedModule.forRoot(), RouterModule.forChild(routes), MatMomentDateModule],
18+
})
19+
export class ComplianceAndEnforcementModule {}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div class="container">
2+
<h2>C&E File ID: {{ file?.fileNumber }}</h2>
3+
4+
<form [formGroup]="form">
5+
<section class="form-section">
6+
<app-compliance-and-enforcement-overview #overviewComponent [parentForm]="form" [file]="file" />
7+
</section>
8+
9+
<div class="button-container">
10+
<button type="button" mat-stroked-button color="primary" (click)="onSaveDraftClicked()">Save Draft</button>
11+
</div>
12+
</form>
13+
</div>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.container {
2+
margin-bottom: 32px;
3+
height: calc(100% - 32px);
4+
padding: 32px 80px 0 80px;
5+
}
6+
7+
section.form-section {
8+
padding: 24px;
9+
border-radius: 4px;
10+
box-shadow: 0 2px 8px 1px rgba(0, 0, 0, 0.25);
11+
margin: 32px 0;
12+
}
13+
14+
.button-container {
15+
display: flex;
16+
justify-content: flex-end;
17+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { HttpClientTestingModule } from '@angular/common/http/testing';
2+
import { NO_ERRORS_SCHEMA } from '@angular/core';
3+
import { ComponentFixture, TestBed } from '@angular/core/testing';
4+
import { convertToParamMap, ActivatedRoute } from '@angular/router';
5+
import { createMock, DeepMocked } from '@golevelup/ts-jest';
6+
import { ToastService } from '../../../services/toast/toast.service';
7+
import { StartOfDayPipe } from '../../../shared/pipes/startOfDay.pipe';
8+
import { DraftComponent } from './draft.component';
9+
import { ComplianceAndEnforcementService } from '../../../services/compliance-and-enforcement/compliance-and-enforcement.service';
10+
import { OverviewComponent } from '../overview/overview.component';
11+
import {
12+
ComplianceAndEnforcementDto,
13+
UpdateComplianceAndEnforcementDto,
14+
} from '../../../services/compliance-and-enforcement/compliance-and-enforcement.dto';
15+
import { of } from 'rxjs';
16+
17+
describe('DraftComponent', () => {
18+
let component: DraftComponent;
19+
let fixture: ComponentFixture<DraftComponent>;
20+
let mockComplianceAndEnforcementService: DeepMocked<ComplianceAndEnforcementService>;
21+
let mockToastService: DeepMocked<ToastService>;
22+
23+
beforeEach(async () => {
24+
mockComplianceAndEnforcementService = createMock();
25+
mockToastService = createMock();
26+
27+
await TestBed.configureTestingModule({
28+
imports: [HttpClientTestingModule],
29+
declarations: [DraftComponent, StartOfDayPipe],
30+
providers: [
31+
{
32+
provide: ComplianceAndEnforcementService,
33+
useValue: mockComplianceAndEnforcementService,
34+
},
35+
{
36+
provide: ToastService,
37+
useValue: mockToastService,
38+
},
39+
{
40+
provide: ActivatedRoute,
41+
useValue: {
42+
snapshot: {
43+
paramMap: convertToParamMap({ fileNumber: '12345' }),
44+
},
45+
},
46+
},
47+
],
48+
schemas: [NO_ERRORS_SCHEMA],
49+
}).compileComponents();
50+
51+
fixture = TestBed.createComponent(DraftComponent);
52+
component = fixture.componentInstance;
53+
fixture.detectChanges();
54+
});
55+
56+
it('should create', () => {
57+
expect(component).toBeTruthy();
58+
});
59+
60+
it('loads file on init', async () => {
61+
await component.loadFile('12345');
62+
63+
expect(mockComplianceAndEnforcementService.fetchByFileNumber).toHaveBeenCalledWith('12345');
64+
expect(mockToastService.showSuccessToast).toHaveBeenCalledWith('C&E file loaded');
65+
});
66+
67+
it('shows error if loadFile fails', async () => {
68+
mockComplianceAndEnforcementService.fetchByFileNumber.mockRejectedValueOnce(new Error('fail'));
69+
await component.loadFile('12345');
70+
71+
expect(mockComplianceAndEnforcementService.fetchByFileNumber).toHaveBeenCalledWith('12345');
72+
expect(mockToastService.showErrorToast).toHaveBeenCalledWith('Failed to load C&E file');
73+
});
74+
75+
it('calls service update when onSaveDraftClicked is triggered', async () => {
76+
const changes: UpdateComplianceAndEnforcementDto = {};
77+
component.file = { uuid: '12345', fileNumber: '12345' } as ComplianceAndEnforcementDto;
78+
component.overviewComponent = { $changes: { getValue: () => changes } } as OverviewComponent;
79+
80+
mockComplianceAndEnforcementService.update.mockReturnValue(
81+
of({
82+
uuid: '12345',
83+
fileNumber: '12345',
84+
} as ComplianceAndEnforcementDto),
85+
);
86+
87+
await component.onSaveDraftClicked();
88+
89+
expect(mockComplianceAndEnforcementService.update).toHaveBeenCalledWith('12345', changes);
90+
});
91+
92+
it('unsubscribes on destroy', () => {
93+
const completeSpy = jest.spyOn(component['$destroy'], 'complete');
94+
95+
component.ngOnDestroy();
96+
97+
expect(completeSpy).toHaveBeenCalled();
98+
});
99+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
2+
import { ActivatedRoute } from '@angular/router';
3+
import {
4+
catchError,
5+
combineLatest,
6+
debounceTime,
7+
EMPTY,
8+
firstValueFrom,
9+
Observable,
10+
skip,
11+
Subject,
12+
switchMap,
13+
takeUntil,
14+
} from 'rxjs';
15+
import {
16+
ComplianceAndEnforcementDto,
17+
UpdateComplianceAndEnforcementDto,
18+
} from '../../../services/compliance-and-enforcement/compliance-and-enforcement.dto';
19+
import { ComplianceAndEnforcementService } from '../../../services/compliance-and-enforcement/compliance-and-enforcement.service';
20+
import { OverviewComponent } from '../overview/overview.component';
21+
import { ToastService } from '../../../services/toast/toast.service';
22+
import { FormGroup } from '@angular/forms';
23+
24+
@Component({
25+
selector: 'app-compliance-and-enforcement-draft',
26+
templateUrl: './draft.component.html',
27+
styleUrls: ['./draft.component.scss'],
28+
})
29+
export class DraftComponent implements OnInit, AfterViewInit, OnDestroy {
30+
$destroy = new Subject<void>();
31+
32+
file?: ComplianceAndEnforcementDto;
33+
form = new FormGroup({ overview: new FormGroup({}), submitter: new FormGroup({}) });
34+
35+
@ViewChild(OverviewComponent) overviewComponent?: OverviewComponent;
36+
37+
constructor(
38+
private readonly complianceAndEnforcementService: ComplianceAndEnforcementService,
39+
private readonly route: ActivatedRoute,
40+
private readonly toastService: ToastService,
41+
) {}
42+
43+
ngOnInit(): void {
44+
const fileNumber = this.route.snapshot.paramMap.get('fileNumber');
45+
46+
if (fileNumber) {
47+
this.loadFile(fileNumber);
48+
}
49+
}
50+
51+
ngAfterViewInit(): void {
52+
if (!this.overviewComponent) {
53+
console.warn('Not all form sections component not initialized');
54+
return;
55+
}
56+
57+
combineLatest([this.overviewComponent.$changes])
58+
.pipe(
59+
skip(1), // Skip the initial emission to prevent save on load
60+
debounceTime(1000),
61+
switchMap(([overviewUpdate]) =>
62+
this.file?.uuid
63+
? this.complianceAndEnforcementService.update(this.file.uuid, {
64+
...overviewUpdate,
65+
})
66+
: EMPTY,
67+
),
68+
catchError((error) => {
69+
console.error('Error saving C&E file draft', error);
70+
this.toastService.showErrorToast('Failed to save C&E file draft');
71+
return EMPTY;
72+
}),
73+
takeUntil(this.$destroy),
74+
)
75+
.subscribe(() => {
76+
this.toastService.showSuccessToast('C&E file draft saved');
77+
});
78+
}
79+
80+
async loadFile(fileNumber: string) {
81+
try {
82+
this.file = await this.complianceAndEnforcementService.fetchByFileNumber(fileNumber);
83+
this.toastService.showSuccessToast('C&E file loaded');
84+
} catch (error) {
85+
console.error('Error loading C&E file', error);
86+
this.toastService.showErrorToast('Failed to load C&E file');
87+
}
88+
}
89+
90+
async onSaveDraftClicked() {
91+
if (!this.overviewComponent || !this.file?.uuid) {
92+
return;
93+
}
94+
95+
const overviewUpdate = this.overviewComponent.$changes.getValue();
96+
97+
try {
98+
await firstValueFrom(
99+
this.complianceAndEnforcementService.update(this.file.uuid, {
100+
...overviewUpdate,
101+
}),
102+
);
103+
this.toastService.showSuccessToast('C&E file draft saved');
104+
} catch (error) {
105+
console.error('Error saving C&E file draft', error);
106+
this.toastService.showErrorToast('Failed to save C&E file draft');
107+
}
108+
}
109+
110+
ngOnDestroy(): void {
111+
this.$destroy.next();
112+
this.$destroy.complete();
113+
}
114+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<h3>Overview</h3>
2+
3+
<form [formGroup]="form">
4+
<mat-form-field appearance="outline" class="date-picker">
5+
<mat-label>Date Submitted</mat-label>
6+
<input
7+
matInput
8+
(click)="datePicker.open()"
9+
[matDatepicker]="datePicker"
10+
name="date-submitted"
11+
formControlName="dateSubmitted"
12+
/>
13+
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
14+
<mat-datepicker #datePicker type="date"></mat-datepicker>
15+
</mat-form-field>
16+
17+
<mat-form-field appearance="outline">
18+
<mat-label>Initial Submission Type</mat-label>
19+
<mat-select multiple="false" formControlName="initialSubmissionType">
20+
<mat-option *ngFor="let type of initialSubmissionTypes" [value]="type.value">
21+
{{ type.value }}
22+
</mat-option>
23+
</mat-select>
24+
</mat-form-field>
25+
26+
<mat-form-field class="full-width" appearance="outline">
27+
<mat-label>Alleged Contravention Narrative</mat-label>
28+
<textarea matInput formControlName="allegedContraventionNarrative" rows="3"></textarea>
29+
</mat-form-field>
30+
31+
<mat-form-field class="full-width" appearance="outline">
32+
<mat-label>Alleged Activity</mat-label>
33+
<mat-select multiple="true" formControlName="allegedActivity">
34+
<mat-option *ngFor="let activity of allegedActivities" [value]="activity.value">
35+
{{ activity.value }}
36+
</mat-option>
37+
</mat-select>
38+
</mat-form-field>
39+
40+
<mat-form-field class="full-width" appearance="outline">
41+
<mat-label>Intake Notes</mat-label>
42+
<textarea matInput formControlName="intakeNotes" rows="3"></textarea>
43+
</mat-form-field>
44+
</form>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
@use '../../../../styles/colors.scss' as colors;
2+
3+
form {
4+
display: grid;
5+
grid-template-columns: 1fr 1fr;
6+
gap: 24px;
7+
8+
margin-top: 24px;
9+
10+
.full-width {
11+
grid-column: 1 / -1;
12+
}
13+
}

0 commit comments

Comments
 (0)