+82
-15
@@ -21,6 +21,8 @@ import { CheckRegisterPageComponent } from './features/disbursement/pages/check-
|
||||
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Public routes
|
||||
@@ -39,85 +41,150 @@ export const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: DashboardComponent,
|
||||
data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' },
|
||||
},
|
||||
{
|
||||
path: 'admin/members',
|
||||
component: MembersPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Members, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Members, action: 'read' },
|
||||
title: 'Member Management', titleZh: '會友管理', section: 'Admin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
component: UsersPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Users, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Users, action: 'read' },
|
||||
title: 'User Management', titleZh: '使用者管理', section: 'Admin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/permissions',
|
||||
component: PermissionsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Permissions, action: 'read' },
|
||||
title: 'Role Permissions', titleZh: '權限設定', section: 'Admin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/logs/system',
|
||||
component: SystemLogsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permission: { module: PermissionModules.SystemLogs, action: 'read' },
|
||||
title: 'System Logs', titleZh: '系統日誌', section: 'Admin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/logs/audit',
|
||||
component: AuditLogsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permission: { module: PermissionModules.AuditLogs, action: 'read' },
|
||||
title: 'Audit Logs', titleZh: '稽核日誌', section: 'Admin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/dashboard',
|
||||
component: FinanceDashboardPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.FinanceDashboard, action: 'read' },
|
||||
title: 'Finance Dashboard', titleZh: '財務儀表板', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/giving-categories',
|
||||
component: GivingCategoriesPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.GivingCategories, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.GivingCategories, action: 'read' },
|
||||
title: 'Giving Types', titleZh: '奉獻類型', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/givings',
|
||||
component: GivingsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Givings, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Givings, action: 'read' },
|
||||
title: 'Givings', titleZh: '單筆奉獻', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/offering-session',
|
||||
component: OfferingSessionPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.OfferingSessions, action: 'read' },
|
||||
title: 'Sunday Offering Entry', titleZh: '主日奉獻錄入', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'reimbursements',
|
||||
component: MyReimbursementsPageComponent,
|
||||
data: { title: 'My Reimbursements', titleZh: '我的報銷', section: 'Finance' },
|
||||
},
|
||||
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
|
||||
{
|
||||
path: 'finance/expenses',
|
||||
component: ExpensesPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Expenses, action: 'read' },
|
||||
title: 'Expenses', titleZh: '支出', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/expense-categories',
|
||||
component: ExpenseCategoriesPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.ExpenseCategories, action: 'read' },
|
||||
title: 'Expense Categories', titleZh: '費用類別', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/monthly-statement',
|
||||
component: MonthlyStatementPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.MonthlyStatements, action: 'read' },
|
||||
title: 'Monthly Statement', titleZh: '月報表', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/disbursements',
|
||||
component: DisbursementPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' },
|
||||
title: 'Disbursement Management', titleZh: '支票開立', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/check-register',
|
||||
component: CheckRegisterPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' },
|
||||
title: 'Check Register', titleZh: '支票登記簿', section: 'Finance',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'finance/church-profile',
|
||||
component: ChurchProfilePageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.ChurchProfile, action: 'read' } },
|
||||
data: {
|
||||
permission: { module: PermissionModules.ChurchProfile, action: 'read' },
|
||||
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -29,6 +29,8 @@ export const PermissionModules = {
|
||||
Disbursements: 'Disbursements',
|
||||
MealAttendance: 'MealAttendance',
|
||||
Permissions: 'Permissions',
|
||||
SystemLogs: 'SystemLogs',
|
||||
AuditLogs: 'AuditLogs',
|
||||
} as const;
|
||||
|
||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||
|
||||
-4
@@ -1,8 +1,4 @@
|
||||
<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
|
||||
|
||||
-4
@@ -1,8 +1,4 @@
|
||||
<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">
|
||||
|
||||
+2
-3
@@ -1,10 +1,9 @@
|
||||
<div class="page">
|
||||
<header class="page-header flex items-center justify-between">
|
||||
<h2>Disbursement Management / 支票開立</h2>
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
|
||||
Issue Checks ({{ selectedCount }})
|
||||
</button>
|
||||
</header>
|
||||
</ng-template>
|
||||
|
||||
<p class="text-sm mb-3" style="color:#6b7280;">
|
||||
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
.page-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
+2
-1
@@ -6,13 +6,14 @@ 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';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
interface PayeeRow extends PayeeGroupDto { key: string; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-disbursement-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent],
|
||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent, PageHeaderActionsDirective],
|
||||
templateUrl: './disbursement-page.component.html',
|
||||
styleUrls: ['./disbursement-page.component.scss'],
|
||||
})
|
||||
|
||||
-4
@@ -1,8 +1,4 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Expense Categories / 費用類別</h2>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Left: Category Groups -->
|
||||
|
||||
-7
@@ -1,10 +1,3 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Expenses</h2>
|
||||
</header>
|
||||
|
||||
<!-- Filter toolbar -->
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
|
||||
-4
@@ -1,8 +1,4 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Monthly Statements</h2>
|
||||
</header>
|
||||
|
||||
<!-- Filter toolbar -->
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>My Reimbursements</h2>
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
|
||||
</header>
|
||||
</ng-template>
|
||||
|
||||
<kendo-grid [data]="rows" [loading]="loading">
|
||||
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||
|
||||
+2
-1
@@ -6,11 +6,12 @@ import { ExpenseApiService } from '../../services/expense-api.service';
|
||||
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
|
||||
import { ExpenseListItemDto } from '../../models/expense.model';
|
||||
import { switchMap, of } from 'rxjs';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-reimbursements-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent],
|
||||
imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent, PageHeaderActionsDirective],
|
||||
templateUrl: './my-reimbursements-page.component.html',
|
||||
styleUrls: ['./my-reimbursements-page.component.scss'],
|
||||
})
|
||||
|
||||
+14
-22
@@ -1,11 +1,19 @@
|
||||
<div class="fin">
|
||||
<!-- Page header -->
|
||||
<header class="fin__head">
|
||||
<div>
|
||||
<span class="fin__eyebrow">River of Life · Finance</span>
|
||||
<h1 class="fin__title">Finance Dashboard <span>財務儀表板</span></h1>
|
||||
<!-- Range filter — projected into the shared system header's right slot -->
|
||||
<ng-template appPageHeaderActions>
|
||||
<div class="chips">
|
||||
<button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
|
||||
<button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
|
||||
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="range">
|
||||
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
|
||||
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
|
||||
<span class="range__sep">→</span>
|
||||
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
|
||||
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Top band: hero balance + supporting stats (all-time) -->
|
||||
<section class="fin__band rise" style="--d: 0ms">
|
||||
@@ -36,22 +44,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Range filter -->
|
||||
<section class="fin__filter rise" style="--d: 80ms">
|
||||
<div class="chips">
|
||||
<button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
|
||||
<button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
|
||||
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
|
||||
</div>
|
||||
<div class="range">
|
||||
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
|
||||
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
|
||||
<span class="range__sep">→</span>
|
||||
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
|
||||
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Analytics grid -->
|
||||
<section class="fin__grid">
|
||||
<!-- 2.1 Income vs Expense -->
|
||||
|
||||
+1
-43
@@ -21,34 +21,6 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.fin__head {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.fin__eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--kendo-color-primary, #0279cf);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.fin__title {
|
||||
font-size: clamp(26px, 3.2vw, 36px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
|
||||
span {
|
||||
font-weight: 400;
|
||||
color: var(--ink-soft);
|
||||
margin-left: 8px;
|
||||
font-size: 0.62em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Top band ---------- */
|
||||
.fin__band {
|
||||
display: grid;
|
||||
@@ -174,21 +146,7 @@
|
||||
.stat--income .stat__value { color: var(--income); }
|
||||
.stat--expense .stat__value { color: var(--expense); }
|
||||
|
||||
/* ---------- Filter ---------- */
|
||||
.fin__filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* ---------- Filter (projected into the system header's actions slot) ---------- */
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.chip {
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import { ExpenseApiService } from '../../../expense/services/expense-api.service
|
||||
import { ExpenseListItemDto } from '../../../expense/models/expense.model';
|
||||
import { FinanceDashboardApiService } from '../../services/finance-dashboard-api.service';
|
||||
import { FinanceSummaryDto, PieSlice, DrillLevel, Crumb } from '../../models/finance-dashboard.model';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
// Expense scope for the dashboard: Paid + Approved (shared by the pies and the detail grid).
|
||||
const DASHBOARD_STATUSES = 'Paid,Approved';
|
||||
@@ -18,7 +19,7 @@ type QuickRange = 'month' | 'lastMonth' | 'year' | null;
|
||||
@Component({
|
||||
selector: 'app-finance-dashboard-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ChartsModule, DateInputsModule, GridModule, ButtonsModule],
|
||||
imports: [CommonModule, FormsModule, ChartsModule, DateInputsModule, GridModule, ButtonsModule, PageHeaderActionsDirective],
|
||||
templateUrl: './finance-dashboard-page.component.html',
|
||||
styleUrls: ['./finance-dashboard-page.component.scss'],
|
||||
})
|
||||
|
||||
+6
-9
@@ -1,13 +1,10 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Giving Types / 奉獻類型</h2>
|
||||
<div class="header-actions">
|
||||
<label class="inactive-toggle">
|
||||
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
|
||||
</label>
|
||||
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
|
||||
</div>
|
||||
</header>
|
||||
<ng-template appPageHeaderActions>
|
||||
<label class="inactive-toggle">
|
||||
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
|
||||
</label>
|
||||
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
|
||||
</ng-template>
|
||||
|
||||
<kendo-grid [data]="data" [loading]="isLoading">
|
||||
<kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column>
|
||||
|
||||
-7
@@ -1,10 +1,3 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+2
-1
@@ -9,11 +9,12 @@ import { GivingCategoryApiService } from '../../services/giving-category-api.ser
|
||||
import {
|
||||
GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest,
|
||||
} from '../../models/giving.model';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-giving-categories-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule],
|
||||
imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule, PageHeaderActionsDirective],
|
||||
templateUrl: './giving-categories-page.component.html',
|
||||
styleUrls: ['./giving-categories-page.component.scss'],
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h2>Givings / 單筆奉獻</h2>
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add Giving</button>
|
||||
</header>
|
||||
</ng-template>
|
||||
|
||||
<div class="filters">
|
||||
<kendo-textbox placeholder="Search name / check # / notes" [(ngModel)]="search" (keydown.enter)="onSearch()"></kendo-textbox>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult,
|
||||
} from '../../models/giving.model';
|
||||
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
/** Flattened member item with a single displayName field for the dropdown. */
|
||||
interface MemberOption {
|
||||
@@ -27,7 +28,7 @@ interface MemberOption {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
|
||||
DropDownsModule, DialogsModule, DateInputsModule,
|
||||
DropDownsModule, DialogsModule, DateInputsModule, PageHeaderActionsDirective,
|
||||
],
|
||||
templateUrl: './givings-page.component.html',
|
||||
styleUrls: ['./givings-page.component.scss'],
|
||||
|
||||
-6
@@ -1,10 +1,4 @@
|
||||
<div class="off">
|
||||
<!-- Header (always) -->
|
||||
<header class="off__head">
|
||||
<span class="off__eyebrow">River of Life · Offering</span>
|
||||
<h1 class="off__title">Sunday Offering Entry <span>主日奉獻錄入</span></h1>
|
||||
</header>
|
||||
|
||||
<!-- ============================ LANDING ============================ -->
|
||||
<ng-container *ngIf="mode === 'landing'">
|
||||
<!-- Start card -->
|
||||
|
||||
-27
@@ -21,33 +21,6 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.off__head { margin-bottom: 22px; }
|
||||
|
||||
.off__eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--kendo-color-primary, #0279cf);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.off__title {
|
||||
font-size: clamp(26px, 3.2vw, 36px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
|
||||
span {
|
||||
font-weight: 400;
|
||||
color: var(--ink-soft);
|
||||
margin-left: 8px;
|
||||
font-size: 0.62em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Card ---------- */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Mirrors the C# DTOs.Shared.PagedResult<T>. */
|
||||
export interface PagedResult<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ── System logs ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SystemLogListItem {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
category: string;
|
||||
message: string;
|
||||
hasException: boolean;
|
||||
statusCode?: number | null;
|
||||
requestPath?: string | null;
|
||||
httpMethod?: string | null;
|
||||
userId?: string | null;
|
||||
correlationId?: string | null;
|
||||
}
|
||||
|
||||
export interface SystemLogDetail extends SystemLogListItem {
|
||||
eventId?: number | null;
|
||||
exception?: string | null;
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
|
||||
export interface SystemLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
minLevel?: string;
|
||||
level?: string;
|
||||
search?: string;
|
||||
userId?: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
// ── Audit logs ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AuditLogListItem {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
action: string;
|
||||
category: string;
|
||||
entityName?: string | null;
|
||||
entityId?: string | null;
|
||||
summary?: string | null;
|
||||
userId?: string | null;
|
||||
userEmail?: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogDetail extends AuditLogListItem {
|
||||
changes?: string | null;
|
||||
ipAddress?: string | null;
|
||||
correlationId?: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
category?: string;
|
||||
action?: string;
|
||||
entityName?: string;
|
||||
entityId?: string;
|
||||
userId?: string;
|
||||
minLevel?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface AuditCatalog {
|
||||
categories: string[];
|
||||
actions: string[];
|
||||
levels: string[];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<div class="page">
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Search / 搜尋
|
||||
<kendo-textbox placeholder="Summary / entity / user" [(ngModel)]="search"
|
||||
(keydown.enter)="applyFilter()"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Category / 類別
|
||||
<kendo-dropdownlist [data]="categories" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="category" [defaultItem]="{ value: null, label: 'All Categories/全部類別' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Action / 動作
|
||||
<kendo-dropdownlist [data]="actions" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="action" [defaultItem]="{ value: null, label: 'All Actions/全部動作' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
From / 起
|
||||
<kendo-datepicker [(ngModel)]="from"></kendo-datepicker>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
To / 迄
|
||||
<kendo-datepicker [(ngModel)]="to"></kendo-datepicker>
|
||||
</label>
|
||||
<button kendoButton themeColor="primary" (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 title="Time / 時間" [width]="170">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.timestamp | date:'short' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Level / 等級" [width]="100">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="levelClass(dataItem.level)">{{ dataItem.level }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="category" title="Category / 類別" [width]="120"></kendo-grid-column>
|
||||
<kendo-grid-column field="action" title="Action / 動作" [width]="140"></kendo-grid-column>
|
||||
<kendo-grid-column title="Entity / 對象" [width]="160">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span *ngIf="dataItem.entityName">{{ dataItem.entityName }}<span *ngIf="dataItem.entityId"> #{{ dataItem.entityId }}</span></span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="summary" title="Summary / 摘要"></kendo-grid-column>
|
||||
<kendo-grid-column title="User / 使用者" [width]="180">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.userEmail || dataItem.userId }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="" [width]="90">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="view(dataItem)">View</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="Audit Log #{{ detail.id }}" [width]="720" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2 text-sm">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
|
||||
<div><strong>Level:</strong> <span [class]="levelClass(detail.level)">{{ detail.level }}</span></div>
|
||||
<div><strong>Category:</strong> {{ detail.category }}</div>
|
||||
<div><strong>Action:</strong> {{ detail.action }}</div>
|
||||
<div *ngIf="detail.entityName"><strong>Entity:</strong> {{ detail.entityName }} <span *ngIf="detail.entityId">#{{ detail.entityId }}</span></div>
|
||||
<div *ngIf="detail.userEmail || detail.userId"><strong>User:</strong> {{ detail.userEmail || detail.userId }}</div>
|
||||
<div *ngIf="detail.ipAddress"><strong>IP:</strong> {{ detail.ipAddress }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.summary"><strong>Summary:</strong> {{ detail.summary }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.correlationId"><strong>Correlation:</strong> {{ detail.correlationId }}</div>
|
||||
</div>
|
||||
<ng-container *ngIf="detailChanges">
|
||||
<div><strong>Changes (before → after)</strong></div>
|
||||
<pre class="detail-block">{{ detailChanges }}</pre>
|
||||
</ng-container>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="detail = null">Close / 關閉</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
.page { padding: 0; }
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
min-width: 78px;
|
||||
text-align: center;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.log-level--trace,
|
||||
.log-level--debug { background: #9ca3af; }
|
||||
.log-level--information { background: #2563eb; }
|
||||
.log-level--warning { background: #d97706; }
|
||||
.log-level--error { background: #dc2626; }
|
||||
.log-level--critical { background: #7f1d1d; }
|
||||
|
||||
.detail-block {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { AUDIT_CATEGORY_OPTIONS, LOG_LEVEL_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { LoggingApiService } from '../../services/logging-api.service';
|
||||
import { AuditLogListItem, AuditLogDetail, AuditLogQuery } from '../../models/logging.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-logs-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule,
|
||||
DropDownsModule, InputsModule, DateInputsModule, DialogsModule,
|
||||
],
|
||||
templateUrl: './audit-logs-page.component.html',
|
||||
styleUrls: ['./audit-logs-page.component.scss'],
|
||||
})
|
||||
export class AuditLogsPageComponent implements OnInit {
|
||||
rows: AuditLogListItem[] = [];
|
||||
total = 0;
|
||||
page = 1;
|
||||
pageSize = 20;
|
||||
loading = false;
|
||||
|
||||
readonly categories = AUDIT_CATEGORY_OPTIONS;
|
||||
readonly levels = LOG_LEVEL_OPTIONS;
|
||||
actions: { value: string; label: string }[] = [];
|
||||
|
||||
category: string | null = null;
|
||||
action: string | null = null;
|
||||
search = '';
|
||||
from: Date | null = null;
|
||||
to: Date | null = null;
|
||||
|
||||
detail: AuditLogDetail | null = null;
|
||||
detailChanges = '';
|
||||
|
||||
constructor(private api: LoggingApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getAuditCatalog().subscribe(c => {
|
||||
this.actions = c.actions.map(a => ({ value: a, label: a }));
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
const query: AuditLogQuery = {
|
||||
page: this.page,
|
||||
pageSize: this.pageSize,
|
||||
category: this.category ?? undefined,
|
||||
action: this.action ?? undefined,
|
||||
search: this.search || undefined,
|
||||
from: this.toLocalDate(this.from),
|
||||
to: this.toLocalDate(this.to),
|
||||
};
|
||||
this.api.getAuditLogs(query).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: AuditLogListItem): void {
|
||||
this.api.getAuditLog(row.id).subscribe(d => {
|
||||
this.detail = d;
|
||||
this.detailChanges = this.prettyJson(d.changes);
|
||||
});
|
||||
}
|
||||
|
||||
levelClass(level: string): string { return 'log-level log-level--' + level.toLowerCase(); }
|
||||
|
||||
/** Pretty-print the stored Changes JSON; fall back to the raw string if it isn't JSON. */
|
||||
private prettyJson(raw: string | null | undefined): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
private toLocalDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<div class="page">
|
||||
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Search / 搜尋
|
||||
<kendo-textbox placeholder="Message / category" [(ngModel)]="search"
|
||||
(keydown.enter)="applyFilter()"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Min Level / 最低等級
|
||||
<kendo-dropdownlist [data]="levels" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="minLevel" [defaultItem]="{ value: null, label: 'All Levels/全部等級' }">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
From / 起
|
||||
<kendo-datepicker [(ngModel)]="from"></kendo-datepicker>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
To / 迄
|
||||
<kendo-datepicker [(ngModel)]="to"></kendo-datepicker>
|
||||
</label>
|
||||
<button kendoButton themeColor="primary" (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 title="Time / 時間" [width]="170">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.timestamp | date:'short' }}</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Level / 等級" [width]="110">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span [class]="levelClass(dataItem.level)">{{ dataItem.level }}</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="category" title="Source / 來源" [width]="240"></kendo-grid-column>
|
||||
<kendo-grid-column title="Message / 訊息">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<span class="msg">{{ dataItem.message }}</span>
|
||||
<span *ngIf="dataItem.hasException" class="exc-flag" title="Has exception">⚠</span>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="statusCode" title="Status" [width]="90"></kendo-grid-column>
|
||||
<kendo-grid-column title="" [width]="90">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="view(dataItem)">View</button>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<kendo-dialog *ngIf="detail" title="System Log #{{ detail.id }}" [width]="720" (close)="detail = null">
|
||||
<div class="p-2 flex flex-col gap-2 text-sm">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
|
||||
<div><strong>Level:</strong> <span [class]="levelClass(detail.level)">{{ detail.level }}</span></div>
|
||||
<div class="col-span-2"><strong>Source:</strong> {{ detail.category }}</div>
|
||||
<div *ngIf="detail.httpMethod"><strong>Request:</strong> {{ detail.httpMethod }} {{ detail.requestPath }}</div>
|
||||
<div *ngIf="detail.statusCode"><strong>Status:</strong> {{ detail.statusCode }}</div>
|
||||
<div *ngIf="detail.userId"><strong>User:</strong> {{ detail.userId }}</div>
|
||||
<div *ngIf="detail.ipAddress"><strong>IP:</strong> {{ detail.ipAddress }}</div>
|
||||
<div class="col-span-2" *ngIf="detail.correlationId"><strong>Correlation:</strong> {{ detail.correlationId }}</div>
|
||||
</div>
|
||||
<div><strong>Message</strong></div>
|
||||
<pre class="detail-block">{{ detail.message }}</pre>
|
||||
<ng-container *ngIf="detail.exception">
|
||||
<div><strong>Exception / Stack Trace</strong></div>
|
||||
<pre class="detail-block detail-block--exc">{{ detail.exception }}</pre>
|
||||
</ng-container>
|
||||
</div>
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="detail = null">Close / 關閉</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
.page { padding: 0; }
|
||||
|
||||
.msg {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.exc-flag {
|
||||
margin-left: 0.25rem;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
min-width: 78px;
|
||||
text-align: center;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.log-level--trace,
|
||||
.log-level--debug { background: #9ca3af; }
|
||||
.log-level--information { background: #2563eb; }
|
||||
.log-level--warning { background: #d97706; }
|
||||
.log-level--error { background: #dc2626; }
|
||||
.log-level--critical { background: #7f1d1d; }
|
||||
|
||||
.detail-block {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.detail-block--exc {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { LOG_LEVEL_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { LoggingApiService } from '../../services/logging-api.service';
|
||||
import { SystemLogListItem, SystemLogDetail, SystemLogQuery } from '../../models/logging.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-system-logs-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule,
|
||||
DropDownsModule, InputsModule, DateInputsModule, DialogsModule,
|
||||
],
|
||||
templateUrl: './system-logs-page.component.html',
|
||||
styleUrls: ['./system-logs-page.component.scss'],
|
||||
})
|
||||
export class SystemLogsPageComponent implements OnInit {
|
||||
rows: SystemLogListItem[] = [];
|
||||
total = 0;
|
||||
page = 1;
|
||||
pageSize = 20;
|
||||
loading = false;
|
||||
|
||||
readonly levels = LOG_LEVEL_OPTIONS;
|
||||
|
||||
// UI-bound filter state; dates are Date objects converted to yyyy-MM-dd on send.
|
||||
minLevel: string | null = null;
|
||||
search = '';
|
||||
from: Date | null = null;
|
||||
to: Date | null = null;
|
||||
|
||||
detail: SystemLogDetail | null = null;
|
||||
|
||||
constructor(private api: LoggingApiService) {}
|
||||
|
||||
ngOnInit(): void { this.load(); }
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
const query: SystemLogQuery = {
|
||||
page: this.page,
|
||||
pageSize: this.pageSize,
|
||||
minLevel: this.minLevel ?? undefined,
|
||||
search: this.search || undefined,
|
||||
from: this.toLocalDate(this.from),
|
||||
to: this.toLocalDate(this.to),
|
||||
};
|
||||
this.api.getSystemLogs(query).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: SystemLogListItem): void {
|
||||
this.api.getSystemLog(row.id).subscribe(d => (this.detail = d));
|
||||
}
|
||||
|
||||
levelClass(level: string): string {
|
||||
return 'log-level log-level--' + level.toLowerCase();
|
||||
}
|
||||
|
||||
/** Local yyyy-MM-dd (never toISOString — that shifts the day by timezone). */
|
||||
private toLocalDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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, SystemLogListItem, SystemLogDetail, SystemLogQuery,
|
||||
AuditLogListItem, AuditLogDetail, AuditLogQuery, AuditCatalog,
|
||||
} from '../models/logging.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LoggingApiService {
|
||||
private readonly systemEndpoint: string;
|
||||
private readonly auditEndpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.systemEndpoint = apiConfig.getApiUrl('system-logs');
|
||||
this.auditEndpoint = apiConfig.getApiUrl('audit-logs');
|
||||
}
|
||||
|
||||
private toParams(q: Record<string, unknown>): HttpParams {
|
||||
let p = new HttpParams();
|
||||
for (const [key, value] of Object.entries(q)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
p = p.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── System logs ─────────────────────────────────────────────────────────────
|
||||
getSystemLogs(query: SystemLogQuery): Observable<PagedResult<SystemLogListItem>> {
|
||||
return this.http.get<PagedResult<SystemLogListItem>>(
|
||||
this.systemEndpoint, { params: this.toParams(query as Record<string, unknown>) });
|
||||
}
|
||||
|
||||
getSystemLog(id: number): Observable<SystemLogDetail> {
|
||||
return this.http.get<SystemLogDetail>(`${this.systemEndpoint}/${id}`);
|
||||
}
|
||||
|
||||
// ── Audit logs ──────────────────────────────────────────────────────────────
|
||||
getAuditLogs(query: AuditLogQuery): Observable<PagedResult<AuditLogListItem>> {
|
||||
return this.http.get<PagedResult<AuditLogListItem>>(
|
||||
this.auditEndpoint, { params: this.toParams(query as Record<string, unknown>) });
|
||||
}
|
||||
|
||||
getAuditLog(id: number): Observable<AuditLogDetail> {
|
||||
return this.http.get<AuditLogDetail>(`${this.auditEndpoint}/${id}`);
|
||||
}
|
||||
|
||||
getAuditCatalog(): Observable<AuditCatalog> {
|
||||
return this.http.get<AuditCatalog>(`${this.auditEndpoint}/catalog`);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
<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>
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary" (click)="openAddDialog()">+ Add Member</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="k-d-flex k-gap-3 k-mb-4">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
PagedResult, memberDisplayName
|
||||
} from '../../models/member.model';
|
||||
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-members-page',
|
||||
@@ -21,7 +22,7 @@ import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, InputsModule,
|
||||
ButtonsModule, IndicatorsModule, DropDownsModule,
|
||||
MemberFormDialogComponent, CreateUserDialogComponent,
|
||||
MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective,
|
||||
],
|
||||
templateUrl: './members-page.component.html',
|
||||
styleUrls: ['./members-page.component.scss'],
|
||||
|
||||
+2
-3
@@ -1,12 +1,11 @@
|
||||
<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">Role Permissions</h2>
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary"
|
||||
[disabled]="!selectedRole || isSuperAdminSelected || isSaving"
|
||||
(click)="save()">
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<p class="k-mb-4" style="color:#666">
|
||||
Choose a role, then grant Read / Write / Delete / Approve per module. Changes apply
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
PermissionMatrixDto,
|
||||
RolePermissionRow,
|
||||
} from '../../../../core/models/permission.model';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-permissions-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, IndicatorsModule,
|
||||
PageHeaderActionsDirective,
|
||||
],
|
||||
templateUrl: './permissions-page.component.html',
|
||||
styleUrls: ['./permissions-page.component.scss'],
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<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 class="k-d-flex k-gap-2">
|
||||
<button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button>
|
||||
<button kendoButton (click)="testAuth()">Test Auth</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template appPageHeaderActions>
|
||||
<button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button>
|
||||
<button kendoButton (click)="testAuth()">Test Auth</button>
|
||||
</ng-template>
|
||||
|
||||
<!-- Auth test result (dev only) -->
|
||||
<div *ngIf="authTestResult" class="k-mb-3 k-p-2" style="background:#f0f4ff;border-radius:4px;font-size:12px">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UserListItemDto, UserDto, CreateUserRequest, UpdateUserRequest, PagedResult
|
||||
} from '../../models/user.model';
|
||||
import { ApiConfigService } from '../../../../core/services/api-config.service';
|
||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-page',
|
||||
@@ -20,6 +21,7 @@ import { ApiConfigService } from '../../../../core/services/api-config.service';
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, InputsModule,
|
||||
ButtonsModule, IndicatorsModule, EditUserDialogComponent, CreateUserDialogComponent,
|
||||
PageHeaderActionsDirective,
|
||||
],
|
||||
templateUrl: './users-page.component.html',
|
||||
styleUrls: ['./users-page.component.scss'],
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
||||
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection || showLogsAdminSection">
|
||||
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
||||
<ng-container *ngFor="let item of memberAdminNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||
@@ -89,6 +89,15 @@
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let item of logsAdminNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="nav-section" *ngIf="showFinanceSection">
|
||||
@@ -163,7 +172,7 @@
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main [class]="mainContentClass">
|
||||
<!-- Top Header -->
|
||||
<!-- Unified system header (shared by every child page) -->
|
||||
<header class="top-header">
|
||||
<div class="header-left">
|
||||
<button class="mobile-menu-btn" (click)="toggleSidebar()" *ngIf="isMobile" title="Toggle menu"
|
||||
@@ -174,29 +183,17 @@
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb-item">{{ currentPageTitle }}</span>
|
||||
<div class="app-header">
|
||||
<span class="app-header__eyebrow">River of Life · {{ currentSection }}</span>
|
||||
<h1 class="app-header__title">
|
||||
{{ currentPageTitle }}
|
||||
<span *ngIf="currentPageTitleZh">{{ currentPageTitleZh }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-actions">
|
||||
<!-- <button class="action-btn" title="Notifications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
</svg>
|
||||
<div class="notification-badge" *ngIf="unreadNotifications > 0">{{ unreadNotifications }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="action-btn" title="Search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
</button> -->
|
||||
</div>
|
||||
<div class="header-right app-header__actions">
|
||||
<ng-container *ngTemplateOutlet="pageHeader.actions()"></ng-container>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -483,11 +483,12 @@
|
||||
}
|
||||
|
||||
.top-header {
|
||||
padding: 1.5rem 2rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
@@ -495,6 +496,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
@@ -518,14 +520,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
.breadcrumb-item {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
||||
import { ActivatedRoute, Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IconsModule } from '@progress/kendo-angular-icons';
|
||||
import {
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
lockIcon,
|
||||
} from '@progress/kendo-svg-icons';
|
||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||
import { PageHeaderService } from '../../shared/services/page-header.service';
|
||||
import { PermissionService } from '../../core/services/permission.service';
|
||||
import { PermissionAction, PermissionModules } from '../../core/models/permission.model';
|
||||
import { Subject, takeUntil, filter } from 'rxjs';
|
||||
@@ -61,6 +62,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
isMobile = false;
|
||||
currentUser: UserInfo | null = null;
|
||||
currentPageTitle = 'Dashboard';
|
||||
currentPageTitleZh = '';
|
||||
currentSection = 'Home';
|
||||
|
||||
public searchQuery = '';
|
||||
|
||||
@@ -86,6 +89,13 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||
];
|
||||
|
||||
public logsAdminNavItems: NavItem[] = [
|
||||
{ text: 'System Logs', icon: fileReportIcon, path: '/user-portal/admin/logs/system',
|
||||
permission: { module: PermissionModules.SystemLogs, action: 'read' } },
|
||||
{ text: 'Audit Logs', icon: fileReportIcon, path: '/user-portal/admin/logs/audit',
|
||||
permission: { module: PermissionModules.AuditLogs, action: 'read' } },
|
||||
];
|
||||
|
||||
public financeGroups: NavGroup[] = [
|
||||
{
|
||||
text: 'Overview',
|
||||
@@ -139,6 +149,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
|
||||
public showMemberAdminSection = false;
|
||||
public showUserAdminSection = false;
|
||||
public showLogsAdminSection = false;
|
||||
public showFinanceSection = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -146,7 +157,9 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private permissions: PermissionService,
|
||||
private router: Router
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
public pageHeader: PageHeaderService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -183,6 +196,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
// Section visibility is derived from effective permissions (super_admin → all).
|
||||
this.showMemberAdminSection = this.memberAdminNavItems.some(item => this.canShow(item));
|
||||
this.showUserAdminSection = this.userAdminNavItems.some(item => this.canShow(item));
|
||||
this.showLogsAdminSection = this.logsAdminNavItems.some(item => this.canShow(item));
|
||||
this.showFinanceSection = this.financeGroups
|
||||
.some(group => group.items.some(item => this.canShow(item)));
|
||||
});
|
||||
@@ -223,6 +237,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
...this.mainNavItems,
|
||||
...this.memberAdminNavItems,
|
||||
...this.userAdminNavItems,
|
||||
...this.logsAdminNavItems,
|
||||
...financeItems,
|
||||
...this.personalNavItems,
|
||||
];
|
||||
@@ -273,49 +288,20 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified header reads its title/subtitle/section from the active route's
|
||||
* `data` (see app.routes.ts). Walk to the deepest activated child so nested
|
||||
* routes win, and fall back gracefully when a route carries no metadata.
|
||||
*/
|
||||
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',
|
||||
'schedule': 'Schedule',
|
||||
'patients': 'Patients',
|
||||
'bed-management': 'Bed Management',
|
||||
'staff': 'Staff',
|
||||
'pharmacy': 'Pharmacy',
|
||||
'reports': 'Reports',
|
||||
'departments': 'Departments',
|
||||
'payments': 'Payments',
|
||||
'support': 'Support',
|
||||
'transactions': 'Escrow Transactions',
|
||||
'tasks': 'Tasks & Todos',
|
||||
'contacts': 'Contacts',
|
||||
'documents': 'Documents',
|
||||
'messages': 'Messages',
|
||||
'settings': 'Settings',
|
||||
'admin/members': 'Member Management',
|
||||
'admin/users': 'User Management',
|
||||
'admin/permissions': 'Role Permissions',
|
||||
'finance/dashboard': 'Finance Dashboard',
|
||||
'finance/offering-session': 'Sunday Offering Entry',
|
||||
'finance/givings': 'Givings',
|
||||
'finance/giving-categories': 'Giving Types',
|
||||
'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';
|
||||
let route = this.activatedRoute;
|
||||
while (route.firstChild) {
|
||||
route = route.firstChild;
|
||||
}
|
||||
const data = route.snapshot.data;
|
||||
this.currentPageTitle = data['title'] ?? 'Dashboard';
|
||||
this.currentPageTitleZh = data['titleZh'] ?? '';
|
||||
this.currentSection = data['section'] ?? 'Home';
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Directive, OnDestroy, OnInit, TemplateRef } from '@angular/core';
|
||||
import { PageHeaderService } from '../services/page-header.service';
|
||||
|
||||
/**
|
||||
* Place on an <ng-template> to project that page's action controls into the
|
||||
* shared system header's right-side slot (rendered by UserPortalComponent):
|
||||
*
|
||||
* <ng-template appPageHeaderActions>
|
||||
* <button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
|
||||
* </ng-template>
|
||||
*
|
||||
* The directive publishes the template on init and clears it on destroy, so a
|
||||
* page's controls never leak to the next page.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appPageHeaderActions]',
|
||||
standalone: true,
|
||||
})
|
||||
export class PageHeaderActionsDirective implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private template: TemplateRef<unknown>,
|
||||
private pageHeader: PageHeaderService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Defer one microtask: the shell consumes the actions signal in a view that
|
||||
// has already been checked by the time this child directive initializes, so
|
||||
// setting it synchronously would trip ExpressionChangedAfterItHasBeenChecked.
|
||||
queueMicrotask(() => this.pageHeader.setActions(this.template));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageHeader.clear();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,23 @@ export const LANGUAGE_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'zh-TW', label: '中文/Chinese' },
|
||||
];
|
||||
|
||||
// Log severities — value must match the C# LogLevelEnum names exactly.
|
||||
export const LOG_LEVEL_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'Trace', label: 'Trace/追蹤' },
|
||||
{ value: 'Debug', label: 'Debug/除錯' },
|
||||
{ value: 'Information', label: 'Information/資訊' },
|
||||
{ value: 'Warning', label: 'Warning/警告' },
|
||||
{ value: 'Error', label: 'Error/錯誤' },
|
||||
{ value: 'Critical', label: 'Critical/嚴重' },
|
||||
];
|
||||
|
||||
// Audit categories — value must match the C# AuditCategories names exactly.
|
||||
export const AUDIT_CATEGORY_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'DataChange', label: 'Data Change/資料異動' },
|
||||
{ value: 'Security', label: 'Security/安全性' },
|
||||
{ value: 'Business', label: 'Business/業務操作' },
|
||||
];
|
||||
|
||||
export const ROLE_OPTIONS: readonly BilingualOption[] = [
|
||||
{ value: 'super_admin', label: 'Super Admin/系統管理員' },
|
||||
{ value: 'pastor', label: 'Pastor/牧師' },
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, TemplateRef, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Bridges per-page action controls into the shared system header rendered by
|
||||
* UserPortalComponent. A child route cannot project content into its parent
|
||||
* shell through <router-outlet>, so pages register a TemplateRef here and the
|
||||
* shell renders it in the header's right-side actions slot.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PageHeaderService {
|
||||
/** The current page's action controls, or null when the page has none. */
|
||||
readonly actions = signal<TemplateRef<unknown> | null>(null);
|
||||
|
||||
/** Called by a page (typically in ngAfterViewInit) to publish its actions. */
|
||||
public setActions(actionsTemplate: TemplateRef<unknown> | null): void {
|
||||
this.actions.set(actionsTemplate);
|
||||
}
|
||||
|
||||
/** Called by a page in ngOnDestroy so its actions do not leak to the next page. */
|
||||
public clear(): void {
|
||||
this.actions.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// =============================================================================
|
||||
// Site-wide layout styles
|
||||
// =============================================================================
|
||||
// Shared, app-level classes used across portal pages. Kept global (not scoped
|
||||
// to any component) so the portal shell and any page can rely on them.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Unified system header
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rendered once by the portal shell (UserPortalComponent) above the router
|
||||
// outlet, so every child page inherits the same eyebrow + bilingual title.
|
||||
.app-header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header__eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--kendo-color-primary, #0279cf);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-header__title {
|
||||
font-size: clamp(22px, 2.6vw, 30px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
color: var(--ink, #1c2a38);
|
||||
|
||||
span {
|
||||
font-weight: 400;
|
||||
color: var(--ink-soft, #50647c);
|
||||
margin-left: 8px;
|
||||
font-size: 0.62em;
|
||||
}
|
||||
}
|
||||
|
||||
// Right-side slot where pages project their own controls (Add buttons,
|
||||
// date-range filters, etc.) via PageHeaderService.
|
||||
.app-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/* Shared UI Utility Classes */
|
||||
@use "app/shared/styles/ui-utils.scss";
|
||||
|
||||
/* Site-wide layout (unified system header, etc.) */
|
||||
@use "app/shared/styles/site.scss";
|
||||
|
||||
@import "https://fonts.googleapis.com/css2?family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap";
|
||||
|
||||
/* Kendo UI Theme */
|
||||
@@ -58,6 +61,9 @@ hr {
|
||||
|
||||
:root {
|
||||
/* Color System */
|
||||
/* Ink tones (shared by the unified system header and finance pages) */
|
||||
--ink: #1c2a38;
|
||||
--ink-soft: #50647c;
|
||||
/* Misc */
|
||||
--kendo-color-app-surface: #fff;
|
||||
--kendo-color-surface: #f2f4f7;
|
||||
|
||||
Reference in New Issue
Block a user