feat(admin): transaction management
Sprint 24. New features/admin/transactions/ module. AdminTransactionsLocalGateway derives one synthetic transaction per Sprint 23's seeded mock order rather than a separate dataset, keeping order numbers/totals consistent across the two mock feature areas. - list: search, status filter, type filter (payment/refund/qr_payment), pagination, CSV export - retry failed transactions (appends an audit entry) - fraud flag toggle - per-transaction audit log (creation/retry/fraud-flag-change), viewed via dialog - intentionally separate from the system-wide audit/security log planned for Sprint 26 (Monitoring) - wired into /:lang/backoffice/transactions, replacing the coming-soon placeholder docs/ADMIN.md + docs/BACKEND.md (new item 13) updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AdminTransaction, AdminTransactionListFilters } from '../models/admin-transaction.model';
|
||||
import { AdminTransactionsLocalGateway } from '../services/admin-transactions-local.gateway';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminTransactionsFacade {
|
||||
private readonly gateway = inject(AdminTransactionsLocalGateway);
|
||||
|
||||
readonly filters = signal<AdminTransactionListFilters>({ search: '', status: 'all', type: 'all', page: 1, pageSize: 10 });
|
||||
readonly transactions = signal<AdminTransaction[]>([]);
|
||||
readonly total = signal(0);
|
||||
readonly loading = signal(false);
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.transactions.set(result.items);
|
||||
this.total.set(result.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.transactions.set([]);
|
||||
this.total.set(0);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateFilters(patch: Partial<AdminTransactionListFilters>): void {
|
||||
this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 }));
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
retryFailed(id: string): void {
|
||||
this.gateway.retryFailed(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
}
|
||||
|
||||
toggleFraudFlag(id: string, flagged: boolean): void {
|
||||
this.gateway.setFraudFlag(id, flagged).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
}
|
||||
|
||||
exportCsv(): string {
|
||||
const header = 'Order Number,Type,Method,Status,Amount,Currency,Fraud Flag,Created At';
|
||||
const rows = this.transactions().map(tx =>
|
||||
[tx.orderNumber, tx.type, tx.method, tx.status, tx.amount, tx.currency, tx.fraudFlag, tx.createdAt].join(',')
|
||||
);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type AdminTransactionType = 'payment' | 'refund' | 'qr_payment';
|
||||
export type AdminTransactionStatus = 'pending' | 'success' | 'failed' | 'retried';
|
||||
|
||||
export interface AdminTransactionAuditEntry {
|
||||
action: string;
|
||||
actor: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AdminTransaction {
|
||||
id: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
type: AdminTransactionType;
|
||||
method: string;
|
||||
status: AdminTransactionStatus;
|
||||
amount: number;
|
||||
currency: string;
|
||||
fraudFlag: boolean;
|
||||
audit: AdminTransactionAuditEntry[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminTransactionListFilters {
|
||||
search: string;
|
||||
status: 'all' | AdminTransactionStatus;
|
||||
type: 'all' | AdminTransactionType;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AdminTransactionsListResult {
|
||||
items: AdminTransaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<section class="admin-transactions-card">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminTransactions.search' | translate" />
|
||||
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
|
||||
@for (status of statuses; track status) {
|
||||
<option [value]="status">{{ ('adminTransactions.status.' + status) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [ngModel]="facade.filters().type" (ngModelChange)="facade.updateFilters({ type: $event })">
|
||||
@for (type of types; track type) {
|
||||
<option [value]="type">{{ ('adminTransactions.type.' + type) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</div>
|
||||
|
||||
@if (!facade.loading() && facade.transactions().length === 0) {
|
||||
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminOrders.orderNumber' | translate }}</th>
|
||||
<th>{{ 'adminTransactions.type' | translate }}</th>
|
||||
<th>{{ 'adminTransactions.method' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'backoffice.price' | translate }}</th>
|
||||
<th>{{ 'adminTransactions.fraud' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (tx of facade.transactions(); track tx.id) {
|
||||
<tr>
|
||||
<td>{{ tx.orderNumber }}</td>
|
||||
<td>{{ ('adminTransactions.type.' + tx.type) | translate }}</td>
|
||||
<td>{{ tx.method }}</td>
|
||||
<td><app-badge [variant]="tx.status === 'success' ? 'success' : tx.status === 'failed' ? 'danger' : 'neutral'">{{ ('adminTransactions.status.' + tx.status) | translate }}</app-badge></td>
|
||||
<td>{{ tx.amount }} {{ tx.currency }}</td>
|
||||
<td>
|
||||
@if (tx.fraudFlag) {
|
||||
<app-badge variant="danger">{{ 'adminTransactions.flagged' | translate }}</app-badge>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (tx.status === 'failed') {
|
||||
<app-button variant="secondary" size="sm" (click)="facade.retryFailed(tx.id)">{{ 'adminTransactions.retry' | translate }}</app-button>
|
||||
}
|
||||
<app-button variant="secondary" size="sm" (click)="facade.toggleFraudFlag(tx.id, !tx.fraudFlag)">{{ (tx.fraudFlag ? 'adminTransactions.clearFlag' : 'adminTransactions.flag') | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="viewAudit(tx)">{{ 'adminTransactions.audit' | translate }}</app-button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-dialog [open]="!!auditTarget()" [titleText]="'adminTransactions.audit' | translate" size="sm" (closed)="closeAudit()">
|
||||
@if (auditTarget(); as tx) {
|
||||
@for (entry of tx.audit; track $index) {
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ entry.actor }} — {{ entry.action }}</p>
|
||||
}
|
||||
}
|
||||
</app-dialog>
|
||||
</section>
|
||||
@@ -0,0 +1,7 @@
|
||||
.admin-transactions-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; }
|
||||
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminTransactionsFacade } from '../facade/admin-transactions.facade';
|
||||
import { AdminTransaction } from '../models/admin-transaction.model';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-transactions-list-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, DialogComponent],
|
||||
templateUrl: './admin-transactions-list-page.component.html',
|
||||
styleUrls: ['./admin-transactions-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminTransactionsListPageComponent {
|
||||
readonly facade = inject(AdminTransactionsFacade);
|
||||
|
||||
readonly statuses = ['all', 'pending', 'success', 'failed', 'retried'] as const;
|
||||
readonly types = ['all', 'payment', 'refund', 'qr_payment'] as const;
|
||||
readonly auditTarget = signal<AdminTransaction | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
}
|
||||
|
||||
totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize));
|
||||
}
|
||||
|
||||
viewAudit(tx: AdminTransaction): void {
|
||||
this.auditTarget.set(tx);
|
||||
}
|
||||
|
||||
closeAudit(): void {
|
||||
this.auditTarget.set(null);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const csv = this.facade.exportCsv();
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'transactions.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||
|
||||
export interface AdminTransactionsGateway {
|
||||
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult>;
|
||||
retryFailed(id: string): Observable<AdminTransaction | null>;
|
||||
setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null>;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
|
||||
const METHODS = ['card', 'qr', 'cash_on_delivery'];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
||||
private cache: AdminTransaction[] | null = null;
|
||||
|
||||
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
|
||||
|
||||
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
|
||||
return new Observable<AdminTransactionsListResult>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
const filtered = (this.cache ?? [])
|
||||
.filter(tx => !filters.search || `${tx.orderNumber} ${tx.method}`.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
.filter(tx => filters.status === 'all' || tx.status === filters.status)
|
||||
.filter(tx => filters.type === 'all' || tx.type === filters.type)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||
const start = (filters.page - 1) * filters.pageSize;
|
||||
subscriber.next({
|
||||
items: filtered.slice(start, start + filters.pageSize),
|
||||
total: filtered.length,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
});
|
||||
subscriber.complete();
|
||||
});
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
retryFailed(id: string): Observable<AdminTransaction | null> {
|
||||
return this.mutate(id, tx => ({
|
||||
...tx,
|
||||
status: 'retried',
|
||||
updatedAt: new Date().toISOString(),
|
||||
audit: [...tx.audit, { action: 'Retried failed transaction', actor: 'admin', timestamp: new Date().toISOString() }],
|
||||
}));
|
||||
}
|
||||
|
||||
setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null> {
|
||||
return this.mutate(id, tx => ({
|
||||
...tx,
|
||||
fraudFlag: flagged,
|
||||
updatedAt: new Date().toISOString(),
|
||||
audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: 'admin', timestamp: new Date().toISOString() }],
|
||||
}));
|
||||
}
|
||||
|
||||
private mutate(id: string, update: (tx: AdminTransaction) => AdminTransaction): Observable<AdminTransaction | null> {
|
||||
const existing = (this.cache ?? []).find(tx => tx.id === id);
|
||||
if (!existing) {
|
||||
return of(null);
|
||||
}
|
||||
const updated = update(existing);
|
||||
this.cache = (this.cache ?? []).map(tx => tx.id === id ? updated : tx);
|
||||
return of(updated).pipe(delay(50));
|
||||
}
|
||||
|
||||
private async ensureData(): Promise<void> {
|
||||
if (this.cache) {
|
||||
return;
|
||||
}
|
||||
const orders = await new Promise<{ id: string; orderNumber: string; total: number; currency: string; status: string; createdAt: string }[]>(resolve =>
|
||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 1000 }).subscribe(result => resolve(result.items))
|
||||
);
|
||||
this.cache = orders.map((order, index) => this.seedTransaction(order, index));
|
||||
}
|
||||
|
||||
private seedTransaction(order: { id: string; orderNumber: string; total: number; currency: string; status: string; createdAt: string }, index: number): AdminTransaction {
|
||||
const status = order.status === 'cancelled' ? 'failed' : order.status === 'refunded' ? 'success' : index % 7 === 0 ? 'pending' : 'success';
|
||||
return {
|
||||
id: `tx-${index + 1}`,
|
||||
orderId: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
type: order.status === 'refunded' ? 'refund' : index % 3 === 0 ? 'qr_payment' : 'payment',
|
||||
method: METHODS[index % METHODS.length],
|
||||
status,
|
||||
amount: order.total,
|
||||
currency: order.currency,
|
||||
fraudFlag: index % 11 === 0,
|
||||
audit: [{ action: 'Transaction created', actor: 'system', timestamp: order.createdAt }],
|
||||
createdAt: order.createdAt,
|
||||
updatedAt: order.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user