Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s

This commit is contained in:
Chris Chen
2026-06-23 12:13:47 -07:00
parent 870eeec82a
commit 62592c29ae
106 changed files with 2522 additions and 311 deletions
@@ -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
@@ -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">
@@ -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.
@@ -1,3 +0,0 @@
.page-header {
margin-bottom: 0.5rem;
}
@@ -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'],
})
@@ -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 -->
@@ -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">
@@ -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">
@@ -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>
@@ -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'],
})
@@ -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 -->
@@ -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 {
@@ -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'],
})
@@ -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>
@@ -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;
@@ -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'],
@@ -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 -->
@@ -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'],
@@ -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'],