61c6697c87
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1656 lines
54 KiB
Markdown
1656 lines
54 KiB
Markdown
# Member & User Management — Part 3: Angular Frontend (Tasks 10–16)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task.
|
||
> **Prerequisite:** Parts 1 & 2 complete (API running at configured `environment.apiUrl`).
|
||
|
||
**Goal:** Build Member Management page (Kendo Grid + form dialog + create-user-account dialog) and User Management page (Kendo Grid + edit dialog), wired into the sidebar.
|
||
|
||
**Architecture:** Two standalone feature folders under `features/members` and `features/users`. Custom Angular services (not extending `CrudBaseApiService`) because the API returns `PagedResult<T>`, not `T[]`. All components are standalone. Dialogs are embedded in the page component template.
|
||
|
||
**Tech Stack:** Angular 18, Kendo Angular Grid/Dialog/Inputs/Layout, Reactive Forms
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
APP/src/app/
|
||
features/
|
||
members/
|
||
models/
|
||
member.model.ts ← NEW
|
||
services/
|
||
member-api.service.ts ← NEW
|
||
components/
|
||
member-form-dialog/
|
||
member-form-dialog.component.ts ← NEW
|
||
member-form-dialog.component.html← NEW
|
||
create-user-dialog/
|
||
create-user-dialog.component.ts ← NEW
|
||
create-user-dialog.component.html← NEW
|
||
pages/
|
||
members-page/
|
||
members-page.component.ts ← NEW
|
||
members-page.component.html ← NEW
|
||
members-page.component.scss ← NEW
|
||
users/
|
||
models/
|
||
user.model.ts ← NEW
|
||
services/
|
||
user-api.service.ts ← NEW
|
||
components/
|
||
edit-user-dialog/
|
||
edit-user-dialog.component.ts ← NEW
|
||
edit-user-dialog.component.html ← NEW
|
||
pages/
|
||
users-page/
|
||
users-page.component.ts ← NEW
|
||
users-page.component.html ← NEW
|
||
users-page.component.scss ← NEW
|
||
app.routes.ts ← MODIFY
|
||
portals/user-portal/
|
||
components/user-navbar/
|
||
user-navbar.component.ts ← MODIFY
|
||
user-navbar.component.html ← MODIFY
|
||
user-portal.component.ts ← MODIFY (add getPageTitle entries)
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Angular Models + API Services
|
||
|
||
**Files:**
|
||
- Create: `APP/src/app/features/members/models/member.model.ts`
|
||
- Create: `APP/src/app/features/members/services/member-api.service.ts`
|
||
- Create: `APP/src/app/features/users/models/user.model.ts`
|
||
- Create: `APP/src/app/features/users/services/user-api.service.ts`
|
||
|
||
- [ ] **Step 1: Create `member.model.ts`**
|
||
|
||
```typescript
|
||
// APP/src/app/features/members/models/member.model.ts
|
||
|
||
export type MemberStatus = 'Member' | 'Visitor' | 'Inactive' | 'Former';
|
||
|
||
export interface MemberListItemDto {
|
||
id: number;
|
||
firstName_en: string;
|
||
lastName_en: string;
|
||
nickName: string | null;
|
||
firstName_zh: string | null;
|
||
lastName_zh: string | null;
|
||
status: MemberStatus;
|
||
email: string | null;
|
||
phoneCell: string | null;
|
||
joinDate: string | null;
|
||
linkedUserId: string | null;
|
||
}
|
||
|
||
export interface MemberDto extends MemberListItemDto {
|
||
gender: string | null;
|
||
dateOfBirth: string | null;
|
||
baptismDate: string | null;
|
||
baptismChurch: string | null;
|
||
phoneHome: string | null;
|
||
address: string | null;
|
||
city: string | null;
|
||
state: string | null;
|
||
zipCode: string | null;
|
||
country: string;
|
||
photoBlobPath: string | null;
|
||
languagePreference: string;
|
||
notes: string | null;
|
||
familyUnitId: number | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export interface CreateMemberRequest {
|
||
firstName_en: string;
|
||
lastName_en: string;
|
||
nickName: string | null;
|
||
firstName_zh: string | null;
|
||
lastName_zh: string | null;
|
||
gender: string | null;
|
||
dateOfBirth: string | null;
|
||
baptismDate: string | null;
|
||
baptismChurch: string | null;
|
||
email: string | null;
|
||
phoneCell: string | null;
|
||
phoneHome: string | null;
|
||
address: string | null;
|
||
city: string | null;
|
||
state: string | null;
|
||
zipCode: string | null;
|
||
country: string;
|
||
status: string;
|
||
languagePreference: string;
|
||
joinDate: string | null;
|
||
notes: string | null;
|
||
familyUnitId: number | null;
|
||
}
|
||
|
||
export type UpdateMemberRequest = CreateMemberRequest;
|
||
|
||
export interface PagedResult<T> {
|
||
items: T[];
|
||
totalCount: number;
|
||
page: number;
|
||
pageSize: number;
|
||
totalPages: number;
|
||
}
|
||
|
||
export interface MemberQueryParams {
|
||
page?: number;
|
||
pageSize?: number;
|
||
search?: string;
|
||
status?: string;
|
||
hasUser?: boolean;
|
||
}
|
||
|
||
/** Display name: NickName (if present) else FirstName_en, plus LastName_en */
|
||
export function memberDisplayName(
|
||
m: Pick<MemberListItemDto, 'nickName' | 'firstName_en' | 'lastName_en'>
|
||
): string {
|
||
return `${m.nickName ?? m.firstName_en} ${m.lastName_en}`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `member-api.service.ts`**
|
||
|
||
```typescript
|
||
// APP/src/app/features/members/services/member-api.service.ts
|
||
import { Injectable } from '@angular/core';
|
||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||
import { Observable } from 'rxjs';
|
||
import { catchError } from 'rxjs/operators';
|
||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||
import {
|
||
MemberDto, MemberListItemDto, CreateMemberRequest,
|
||
UpdateMemberRequest, MemberQueryParams, PagedResult
|
||
} from '../models/member.model';
|
||
|
||
@Injectable({ providedIn: 'root' })
|
||
export class MemberApiService {
|
||
private readonly endpoint: string;
|
||
|
||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||
this.endpoint = apiConfig.getApiUrl('members');
|
||
}
|
||
|
||
getPaged(params: MemberQueryParams = {}): Observable<PagedResult<MemberListItemDto>> {
|
||
let p = new HttpParams()
|
||
.set('page', params.page ?? 1)
|
||
.set('pageSize', params.pageSize ?? 20);
|
||
if (params.search !== undefined && params.search !== '') p = p.set('search', params.search);
|
||
if (params.status !== undefined && params.status !== '') p = p.set('status', params.status);
|
||
if (params.hasUser !== undefined) p = p.set('hasUser', params.hasUser);
|
||
return this.http.get<PagedResult<MemberListItemDto>>(this.endpoint, { params: p });
|
||
}
|
||
|
||
getById(id: number): Observable<MemberDto> {
|
||
return this.http.get<MemberDto>(`${this.endpoint}/${id}`);
|
||
}
|
||
|
||
create(request: CreateMemberRequest): Observable<{ id: number }> {
|
||
return this.http.post<{ id: number }>(this.endpoint, request);
|
||
}
|
||
|
||
update(id: number, request: UpdateMemberRequest): Observable<void> {
|
||
return this.http.put<void>(`${this.endpoint}/${id}`, request);
|
||
}
|
||
|
||
delete(id: number): Observable<void> {
|
||
return this.http.delete<void>(`${this.endpoint}/${id}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `user.model.ts`**
|
||
|
||
```typescript
|
||
// APP/src/app/features/users/models/user.model.ts
|
||
|
||
export interface UserListItemDto {
|
||
id: string;
|
||
email: string;
|
||
memberId: number | null;
|
||
memberDisplayName: string | null;
|
||
roles: string[];
|
||
isActive: boolean;
|
||
languagePreference: string;
|
||
lastLoginAt: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
export type UserDto = UserListItemDto;
|
||
|
||
export interface CreateUserRequest {
|
||
memberId: number;
|
||
email: string;
|
||
roles: string[];
|
||
languagePreference: string;
|
||
}
|
||
|
||
export interface CreateUserResult {
|
||
userId: string;
|
||
tempPassword: string;
|
||
}
|
||
|
||
export interface UpdateUserRequest {
|
||
email: string;
|
||
roles: string[];
|
||
isActive: boolean;
|
||
languagePreference: string;
|
||
}
|
||
|
||
export interface UserQueryParams {
|
||
page?: number;
|
||
pageSize?: number;
|
||
search?: string;
|
||
}
|
||
|
||
export interface PagedResult<T> {
|
||
items: T[];
|
||
totalCount: number;
|
||
page: number;
|
||
pageSize: number;
|
||
totalPages: number;
|
||
}
|
||
|
||
export const ALL_ROLES = [
|
||
'super_admin','pastor','board_member','coworker_chair','ministry_leader',
|
||
'district_leader','cell_leader','coworker','finance','secretary',
|
||
'worship_leader','member','visitor'
|
||
] as const;
|
||
```
|
||
|
||
- [ ] **Step 4: Create `user-api.service.ts`**
|
||
|
||
```typescript
|
||
// APP/src/app/features/users/services/user-api.service.ts
|
||
import { Injectable } from '@angular/core';
|
||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||
import { Observable } from 'rxjs';
|
||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||
import {
|
||
UserDto, UserListItemDto, CreateUserRequest, CreateUserResult,
|
||
UpdateUserRequest, UserQueryParams, PagedResult
|
||
} from '../models/user.model';
|
||
|
||
@Injectable({ providedIn: 'root' })
|
||
export class UserApiService {
|
||
private readonly endpoint: string;
|
||
|
||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||
this.endpoint = apiConfig.getApiUrl('users');
|
||
}
|
||
|
||
getPaged(params: UserQueryParams = {}): Observable<PagedResult<UserListItemDto>> {
|
||
let p = new HttpParams()
|
||
.set('page', params.page ?? 1)
|
||
.set('pageSize', params.pageSize ?? 20);
|
||
if (params.search) p = p.set('search', params.search);
|
||
return this.http.get<PagedResult<UserListItemDto>>(this.endpoint, { params: p });
|
||
}
|
||
|
||
getById(id: string): Observable<UserDto> {
|
||
return this.http.get<UserDto>(`${this.endpoint}/${id}`);
|
||
}
|
||
|
||
createUser(request: CreateUserRequest): Observable<CreateUserResult> {
|
||
return this.http.post<CreateUserResult>(this.endpoint, request);
|
||
}
|
||
|
||
update(id: string, request: UpdateUserRequest): Observable<void> {
|
||
return this.http.put<void>(`${this.endpoint}/${id}`, request);
|
||
}
|
||
|
||
deactivate(id: string): Observable<void> {
|
||
return this.http.delete<void>(`${this.endpoint}/${id}`);
|
||
}
|
||
|
||
resetPassword(id: string): Observable<{ tempPassword: string }> {
|
||
return this.http.post<{ tempPassword: string }>(
|
||
`${this.endpoint}/${id}/reset-password`, {});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```
|
||
cd APP && ng build --configuration development
|
||
```
|
||
Expected: Compiled successfully.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/features/
|
||
git commit -m "feat: add Angular member and user models + API services"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: MemberFormDialogComponent
|
||
|
||
**Files:**
|
||
- Create: `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts`
|
||
- Create: `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html`
|
||
|
||
- [ ] **Step 1: Create `member-form-dialog.component.ts`**
|
||
|
||
```typescript
|
||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||
import { LabelModule } from '@progress/kendo-angular-label';
|
||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||
import { LayoutModule } from '@progress/kendo-angular-layout';
|
||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||
import { MemberDto, CreateMemberRequest } from '../../models/member.model';
|
||
|
||
@Component({
|
||
selector: 'app-member-form-dialog',
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule, ReactiveFormsModule, DialogsModule, InputsModule,
|
||
LabelModule, DropDownsModule, DateInputsModule, LayoutModule, ButtonsModule
|
||
],
|
||
templateUrl: './member-form-dialog.component.html',
|
||
})
|
||
export class MemberFormDialogComponent implements OnInit {
|
||
@Input() member: MemberDto | null = null; // null = create mode
|
||
@Output() saved = new EventEmitter<CreateMemberRequest>();
|
||
@Output() cancelled = new EventEmitter<void>();
|
||
|
||
form!: FormGroup;
|
||
isEditMode = false;
|
||
|
||
readonly statusOptions = ['Member', 'Visitor', 'Inactive', 'Former'];
|
||
readonly genderOptions = [
|
||
{ text: 'Male', value: 'M' },
|
||
{ text: 'Female', value: 'F' },
|
||
{ text: 'Other', value: 'Other' },
|
||
];
|
||
readonly langOptions = [
|
||
{ text: 'English', value: 'en' },
|
||
{ text: '中文', value: 'zh-TW' },
|
||
];
|
||
|
||
constructor(private fb: FormBuilder) {}
|
||
|
||
ngOnInit(): void {
|
||
this.isEditMode = this.member !== null;
|
||
this.form = this.fb.group({
|
||
// Basic Info
|
||
firstName_en: [this.member?.firstName_en ?? '', [Validators.required, Validators.maxLength(100)]],
|
||
lastName_en: [this.member?.lastName_en ?? '', [Validators.required, Validators.maxLength(100)]],
|
||
nickName: [this.member?.nickName ?? null, Validators.maxLength(100)],
|
||
firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)],
|
||
lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)],
|
||
gender: [this.member?.gender ?? null],
|
||
dateOfBirth: [this.member?.dateOfBirth ?? null],
|
||
status: [this.member?.status ?? 'Member', Validators.required],
|
||
languagePreference: [this.member?.languagePreference ?? 'en', Validators.required],
|
||
// Contact
|
||
email: [this.member?.email ?? null, [Validators.email, Validators.maxLength(200)]],
|
||
phoneCell:[this.member?.phoneCell ?? null, Validators.maxLength(30)],
|
||
phoneHome:[this.member?.phoneHome ?? null, Validators.maxLength(30)],
|
||
address: [this.member?.address ?? null, Validators.maxLength(500)],
|
||
city: [this.member?.city ?? null, Validators.maxLength(100)],
|
||
state: [this.member?.state ?? null, Validators.maxLength(50)],
|
||
zipCode: [this.member?.zipCode ?? null, Validators.maxLength(20)],
|
||
country: [this.member?.country ?? 'USA', Validators.maxLength(100)],
|
||
// Church Info
|
||
joinDate: [this.member?.joinDate ?? null],
|
||
baptismDate: [this.member?.baptismDate ?? null],
|
||
baptismChurch:[this.member?.baptismChurch ?? null, Validators.maxLength(200)],
|
||
notes: [this.member?.notes ?? null],
|
||
});
|
||
}
|
||
|
||
get title(): string {
|
||
return this.isEditMode ? 'Edit Member' : 'Add Member';
|
||
}
|
||
|
||
onSubmit(): void {
|
||
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||
this.saved.emit(this.form.value as CreateMemberRequest);
|
||
}
|
||
|
||
onCancel(): void {
|
||
this.cancelled.emit();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `member-form-dialog.component.html`**
|
||
|
||
```html
|
||
<kendo-dialog [title]="title" (close)="onCancel()" [minWidth]="600" [width]="750">
|
||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||
<kendo-tabstrip>
|
||
|
||
<!-- TAB 1: Basic Info -->
|
||
<kendo-tabstrip-tab title="Basic Info" [selected]="true">
|
||
<ng-template kendoTabContent>
|
||
<div class="k-form k-form-horizontal k-mt-4">
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Legal First Name *"></kendo-label>
|
||
<kendo-textbox formControlName="firstName_en"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Last Name *"></kendo-label>
|
||
<kendo-textbox formControlName="lastName_en"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Nick Name (Common Name)"></kendo-label>
|
||
<kendo-textbox formControlName="nickName" placeholder="e.g. Chris"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Chinese First Name"></kendo-label>
|
||
<kendo-textbox formControlName="firstName_zh"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Chinese Last Name"></kendo-label>
|
||
<kendo-textbox formControlName="lastName_zh"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Gender"></kendo-label>
|
||
<kendo-dropdownlist
|
||
formControlName="gender"
|
||
[data]="genderOptions"
|
||
textField="text" valueField="value"
|
||
[defaultItem]="{ text: '-- Select --', value: null }">
|
||
</kendo-dropdownlist>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Date of Birth"></kendo-label>
|
||
<kendo-datepicker formControlName="dateOfBirth"></kendo-datepicker>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Status *"></kendo-label>
|
||
<kendo-dropdownlist formControlName="status" [data]="statusOptions">
|
||
</kendo-dropdownlist>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Language"></kendo-label>
|
||
<kendo-dropdownlist
|
||
formControlName="languagePreference"
|
||
[data]="langOptions" textField="text" valueField="value">
|
||
</kendo-dropdownlist>
|
||
</kendo-formfield>
|
||
|
||
</div>
|
||
</ng-template>
|
||
</kendo-tabstrip-tab>
|
||
|
||
<!-- TAB 2: Contact -->
|
||
<kendo-tabstrip-tab title="Contact">
|
||
<ng-template kendoTabContent>
|
||
<div class="k-form k-form-horizontal k-mt-4">
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Email"></kendo-label>
|
||
<kendo-textbox formControlName="email" type="email"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Cell Phone"></kendo-label>
|
||
<kendo-textbox formControlName="phoneCell"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Home Phone"></kendo-label>
|
||
<kendo-textbox formControlName="phoneHome"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Address"></kendo-label>
|
||
<kendo-textbox formControlName="address"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="City"></kendo-label>
|
||
<kendo-textbox formControlName="city"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="State"></kendo-label>
|
||
<kendo-textbox formControlName="state"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Zip Code"></kendo-label>
|
||
<kendo-textbox formControlName="zipCode"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Country"></kendo-label>
|
||
<kendo-textbox formControlName="country"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
</div>
|
||
</ng-template>
|
||
</kendo-tabstrip-tab>
|
||
|
||
<!-- TAB 3: Church Info -->
|
||
<kendo-tabstrip-tab title="Church Info">
|
||
<ng-template kendoTabContent>
|
||
<div class="k-form k-form-horizontal k-mt-4">
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Join Date"></kendo-label>
|
||
<kendo-datepicker formControlName="joinDate"></kendo-datepicker>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Baptism Date"></kendo-label>
|
||
<kendo-datepicker formControlName="baptismDate"></kendo-datepicker>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Baptism Church"></kendo-label>
|
||
<kendo-textbox formControlName="baptismChurch"></kendo-textbox>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Notes"></kendo-label>
|
||
<kendo-textarea formControlName="notes" [rows]="4"></kendo-textarea>
|
||
</kendo-formfield>
|
||
|
||
</div>
|
||
</ng-template>
|
||
</kendo-tabstrip-tab>
|
||
|
||
</kendo-tabstrip>
|
||
</form>
|
||
|
||
<kendo-dialog-actions>
|
||
<button kendoButton (click)="onCancel()">Cancel</button>
|
||
<button kendoButton themeColor="primary" (click)="onSubmit()" [disabled]="form.invalid">
|
||
{{ isEditMode ? 'Save Changes' : 'Add Member' }}
|
||
</button>
|
||
</kendo-dialog-actions>
|
||
</kendo-dialog>
|
||
```
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
```
|
||
cd APP && ng build --configuration development
|
||
```
|
||
Expected: Compiled successfully.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/features/members/components/member-form-dialog/
|
||
git commit -m "feat: add MemberFormDialogComponent (3-tab form)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: CreateUserAccountDialogComponent
|
||
|
||
**Files:**
|
||
- Create: `APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.ts`
|
||
- Create: `APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html`
|
||
|
||
- [ ] **Step 1: Create `create-user-dialog.component.ts`**
|
||
|
||
```typescript
|
||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||
import { LabelModule } from '@progress/kendo-angular-label';
|
||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||
import { MemberListItemDto, memberDisplayName } from '../../models/member.model';
|
||
import { CreateUserRequest, CreateUserResult, ALL_ROLES } from '../../../users/models/user.model';
|
||
import { UserApiService } from '../../../users/services/user-api.service';
|
||
|
||
@Component({
|
||
selector: 'app-create-user-dialog',
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule, ReactiveFormsModule, DialogsModule, InputsModule,
|
||
LabelModule, DropDownsModule, ButtonsModule, IndicatorsModule
|
||
],
|
||
templateUrl: './create-user-dialog.component.html',
|
||
})
|
||
export class CreateUserDialogComponent implements OnInit {
|
||
@Input({ required: true }) member!: MemberListItemDto;
|
||
@Output() created = new EventEmitter<void>();
|
||
@Output() cancelled = new EventEmitter<void>();
|
||
|
||
form!: FormGroup;
|
||
step: 'form' | 'success' = 'form';
|
||
tempPassword = '';
|
||
copied = false;
|
||
isLoading = false;
|
||
errorMessage = '';
|
||
|
||
readonly roleOptions = [...ALL_ROLES];
|
||
readonly langOptions = [
|
||
{ text: 'English', value: 'en' },
|
||
{ text: '中文', value: 'zh-TW' },
|
||
];
|
||
|
||
get memberName(): string { return memberDisplayName(this.member); }
|
||
|
||
constructor(private fb: FormBuilder, private userApi: UserApiService) {}
|
||
|
||
ngOnInit(): void {
|
||
this.form = this.fb.group({
|
||
email: [this.member.email ?? '', [Validators.required, Validators.email]],
|
||
roles: [['member'], Validators.required],
|
||
languagePreference: [this.member.languagePreference ?? 'en'],
|
||
});
|
||
}
|
||
|
||
onSubmit(): void {
|
||
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||
this.isLoading = true;
|
||
this.errorMessage = '';
|
||
|
||
const request: CreateUserRequest = {
|
||
memberId: this.member.id,
|
||
email: this.form.value.email,
|
||
roles: this.form.value.roles,
|
||
languagePreference: this.form.value.languagePreference,
|
||
};
|
||
|
||
this.userApi.createUser(request).subscribe({
|
||
next: (result: CreateUserResult) => {
|
||
this.tempPassword = result.tempPassword;
|
||
this.step = 'success';
|
||
this.isLoading = false;
|
||
},
|
||
error: (err: any) => {
|
||
this.errorMessage = err.error?.message ?? 'Failed to create account.';
|
||
this.isLoading = false;
|
||
},
|
||
});
|
||
}
|
||
|
||
copyPassword(): void {
|
||
navigator.clipboard.writeText(this.tempPassword).then(() => {
|
||
this.copied = true;
|
||
setTimeout(() => (this.copied = false), 2000);
|
||
});
|
||
}
|
||
|
||
onDone(): void {
|
||
this.created.emit();
|
||
}
|
||
|
||
onCancel(): void {
|
||
this.cancelled.emit();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `create-user-dialog.component.html`**
|
||
|
||
```html
|
||
<kendo-dialog title="Create User Account" (close)="onCancel()" [minWidth]="480" [width]="520">
|
||
|
||
<!-- STEP 1: Form -->
|
||
<ng-container *ngIf="step === 'form'">
|
||
<p class="k-mb-4">Creating account for <strong>{{ memberName }}</strong></p>
|
||
|
||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="k-form k-form-vertical">
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Login Email *"></kendo-label>
|
||
<kendo-textbox formControlName="email" type="email"></kendo-textbox>
|
||
<kendo-formerror *ngIf="form.get('email')?.errors?.['required']">Email is required.</kendo-formerror>
|
||
<kendo-formerror *ngIf="form.get('email')?.errors?.['email']">Invalid email address.</kendo-formerror>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield class="k-mt-3">
|
||
<kendo-label text="Roles *"></kendo-label>
|
||
<kendo-multiselect
|
||
formControlName="roles"
|
||
[data]="roleOptions"
|
||
placeholder="Select role(s)">
|
||
</kendo-multiselect>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield class="k-mt-3">
|
||
<kendo-label text="Language"></kendo-label>
|
||
<kendo-dropdownlist
|
||
formControlName="languagePreference"
|
||
[data]="langOptions" textField="text" valueField="value">
|
||
</kendo-dropdownlist>
|
||
</kendo-formfield>
|
||
|
||
<p *ngIf="errorMessage" class="k-color-error k-mt-3">{{ errorMessage }}</p>
|
||
|
||
</form>
|
||
|
||
<kendo-dialog-actions>
|
||
<button kendoButton (click)="onCancel()">Cancel</button>
|
||
<button kendoButton themeColor="primary" (click)="onSubmit()" [disabled]="isLoading">
|
||
<kendo-loader *ngIf="isLoading" size="small"></kendo-loader>
|
||
Create Account
|
||
</button>
|
||
</kendo-dialog-actions>
|
||
</ng-container>
|
||
|
||
<!-- STEP 2: Success — show temp password -->
|
||
<ng-container *ngIf="step === 'success'">
|
||
<div class="k-text-center k-p-4">
|
||
<p class="k-font-size-lg k-mb-2">✅ Account created!</p>
|
||
<p class="k-mb-4">Share this temporary password with <strong>{{ memberName }}</strong>.</p>
|
||
|
||
<div class="k-d-flex k-justify-content-center k-align-items-center k-gap-2 k-mb-3">
|
||
<code class="k-font-size-lg k-border k-p-2 k-rounded">{{ tempPassword }}</code>
|
||
<button kendoButton (click)="copyPassword()">{{ copied ? 'Copied!' : 'Copy' }}</button>
|
||
</div>
|
||
|
||
<p class="k-color-warning k-font-weight-bold">
|
||
⚠️ This password will not be shown again.
|
||
</p>
|
||
</div>
|
||
|
||
<kendo-dialog-actions>
|
||
<button kendoButton themeColor="primary" (click)="onDone()">Done</button>
|
||
</kendo-dialog-actions>
|
||
</ng-container>
|
||
|
||
</kendo-dialog>
|
||
```
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
```
|
||
cd APP && ng build --configuration development
|
||
```
|
||
Expected: Compiled successfully.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/features/members/components/create-user-dialog/
|
||
git commit -m "feat: add CreateUserAccountDialogComponent with temp-password reveal"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: MembersPageComponent + Routing
|
||
|
||
**Files:**
|
||
- Create: `APP/src/app/features/members/pages/members-page/members-page.component.ts`
|
||
- Create: `APP/src/app/features/members/pages/members-page/members-page.component.html`
|
||
- Create: `APP/src/app/features/members/pages/members-page/members-page.component.scss`
|
||
|
||
- [ ] **Step 1: Create `members-page.component.ts`**
|
||
|
||
```typescript
|
||
import { Component, OnInit } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
|
||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||
import { MemberApiService } from '../../services/member-api.service';
|
||
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
|
||
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
||
import {
|
||
MemberListItemDto, MemberDto, CreateMemberRequest,
|
||
PagedResult, memberDisplayName
|
||
} from '../../models/member.model';
|
||
|
||
@Component({
|
||
selector: 'app-members-page',
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule, FormsModule, GridModule, InputsModule,
|
||
ButtonsModule, IndicatorsModule, DropDownsModule,
|
||
MemberFormDialogComponent, CreateUserDialogComponent,
|
||
],
|
||
templateUrl: './members-page.component.html',
|
||
styleUrls: ['./members-page.component.scss'],
|
||
})
|
||
export class MembersPageComponent implements OnInit {
|
||
// Grid state
|
||
data: MemberListItemDto[] = [];
|
||
totalCount = 0;
|
||
page = 1;
|
||
pageSize = 20;
|
||
isLoading = false;
|
||
|
||
// Filters
|
||
searchText = '';
|
||
filterStatus = '';
|
||
readonly statusOptions = ['', 'Member', 'Visitor', 'Inactive', 'Former'];
|
||
|
||
// Dialogs
|
||
showMemberDialog = false;
|
||
showCreateUserDialog = false;
|
||
editingMember: MemberDto | null = null;
|
||
selectedMemberForUser: MemberListItemDto | null = null;
|
||
|
||
readonly memberDisplayName = memberDisplayName;
|
||
|
||
constructor(private memberApi: MemberApiService) {}
|
||
|
||
ngOnInit(): void {
|
||
this.loadData();
|
||
}
|
||
|
||
loadData(): void {
|
||
this.isLoading = true;
|
||
this.memberApi.getPaged({
|
||
page: this.page,
|
||
pageSize: this.pageSize,
|
||
search: this.searchText || undefined,
|
||
status: this.filterStatus || undefined,
|
||
}).subscribe({
|
||
next: (result: PagedResult<MemberListItemDto>) => {
|
||
this.data = result.items;
|
||
this.totalCount = result.totalCount;
|
||
this.isLoading = false;
|
||
},
|
||
error: () => { this.isLoading = false; }
|
||
});
|
||
}
|
||
|
||
onPageChange(event: PageChangeEvent): void {
|
||
this.page = event.skip / this.pageSize + 1;
|
||
this.pageSize = event.take;
|
||
this.loadData();
|
||
}
|
||
|
||
onSearch(): void {
|
||
this.page = 1;
|
||
this.loadData();
|
||
}
|
||
|
||
// ── Member CRUD ─────────────────────────────────────────────────────────────
|
||
|
||
openAddDialog(): void {
|
||
this.editingMember = null;
|
||
this.showMemberDialog = true;
|
||
}
|
||
|
||
openEditDialog(member: MemberListItemDto): void {
|
||
this.memberApi.getById(member.id).subscribe(dto => {
|
||
this.editingMember = dto;
|
||
this.showMemberDialog = true;
|
||
});
|
||
}
|
||
|
||
closeMemberDialog(): void {
|
||
this.showMemberDialog = false;
|
||
this.editingMember = null;
|
||
}
|
||
|
||
onMemberSaved(request: CreateMemberRequest): void {
|
||
if (this.editingMember) {
|
||
this.memberApi.update(this.editingMember.id, request).subscribe(() => {
|
||
this.closeMemberDialog();
|
||
this.loadData();
|
||
});
|
||
} else {
|
||
this.memberApi.create(request).subscribe(() => {
|
||
this.closeMemberDialog();
|
||
this.loadData();
|
||
});
|
||
}
|
||
}
|
||
|
||
deleteMember(member: MemberListItemDto): void {
|
||
if (!confirm(`Delete ${memberDisplayName(member)}? This cannot be undone.`)) return;
|
||
this.memberApi.delete(member.id).subscribe(() => this.loadData());
|
||
}
|
||
|
||
// ── Create User Account ─────────────────────────────────────────────────────
|
||
|
||
openCreateUserDialog(member: MemberListItemDto): void {
|
||
this.selectedMemberForUser = member;
|
||
this.showCreateUserDialog = true;
|
||
}
|
||
|
||
closeCreateUserDialog(): void {
|
||
this.showCreateUserDialog = false;
|
||
this.selectedMemberForUser = null;
|
||
}
|
||
|
||
onUserCreated(): void {
|
||
this.closeCreateUserDialog();
|
||
this.loadData();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `members-page.component.html`**
|
||
|
||
```html
|
||
<div class="k-p-4">
|
||
|
||
<!-- Toolbar -->
|
||
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
|
||
<h2 class="k-m-0">Member Management</h2>
|
||
<button kendoButton themeColor="primary" (click)="openAddDialog()">+ Add Member</button>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="k-d-flex k-gap-3 k-mb-4">
|
||
<kendo-textbox
|
||
[(ngModel)]="searchText"
|
||
placeholder="Search name, nick name, email…"
|
||
(keyup.enter)="onSearch()"
|
||
style="width: 300px">
|
||
</kendo-textbox>
|
||
<kendo-dropdownlist
|
||
[(ngModel)]="filterStatus"
|
||
[data]="statusOptions"
|
||
[defaultItem]="'All Status'"
|
||
(valueChange)="onSearch()"
|
||
style="width: 160px">
|
||
</kendo-dropdownlist>
|
||
<button kendoButton (click)="onSearch()">Search</button>
|
||
</div>
|
||
|
||
<!-- Grid -->
|
||
<kendo-grid
|
||
[data]="{ data: data, total: totalCount }"
|
||
[pageSize]="pageSize"
|
||
[skip]="(page - 1) * pageSize"
|
||
[pageable]="{ pageSizes: [10, 20, 50] }"
|
||
[sortable]="false"
|
||
(pageChange)="onPageChange($event)">
|
||
|
||
<kendo-grid-column title="Name" [width]="200">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<div>
|
||
<strong>{{ memberDisplayName(row) }}</strong>
|
||
<span *ngIf="row.firstName_zh || row.lastName_zh" class="k-ml-1 k-color-subtle">
|
||
({{ row.lastName_zh }}{{ row.firstName_zh }})
|
||
</span>
|
||
</div>
|
||
<div *ngIf="row.nickName && row.nickName !== row.firstName_en" class="k-font-size-sm k-color-subtle">
|
||
Legal: {{ row.firstName_en }}
|
||
</div>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Status" [width]="100">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<kendo-badge
|
||
[themeColor]="row.status === 'Member' ? 'success' :
|
||
row.status === 'Visitor' ? 'info' :
|
||
row.status === 'Former' ? 'warning' : 'base'">
|
||
{{ row.status }}
|
||
</kendo-badge>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column field="email" title="Email" [width]="200"></kendo-grid-column>
|
||
<kendo-grid-column field="phoneCell" title="Phone" [width]="130"></kendo-grid-column>
|
||
<kendo-grid-column field="joinDate" title="Joined" [width]="110"></kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Account" [width]="100">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<kendo-badge [themeColor]="row.linkedUserId ? 'success' : 'base'">
|
||
{{ row.linkedUserId ? '✓ User' : '—' }}
|
||
</kendo-badge>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Actions" [width]="200" [locked]="false">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<div class="k-d-flex k-gap-2">
|
||
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
||
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
|
||
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
|
||
(click)="openCreateUserDialog(row)">+ Account</button>
|
||
</div>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
</kendo-grid>
|
||
</div>
|
||
|
||
<!-- Member Form Dialog -->
|
||
<app-member-form-dialog
|
||
*ngIf="showMemberDialog"
|
||
[member]="editingMember"
|
||
(saved)="onMemberSaved($event)"
|
||
(cancelled)="closeMemberDialog()">
|
||
</app-member-form-dialog>
|
||
|
||
<!-- Create User Account Dialog -->
|
||
<app-create-user-dialog
|
||
*ngIf="showCreateUserDialog && selectedMemberForUser"
|
||
[member]="selectedMemberForUser"
|
||
(created)="onUserCreated()"
|
||
(cancelled)="closeCreateUserDialog()">
|
||
</app-create-user-dialog>
|
||
```
|
||
|
||
- [ ] **Step 3: Create `members-page.component.scss`**
|
||
|
||
```scss
|
||
:host {
|
||
display: block;
|
||
height: 100%;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add routes to `app.routes.ts`**
|
||
|
||
Open `APP/src/app/app.routes.ts` and add admin child routes inside the `user-portal` children array:
|
||
|
||
```typescript
|
||
// Add these imports at the top:
|
||
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
|
||
|
||
// Add inside user-portal children:
|
||
{ path: 'admin/members', component: MembersPageComponent },
|
||
{ path: 'admin/users', component: UsersPageComponent },
|
||
```
|
||
|
||
The full routes file after edit:
|
||
```typescript
|
||
import { Routes } from '@angular/router';
|
||
import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashboard.component';
|
||
import { LoginPage } from './features/login-page/login-page';
|
||
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
||
import { AuthGuard } from './core/guards/auth.guard';
|
||
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
|
||
|
||
export const routes: Routes = [
|
||
{ path: 'login', component: LoginPage },
|
||
{
|
||
path: 'user-portal',
|
||
component: UserPortalComponent,
|
||
canActivate: [AuthGuard],
|
||
children: [
|
||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||
{ path: 'dashboard', component: DashboardComponent },
|
||
{ path: 'admin/members', component: MembersPageComponent },
|
||
{ path: 'admin/users', component: UsersPageComponent },
|
||
]
|
||
},
|
||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||
{ path: 'dashboard', redirectTo: 'user-portal/dashboard' },
|
||
{ path: '**', redirectTo: 'login' }
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```
|
||
cd APP && ng build --configuration development
|
||
```
|
||
Expected: Compiled successfully.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/features/members/pages/ APP/src/app/app.routes.ts
|
||
git commit -m "feat: add MembersPageComponent with Kendo Grid and routing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: EditUserDialogComponent
|
||
|
||
**Files:**
|
||
- Create: `APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.ts`
|
||
- Create: `APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html`
|
||
|
||
- [ ] **Step 1: Create `edit-user-dialog.component.ts`**
|
||
|
||
```typescript
|
||
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||
import { LabelModule } from '@progress/kendo-angular-label';
|
||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||
import { UserDto, UpdateUserRequest, ALL_ROLES } from '../../models/user.model';
|
||
|
||
@Component({
|
||
selector: 'app-edit-user-dialog',
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule, ReactiveFormsModule, DialogsModule,
|
||
InputsModule, LabelModule, DropDownsModule, ButtonsModule
|
||
],
|
||
templateUrl: './edit-user-dialog.component.html',
|
||
})
|
||
export class EditUserDialogComponent implements OnInit {
|
||
@Input({ required: true }) user!: UserDto;
|
||
@Output() saved = new EventEmitter<UpdateUserRequest>();
|
||
@Output() cancelled = new EventEmitter<void>();
|
||
|
||
form!: FormGroup;
|
||
readonly roleOptions = [...ALL_ROLES];
|
||
readonly langOptions = [
|
||
{ text: 'English', value: 'en' },
|
||
{ text: '中文', value: 'zh-TW' },
|
||
];
|
||
|
||
constructor(private fb: FormBuilder) {}
|
||
|
||
ngOnInit(): void {
|
||
this.form = this.fb.group({
|
||
email: [this.user.email, [Validators.required, Validators.email]],
|
||
roles: [this.user.roles, Validators.required],
|
||
isActive: [this.user.isActive],
|
||
languagePreference: [this.user.languagePreference],
|
||
});
|
||
}
|
||
|
||
onSubmit(): void {
|
||
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||
this.saved.emit(this.form.value as UpdateUserRequest);
|
||
}
|
||
|
||
onCancel(): void {
|
||
this.cancelled.emit();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `edit-user-dialog.component.html`**
|
||
|
||
```html
|
||
<kendo-dialog title="Edit User" (close)="onCancel()" [minWidth]="460" [width]="500">
|
||
<form [formGroup]="form" class="k-form k-form-vertical k-p-2">
|
||
|
||
<kendo-formfield>
|
||
<kendo-label text="Email *"></kendo-label>
|
||
<kendo-textbox formControlName="email" type="email"></kendo-textbox>
|
||
<kendo-formerror *ngIf="form.get('email')?.errors?.['required']">Required.</kendo-formerror>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield class="k-mt-3">
|
||
<kendo-label text="Roles *"></kendo-label>
|
||
<kendo-multiselect formControlName="roles" [data]="roleOptions"
|
||
placeholder="Select roles"></kendo-multiselect>
|
||
</kendo-formfield>
|
||
|
||
<kendo-formfield class="k-mt-3">
|
||
<kendo-label text="Language"></kendo-label>
|
||
<kendo-dropdownlist formControlName="languagePreference"
|
||
[data]="langOptions" textField="text" valueField="value">
|
||
</kendo-dropdownlist>
|
||
</kendo-formfield>
|
||
|
||
<div class="k-d-flex k-align-items-center k-gap-2 k-mt-4">
|
||
<input kendoCheckBox type="checkbox" formControlName="isActive" id="isActiveCheck" />
|
||
<kendo-label for="isActiveCheck" text="Account Active"></kendo-label>
|
||
</div>
|
||
|
||
</form>
|
||
|
||
<kendo-dialog-actions>
|
||
<button kendoButton (click)="onCancel()">Cancel</button>
|
||
<button kendoButton themeColor="primary" (click)="onSubmit()" [disabled]="form.invalid">
|
||
Save Changes
|
||
</button>
|
||
</kendo-dialog-actions>
|
||
</kendo-dialog>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/features/users/components/edit-user-dialog/
|
||
git commit -m "feat: add EditUserDialogComponent"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: UsersPageComponent + Routing
|
||
|
||
**Files:**
|
||
- Create: `APP/src/app/features/users/pages/users-page/users-page.component.ts`
|
||
- Create: `APP/src/app/features/users/pages/users-page/users-page.component.html`
|
||
- Create: `APP/src/app/features/users/pages/users-page/users-page.component.scss`
|
||
|
||
- [ ] **Step 1: Create `users-page.component.ts`**
|
||
|
||
```typescript
|
||
import { Component, OnInit } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
|
||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||
import { UserApiService } from '../../services/user-api.service';
|
||
import { EditUserDialogComponent } from '../../components/edit-user-dialog/edit-user-dialog.component';
|
||
import {
|
||
UserListItemDto, UserDto, UpdateUserRequest, PagedResult
|
||
} from '../../models/user.model';
|
||
|
||
@Component({
|
||
selector: 'app-users-page',
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule, FormsModule, GridModule, InputsModule,
|
||
ButtonsModule, IndicatorsModule, EditUserDialogComponent,
|
||
],
|
||
templateUrl: './users-page.component.html',
|
||
styleUrls: ['./users-page.component.scss'],
|
||
})
|
||
export class UsersPageComponent implements OnInit {
|
||
data: UserListItemDto[] = [];
|
||
totalCount = 0;
|
||
page = 1;
|
||
pageSize = 20;
|
||
isLoading = false;
|
||
searchText = '';
|
||
|
||
// Edit dialog
|
||
showEditDialog = false;
|
||
editingUser: UserDto | null = null;
|
||
|
||
// Reset password result
|
||
resetPasswordResult: { userId: string; tempPassword: string } | null = null;
|
||
|
||
constructor(private userApi: UserApiService) {}
|
||
|
||
ngOnInit(): void { this.loadData(); }
|
||
|
||
loadData(): void {
|
||
this.isLoading = true;
|
||
this.userApi.getPaged({ page: this.page, pageSize: this.pageSize, search: this.searchText || undefined })
|
||
.subscribe({
|
||
next: (result: PagedResult<UserListItemDto>) => {
|
||
this.data = result.items;
|
||
this.totalCount = result.totalCount;
|
||
this.isLoading = false;
|
||
},
|
||
error: () => { this.isLoading = false; }
|
||
});
|
||
}
|
||
|
||
onPageChange(event: PageChangeEvent): void {
|
||
this.page = event.skip / this.pageSize + 1;
|
||
this.pageSize = event.take;
|
||
this.loadData();
|
||
}
|
||
|
||
onSearch(): void { this.page = 1; this.loadData(); }
|
||
|
||
openEditDialog(user: UserListItemDto): void {
|
||
this.userApi.getById(user.id).subscribe(dto => {
|
||
this.editingUser = dto;
|
||
this.showEditDialog = true;
|
||
});
|
||
}
|
||
|
||
closeEditDialog(): void {
|
||
this.showEditDialog = false;
|
||
this.editingUser = null;
|
||
}
|
||
|
||
onUserSaved(request: UpdateUserRequest): void {
|
||
if (!this.editingUser) return;
|
||
this.userApi.update(this.editingUser.id, request).subscribe(() => {
|
||
this.closeEditDialog();
|
||
this.loadData();
|
||
});
|
||
}
|
||
|
||
deactivateUser(user: UserListItemDto): void {
|
||
if (!confirm(`Deactivate ${user.email}? They will lose access immediately.`)) return;
|
||
this.userApi.deactivate(user.id).subscribe(() => this.loadData());
|
||
}
|
||
|
||
resetPassword(user: UserListItemDto): void {
|
||
if (!confirm(`Reset password for ${user.email}? A new temporary password will be generated.`)) return;
|
||
this.userApi.resetPassword(user.id).subscribe(result => {
|
||
this.resetPasswordResult = { userId: user.id, tempPassword: result.tempPassword };
|
||
});
|
||
}
|
||
|
||
copyResetPassword(): void {
|
||
if (this.resetPasswordResult) {
|
||
navigator.clipboard.writeText(this.resetPasswordResult.tempPassword);
|
||
}
|
||
}
|
||
|
||
dismissResetResult(): void { this.resetPasswordResult = null; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `users-page.component.html`**
|
||
|
||
```html
|
||
<div class="k-p-4">
|
||
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
|
||
<h2 class="k-m-0">User Management</h2>
|
||
</div>
|
||
|
||
<!-- Reset password result banner -->
|
||
<div *ngIf="resetPasswordResult"
|
||
class="k-d-flex k-align-items-center k-gap-3 k-p-3 k-mb-4 k-border k-rounded k-bg-success-subtle">
|
||
<span>New temporary password: <strong><code>{{ resetPasswordResult.tempPassword }}</code></strong></span>
|
||
<button kendoButton size="small" (click)="copyResetPassword()">Copy</button>
|
||
<button kendoButton size="small" (click)="dismissResetResult()">✕</button>
|
||
<span class="k-color-warning k-ml-2">⚠️ This will not be shown again.</span>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div class="k-d-flex k-gap-3 k-mb-4">
|
||
<kendo-textbox [(ngModel)]="searchText" placeholder="Search email or member name…"
|
||
(keyup.enter)="onSearch()" style="width:300px"></kendo-textbox>
|
||
<button kendoButton (click)="onSearch()">Search</button>
|
||
</div>
|
||
|
||
<!-- Grid -->
|
||
<kendo-grid
|
||
[data]="{ data: data, total: totalCount }"
|
||
[pageSize]="pageSize"
|
||
[skip]="(page - 1) * pageSize"
|
||
[pageable]="{ pageSizes: [10, 20, 50] }"
|
||
(pageChange)="onPageChange($event)">
|
||
|
||
<kendo-grid-column field="email" title="Email" [width]="220"></kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Member" [width]="180">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
{{ row.memberDisplayName ?? '—' }}
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Roles" [width]="200">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<kendo-badge *ngFor="let role of row.roles" class="k-mr-1" themeColor="info">
|
||
{{ role }}
|
||
</kendo-badge>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Active" [width]="90">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<kendo-badge [themeColor]="row.isActive ? 'success' : 'error'">
|
||
{{ row.isActive ? 'Active' : 'Inactive' }}
|
||
</kendo-badge>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Last Login" [width]="160">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
{{ row.lastLoginAt ? (row.lastLoginAt | date:'MM/dd/yyyy HH:mm') : '—' }}
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
<kendo-grid-column title="Actions" [width]="240">
|
||
<ng-template kendoGridCellTemplate let-row>
|
||
<div class="k-d-flex k-gap-2">
|
||
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
||
<button kendoButton size="small" (click)="resetPassword(row)">Reset Pwd</button>
|
||
<button *ngIf="row.isActive" kendoButton size="small" themeColor="warning"
|
||
(click)="deactivateUser(row)">Deactivate</button>
|
||
</div>
|
||
</ng-template>
|
||
</kendo-grid-column>
|
||
|
||
</kendo-grid>
|
||
</div>
|
||
|
||
<!-- Edit User Dialog -->
|
||
<app-edit-user-dialog
|
||
*ngIf="showEditDialog && editingUser"
|
||
[user]="editingUser"
|
||
(saved)="onUserSaved($event)"
|
||
(cancelled)="closeEditDialog()">
|
||
</app-edit-user-dialog>
|
||
```
|
||
|
||
- [ ] **Step 3: Create `users-page.component.scss`**
|
||
|
||
```scss
|
||
:host { display: block; height: 100%; }
|
||
```
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```
|
||
cd APP && ng build --configuration development
|
||
```
|
||
Expected: Compiled successfully.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/features/users/pages/ APP/src/app/features/users/components/
|
||
git commit -m "feat: add UsersPageComponent with Kendo Grid + edit/deactivate/reset-password"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Sidebar Navigation Update
|
||
|
||
**Files:**
|
||
- Modify: `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts`
|
||
- Modify: `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html`
|
||
- Modify: `APP/src/app/portals/user-portal/user-portal.component.ts`
|
||
|
||
- [ ] **Step 1: Update `user-navbar.component.ts`** — add admin nav items + role check
|
||
|
||
Replace the component class (keep existing imports, add `AuthService`):
|
||
|
||
```typescript
|
||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { Router, NavigationEnd, RouterModule } from '@angular/router';
|
||
import { LayoutModule } from '@progress/kendo-angular-layout';
|
||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||
import { IconsModule } from '@progress/kendo-angular-icons';
|
||
import { SVGIcon, homeIcon, calendarIcon, userIcon, gearIcon, groupIcon } from '@progress/kendo-svg-icons';
|
||
import { LayoutService } from '../../../../layout/services/layout.service';
|
||
import { AuthService } from '../../../../shared/services/auth.service';
|
||
import { Subject, takeUntil, filter } from 'rxjs';
|
||
|
||
interface NavItem {
|
||
text: string;
|
||
icon: SVGIcon;
|
||
path: string;
|
||
active?: boolean;
|
||
}
|
||
|
||
@Component({
|
||
selector: 'app-user-navbar',
|
||
standalone: true,
|
||
imports: [CommonModule, RouterModule, LayoutModule, ButtonsModule, IconsModule],
|
||
templateUrl: './user-navbar.component.html',
|
||
styleUrls: ['./user-navbar.component.scss']
|
||
})
|
||
export class UserNavbarComponent implements OnInit, OnDestroy {
|
||
public homeIcon: SVGIcon = homeIcon;
|
||
public groupIcon: SVGIcon = groupIcon;
|
||
public userIcon: SVGIcon = userIcon;
|
||
public gearIcon: SVGIcon = gearIcon;
|
||
|
||
public mainNavItems: NavItem[] = [
|
||
{ text: 'Dashboard', icon: homeIcon, path: '/user-portal/dashboard' },
|
||
];
|
||
|
||
public adminNavItems: NavItem[] = [
|
||
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' },
|
||
{ text: 'Users', icon: userIcon, path: '/user-portal/admin/users' },
|
||
];
|
||
|
||
showAdminSection = false;
|
||
|
||
private destroy$ = new Subject<void>();
|
||
|
||
constructor(
|
||
public layoutService: LayoutService,
|
||
private router: Router,
|
||
private authService: AuthService,
|
||
) {}
|
||
|
||
ngOnInit(): void {
|
||
this.router.events.pipe(
|
||
filter(e => e instanceof NavigationEnd),
|
||
takeUntil(this.destroy$)
|
||
).subscribe((e: NavigationEnd) => this.updateActiveStates(e.url));
|
||
|
||
this.updateActiveStates(this.router.url);
|
||
|
||
// Show admin section for super_admin or secretary
|
||
this.authService.currentUser$.pipe(takeUntil(this.destroy$)).subscribe(user => {
|
||
this.showAdminSection = !!user?.roles?.some(
|
||
r => r === 'super_admin' || r === 'secretary'
|
||
);
|
||
});
|
||
}
|
||
|
||
ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); }
|
||
|
||
public navigateTo(path: string): void {
|
||
this.router.navigate([path]);
|
||
this.layoutService.closeDrawer();
|
||
}
|
||
|
||
private updateActiveStates(url: string): void {
|
||
[...this.mainNavItems, ...this.adminNavItems].forEach(i => i.active = false);
|
||
const active = [...this.mainNavItems, ...this.adminNavItems].find(i => url.startsWith(i.path));
|
||
if (active) active.active = true;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `user-navbar.component.html`** — add Administration section
|
||
|
||
Replace the full template (adapt to existing styles — keep whatever button/list pattern is already in the file, and add the admin section):
|
||
|
||
```html
|
||
<div class="nav-menu">
|
||
|
||
<!-- Main -->
|
||
<div class="nav-section">
|
||
<button *ngFor="let item of mainNavItems" class="nav-item"
|
||
[class.active]="item.active" (click)="navigateTo(item.path)">
|
||
<kendo-svg-icon [icon]="item.icon"></kendo-svg-icon>
|
||
<span>{{ item.text }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Administration (role-guarded) -->
|
||
<div class="nav-section" *ngIf="showAdminSection">
|
||
<div class="nav-section-label">Administration</div>
|
||
<button *ngFor="let item of adminNavItems" class="nav-item"
|
||
[class.active]="item.active" (click)="navigateTo(item.path)">
|
||
<kendo-svg-icon [icon]="item.icon"></kendo-svg-icon>
|
||
<span>{{ item.text }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
```
|
||
|
||
> **Note:** Preserve any existing CSS classes from the current `user-navbar.component.html`. Only add the new administration section block — do not remove existing nav items.
|
||
|
||
- [ ] **Step 3: Update `user-portal.component.ts`** — add page titles for admin routes
|
||
|
||
In `getPageTitle()`, add:
|
||
|
||
```typescript
|
||
'admin/members': 'Member Management',
|
||
'admin/users': 'User Management',
|
||
```
|
||
|
||
So the full `titles` map becomes:
|
||
|
||
```typescript
|
||
const titles: { [key: string]: string } = {
|
||
'dashboard': 'Dashboard',
|
||
'admin': 'Administration', // fallback
|
||
// parse the second segment for admin sub-pages
|
||
};
|
||
```
|
||
|
||
Actually, since `updatePageTitle()` uses `segments[1]`, and the paths are `/user-portal/admin/members`, `segments[1]` will be `admin`. Instead, fix `updatePageTitle` to join the last two segments:
|
||
|
||
```typescript
|
||
private updatePageTitle(): void {
|
||
const url = this.router.url;
|
||
const segments = url.split('/').filter(s => s);
|
||
const key = segments.length >= 3
|
||
? `${segments[1]}/${segments[2]}` // e.g. 'admin/members'
|
||
: segments[1] ?? '';
|
||
this.currentPageTitle = this.getPageTitle(key);
|
||
}
|
||
|
||
private getPageTitle(page: string): string {
|
||
const titles: { [key: string]: string } = {
|
||
'dashboard': 'Dashboard',
|
||
'admin/members': 'Member Management',
|
||
'admin/users': 'User Management',
|
||
};
|
||
return titles[page] ?? 'Dashboard';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build and run**
|
||
|
||
```
|
||
cd APP && ng build --configuration development
|
||
```
|
||
Expected: Compiled successfully.
|
||
|
||
Start dev server and verify:
|
||
```
|
||
ng serve
|
||
```
|
||
- Login as `admin@rolac.org / Admin1234!`
|
||
- Sidebar shows **Administration** section with Members and Users links
|
||
- Navigate to `/user-portal/admin/members` — grid loads (empty)
|
||
- Click **+ Add Member** — 3-tab dialog opens
|
||
- Add a member — grid refreshes with the new member
|
||
- Click **+ Account** on the member — create-user dialog opens
|
||
- Enter email + role → submit → temp password is displayed with Copy button
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add APP/src/app/portals/user-portal/
|
||
git commit -m "feat: add Administration section to sidebar with role-gated Member/User nav"
|
||
```
|
||
|
||
---
|
||
|
||
**All 3 parts complete. 🎉**
|
||
|
||
**Summary of what was built:**
|
||
- Backend: `AuditableEntity`/`SoftDeleteEntity` bases, `AuditSaveChangesInterceptor`, `Member`/`FamilyUnit` entities, full EF migration, all DTOs, `MemberService` + `UserManagementService` with tests, `MembersController` + `UsersController`
|
||
- Frontend: Angular models + services, `MemberFormDialogComponent` (3-tab), `CreateUserAccountDialogComponent` (with temp-password reveal), `MembersPageComponent`, `EditUserDialogComponent`, `UsersPageComponent`, sidebar navigation update
|