WIP
This commit is contained in:
@@ -5,13 +5,16 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
import { httpErrorInterceptor } from './core/interceptors/http-error.interceptor';
|
||||
import { AuthService } from './shared/services/auth.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideAnimations(),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
// httpErrorInterceptor is listed first so its catchError runs LAST (outermost),
|
||||
// i.e. after authInterceptor has handled/retried 401s.
|
||||
provideHttpClient(withInterceptors([httpErrorInterceptor, authInterceptor])),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
<router-outlet></router-outlet>
|
||||
<div kendoDialogContainer></div>
|
||||
<div kendoDialogContainer></div>
|
||||
<app-toast-container></app-toast-container>
|
||||
@@ -14,6 +14,9 @@ import { ExpensesPageComponent } from './features/expense/pages/expenses-page/ex
|
||||
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
|
||||
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
|
||||
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
|
||||
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component';
|
||||
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
||||
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Public routes
|
||||
@@ -77,6 +80,24 @@ export const routes: Routes = [
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
},
|
||||
{
|
||||
path: 'finance/disbursements',
|
||||
component: DisbursementPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
},
|
||||
{
|
||||
path: 'finance/check-register',
|
||||
component: CheckRegisterPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
},
|
||||
{
|
||||
path: 'finance/church-profile',
|
||||
component: ChurchProfilePageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ import { Component, ViewEncapsulation } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { DialogModule } from '@progress/kendo-angular-dialog';
|
||||
import { ToastContainerComponent } from './core/components/toast-container/toast-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -9,7 +10,8 @@ import { DialogModule } from '@progress/kendo-angular-dialog';
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
DialogModule
|
||||
DialogModule,
|
||||
ToastContainerComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrls: ['./app.scss', '../styles.scss'],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 11000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 420px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
color: #ffffff;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
animation: toast-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
transform: translateY(-6px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast-container',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="toast-stack">
|
||||
<div *ngFor="let toast of toastService.toasts()"
|
||||
class="toast"
|
||||
[class.toast-error]="toast.type === 'error'"
|
||||
[class.toast-success]="toast.type === 'success'"
|
||||
[class.toast-info]="toast.type === 'info'"
|
||||
(click)="toastService.dismiss(toast.id)">
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
<button type="button" class="toast-close" aria-label="Dismiss"
|
||||
(click)="toastService.dismiss(toast.id); $event.stopPropagation()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./toast-container.component.scss'],
|
||||
})
|
||||
export class ToastContainerComponent {
|
||||
constructor(public toastService: ToastService) {}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, from, map, Observable, of, switchMap, tap, throwError } from 'rxjs';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
/**
|
||||
* Surfaces every failed HTTP response as a toast so errors are never silent.
|
||||
* 401s are left to the auth interceptor (token refresh / redirect to login).
|
||||
*
|
||||
* Must be registered BEFORE the auth interceptor so its catchError runs LAST
|
||||
* (outermost) — i.e. after auth has had a chance to refresh-and-retry a 401.
|
||||
*/
|
||||
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const toastService = inject(ToastService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
// Handled by the auth interceptor (silent refresh or redirect to login).
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return resolveMessage(error).pipe(
|
||||
tap(message => toastService.error(message)),
|
||||
switchMap(() => throwError(() => error)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a human-readable message from the error response. The body can be a
|
||||
* parsed object, a string, or a Blob (when the request used responseType:'blob',
|
||||
* e.g. PDF / signature downloads) — Blobs must be read asynchronously.
|
||||
*/
|
||||
function resolveMessage(error: HttpErrorResponse): Observable<string> {
|
||||
const body = error.error;
|
||||
|
||||
if (body instanceof Blob) {
|
||||
return from(body.text()).pipe(map(text => parseBodyText(text, error.status)));
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return of(parseBodyText(body, error.status));
|
||||
}
|
||||
if (body && typeof body === 'object') {
|
||||
const message = (body as { message?: unknown }).message;
|
||||
const title = (body as { title?: unknown }).title;
|
||||
if (typeof message === 'string' && message.length > 0) {
|
||||
return of(message);
|
||||
}
|
||||
if (typeof title === 'string' && title.length > 0) {
|
||||
return of(title);
|
||||
}
|
||||
}
|
||||
return of(defaultMessage(error.status));
|
||||
}
|
||||
|
||||
function parseBodyText(text: string, status: number): string {
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: unknown; title?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message.length > 0) {
|
||||
return parsed.message;
|
||||
}
|
||||
if (typeof parsed.title === 'string' && parsed.title.length > 0) {
|
||||
return parsed.title;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to the status-based default.
|
||||
}
|
||||
}
|
||||
return defaultMessage(status);
|
||||
}
|
||||
|
||||
function defaultMessage(status: number): string {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'Cannot reach the server / 無法連線到伺服器';
|
||||
case 400:
|
||||
return 'Invalid request / 請求格式錯誤';
|
||||
case 403:
|
||||
return 'You do not have permission for this action / 沒有權限執行此操作';
|
||||
case 404:
|
||||
return 'The requested item was not found / 找不到要求的資料';
|
||||
case 409:
|
||||
return 'This action conflicts with the current state / 操作與目前狀態衝突';
|
||||
case 422:
|
||||
return 'The submitted data is invalid / 提交的資料無效';
|
||||
case 500:
|
||||
return 'A server error occurred / 伺服器發生錯誤';
|
||||
default:
|
||||
return `Request failed (HTTP ${status}) / 請求失敗 (HTTP ${status})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ToastType = 'error' | 'success' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, dependency-free toast/notification store. A single
|
||||
* ToastContainerComponent (mounted in the app root) renders whatever this holds.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
private counter = 0;
|
||||
readonly toasts = signal<Toast[]>([]);
|
||||
|
||||
error(message: string): void {
|
||||
this.show('error', message);
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
this.show('success', message);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.show('info', message);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts.update(list => list.filter(toast => toast.id !== id));
|
||||
}
|
||||
|
||||
private show(type: ToastType, message: string): void {
|
||||
const id = ++this.counter;
|
||||
this.toasts.update(list => [...list, { id, type, message }]);
|
||||
const autoDismissMs = type === 'error' ? 8000 : 4000;
|
||||
setTimeout(() => this.dismiss(id), autoDismissMs);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<kendo-dialog title="Issue Checks / 開立支票" [width]="720" (close)="onClose()">
|
||||
<div class="p-2 flex flex-col gap-4" style="max-height: 70vh; overflow-y: auto;">
|
||||
|
||||
<label class="flex flex-col gap-1 w-60">
|
||||
Check Date / 支票日期
|
||||
<kendo-datepicker [(ngModel)]="checkDate"></kendo-datepicker>
|
||||
</label>
|
||||
|
||||
<div *ngFor="let f of forms; let i = index" class="border rounded p-3 flex flex-col gap-3"
|
||||
style="border-color:#e5e7eb;">
|
||||
<div class="font-semibold">
|
||||
Check #{{ i + 1 }} — {{ f.group.payeeType }} · Total {{ f.group.totalAmount | currency }}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Payee Name / 收款人
|
||||
<kendo-textbox [(ngModel)]="f.payeeName"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Address / 地址
|
||||
<kendo-textbox [(ngModel)]="f.address"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
City / 城市
|
||||
<kendo-textbox [(ngModel)]="f.city"></kendo-textbox>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
State / 州
|
||||
<kendo-textbox [(ngModel)]="f.state"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Zip / 郵遞區號
|
||||
<kendo-textbox [(ngModel)]="f.zip"></kendo-textbox>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1">
|
||||
Check # / 支票號碼
|
||||
<kendo-textbox [(ngModel)]="f.checkNumber"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Memo / 摘要
|
||||
<kendo-textbox [(ngModel)]="f.memo"></kendo-textbox>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
|
||||
<th class="py-1">Date</th><th>Description</th><th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let l of f.group.lines" style="border-bottom:1px solid #f3f4f6;">
|
||||
<td class="py-1">{{ l.expenseDate }}</td>
|
||||
<td>{{ l.description }}</td>
|
||||
<td class="text-right">{{ l.amount | currency }}</td>
|
||||
</tr>
|
||||
<tr class="font-semibold">
|
||||
<td></td><td class="text-right pr-2">Total</td>
|
||||
<td class="text-right">{{ f.group.totalAmount | currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="onClose()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" (click)="confirm()">
|
||||
Issue {{ forms.length }} Check(s)
|
||||
</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||
import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { PayeeGroupDto, IssueChecksRequest, PayeeCheckInstruction } from '../../models/disbursement.model';
|
||||
|
||||
interface PayeeForm {
|
||||
group: PayeeGroupDto;
|
||||
payeeName: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
checkNumber: string;
|
||||
defaultCheckNumber: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-issue-check-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule, DateInputsModule, GridModule],
|
||||
templateUrl: './issue-check-dialog.component.html',
|
||||
})
|
||||
export class IssueCheckDialogComponent implements OnInit {
|
||||
/** The payee groups the user selected to pay. */
|
||||
@Input() groups: PayeeGroupDto[] = [];
|
||||
/** Next sequential check number from the church profile, used to prefill. */
|
||||
@Input() nextCheckNumber = 1001;
|
||||
|
||||
@Output() save = new EventEmitter<IssueChecksRequest>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
|
||||
checkDate = new Date();
|
||||
forms: PayeeForm[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
let n = this.nextCheckNumber;
|
||||
this.forms = this.groups.map(g => {
|
||||
const cn = String(n++);
|
||||
return {
|
||||
group: g,
|
||||
payeeName: g.payeeName,
|
||||
address: g.address ?? '',
|
||||
city: g.city ?? '',
|
||||
state: g.state ?? '',
|
||||
zip: g.zip ?? '',
|
||||
checkNumber: cn,
|
||||
defaultCheckNumber: cn,
|
||||
memo: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
const d = this.checkDate;
|
||||
const checkDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const payees: PayeeCheckInstruction[] = this.forms.map(f => ({
|
||||
payeeType: f.group.payeeType,
|
||||
memberId: f.group.memberId,
|
||||
vendorKey: f.group.vendorKey,
|
||||
payeeName: f.payeeName.trim(),
|
||||
address: f.address.trim() || null,
|
||||
city: f.city.trim() || null,
|
||||
state: f.state.trim() || null,
|
||||
zip: f.zip.trim() || null,
|
||||
// Only send an override when the user changed the prefilled sequential number,
|
||||
// so the server's auto-allocation (and counter consumption) stays in sync.
|
||||
checkNumberOverride: f.checkNumber.trim() !== f.defaultCheckNumber ? f.checkNumber.trim() : null,
|
||||
memo: f.memo.trim() || null,
|
||||
expenseIds: f.group.lines.map(l => l.expenseId),
|
||||
}));
|
||||
|
||||
this.save.emit({ checkDate, payees });
|
||||
}
|
||||
|
||||
onClose(): void { this.cancel.emit(); }
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="480" (close)="onClose()">
|
||||
<div class="p-2 flex flex-col gap-3">
|
||||
<div class="text-sm" style="color:#374151;">
|
||||
Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }}
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
Signed Name / 簽收人姓名
|
||||
<kendo-textbox [(ngModel)]="signedName" placeholder="Recipient name"></kendo-textbox>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm">Signature / 簽名</span>
|
||||
<canvas #pad width="440" height="180"
|
||||
class="border rounded touch-none"
|
||||
style="border-color:#9ca3af; background:#fff; touch-action:none;"
|
||||
(pointerdown)="onDown($event)"
|
||||
(pointermove)="onMove($event)"
|
||||
(pointerup)="onUp()"
|
||||
(pointerleave)="onUp()">
|
||||
</canvas>
|
||||
<button kendoButton fillMode="flat" class="self-start" (click)="clear()">Clear / 清除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="onClose()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="!hasInk || !signedName.trim() || submitting"
|
||||
(click)="confirm()">Confirm / 確認簽收</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { CheckListItemDto } from '../../models/disbursement.model';
|
||||
|
||||
export interface ReceiptSignResult { signature: Blob; signedName: string; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-receipt-sign-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule],
|
||||
templateUrl: './receipt-sign-dialog.component.html',
|
||||
})
|
||||
export class ReceiptSignDialogComponent implements AfterViewInit {
|
||||
@Input() check!: CheckListItemDto;
|
||||
@Output() save = new EventEmitter<ReceiptSignResult>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('pad', { static: false }) padRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
signedName = '';
|
||||
hasInk = false;
|
||||
submitting = false;
|
||||
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private drawing = false;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const canvas = this.padRef.nativeElement;
|
||||
this.signedName = this.check.payeeName || '';
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
this.ctx.fillStyle = '#ffffff';
|
||||
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
this.ctx.strokeStyle = '#111827';
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.lineCap = 'round';
|
||||
this.ctx.lineJoin = 'round';
|
||||
}
|
||||
|
||||
private point(e: PointerEvent): { x: number; y: number } {
|
||||
const rect = this.padRef.nativeElement.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) * (this.padRef.nativeElement.width / rect.width),
|
||||
y: (e.clientY - rect.top) * (this.padRef.nativeElement.height / rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
onDown(e: PointerEvent): void {
|
||||
e.preventDefault();
|
||||
this.drawing = true;
|
||||
const p = this.point(e);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(p.x, p.y);
|
||||
this.padRef.nativeElement.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
onMove(e: PointerEvent): void {
|
||||
if (!this.drawing) return;
|
||||
const p = this.point(e);
|
||||
this.ctx.lineTo(p.x, p.y);
|
||||
this.ctx.stroke();
|
||||
this.hasInk = true;
|
||||
}
|
||||
|
||||
onUp(): void { this.drawing = false; }
|
||||
|
||||
clear(): void {
|
||||
const canvas = this.padRef.nativeElement;
|
||||
this.ctx.fillStyle = '#ffffff';
|
||||
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
this.hasInk = false;
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
if (!this.hasInk || !this.signedName.trim() || this.submitting) return;
|
||||
this.submitting = true;
|
||||
this.padRef.nativeElement.toBlob(blob => {
|
||||
if (!blob) { this.submitting = false; return; }
|
||||
this.save.emit({ signature: blob, signedName: this.signedName.trim() });
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
onClose(): void { this.cancel.emit(); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type PayeeType = 'Vendor' | 'Member';
|
||||
export type CheckStatus = 'Issued' | 'Voided';
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
|
||||
}
|
||||
|
||||
export interface ExpenseLineDto {
|
||||
expenseId: number; expenseDate: string; description: string; amount: number;
|
||||
ministryName: string; categoryName: string;
|
||||
}
|
||||
|
||||
export interface PayeeGroupDto {
|
||||
payeeType: PayeeType; memberId: number | null; vendorKey: string | null;
|
||||
payeeName: string; address: string | null; city: string | null;
|
||||
state: string | null; zip: string | null;
|
||||
totalAmount: number; lines: ExpenseLineDto[];
|
||||
}
|
||||
|
||||
export interface PayeeCheckInstruction {
|
||||
payeeType: PayeeType; memberId: number | null; vendorKey: string | null;
|
||||
payeeName: string; address: string | null; city: string | null;
|
||||
state: string | null; zip: string | null;
|
||||
checkNumberOverride: string | null; memo: string | null; expenseIds: number[];
|
||||
}
|
||||
|
||||
export interface IssueChecksRequest { checkDate: string; payees: PayeeCheckInstruction[]; }
|
||||
|
||||
export interface IssuedCheckDto { checkId: number; checkNumber: string; payeeName: string; amount: number; }
|
||||
export interface IssueChecksResultDto { created: IssuedCheckDto[]; }
|
||||
|
||||
export interface CheckListItemDto {
|
||||
id: number; checkNumber: string; checkDate: string; amount: number;
|
||||
payeeType: PayeeType; payeeName: string; status: CheckStatus; lineCount: number;
|
||||
signed: boolean; receiptSignedName: string | null; receiptSignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CheckLineDto { expenseId: number; description: string; amount: number; }
|
||||
|
||||
export interface CheckDetailDto extends CheckListItemDto {
|
||||
memberId: number | null; address: string | null; city: string | null;
|
||||
state: string | null; zip: string | null; memo: string | null;
|
||||
voidReason: string | null; voidedAt: string | null; issuedAt: string;
|
||||
lines: CheckLineDto[];
|
||||
}
|
||||
|
||||
export interface ChurchProfileDto {
|
||||
id: number; name: string; address: string | null; city: string | null;
|
||||
state: string | null; zipCode: string | null; bankName: string | null;
|
||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
||||
}
|
||||
|
||||
export type UpdateChurchProfileRequest = Omit<ChurchProfileDto, 'id'>;
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Check Register / 支票登記簿</h2>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Search
|
||||
<kendo-textbox placeholder="Check # / payee" [(ngModel)]="filter.search"
|
||||
(keydown.enter)="applyFilter()"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Status
|
||||
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<button kendoButton (click)="applyFilter()">Apply</button>
|
||||
</div>
|
||||
|
||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
||||
|
||||
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
|
||||
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
|
||||
<kendo-grid-column title="Lines" [width]="80">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Status" [width]="110">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Receipt / 簽收" [width]="180">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="dataItem.signed; else notSigned">
|
||||
<span class="badge-paid">Signed</span>
|
||||
<div class="text-xs" style="color:#6b7280;">
|
||||
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #notSigned><span style="color:#9ca3af;">—</span></ng-template>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="600">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
|
||||
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
|
||||
(click)="openSign(dataItem)">簽收</button>
|
||||
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button>
|
||||
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
|
||||
(click)="printReceipt(dataItem)">收據</button>
|
||||
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
|
||||
(click)="openVoid(dataItem)">Void</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><strong>Payee:</strong> {{ detail.payeeName }}</div>
|
||||
<div><strong>Date:</strong> {{ detail.checkDate }}</div>
|
||||
<div><strong>Amount:</strong> {{ detail.amount | currency }}</div>
|
||||
<div><strong>Status:</strong> {{ detail.status }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.memo"><strong>Memo:</strong> {{ detail.memo }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.signed">
|
||||
<strong>Signed:</strong> {{ detail.receiptSignedName }} · {{ detail.receiptSignedAt | date:'short' }}
|
||||
</div>
|
||||
<div class="col-span-2" *ngIf="detail.status === 'Voided'">
|
||||
<strong>Voided:</strong> {{ detail.voidReason }} · {{ detail.voidedAt | date:'short' }}
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
|
||||
<th class="py-1">Description</th>
|
||||
<th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let l of detail.lines" style="border-bottom:1px solid #f3f4f6;">
|
||||
<td class="py-1">{{ l.description }}</td>
|
||||
<td class="text-right">{{ l.amount | currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="detail = null">Close</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Void dialog -->
|
||||
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" (close)="voidRow = null">
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<p class="text-sm" style="color:#991b1b;">
|
||||
Voiding returns the bundled expenses to Approved so they can be re-issued.
|
||||
/ 作廢將使支出退回「已核准」可重新開立。
|
||||
</p>
|
||||
<label class="flex flex-col gap-1">
|
||||
Reason / 原因
|
||||
<kendo-textbox [(ngModel)]="voidReason" placeholder="Optional"></kendo-textbox>
|
||||
</label>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="voidRow = null">Cancel</button>
|
||||
<button kendoButton themeColor="error" (click)="confirmVoid()">Void</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Receipt signature dialog -->
|
||||
<app-receipt-sign-dialog *ngIf="signRow" [check]="signRow" (save)="onSign($event)" (cancel)="signRow = null">
|
||||
</app-receipt-sign-dialog>
|
||||
</div>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
%badge-base {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
@extend %badge-base;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-paid {
|
||||
@extend %badge-base;
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
@extend %badge-base;
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
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 { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { CHECK_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { DisbursementApiService, CheckRegisterQuery } from '../../services/disbursement-api.service';
|
||||
import { ReceiptSignDialogComponent, ReceiptSignResult } from '../../components/receipt-sign-dialog/receipt-sign-dialog.component';
|
||||
import { CheckListItemDto, CheckDetailDto } from '../../models/disbursement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-check-register-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
||||
InputsModule, DialogsModule, ReceiptSignDialogComponent,
|
||||
],
|
||||
templateUrl: './check-register-page.component.html',
|
||||
styleUrls: ['./check-register-page.component.scss'],
|
||||
})
|
||||
export class CheckRegisterPageComponent implements OnInit {
|
||||
rows: CheckListItemDto[] = [];
|
||||
total = 0;
|
||||
page = 1;
|
||||
pageSize = 20;
|
||||
loading = false;
|
||||
|
||||
readonly statuses = CHECK_STATUS_OPTIONS;
|
||||
filter: CheckRegisterQuery = {};
|
||||
|
||||
detail: CheckDetailDto | null = null;
|
||||
signRow: CheckListItemDto | null = null;
|
||||
voidRow: CheckListItemDto | null = null;
|
||||
voidReason = '';
|
||||
|
||||
constructor(private api: DisbursementApiService) {}
|
||||
|
||||
ngOnInit(): void { this.load(); }
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.api.getRegister({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({
|
||||
next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; },
|
||||
error: () => (this.loading = false),
|
||||
});
|
||||
}
|
||||
|
||||
get skip(): number { return (this.page - 1) * this.pageSize; }
|
||||
applyFilter(): void { this.page = 1; this.load(); }
|
||||
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
|
||||
|
||||
view(row: CheckListItemDto): void {
|
||||
this.api.getCheck(row.id).subscribe(d => (this.detail = d));
|
||||
}
|
||||
|
||||
print(row: CheckListItemDto): void {
|
||||
this.api.downloadCheckPdf(row.id).subscribe(blob => this.openBlob(blob));
|
||||
}
|
||||
|
||||
viewSignature(row: CheckListItemDto): void {
|
||||
this.api.getSignature(row.id).subscribe(blob => this.openBlob(blob));
|
||||
}
|
||||
|
||||
printReceipt(row: CheckListItemDto): void {
|
||||
this.api.downloadReceiptPdf(row.id).subscribe(blob => this.openBlob(blob));
|
||||
}
|
||||
|
||||
openSign(row: CheckListItemDto): void { this.signRow = row; }
|
||||
|
||||
onSign(result: ReceiptSignResult): void {
|
||||
if (!this.signRow) return;
|
||||
this.api.acknowledge(this.signRow.id, result.signature, result.signedName).subscribe({
|
||||
next: () => { this.signRow = null; this.load(); },
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openVoid(row: CheckListItemDto): void { this.voidRow = row; this.voidReason = ''; }
|
||||
|
||||
confirmVoid(): void {
|
||||
if (!this.voidRow) return;
|
||||
this.api.voidCheck(this.voidRow.id, this.voidReason || null).subscribe({
|
||||
next: () => { this.voidRow = null; this.load(); },
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
canSign(row: CheckListItemDto): boolean { return row.status === 'Issued' && !row.signed; }
|
||||
canVoid(row: CheckListItemDto): boolean { return row.status === 'Issued'; }
|
||||
|
||||
statusClass(status: string): string {
|
||||
return ({ Issued: 'badge-approved', Voided: 'badge-rejected' } as Record<string, string>)[status] ?? '';
|
||||
}
|
||||
|
||||
private openBlob(blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Church Profile / 教會資料</h2>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
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 { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||
import { ChurchProfileDto } from '../../models/disbursement.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-church-profile-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
||||
templateUrl: './church-profile-page.component.html',
|
||||
})
|
||||
export class ChurchProfilePageComponent implements OnInit {
|
||||
model: ChurchProfileDto | null = null;
|
||||
saving = false;
|
||||
savedMsg = '';
|
||||
|
||||
constructor(private api: DisbursementApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getChurchProfile().subscribe(p => (this.model = p));
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.model || this.saving) return;
|
||||
this.saving = true;
|
||||
this.savedMsg = '';
|
||||
const { id, ...req } = this.model;
|
||||
this.api.updateChurchProfile(req).subscribe({
|
||||
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor.
|
||||
this.saving = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<div class="page">
|
||||
<header class="page-header flex items-center justify-between">
|
||||
<h2>Disbursement Management / 支票開立</h2>
|
||||
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
|
||||
Issue Checks ({{ selectedCount }})
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="text-sm mb-3" style="color:#6b7280;">
|
||||
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
|
||||
/ 已核准待付款支出,依收款人彙整,每位收款人開立一張支票。
|
||||
</p>
|
||||
|
||||
<kendo-grid
|
||||
[kendoGridBinding]="rows"
|
||||
[loading]="loading"
|
||||
kendoGridSelectBy="key"
|
||||
[(selectedKeys)]="selectedKeys"
|
||||
[selectable]="{ checkboxOnly: true, mode: 'multiple' }"
|
||||
[sortable]="true">
|
||||
|
||||
<kendo-grid-checkbox-column [width]="44" [showSelectAll]="true"></kendo-grid-checkbox-column>
|
||||
|
||||
<kendo-grid-column field="payeeName" title="Payee / 收款人"></kendo-grid-column>
|
||||
<kendo-grid-column field="payeeType" title="Type" [width]="120"></kendo-grid-column>
|
||||
<kendo-grid-column title="# Expenses" [width]="120">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lines.length }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="totalAmount" title="Total" [width]="140" format="c2"></kendo-grid-column>
|
||||
|
||||
<ng-template kendoGridDetailTemplate let-dataItem>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
|
||||
<th class="py-1">Date</th><th>Description</th><th>Ministry</th>
|
||||
<th>Category</th><th class="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let l of dataItem.lines" style="border-bottom:1px solid #f3f4f6;">
|
||||
<td class="py-1">{{ l.expenseDate }}</td>
|
||||
<td>{{ l.description }}</td>
|
||||
<td>{{ l.ministryName }}</td>
|
||||
<td>{{ l.categoryName }}</td>
|
||||
<td class="text-right">{{ l.amount | currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-template>
|
||||
</kendo-grid>
|
||||
|
||||
<app-issue-check-dialog
|
||||
*ngIf="issueDialogGroups"
|
||||
[groups]="issueDialogGroups"
|
||||
[nextCheckNumber]="nextCheckNumber"
|
||||
(save)="onIssue($event)"
|
||||
(cancel)="issueDialogGroups = null">
|
||||
</app-issue-check-dialog>
|
||||
</div>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.page-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||
import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component';
|
||||
import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model';
|
||||
|
||||
interface PayeeRow extends PayeeGroupDto { key: string; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-disbursement-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent],
|
||||
templateUrl: './disbursement-page.component.html',
|
||||
styleUrls: ['./disbursement-page.component.scss'],
|
||||
})
|
||||
export class DisbursementPageComponent implements OnInit {
|
||||
rows: PayeeRow[] = [];
|
||||
selectedKeys: string[] = [];
|
||||
loading = false;
|
||||
nextCheckNumber = 1001;
|
||||
|
||||
issueDialogGroups: PayeeGroupDto[] | null = null;
|
||||
|
||||
constructor(private api: DisbursementApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getChurchProfile().subscribe(p => (this.nextCheckNumber = p.nextCheckNumber));
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.selectedKeys = [];
|
||||
this.api.getApprovedUnpaid().subscribe({
|
||||
next: groups => {
|
||||
this.rows = groups.map(g => ({ ...g, key: this.keyOf(g) }));
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => (this.loading = false),
|
||||
});
|
||||
}
|
||||
|
||||
private keyOf(g: PayeeGroupDto): string {
|
||||
return `${g.payeeType}:${g.payeeType === 'Member' ? g.memberId : g.vendorKey}`;
|
||||
}
|
||||
|
||||
get selectedCount(): number { return this.selectedKeys.length; }
|
||||
|
||||
openIssue(): void {
|
||||
const selected = this.rows.filter(r => this.selectedKeys.includes(r.key));
|
||||
if (selected.length === 0) return;
|
||||
this.issueDialogGroups = selected;
|
||||
}
|
||||
|
||||
onIssue(req: IssueChecksRequest): void {
|
||||
this.api.issue(req).subscribe({
|
||||
next: result => {
|
||||
this.issueDialogGroups = null;
|
||||
result.created.forEach(c => this.openPdf(c.checkId));
|
||||
this.load();
|
||||
},
|
||||
error: () => {
|
||||
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private openPdf(checkId: number): void {
|
||||
this.api.downloadCheckPdf(checkId).subscribe(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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 {
|
||||
PagedResult, PayeeGroupDto, IssueChecksRequest, IssueChecksResultDto,
|
||||
CheckListItemDto, CheckDetailDto, ChurchProfileDto, UpdateChurchProfileRequest,
|
||||
} from '../models/disbursement.model';
|
||||
|
||||
export interface CheckRegisterQuery {
|
||||
page?: number; pageSize?: number; status?: string; search?: string; from?: string; to?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DisbursementApiService {
|
||||
private readonly endpoint: string;
|
||||
private readonly profileEndpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('disbursements');
|
||||
this.profileEndpoint = apiConfig.getApiUrl('church-profile');
|
||||
}
|
||||
|
||||
private toParams(q: Record<string, unknown>): HttpParams {
|
||||
let p = new HttpParams();
|
||||
for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p = p.set(k, String(v));
|
||||
return p;
|
||||
}
|
||||
|
||||
getApprovedUnpaid(): Observable<PayeeGroupDto[]> {
|
||||
return this.http.get<PayeeGroupDto[]>(`${this.endpoint}/approved-unpaid`);
|
||||
}
|
||||
|
||||
issue(req: IssueChecksRequest): Observable<IssueChecksResultDto> {
|
||||
return this.http.post<IssueChecksResultDto>(`${this.endpoint}/issue`, req);
|
||||
}
|
||||
|
||||
getRegister(q: CheckRegisterQuery): Observable<PagedResult<CheckListItemDto>> {
|
||||
return this.http.get<PagedResult<CheckListItemDto>>(`${this.endpoint}/checks`, { params: this.toParams(q as Record<string, unknown>) });
|
||||
}
|
||||
|
||||
getCheck(id: number): Observable<CheckDetailDto> {
|
||||
return this.http.get<CheckDetailDto>(`${this.endpoint}/checks/${id}`);
|
||||
}
|
||||
|
||||
voidCheck(id: number, reason: string | null): Observable<void> {
|
||||
return this.http.post<void>(`${this.endpoint}/checks/${id}/void`, { reason });
|
||||
}
|
||||
|
||||
/** Blob fetch via HttpClient so the auth interceptor attaches the JWT (a raw window.open would 401). */
|
||||
downloadCheckPdf(id: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/checks/${id}/pdf`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/** Signed receipt PDF (check info + disbursement detail + e-signature). */
|
||||
downloadReceiptPdf(id: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/checks/${id}/receipt-pdf`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
acknowledge(id: number, signature: Blob, signedName: string): Observable<void> {
|
||||
const form = new FormData();
|
||||
form.append('signature', signature, `check-${id}-signature.png`);
|
||||
form.append('signedName', signedName);
|
||||
return this.http.post<void>(`${this.endpoint}/checks/${id}/acknowledge`, form);
|
||||
}
|
||||
|
||||
getSignature(id: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/checks/${id}/signature`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
getChurchProfile(): Observable<ChurchProfileDto> {
|
||||
return this.http.get<ChurchProfileDto>(this.profileEndpoint);
|
||||
}
|
||||
|
||||
updateChurchProfile(r: UpdateChurchProfileRequest): Observable<void> {
|
||||
return this.http.put<void>(this.profileEndpoint, r);
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -12,6 +12,7 @@ import { MemberApiService } from '../../../members/services/member-api.service';
|
||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||
import {
|
||||
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
||||
ExpenseListItemDto,
|
||||
} from '../../models/expense.model';
|
||||
|
||||
export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; }
|
||||
@@ -29,6 +30,8 @@ export class ExpenseFormDialogComponent implements OnInit {
|
||||
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||
@Input() allowMemberPick = false;
|
||||
@Input() title = 'New Expense';
|
||||
/** When set, the dialog prefills from this row for editing instead of starting blank. */
|
||||
@Input() expense: ExpenseListItemDto | null = null;
|
||||
@Output() save = new EventEmitter<ExpenseFormResult>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
|
||||
@@ -59,7 +62,30 @@ export class ExpenseFormDialogComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||
this.catApi.getAll(false).subscribe(g => (this.groups = g));
|
||||
this.catApi.getAll(false).subscribe(groups => {
|
||||
this.groups = groups;
|
||||
// Populate the sub-category list for the prefilled group so its value displays on edit.
|
||||
if (this.expense) {
|
||||
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
|
||||
}
|
||||
});
|
||||
if (this.expense) this.prefill(this.expense);
|
||||
}
|
||||
|
||||
private prefill(expense: ExpenseListItemDto): void {
|
||||
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
|
||||
const [year, month, day] = expense.expenseDate.split('-').map(Number);
|
||||
this.form = {
|
||||
ministryId: expense.ministryId,
|
||||
categoryGroupId: expense.categoryGroupId,
|
||||
subCategoryId: expense.subCategoryId,
|
||||
amount: expense.amount,
|
||||
description: expense.description,
|
||||
vendorName: expense.vendorName ?? '',
|
||||
checkNumber: expense.checkNumber ?? '',
|
||||
memberId: expense.memberId,
|
||||
expenseDate: new Date(year, month - 1, day),
|
||||
};
|
||||
}
|
||||
|
||||
onGroupChange(groupId: number | null): void {
|
||||
|
||||
+9
-15
@@ -11,21 +11,18 @@
|
||||
<h3>Groups / 組別</h3>
|
||||
<button kendoButton themeColor="primary" (click)="openNewGroup()">+ New Group</button>
|
||||
</div>
|
||||
<kendo-grid [data]="groups" [loading]="loading">
|
||||
<div class="hint-text-sm">Click a row to view its subcategories · right-click for actions</div>
|
||||
<kendo-grid class="clickable-rows" [data]="groups" [loading]="loading"
|
||||
[rowClass]="groupRowClass"
|
||||
(cellClick)="onGroupCellClick($event)">
|
||||
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
|
||||
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
|
||||
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
|
||||
<kendo-grid-column field="isActive" title="Active" [width]="70">
|
||||
<ng-template kendoGridCellTemplate let-g>{{ g.isActive ? 'Yes' : 'No' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Actions" [width]="180">
|
||||
<ng-template kendoGridCellTemplate let-g>
|
||||
<button kendoButton fillMode="flat" (click)="selectGroup(g)" [themeColor]="selectedGroup?.id === g.id ? 'primary' : 'base'">Select</button>
|
||||
<button kendoButton fillMode="flat" (click)="openEditGroup(g)">Edit</button>
|
||||
<button kendoButton fillMode="flat" *ngIf="g.isActive" (click)="deactivateGroup(g)">Deactivate</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
<kendo-contextmenu #groupMenu [items]="groupMenuItems" (select)="onGroupMenuSelect($event)"></kendo-contextmenu>
|
||||
</div>
|
||||
|
||||
<!-- Right: Subcategories of selected group -->
|
||||
@@ -35,20 +32,17 @@
|
||||
<button kendoButton themeColor="primary" [disabled]="!selectedGroup" (click)="openNewSub()">+ New Subcategory</button>
|
||||
</div>
|
||||
<div *ngIf="!selectedGroup" class="hint-text">Select a group on the left to view its subcategories.</div>
|
||||
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading">
|
||||
<div *ngIf="selectedGroup" class="hint-text-sm">Right-click a row for actions</div>
|
||||
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading"
|
||||
(cellClick)="onSubCellClick($event)">
|
||||
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
|
||||
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
|
||||
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
|
||||
<kendo-grid-column field="isActive" title="Active" [width]="70">
|
||||
<ng-template kendoGridCellTemplate let-s>{{ s.isActive ? 'Yes' : 'No' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Actions" [width]="150">
|
||||
<ng-template kendoGridCellTemplate let-s>
|
||||
<button kendoButton fillMode="flat" (click)="openEditSub(s)">Edit</button>
|
||||
<button kendoButton fillMode="flat" *ngIf="s.isActive" (click)="deactivateSub(s)">Deactivate</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
<kendo-contextmenu #subMenu [items]="subMenuItems" (select)="onSubMenuSelect($event)"></kendo-contextmenu>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+15
@@ -28,3 +28,18 @@
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hint-text-sm {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// Group grid: rows are clickable to select.
|
||||
.clickable-rows ::ng-deep .k-grid-content tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::ng-deep .k-grid .k-table-row.selected-row > td {
|
||||
background-color: rgba(0, 105, 217, 0.12);
|
||||
}
|
||||
|
||||
+55
-3
@@ -1,17 +1,18 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-expense-categories-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule],
|
||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule],
|
||||
templateUrl: './expense-categories-page.component.html',
|
||||
styleUrls: ['./expense-categories-page.component.scss'],
|
||||
})
|
||||
@@ -20,6 +21,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
||||
loading = false;
|
||||
|
||||
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
||||
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
||||
groupMenuItems: { text: string }[] = [];
|
||||
subMenuItems: { text: string }[] = [];
|
||||
private contextGroup: ExpenseCategoryGroupDto | null = null;
|
||||
private contextSub: ExpenseSubCategoryDto | null = null;
|
||||
|
||||
groupDialogOpen = false;
|
||||
editingGroupId: number | null = null;
|
||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
|
||||
@@ -47,6 +55,50 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
selectGroup(g: ExpenseCategoryGroupDto): void { this.selectedGroup = g; }
|
||||
get subCategories(): ExpenseSubCategoryDto[] { return this.selectedGroup?.subCategories ?? []; }
|
||||
|
||||
// Highlight the currently selected group row.
|
||||
groupRowClass = (context: RowClassArgs): Record<string, boolean> => {
|
||||
return { 'selected-row': this.selectedGroup?.id === context.dataItem.id };
|
||||
};
|
||||
|
||||
// Left-click selects the group (reveals its subcategories); right-click opens the actions menu.
|
||||
onGroupCellClick(event: CellClickEvent): void {
|
||||
if (event.type === 'contextmenu') {
|
||||
event.originalEvent.preventDefault();
|
||||
this.contextGroup = event.dataItem;
|
||||
this.groupMenuItems = this.buildMenuItems(event.dataItem.isActive);
|
||||
this.groupMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||
} else {
|
||||
this.selectGroup(event.dataItem);
|
||||
}
|
||||
}
|
||||
|
||||
onGroupMenuSelect(event: ContextMenuSelectEvent): void {
|
||||
if (!this.contextGroup) return;
|
||||
if (event.item.text === 'Edit') this.openEditGroup(this.contextGroup);
|
||||
else if (event.item.text === 'Deactivate') this.deactivateGroup(this.contextGroup);
|
||||
}
|
||||
|
||||
// Subcategory rows have no selection behaviour; only the right-click actions menu.
|
||||
onSubCellClick(event: CellClickEvent): void {
|
||||
if (event.type !== 'contextmenu') return;
|
||||
event.originalEvent.preventDefault();
|
||||
this.contextSub = event.dataItem;
|
||||
this.subMenuItems = this.buildMenuItems(event.dataItem.isActive);
|
||||
this.subMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||
}
|
||||
|
||||
onSubMenuSelect(event: ContextMenuSelectEvent): void {
|
||||
if (!this.contextSub) return;
|
||||
if (event.item.text === 'Edit') this.openEditSub(this.contextSub);
|
||||
else if (event.item.text === 'Deactivate') this.deactivateSub(this.contextSub);
|
||||
}
|
||||
|
||||
private buildMenuItems(isActive: boolean): { text: string }[] {
|
||||
const items: { text: string }[] = [{ text: 'Edit' }];
|
||||
if (isActive) items.push({ text: 'Deactivate' });
|
||||
return items;
|
||||
}
|
||||
|
||||
openNewGroup(): void {
|
||||
this.editingGroupId = null;
|
||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
|
||||
|
||||
@@ -7,33 +7,22 @@
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Search
|
||||
<kendo-textbox placeholder="Search description / vendor / member / check #"
|
||||
[(ngModel)]="filter.search"
|
||||
<kendo-textbox placeholder="Search description / vendor / member / check #" [(ngModel)]="filter.search"
|
||||
(keydown.enter)="applyFilter()">
|
||||
</kendo-textbox>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
Ministry
|
||||
<kendo-dropdownlist
|
||||
[data]="ministries"
|
||||
textField="label"
|
||||
valueField="id"
|
||||
[valuePrimitive]="true"
|
||||
[(ngModel)]="filter.ministryId"
|
||||
[defaultItem]="{ id: null, label: 'All Ministries/全部事工' }">
|
||||
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
|
||||
[(ngModel)]="filter.ministryId" [defaultItem]="{ id: null, label: 'All Ministries/全部事工' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
Status
|
||||
<kendo-dropdownlist
|
||||
[data]="statuses"
|
||||
textField="label"
|
||||
valueField="value"
|
||||
[valuePrimitive]="true"
|
||||
[(ngModel)]="filter.status"
|
||||
[defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
|
||||
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
|
||||
@@ -46,28 +35,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<kendo-grid
|
||||
[data]="{ data: rows, total: total }"
|
||||
[loading]="loading"
|
||||
[pageable]="true"
|
||||
[skip]="skip"
|
||||
[pageSize]="pageSize"
|
||||
(pageChange)="onPageChange($event)">
|
||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
||||
|
||||
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
|
||||
<kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column>
|
||||
<!-- <kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column> -->
|
||||
|
||||
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||
|
||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="280"></kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Category" [width]="180">
|
||||
<kendo-grid-column title="Category" [width]="360">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||
<kendo-grid-column title="Payee" [width]="150">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
{{ dataItem.vendorName || dataItem.memberName || '—' }}
|
||||
@@ -88,7 +72,7 @@
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="240">
|
||||
<kendo-grid-column title="Actions" [width]="100">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="canApproveOrReject(dataItem)">
|
||||
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
|
||||
@@ -96,30 +80,21 @@
|
||||
</ng-container>
|
||||
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
||||
(click)="openPay(dataItem)">Pay</button>
|
||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
|
||||
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
|
||||
class="receipt-link">Receipt</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Vendor Payment dialog -->
|
||||
<app-expense-form-dialog
|
||||
*ngIf="vendorDialogOpen"
|
||||
mode="vendor"
|
||||
title="Vendor Payment"
|
||||
(save)="onVendorSave($event)"
|
||||
<app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)"
|
||||
(cancel)="vendorDialogOpen = false">
|
||||
</app-expense-form-dialog>
|
||||
|
||||
<!-- Reimbursement (on behalf) dialog -->
|
||||
<app-expense-form-dialog
|
||||
*ngIf="reimbDialogOpen"
|
||||
mode="reimbursement"
|
||||
[allowMemberPick]="true"
|
||||
title="Reimbursement (on behalf)"
|
||||
(save)="onReimbSave($event)"
|
||||
(cancel)="reimbDialogOpen = false">
|
||||
<app-expense-form-dialog *ngIf="reimbDialogOpen" mode="reimbursement" [allowMemberPick]="true"
|
||||
title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false">
|
||||
</app-expense-form-dialog>
|
||||
|
||||
<!-- Mark Paid dialog -->
|
||||
@@ -154,4 +129,4 @@
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ export class ExpensesPageComponent implements OnInit {
|
||||
rejectRow: ExpenseListItemDto | null = null;
|
||||
rejectNotes = '';
|
||||
|
||||
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) {}
|
||||
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||
@@ -124,7 +124,11 @@ export class ExpensesPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; }
|
||||
canPay(row: ExpenseListItemDto): boolean { return row.status === 'Approved'; }
|
||||
canPay(row: ExpenseListItemDto): boolean {
|
||||
return false;
|
||||
// row.status === 'Approved';
|
||||
//should be pay by disbursement
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return ({
|
||||
|
||||
+4
-2
@@ -22,6 +22,7 @@
|
||||
<kendo-grid-column title="Actions" [width]="200">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<ng-container *ngIf="canEdit(dataItem)">
|
||||
<button kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
||||
<button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
||||
</ng-container>
|
||||
@@ -34,8 +35,9 @@
|
||||
<app-expense-form-dialog
|
||||
*ngIf="dialogOpen"
|
||||
mode="reimbursement"
|
||||
title="New Reimbursement"
|
||||
[expense]="editRow"
|
||||
[title]="editRow ? 'Edit Reimbursement' : 'New Reimbursement'"
|
||||
(save)="onSave($event)"
|
||||
(cancel)="dialogOpen=false">
|
||||
(cancel)="closeDialog()">
|
||||
</app-expense-form-dialog>
|
||||
</div>
|
||||
|
||||
+16
-6
@@ -18,6 +18,7 @@ export class MyReimbursementsPageComponent implements OnInit {
|
||||
rows: ExpenseListItemDto[] = [];
|
||||
loading = false;
|
||||
dialogOpen = false;
|
||||
editRow: ExpenseListItemDto | null = null;
|
||||
|
||||
constructor(private api: ExpenseApiService) {}
|
||||
|
||||
@@ -31,14 +32,23 @@ export class MyReimbursementsPageComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
openNew(): void { this.dialogOpen = true; }
|
||||
openNew(): void { this.editRow = null; this.dialogOpen = true; }
|
||||
openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; }
|
||||
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
|
||||
|
||||
onSave(result: ExpenseFormResult): void {
|
||||
this.api.create(result.request).pipe(
|
||||
switchMap(created => result.receipt
|
||||
? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created)))
|
||||
: of(created)),
|
||||
).subscribe(() => { this.dialogOpen = false; this.load(); });
|
||||
if (this.editRow) {
|
||||
const id = this.editRow.id;
|
||||
this.api.update(id, result.request).pipe(
|
||||
switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)),
|
||||
).subscribe(() => { this.closeDialog(); this.load(); });
|
||||
} else {
|
||||
this.api.create(result.request).pipe(
|
||||
switchMap(created => result.receipt
|
||||
? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created)))
|
||||
: of(created)),
|
||||
).subscribe(() => { this.closeDialog(); this.load(); });
|
||||
}
|
||||
}
|
||||
|
||||
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
|
||||
|
||||
@@ -96,7 +96,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' },
|
||||
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' },
|
||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' },
|
||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements' },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register' },
|
||||
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' },
|
||||
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile' },
|
||||
];
|
||||
|
||||
public personalNavItems: NavItem[] = [
|
||||
@@ -226,7 +229,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
'reimbursements': 'My Reimbursements',
|
||||
'finance/expenses': 'Expenses',
|
||||
'finance/expense-categories': 'Expense Categories',
|
||||
'finance/disbursements': 'Disbursement Management',
|
||||
'finance/check-register': 'Check Register',
|
||||
'finance/monthly-statement': 'Monthly Statement',
|
||||
'finance/church-profile': 'Church Profile',
|
||||
};
|
||||
return titles[page] ?? 'Dashboard';
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ export const EXPENSE_STATUS_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'Rejected', label: 'Rejected/已拒絕' },
|
||||
];
|
||||
|
||||
export const CHECK_STATUS_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'Issued', label: 'Issued/已開立' },
|
||||
{ value: 'Voided', label: 'Voided/已作廢' },
|
||||
];
|
||||
|
||||
export const MEMBER_STATUS_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'Member', label: 'Member/會友' },
|
||||
{ value: 'Visitor', label: 'Visitor/訪客' },
|
||||
|
||||
Reference in New Issue
Block a user