fix(backoffice): wording quality pass across orders, moderation, transactions, users, customers
- Replaced hardcoded English audit/timeline text (order status changes,
review moderation events, user role/status changes) with proper
adminXxx.timelineEvent.*/adminUsers.audit.* i18n keys, so Recent
Activity/Timeline/Audit panels no longer mix English into ru/hy UI.
- Translated raw internal codes rendered directly to users: transaction
payment method ('card'/'qr'/'cash_on_delivery' -> adminTransactions.methodValue.*)
and user roles/permissions ('products.manage' etc -> adminUsers.roleValue.*/
adminUsers.permission.*), replacing developer-facing enum leakage with
real copy.
- Fixed wrong-noun list-footer counts: Orders/Transactions/Moderation
list pages all reused adminProducts.items ("N товаров"/"N products")
regardless of what was actually listed; each now has its own itemsCount
key ("N заказов", "N транзакций", "N отзывов").
- Fixed customer detail page's "Back" button reusing adminOrders.back
("Back to orders") instead of a customers-specific label.
- Added translation keys to en/ru/hy + translations.ts interface for all
of the above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
@if (facade.selected(); as customer) {
|
||||
<main class="customer-detail">
|
||||
<header class="toolbar">
|
||||
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminCustomers.back' | translate }}</app-button>
|
||||
<h1>{{ customer.name }}</h1>
|
||||
@if (customer.orderCount > 1) {
|
||||
<app-badge variant="success">{{ 'adminCustomers.returning' | translate }}</app-badge>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminCustomersFacade } from '../facade/admin-customers.facade';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
@@ -21,12 +22,20 @@ export class AdminCustomerDetailPageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly activity = computed<OrderTimelineEntry[]>(() => {
|
||||
const customer = this.facade.selected();
|
||||
if (!customer) return [];
|
||||
return customer.orders
|
||||
.flatMap(order => order.timeline.map(entry => ({ status: entry.status, timestamp: entry.timestamp, note: entry.note, orderNumber: order.orderNumber })))
|
||||
.flatMap(order => order.timeline.map(entry => ({
|
||||
status: entry.status,
|
||||
timestamp: entry.timestamp,
|
||||
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
||||
status: this.translate.t('adminOrders.status.' + entry.status),
|
||||
}),
|
||||
orderNumber: order.orderNumber,
|
||||
})))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
} @else {
|
||||
<ul>
|
||||
@for (entry of stats().recentActivity; track entry.timestamp + entry.reviewId) {
|
||||
<li>{{ entry.timestamp | date:'short' }} — {{ entry.productName }} — {{ entry.action }}</li>
|
||||
<li>{{ entry.timestamp | date:'short' }} — {{ entry.productName }} — {{ activityText(entry) }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||
import { CardComponent } from '../../../../../shared/ui/card/card.component';
|
||||
import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
|
||||
import { AdminModerationDashboardStats } from '../../facade/admin-moderation.facade';
|
||||
@@ -14,5 +15,12 @@ import { AdminModerationDashboardStats } from '../../facade/admin-moderation.fac
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ModerationDashboardComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly stats = input.required<AdminModerationDashboardStats>();
|
||||
|
||||
activityText(entry: AdminModerationDashboardStats['recentActivity'][number]): string {
|
||||
const status = entry.status ? this.translate.t('adminModeration.status.' + entry.status) : '';
|
||||
return this.translate.t('adminModeration.timelineEvent.' + entry.eventKey, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface AdminModerationDashboardStats {
|
||||
reported: number;
|
||||
spam: number;
|
||||
averageRating: number | null;
|
||||
recentActivity: { reviewId: string; productName: string; action: string; timestamp: string }[];
|
||||
recentActivity: { reviewId: string; productName: string; eventKey: string; status?: AdminReviewStatus; timestamp: string }[];
|
||||
moderationHealthPercent: number;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export class AdminModerationFacade {
|
||||
const averageRating = rated.length > 0 ? Math.round((rated.reduce((sum, review) => sum + review.rating, 0) / rated.length) * 10) / 10 : null;
|
||||
|
||||
const recentActivity = reviews
|
||||
.flatMap(review => review.timeline.map(entry => ({ reviewId: review.id, productName: review.productName, action: entry.action, timestamp: entry.timestamp })))
|
||||
.flatMap(review => review.timeline.map(entry => ({ reviewId: review.id, productName: review.productName, eventKey: entry.eventKey, status: entry.status, timestamp: entry.timestamp })))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 8);
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export type AdminReviewStatus = 'pending' | 'approved' | 'rejected' | 'spam';
|
||||
export type AdminReviewTimelineEventKey = 'submitted' | 'statusChanged' | 'restored' | 'hidden';
|
||||
export type AdminReviewTimelineActor = 'admin' | 'customer';
|
||||
|
||||
export interface AdminReviewTimelineEntry {
|
||||
action: string;
|
||||
actor: string;
|
||||
eventKey: AdminReviewTimelineEventKey;
|
||||
actor: AdminReviewTimelineActor;
|
||||
status?: AdminReviewStatus;
|
||||
note: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminModerationFacade } from '../facade/admin-moderation.facade';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
@@ -22,6 +23,7 @@ export class AdminReviewDetailPageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly noteDraft = signal('');
|
||||
|
||||
@@ -34,7 +36,14 @@ export class AdminReviewDetailPageComponent {
|
||||
|
||||
timelineEntries(): OrderTimelineEntry[] {
|
||||
const review = this.facade.selected();
|
||||
return review ? review.timeline.map(entry => ({ timestamp: entry.timestamp, note: `${entry.action}${entry.note ? ' — ' + entry.note : ''} (${entry.actor})` })) : [];
|
||||
if (!review) return [];
|
||||
return review.timeline.map(entry => {
|
||||
const status = entry.status ? this.translate.t('adminModeration.status.' + entry.status) : '';
|
||||
const eventText = this.translate.t('adminModeration.timelineEvent.' + entry.eventKey, { status });
|
||||
const actor = this.translate.t('adminModeration.actor.' + entry.actor);
|
||||
const note = entry.note ? `${eventText} — ${entry.note}` : eventText;
|
||||
return { timestamp: entry.timestamp, note: `${note} (${actor})` };
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
@if (facade.reviews().length > 0) {
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
<span>{{ facade.total() }} {{ 'adminModeration.itemsCount' | translate }}</span>
|
||||
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service';
|
||||
import { ProductCardConfig } from '../../../../shared/models/ui';
|
||||
import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus } from '../models/admin-review.model';
|
||||
import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus, AdminReviewTimelineEventKey } from '../models/admin-review.model';
|
||||
import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
|
||||
import { AdminModerationGateway } from './admin-moderation-gateway.interface';
|
||||
|
||||
@@ -61,16 +61,17 @@ export class AdminModerationLocalGateway implements AdminModerationGateway {
|
||||
...review,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
timeline: [...review.timeline, { action: `Status set to ${status}`, actor: 'Admin', note, timestamp: new Date().toISOString() }],
|
||||
timeline: [...review.timeline, { eventKey: 'statusChanged' as const, status, actor: 'admin' as const, note, timestamp: new Date().toISOString() }],
|
||||
}));
|
||||
}
|
||||
|
||||
setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null> {
|
||||
const eventKey: AdminReviewTimelineEventKey = visible ? 'restored' : 'hidden';
|
||||
return this.mutate(id, review => ({
|
||||
...review,
|
||||
visible,
|
||||
updatedAt: new Date().toISOString(),
|
||||
timeline: [...review.timeline, { action: visible ? 'Restored' : 'Hidden', actor: 'Admin', note: '', timestamp: new Date().toISOString() }],
|
||||
timeline: [...review.timeline, { eventKey, actor: 'admin' as const, note: '', timestamp: new Date().toISOString() }],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -158,7 +159,7 @@ export class AdminModerationLocalGateway implements AdminModerationGateway {
|
||||
featured: false,
|
||||
reportCount,
|
||||
moderatorNotes: '',
|
||||
timeline: [{ action: 'Submitted', actor: 'Customer', note: '', timestamp: createdAt }],
|
||||
timeline: [{ eventKey: 'submitted' as const, actor: 'customer' as const, note: '', timestamp: createdAt }],
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
export interface OrderTimelineEntry {
|
||||
status?: string;
|
||||
timestamp: string;
|
||||
/** Pre-resolved, already-translated display text for this event. */
|
||||
note: string;
|
||||
orderNumber?: string;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
} @else {
|
||||
<ul>
|
||||
@for (entry of stats().recentActivity; track entry.timestamp + entry.orderId) {
|
||||
<li>{{ entry.timestamp | date:'short' }} — {{ entry.orderNumber }} — {{ entry.note }}</li>
|
||||
<li>{{ entry.timestamp | date:'short' }} — {{ entry.orderNumber }} — {{ activityText(entry) }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||
import { CardComponent } from '../../../../../shared/ui/card/card.component';
|
||||
import { BadgeComponent } from '../../../../../shared/ui/badge/badge.component';
|
||||
import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
|
||||
@@ -15,5 +16,13 @@ import { AdminOrdersDashboardStats } from '../../facade/admin-orders.facade';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrdersDashboardComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly stats = input.required<AdminOrdersDashboardStats>();
|
||||
|
||||
activityText(entry: AdminOrdersDashboardStats['recentActivity'][number]): string {
|
||||
return this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
||||
status: this.translate.t('adminOrders.status.' + entry.status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface AdminOrdersDashboardStats {
|
||||
returningCustomers: number;
|
||||
averageOrder: number;
|
||||
currency: string;
|
||||
recentActivity: { orderNumber: string; orderId: string; note: string; status: AdminOrderStatus; timestamp: string }[];
|
||||
recentActivity: { orderNumber: string; orderId: string; eventKey: string; status: AdminOrderStatus; timestamp: string }[];
|
||||
alerts: { labelKey: string; count: number }[];
|
||||
}
|
||||
|
||||
@@ -99,12 +99,12 @@ export class AdminOrdersFacade {
|
||||
this.gateway.loadOrder(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
|
||||
}
|
||||
|
||||
setStatus(id: string, status: AdminOrderStatus, note: string): void {
|
||||
this.gateway.updateStatus(id, status, note).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
|
||||
setStatus(id: string, status: AdminOrderStatus): void {
|
||||
this.gateway.updateStatus(id, status).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
|
||||
}
|
||||
|
||||
cancelOrder(id: string): void {
|
||||
this.setStatus(id, 'cancelled', 'Cancelled by admin');
|
||||
this.setStatus(id, 'cancelled');
|
||||
}
|
||||
|
||||
requestRefund(id: string): void {
|
||||
@@ -118,7 +118,7 @@ export class AdminOrdersFacade {
|
||||
|
||||
applyBulkStatus(status: AdminOrderStatus): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => this.gateway.updateStatus(id, status, `Bulk status change to ${status}`).pipe(take(1)).subscribe());
|
||||
ids.forEach(id => this.gateway.updateStatus(id, status).pipe(take(1)).subscribe());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
@@ -185,7 +185,7 @@ export class AdminOrdersFacade {
|
||||
const averageOrder = orders.length > 0 ? Math.round(total / orders.length) : 0;
|
||||
|
||||
const recentActivity = orders
|
||||
.flatMap(order => order.timeline.map(entry => ({ orderNumber: order.orderNumber, orderId: order.id, note: entry.note, status: entry.status, timestamp: entry.timestamp })))
|
||||
.flatMap(order => order.timeline.map(entry => ({ orderNumber: order.orderNumber, orderId: order.id, eventKey: entry.eventKey, status: entry.status, timestamp: entry.timestamp })))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 8);
|
||||
|
||||
|
||||
@@ -27,10 +27,12 @@ export interface AdminOrderItem {
|
||||
price: number;
|
||||
}
|
||||
|
||||
export type AdminOrderTimelineEventKey = 'created' | 'statusChanged' | 'refundRequested';
|
||||
|
||||
export interface AdminOrderTimelineEntry {
|
||||
status: AdminOrderStatus;
|
||||
timestamp: string;
|
||||
note: string;
|
||||
eventKey: AdminOrderTimelineEventKey;
|
||||
}
|
||||
|
||||
export interface AdminOrder {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminOrders.timeline' | translate }}</h3>
|
||||
<app-order-timeline [entries]="order.timeline" />
|
||||
<app-order-timeline [entries]="timelineEntries()" />
|
||||
</section>
|
||||
|
||||
<section class="grid two no-print">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { OrderTimelineComponent } from '../components/order-timeline/order-timeline.component';
|
||||
import { OrderTimelineComponent, OrderTimelineEntry } from '../components/order-timeline/order-timeline.component';
|
||||
|
||||
const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
|
||||
const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
||||
@@ -45,6 +45,18 @@ export class AdminOrderDetailPageComponent {
|
||||
return WORKFLOW_STEPS.indexOf(order.status);
|
||||
});
|
||||
|
||||
readonly timelineEntries = computed<OrderTimelineEntry[]>(() => {
|
||||
const order = this.facade.selected();
|
||||
if (!order) return [];
|
||||
return order.timeline.map(entry => ({
|
||||
status: entry.status,
|
||||
timestamp: entry.timestamp,
|
||||
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
||||
status: this.translate.t('adminOrders.status.' + entry.status),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
@@ -61,7 +73,7 @@ export class AdminOrderDetailPageComponent {
|
||||
}
|
||||
|
||||
setStatus(id: string, status: AdminOrderStatus): void {
|
||||
this.facade.setStatus(id, status, `Status changed to ${status}`);
|
||||
this.facade.setStatus(id, status);
|
||||
}
|
||||
|
||||
cancel(id: string): void {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
<span>{{ facade.total() }} {{ 'adminOrders.itemsCount' | translate }}</span>
|
||||
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderSta
|
||||
export interface AdminOrdersGateway {
|
||||
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>;
|
||||
loadOrder(id: string): Observable<AdminOrder | null>;
|
||||
updateStatus(id: string, status: AdminOrderStatus, note: string): Observable<AdminOrder | null>;
|
||||
updateStatus(id: string, status: AdminOrderStatus): Observable<AdminOrder | null>;
|
||||
requestRefund(id: string): Observable<AdminOrder | null>;
|
||||
addNote(id: string, note: string, internal: boolean): Observable<AdminOrder | null>;
|
||||
archiveOrder(id: string): Observable<AdminOrder | null>;
|
||||
|
||||
@@ -31,12 +31,12 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
return of(this.ensureData().find(order => order.id === id) ?? null).pipe(delay(50));
|
||||
}
|
||||
|
||||
updateStatus(id: string, status: AdminOrderStatus, note: string): Observable<AdminOrder | null> {
|
||||
updateStatus(id: string, status: AdminOrderStatus): Observable<AdminOrder | null> {
|
||||
return this.mutate(id, order => ({
|
||||
...order,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), note }],
|
||||
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const }],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
...order,
|
||||
payment: { ...order.payment, status: 'refund_requested' },
|
||||
updatedAt: new Date().toISOString(),
|
||||
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), note: 'Refund requested' }],
|
||||
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const }],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -122,8 +122,8 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [
|
||||
{ status: 'pending', timestamp: createdAt, note: 'Order created' },
|
||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, note: `Status set to ${status}` }] : []),
|
||||
{ status: 'pending', timestamp: createdAt, eventKey: 'created' },
|
||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []),
|
||||
],
|
||||
archived: false,
|
||||
createdAt,
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<tr>
|
||||
<th scope="row">{{ tx.orderNumber }}</th>
|
||||
<td>{{ ('adminTransactions.typeValue.' + tx.type) | translate }}</td>
|
||||
<td>{{ tx.method }}</td>
|
||||
<td>{{ ('adminTransactions.methodValue.' + tx.method) | translate }}</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>
|
||||
@@ -63,7 +63,7 @@
|
||||
</app-table>
|
||||
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
<span>{{ facade.total() }} {{ 'adminTransactions.itemsCount' | translate }}</span>
|
||||
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ export interface AdminSession {
|
||||
}
|
||||
|
||||
export interface AdminUserAuditEntry {
|
||||
action: string;
|
||||
eventKey: 'roleChanged' | 'statusChanged';
|
||||
roleId?: string;
|
||||
status?: AdminUserStatus;
|
||||
actor: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<td>
|
||||
<select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)">
|
||||
@for (role of facade.roles(); track role.id) {
|
||||
<option [value]="role.id">{{ role.name }}</option>
|
||||
<option [value]="role.id">{{ roleLabel(role.id) }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
@@ -53,7 +53,7 @@
|
||||
<app-input type="email" [ngModel]="inviteEmail()" (ngModelChange)="inviteEmail.set($event)" [placeholder]="'adminUsers.email' | translate" />
|
||||
<select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="inviteRoleId()" (ngModelChange)="inviteRoleId.set($event)">
|
||||
@for (role of facade.roles(); track role.id) {
|
||||
<option [value]="role.id">{{ role.name }}</option>
|
||||
<option [value]="role.id">{{ roleLabel(role.id) }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [attr.aria-label]="'adminUsers.scope' | translate" [ngModel]="inviteScope()" (ngModelChange)="inviteScope.set($event)">
|
||||
@@ -77,7 +77,7 @@
|
||||
@for (invite of facade.invitations(); track invite.id) {
|
||||
<tr>
|
||||
<th scope="row">{{ invite.email }}</th>
|
||||
<td>{{ facade.roleName(invite.roleId) }}</td>
|
||||
<td>{{ roleLabel(invite.roleId) }}</td>
|
||||
<td><app-badge variant="neutral">{{ ('adminUsers.invitationStatus.' + invite.status) | translate }}</app-badge></td>
|
||||
<td>
|
||||
@if (invite.status === 'pending') {
|
||||
@@ -96,7 +96,7 @@
|
||||
<div class="card">
|
||||
<h2>{{ 'adminUsers.roles' | translate }}</h2>
|
||||
@for (role of facade.roles(); track role.id) {
|
||||
<p><strong>{{ role.name }}</strong> — {{ role.permissions.join(', ') }}</p>
|
||||
<p><strong>{{ roleLabel(role.id) }}</strong> — {{ role.permissions.map(permissionLabel).join(', ') }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
<app-dialog [open]="!!facade.auditTarget()" [titleText]="'adminTransactions.audit' | translate" size="sm" (closed)="facade.closeAudit()">
|
||||
@for (entry of facade.audit(); track $index) {
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ entry.actor }} — {{ entry.action }}</p>
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
|
||||
}
|
||||
</app-dialog>
|
||||
</section>
|
||||
|
||||
@@ -45,4 +45,31 @@ export class AdminUsersPageComponent {
|
||||
}
|
||||
this.facade.setStatus(userId, next);
|
||||
}
|
||||
|
||||
private static readonly PERMISSION_KEYS: Record<string, string> = {
|
||||
'*': 'all',
|
||||
'products.manage': 'productsManage',
|
||||
'products.view': 'productsView',
|
||||
'categories.manage': 'categoriesManage',
|
||||
'orders.manage': 'ordersManage',
|
||||
'orders.view': 'ordersView',
|
||||
'media.manage': 'mediaManage',
|
||||
};
|
||||
|
||||
readonly roleLabel = (roleId: string): string => this.translate.t('adminUsers.roleValue.' + roleId);
|
||||
|
||||
readonly permissionLabel = (code: string): string => {
|
||||
const key = AdminUsersPageComponent.PERMISSION_KEYS[code];
|
||||
return key ? this.translate.t('adminUsers.permission.' + key) : code;
|
||||
};
|
||||
|
||||
auditText(entry: { eventKey: 'roleChanged' | 'statusChanged'; roleId?: string; status?: AdminUserStatus }): string {
|
||||
if (entry.eventKey === 'roleChanged' && entry.roleId) {
|
||||
return this.translate.t('adminUsers.audit.roleChanged', { role: this.roleLabel(entry.roleId) });
|
||||
}
|
||||
if (entry.eventKey === 'statusChanged' && entry.status) {
|
||||
return this.translate.t('adminUsers.audit.statusChanged', { status: this.translate.t('adminUsers.statusValue.' + entry.status) });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,9 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
|
||||
this.users = users.map(user => user.id === userId ? updated : user);
|
||||
this.audit[userId] = [
|
||||
...(this.audit[userId] ?? []),
|
||||
{ action: roleId ? `Role changed to ${roleId}` : `Status changed to ${status}`, actor: 'admin', timestamp: new Date().toISOString() },
|
||||
roleId
|
||||
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
|
||||
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
|
||||
];
|
||||
return of(updated).pipe(delay(50));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user