add church profile.
ci-cd-vm / ci-cd (push) Successful in 2m31s

This commit is contained in:
Chris Chen
2026-06-24 08:21:31 -07:00
parent 99585a1c0e
commit e88ea7917f
29 changed files with 1240 additions and 72 deletions
@@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto {
}
export interface ChurchProfileDto {
id: number; name: string; address: string | null; city: string | null;
id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
}
@@ -1,49 +1,86 @@
<div class="page">
<div *ngIf="model" class="max-w-3xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Bank Name / 銀行名稱
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Bank Account # / 銀行帳號
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
</div>
<kendo-tabstrip>
<!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
<kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Church Name (ZH) / 教會名稱(中)
<kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Phone / 電話
<kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Email / 電子郵件
<kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Website / 網站
<kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Bank Name / 銀行名稱
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Bank Account # / 銀行帳號
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 2: Site Settings (Settings permission) ─────────────────────── -->
<kendo-tabstrip-tab title="Site Settings / 網站設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<app-site-settings-tab></app-site-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 3: Notification Settings (Settings permission) ─────────────── -->
<kendo-tabstrip-tab title="Notifications / 通知設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<app-notification-settings-tab></app-notification-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
</kendo-tabstrip>
</div>
@@ -3,13 +3,21 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
import { PermissionModules } from '../../../../core/models/permission.model';
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
import { NotificationSettingsTabComponent } from '../../../settings/components/notification-settings-tab/notification-settings-tab.component';
@Component({
selector: 'app-church-profile-page',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
imports: [
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
],
templateUrl: './church-profile-page.component.html',
})
export class ChurchProfilePageComponent implements OnInit {
@@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit {
saving = false;
savedMsg = '';
/** Settings module gates the Site / Notification tabs. */
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
constructor(private api: DisbursementApiService) {}
ngOnInit(): void {
@@ -0,0 +1,104 @@
<div *ngIf="model" class="max-w-3xl pt-4 flex flex-col gap-6">
<!-- ── Email (SMTP) ─────────────────────────────────────────────────────── -->
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">Email (SMTP) / 電子郵件</h3>
<label class="flex items-center gap-2 text-sm">
Enabled / 啟用
<kendo-switch [(ngModel)]="model.enableEmail"></kendo-switch>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
SMTP Host / 主機
<kendo-textbox [(ngModel)]="model.smtpHost" placeholder="smtp.example.com"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
Port / 連接埠
<kendo-numerictextbox [(ngModel)]="model.smtpPort" [min]="0" [max]="65535" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
Use SSL / 加密
<kendo-switch [(ngModel)]="model.smtpUseSsl"></kendo-switch>
</label>
</div>
<label class="flex flex-col gap-1">
SMTP User / 帳號
<kendo-textbox [(ngModel)]="model.smtpUser"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
SMTP Password / 密碼
<kendo-textbox [(ngModel)]="smtpPassword" type="password"
[placeholder]="model.hasSmtpPassword ? ' stored (blank = keep) / 已設定' : ''"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
From Address / 寄件地址
<kendo-textbox [(ngModel)]="model.fromAddress" placeholder="noreply@church.org"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
From Name / 寄件人名稱
<kendo-textbox [(ngModel)]="model.fromName"></kendo-textbox>
</label>
</div>
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1 grow max-w-xs">
<span class="text-sm">Test recipient (blank = you) / 測試收件人</span>
<kendo-textbox [(ngModel)]="testEmailTo" placeholder="you@example.com"></kendo-textbox>
</label>
<button kendoButton [disabled]="testingEmail" (click)="sendTestEmail()">Send test email / 寄送測試</button>
<span class="text-sm" [style.color]="testEmailMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testEmailMsg }}</span>
</div>
</section>
<hr class="border-gray-200" />
<!-- ── Line ─────────────────────────────────────────────────────────────── -->
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">Line / Line 通知</h3>
<label class="flex items-center gap-2 text-sm">
Enabled / 啟用
<kendo-switch [(ngModel)]="model.enableLine"></kendo-switch>
</label>
</div>
<div class="grid grid-cols-1 gap-y-3">
<label class="flex flex-col gap-1">
Channel Access Token / 頻道存取權杖
<kendo-textbox [(ngModel)]="lineToken" type="password"
[placeholder]="model.hasLineChannelAccessToken ? ' stored (blank = keep) / 已設定' : ''"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Channel Secret / 頻道密鑰
<kendo-textbox [(ngModel)]="lineSecret" type="password"
[placeholder]="model.hasLineChannelSecret ? ' stored (blank = keep) / 已設定' : ''"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Webhook URL (register in Line console) / Webhook 網址
<kendo-textbox [ngModel]="model.webhookUrl" [readonly]="true"></kendo-textbox>
</label>
</div>
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1">
<span class="text-sm">Test member ID / 會員編號</span>
<kendo-numerictextbox [(ngModel)]="testLineMemberId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">or Group ID / 群組編號</span>
<kendo-numerictextbox [(ngModel)]="testLineGroupId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
</label>
<button kendoButton [disabled]="testingLine" (click)="sendTestLine()">Send test Line / 寄送測試</button>
<span class="text-sm" [style.color]="testLineMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testLineMsg }}</span>
</div>
</section>
<div class="flex items-center gap-3">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
@@ -0,0 +1,106 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { SettingsApiService } from '../../services/settings-api.service';
import {
NotificationSettingDto, UpdateNotificationSettingRequest, NotificationResult,
} from '../../models/settings.model';
@Component({
selector: 'app-notification-settings-tab',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
templateUrl: './notification-settings-tab.component.html',
})
export class NotificationSettingsTabComponent implements OnInit {
model: NotificationSettingDto | null = null;
saving = false;
savedMsg = '';
// Write-only secret inputs — blank means "keep the stored value".
smtpPassword = '';
lineToken = '';
lineSecret = '';
// Test-send state.
testEmailTo = '';
testEmailMsg = '';
testingEmail = false;
testLineMemberId: number | null = null;
testLineGroupId: number | null = null;
testLineMsg = '';
testingLine = false;
constructor(private api: SettingsApiService) {}
ngOnInit(): void {
this.load();
}
private load(): void {
this.api.getNotification().subscribe(n => (this.model = n));
}
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const m = this.model;
const request: UpdateNotificationSettingRequest = {
enableEmail: m.enableEmail,
smtpHost: m.smtpHost,
smtpPort: m.smtpPort,
smtpUseSsl: m.smtpUseSsl,
smtpUser: m.smtpUser,
fromAddress: m.fromAddress,
fromName: m.fromName,
smtpPassword: this.smtpPassword || null,
enableLine: m.enableLine,
lineChannelAccessToken: this.lineToken || null,
lineChannelSecret: this.lineSecret || null,
};
this.api.updateNotification(request).subscribe({
next: () => {
this.saving = false;
this.savedMsg = 'Saved / 已儲存';
// Clear secret inputs and refresh the "configured" flags.
this.smtpPassword = this.lineToken = this.lineSecret = '';
this.load();
},
error: () => { this.saving = false; },
});
}
sendTestEmail(): void {
if (this.testingEmail) return;
this.testingEmail = true;
this.testEmailMsg = '';
this.api.testEmail({ toAddress: this.testEmailTo || null }).subscribe({
next: result => { this.testingEmail = false; this.testEmailMsg = this.describe(result); },
error: err => { this.testingEmail = false; this.testEmailMsg = this.errorText(err); },
});
}
sendTestLine(): void {
if (this.testingLine) return;
this.testingLine = true;
this.testLineMsg = '';
this.api.testLine({ memberId: this.testLineMemberId, groupId: this.testLineGroupId }).subscribe({
next: result => { this.testingLine = false; this.testLineMsg = this.describe(result); },
error: err => { this.testingLine = false; this.testLineMsg = this.errorText(err); },
});
}
private describe(result: NotificationResult): string {
if (result.failedCount > 0)
return `Failed / 失敗:${result.failures.map(f => f.error).join('; ')}`;
return result.sentCount > 0 ? 'Sent ✓ / 已發送' : 'Nothing sent / 未發送';
}
private errorText(err: { error?: { message?: string } }): string {
return err?.error?.message ?? 'Failed / 失敗';
}
}
@@ -0,0 +1,35 @@
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Site Title (EN) / 網站名稱
<kendo-textbox [(ngModel)]="model.siteTitle"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Site Title (ZH) / 網站名稱(中)
<kendo-textbox [(ngModel)]="model.siteTitleZh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Default Language / 預設語言
<kendo-dropdownlist
[data]="languages" textField="text" valueField="value" [valuePrimitive]="true"
[(ngModel)]="model.defaultLanguage"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Time Zone / 時區
<kendo-textbox [(ngModel)]="model.timeZone" placeholder="America/Los_Angeles"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Date Format / 日期格式
<kendo-textbox [(ngModel)]="model.dateFormat" placeholder="yyyy-MM-dd"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Currency / 貨幣
<kendo-textbox [(ngModel)]="model.currency" placeholder="USD"></kendo-textbox>
</label>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
@@ -0,0 +1,42 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { SettingsApiService } from '../../services/settings-api.service';
import { SiteSettingDto } from '../../models/settings.model';
@Component({
selector: 'app-site-settings-tab',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule, DropDownsModule],
templateUrl: './site-settings-tab.component.html',
})
export class SiteSettingsTabComponent implements OnInit {
model: SiteSettingDto | null = null;
saving = false;
savedMsg = '';
readonly languages = [
{ text: 'English', value: 'en' },
{ text: '中文 Chinese', value: 'zh' },
];
constructor(private api: SettingsApiService) {}
ngOnInit(): void {
this.api.getSite().subscribe(s => (this.model = s));
}
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
this.api.updateSite(this.model).subscribe({
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
// Errors surface globally via httpErrorInterceptor.
error: () => { this.saving = false; },
});
}
}
@@ -0,0 +1,66 @@
// Mirrors ROLAC.API.DTOs.Settings — site + notification settings edited from the
// Church Profile tabbed page (Settings permission module).
export interface SiteSettingDto {
siteTitle: string;
siteTitleZh: string | null;
defaultLanguage: string; // 'en' | 'zh'
timeZone: string;
dateFormat: string;
currency: string;
}
export type UpdateSiteSettingRequest = SiteSettingDto;
export interface NotificationSettingDto {
enableEmail: boolean;
smtpHost: string;
smtpPort: number;
smtpUseSsl: boolean;
smtpUser: string;
fromAddress: string;
fromName: string;
/** True when a password is stored — secrets themselves are never returned. */
hasSmtpPassword: boolean;
enableLine: boolean;
hasLineChannelAccessToken: boolean;
hasLineChannelSecret: boolean;
/** Read-only webhook URL to register in the Line console. */
webhookUrl: string;
}
export interface UpdateNotificationSettingRequest {
enableEmail: boolean;
smtpHost: string;
smtpPort: number;
smtpUseSsl: boolean;
smtpUser: string;
fromAddress: string | null;
fromName: string | null;
/** Leave blank/omit to keep the stored password. */
smtpPassword?: string | null;
enableLine: boolean;
/** Leave blank/omit to keep the stored token. */
lineChannelAccessToken?: string | null;
/** Leave blank/omit to keep the stored secret. */
lineChannelSecret?: string | null;
}
export interface TestEmailRequest {
toAddress?: string | null;
}
export interface TestLineRequest {
memberId?: number | null;
groupId?: number | null;
}
/** Mirrors ROLAC.API NotificationResult. */
export interface NotificationResult {
sentCount: number;
failedCount: number;
failures: { target: string; error: string }[];
}
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
SiteSettingDto, UpdateSiteSettingRequest,
NotificationSettingDto, UpdateNotificationSettingRequest,
TestEmailRequest, TestLineRequest, NotificationResult,
} from '../models/settings.model';
@Injectable({ providedIn: 'root' })
export class SettingsApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('settings');
}
getSite(): Observable<SiteSettingDto> {
return this.http.get<SiteSettingDto>(`${this.endpoint}/site`);
}
updateSite(request: UpdateSiteSettingRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/site`, request);
}
getNotification(): Observable<NotificationSettingDto> {
return this.http.get<NotificationSettingDto>(`${this.endpoint}/notification`);
}
updateNotification(request: UpdateNotificationSettingRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/notification`, request);
}
testEmail(request: TestEmailRequest): Observable<NotificationResult> {
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-email`, request);
}
testLine(request: TestLineRequest): Observable<NotificationResult> {
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-line`, request);
}
}