feat(admin): implement order and customer operations experience
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Orders dashboard (real orders-today/pending/paid/cancelled/refunded/customers/returning-customers/average-order/recent-activity/system-alerts, computed from the full order book not just the current page); orders list gains density, saved column visibility, bulk status change/archive/delete/export/print alongside existing search+status filter+pagination, sticky table header; order detail gets a visual status workflow stepper in business language (Pending/Packing/Shipping/Completed, with Cancelled/Refunded as terminal states) and a reusable OrderTimelineComponent replacing the flat text log; added a derived Customers feature (list + profile detail: statistics/lifetime value/addresses/orders/activity/notes) built entirely by grouping existing AdminOrder records by email - no new backend, no invented gateway, since no customer entity existed. Added archive/restore/delete to the orders gateway (soft-delete pattern mirroring categories) and filled in the adminOrders/adminCustomers i18n namespaces, which previously didn't exist at all (every adminOrders.* label was rendering as a raw key).
This commit is contained in:
@@ -167,6 +167,24 @@ const coreRoutes: Routes = [
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.orders', path: ['orders'] }, { labelKey: 'adminShell.pages.orderDetail.title' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'customers',
|
||||
loadComponent: () => import('./features/admin/customers/pages/admin-customers-list-page.component').then(m => m.AdminCustomersListPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.pages.customers.title',
|
||||
descriptionKey: 'adminShell.pages.customers.description',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.customers' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'customers/:email',
|
||||
loadComponent: () => import('./features/admin/customers/pages/admin-customer-detail-page.component').then(m => m.AdminCustomerDetailPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.pages.customerDetail.title',
|
||||
descriptionKey: 'adminShell.pages.customerDetail.description',
|
||||
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'media',
|
||||
loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
import { AdminCustomer } from '../models/admin-customer.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCustomersFacade {
|
||||
private readonly ordersGateway = inject(AdminOrdersLocalGateway);
|
||||
|
||||
readonly customers = signal<AdminCustomer[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly search = signal('');
|
||||
readonly selected = signal<AdminCustomer | null>(null);
|
||||
|
||||
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
|
||||
const byEmail = new Map<string, AdminOrder[]>();
|
||||
for (const order of orders) {
|
||||
const list = byEmail.get(order.customer.email) ?? [];
|
||||
list.push(order);
|
||||
byEmail.set(order.customer.email, list);
|
||||
}
|
||||
|
||||
return [...byEmail.entries()].map(([email, customerOrders]) => {
|
||||
const sorted = [...customerOrders].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
const latest = customerOrders[0];
|
||||
return {
|
||||
email,
|
||||
name: latest.customer.name,
|
||||
phone: latest.customer.phone,
|
||||
orderCount: customerOrders.length,
|
||||
totalSpent: customerOrders.reduce((sum, order) => sum + order.total, 0),
|
||||
currency: latest.currency,
|
||||
firstOrderAt: sorted[0].createdAt,
|
||||
lastOrderAt: sorted[sorted.length - 1].createdAt,
|
||||
addresses: [...new Set(customerOrders.map(order => order.shipping.address))],
|
||||
orders: [...customerOrders].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.customers.set(this.buildCustomers(result.items));
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.customers.set([]);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadDetail(email: string): void {
|
||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
const decoded = decodeURIComponent(email);
|
||||
const customers = this.buildCustomers(result.items);
|
||||
this.selected.set(customers.find(customer => customer.email === decoded) ?? null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSearch(value: string): void {
|
||||
this.search.set(value);
|
||||
}
|
||||
|
||||
filteredCustomers(): AdminCustomer[] {
|
||||
const query = this.search().trim().toLowerCase();
|
||||
if (!query) {
|
||||
return this.customers();
|
||||
}
|
||||
return this.customers().filter(customer =>
|
||||
`${customer.name} ${customer.email} ${customer.phone}`.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
|
||||
/**
|
||||
* A customer is not its own stored entity - it's derived by grouping existing
|
||||
* AdminOrder records by customer.email, since no customer gateway/backend exists.
|
||||
* Every field here is a real aggregate over that customer's orders, never fabricated.
|
||||
*/
|
||||
export interface AdminCustomer {
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
orderCount: number;
|
||||
totalSpent: number;
|
||||
currency: string;
|
||||
firstOrderAt: string;
|
||||
lastOrderAt: string;
|
||||
addresses: string[];
|
||||
orders: AdminOrder[];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@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>
|
||||
<h1>{{ customer.name }}</h1>
|
||||
@if (customer.orderCount > 1) {
|
||||
<app-badge variant="success">{{ 'adminCustomers.returning' | translate }}</app-badge>
|
||||
}
|
||||
</header>
|
||||
|
||||
<section class="grid two">
|
||||
<div class="card">
|
||||
<h3>{{ 'adminCustomers.profile' | translate }}</h3>
|
||||
<p>{{ customer.email }}</p>
|
||||
<p>{{ customer.phone }}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>{{ 'adminCustomers.statistics' | translate }}</h3>
|
||||
<p>{{ 'adminCustomers.orderCount' | translate }}: {{ customer.orderCount }}</p>
|
||||
<p>{{ 'adminCustomers.lifetimeValue' | translate }}: {{ customer.totalSpent }} {{ customer.currency }}</p>
|
||||
<p>{{ 'adminCustomers.firstOrder' | translate }}: {{ customer.firstOrderAt | date:'short' }}</p>
|
||||
<p>{{ 'adminCustomers.lastOrder' | translate }}: {{ customer.lastOrderAt | date:'short' }}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>{{ 'adminCustomers.addresses' | translate }}</h3>
|
||||
@for (address of customer.addresses; track address) { <p>{{ address }}</p> }
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>{{ 'adminCustomers.notes' | translate }}</h3>
|
||||
<p class="muted">{{ 'adminCustomers.notesUnavailable' | translate }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminCustomers.orders' | translate }}</h3>
|
||||
@for (order of customer.orders; track order.id) {
|
||||
<div class="order-row">
|
||||
<button type="button" class="link-button" (click)="viewOrder(order.id)">{{ order.orderNumber }}</button>
|
||||
<app-badge variant="neutral">{{ ('adminOrders.status.' + order.status) | translate }}</app-badge>
|
||||
<span>{{ order.total }} {{ order.currency }}</span>
|
||||
<span class="muted">{{ order.createdAt | date:'short' }}</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminCustomers.activity' | translate }}</h3>
|
||||
<app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
|
||||
</section>
|
||||
</main>
|
||||
} @else {
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.customer-detail { max-width: 1000px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; }
|
||||
.toolbar h1 { margin: 0; font-size: 1.25rem; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.card { border: 1px solid var(--border-color, #d3dad9); border-radius: 12px; padding: 14px; display: grid; gap: 6px; }
|
||||
.card h3 { margin: 0 0 6px; }
|
||||
.card p { margin: 0; }
|
||||
.muted { color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; }
|
||||
.order-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border-subtle, #e7ece9); }
|
||||
.link-button { all: unset; cursor: pointer; color: var(--brand-primary, #1e8a6e); font-weight: 600; }
|
||||
.link-button:hover, .link-button:focus-visible { text-decoration: underline; }
|
||||
@media (max-width: 700px) { .grid.two { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
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 { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { OrderTimelineComponent, OrderTimelineEntry } from '../../orders/components/order-timeline/order-timeline.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-customer-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
|
||||
templateUrl: './admin-customer-detail-page.component.html',
|
||||
styleUrls: ['./admin-customer-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminCustomerDetailPageComponent {
|
||||
readonly facade = inject(AdminCustomersFacade);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
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 })))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const email = this.route.snapshot.paramMap.get('email');
|
||||
if (email) {
|
||||
this.facade.loadDetail(email);
|
||||
}
|
||||
}
|
||||
|
||||
back(): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'customers']);
|
||||
}
|
||||
|
||||
viewOrder(id: string): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'orders', id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<section class="admin-customers-card">
|
||||
<div class="toolbar">
|
||||
<app-input type="search" [ngModel]="facade.search()" (ngModelChange)="facade.setSearch($event)" [placeholder]="'adminCustomers.search' | translate" />
|
||||
</div>
|
||||
|
||||
@if (facade.loading()) {
|
||||
<div class="skeleton-rows">
|
||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||
</div>
|
||||
} @else if (facade.filteredCustomers().length === 0) {
|
||||
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminCustomers.name' | translate }}</th>
|
||||
<th>{{ 'adminCustomers.email' | translate }}</th>
|
||||
<th>{{ 'adminCustomers.phone' | translate }}</th>
|
||||
<th>{{ 'adminCustomers.orderCount' | translate }}</th>
|
||||
<th>{{ 'adminCustomers.lifetimeValue' | translate }}</th>
|
||||
<th>{{ 'adminOrders.createdAt' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (customer of facade.filteredCustomers(); track customer.email) {
|
||||
<tr>
|
||||
<td>{{ customer.name }}</td>
|
||||
<td>{{ customer.email }}</td>
|
||||
<td>{{ customer.phone }}</td>
|
||||
<td>
|
||||
{{ customer.orderCount }}
|
||||
@if (customer.orderCount > 1) {
|
||||
<app-badge variant="success">{{ 'adminCustomers.returning' | translate }}</app-badge>
|
||||
}
|
||||
</td>
|
||||
<td>{{ customer.totalSpent }} {{ customer.currency }}</td>
|
||||
<td>{{ customer.lastOrderAt | date:'short' }}</td>
|
||||
<td><app-button variant="secondary" size="sm" (click)="view(customer.email)">{{ 'adminOrders.view' | translate }}</app-button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,3 @@
|
||||
.admin-customers-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||
.toolbar { display: flex; }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AdminCustomersFacade } from '../facade/admin-customers.facade';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
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 { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-customers-list-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent, SkeletonComponent],
|
||||
templateUrl: './admin-customers-list-page.component.html',
|
||||
styleUrls: ['./admin-customers-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminCustomersListPageComponent {
|
||||
readonly facade = inject(AdminCustomersFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
}
|
||||
|
||||
view(email: string): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'customers', encodeURIComponent(email)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<ol class="order-timeline">
|
||||
@for (entry of entries(); track entry.timestamp + (entry.note || '')) {
|
||||
<li class="order-timeline__item">
|
||||
<span class="order-timeline__dot" aria-hidden="true"></span>
|
||||
<div class="order-timeline__body">
|
||||
<div class="order-timeline__meta">
|
||||
<time>{{ entry.timestamp | date:'short' }}</time>
|
||||
@if (entry.status) {
|
||||
<span class="order-timeline__status">{{ ('adminOrders.status.' + entry.status) | translate }}</span>
|
||||
}
|
||||
@if (showOrderNumber() && entry.orderNumber) {
|
||||
<span class="order-timeline__order">{{ entry.orderNumber }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="order-timeline__note">{{ entry.note }}</p>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@@ -0,0 +1,57 @@
|
||||
.order-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.order-timeline__item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
|
||||
&:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 14px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border-subtle, #e7ece9);
|
||||
}
|
||||
}
|
||||
|
||||
.order-timeline__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary, #1e8a6e);
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-timeline__body {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.order-timeline__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary, #9aa6a2);
|
||||
}
|
||||
|
||||
.order-timeline__status {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1e3c38);
|
||||
}
|
||||
|
||||
.order-timeline__note {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
export interface OrderTimelineEntry {
|
||||
status?: string;
|
||||
timestamp: string;
|
||||
note: string;
|
||||
orderNumber?: string;
|
||||
}
|
||||
|
||||
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */
|
||||
@Component({
|
||||
selector: 'app-order-timeline',
|
||||
standalone: true,
|
||||
imports: [DatePipe, TranslatePipe],
|
||||
templateUrl: './order-timeline.component.html',
|
||||
styleUrl: './order-timeline.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrderTimelineComponent {
|
||||
readonly entries = input.required<OrderTimelineEntry[]>();
|
||||
readonly showOrderNumber = input(false);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<div class="orders-dashboard">
|
||||
<div class="orders-dashboard__metrics">
|
||||
<app-dashboard-metric labelKey="adminOrders.ordersToday" [value]="stats().ordersToday.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.status.pending" [value]="stats().pending.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.paidCount" [value]="stats().paid.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.status.cancelled" [value]="stats().cancelled.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.status.refunded" [value]="stats().refunded.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.customersCount" [value]="stats().customers.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.returningCustomers" [value]="stats().returningCustomers.toString()" />
|
||||
<app-dashboard-metric labelKey="adminOrders.averageOrder" [value]="stats().averageOrder + ' ' + stats().currency" />
|
||||
</div>
|
||||
|
||||
@if (stats().alerts.length > 0) {
|
||||
<app-card padding="sm" class="orders-dashboard__alerts">
|
||||
@for (alert of stats().alerts; track alert.labelKey) {
|
||||
<app-badge variant="warning">{{ alert.count }} — {{ alert.labelKey | translate }}</app-badge>
|
||||
}
|
||||
</app-card>
|
||||
}
|
||||
|
||||
<app-card padding="sm" class="orders-dashboard__activity">
|
||||
<h4>{{ 'adminOrders.recentActivity' | translate }}</h4>
|
||||
@if (stats().recentActivity.length === 0) {
|
||||
<p>{{ 'adminOrders.noRecentActivity' | translate }}</p>
|
||||
} @else {
|
||||
<ul>
|
||||
@for (entry of stats().recentActivity; track entry.timestamp + entry.orderId) {
|
||||
<li>{{ entry.timestamp | date:'short' }} — {{ entry.orderNumber }} — {{ entry.note }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</app-card>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
.orders-dashboard {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.orders-dashboard__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.orders-dashboard__metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.orders-dashboard__metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.orders-dashboard__alerts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orders-dashboard__activity h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.orders-dashboard__activity ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
}
|
||||
|
||||
.orders-dashboard__activity p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { CardComponent } from '../../../../../shared/ui/card/card.component';
|
||||
import { BadgeComponent } from '../../../../../shared/ui/badge/badge.component';
|
||||
import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
|
||||
import { AdminOrdersDashboardStats } from '../../facade/admin-orders.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-orders-dashboard',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, DatePipe, CardComponent, BadgeComponent, DashboardMetricComponent],
|
||||
templateUrl: './orders-dashboard.component.html',
|
||||
styleUrl: './orders-dashboard.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrdersDashboardComponent {
|
||||
readonly stats = input.required<AdminOrdersDashboardStats>();
|
||||
}
|
||||
@@ -1,11 +1,37 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AdminOrder, AdminOrderListFilters, AdminOrderStatus } from '../models/admin-order.model';
|
||||
import { AdminOrdersLocalGateway } from '../services/admin-orders-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
|
||||
export type AdminOrdersViewMode = 'table' | 'cards';
|
||||
export type AdminOrdersDensity = 'comfortable' | 'compact';
|
||||
|
||||
export const ALL_ORDER_COLUMNS = ['customer', 'status', 'payment', 'total', 'created'] as const;
|
||||
export type AdminOrderColumn = typeof ALL_ORDER_COLUMNS[number];
|
||||
|
||||
export interface AdminOrdersDashboardStats {
|
||||
ordersToday: number;
|
||||
pending: number;
|
||||
paid: number;
|
||||
cancelled: number;
|
||||
refunded: number;
|
||||
customers: number;
|
||||
returningCustomers: number;
|
||||
averageOrder: number;
|
||||
currency: string;
|
||||
recentActivity: { orderNumber: string; orderId: string; note: string; status: AdminOrderStatus; timestamp: string }[];
|
||||
alerts: { labelKey: string; count: number }[];
|
||||
}
|
||||
|
||||
const VIEW_MODE_KEY = 'admin-orders:view-mode';
|
||||
const DENSITY_KEY = 'admin-orders:density';
|
||||
const COLUMNS_KEY = 'admin-orders:visible-columns';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminOrdersFacade {
|
||||
private readonly gateway = inject(AdminOrdersLocalGateway);
|
||||
private readonly localStorage = inject(LocalStorageService);
|
||||
|
||||
readonly filters = signal<AdminOrderListFilters>({ search: '', status: 'all', page: 1, pageSize: 10 });
|
||||
readonly orders = signal<AdminOrder[]>([]);
|
||||
@@ -13,6 +39,41 @@ export class AdminOrdersFacade {
|
||||
readonly loading = signal(false);
|
||||
readonly selected = signal<AdminOrder | null>(null);
|
||||
|
||||
readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table');
|
||||
readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
|
||||
readonly visibleColumns = signal<AdminOrderColumn[]>(this.localStorage.getJSON<AdminOrderColumn[]>(COLUMNS_KEY) ?? [...ALL_ORDER_COLUMNS]);
|
||||
readonly selectedIds = signal<string[]>([]);
|
||||
readonly hasSelection = computed(() => this.selectedIds().length > 0);
|
||||
readonly dashboardStats = signal<AdminOrdersDashboardStats | null>(null);
|
||||
|
||||
setViewMode(mode: AdminOrdersViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
this.localStorage.setItem(VIEW_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
setDensity(density: AdminOrdersDensity): void {
|
||||
this.density.set(density);
|
||||
this.localStorage.setItem(DENSITY_KEY, density);
|
||||
}
|
||||
|
||||
setColumnVisible(column: AdminOrderColumn, visible: boolean): void {
|
||||
const next = visible ? [...new Set([...this.visibleColumns(), column])] : this.visibleColumns().filter(c => c !== column);
|
||||
this.visibleColumns.set(next);
|
||||
this.localStorage.setJSON(COLUMNS_KEY, next);
|
||||
}
|
||||
|
||||
toggleSelection(id: string, checked: boolean): void {
|
||||
this.selectedIds.update(current => checked ? [...new Set([...current, id])] : current.filter(item => item !== id));
|
||||
}
|
||||
|
||||
toggleAll(checked: boolean): void {
|
||||
this.selectedIds.set(checked ? this.orders().map(order => order.id) : []);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedIds.set([]);
|
||||
}
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
|
||||
@@ -55,6 +116,29 @@ export class AdminOrdersFacade {
|
||||
this.gateway.addNote(id, note.trim(), internal).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
|
||||
}
|
||||
|
||||
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());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
applyBulkArchive(archived: boolean): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => (archived ? this.gateway.archiveOrder(id) : this.gateway.restoreOrder(id)).pipe(take(1)).subscribe());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
applyBulkDelete(): void {
|
||||
const ids = [...this.selectedIds()];
|
||||
ids.forEach(id => this.gateway.deleteOrder(id).pipe(take(1)).subscribe());
|
||||
this.clearSelection();
|
||||
this.loadList();
|
||||
this.loadDashboardStats();
|
||||
}
|
||||
|
||||
exportCsv(): string {
|
||||
const header = 'Order Number,Status,Customer,Email,Total,Currency,Created At';
|
||||
const rows = this.orders().map(order =>
|
||||
@@ -62,4 +146,68 @@ export class AdminOrdersFacade {
|
||||
);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
exportSelectedAsCsv(): void {
|
||||
const selected = new Set(this.selectedIds());
|
||||
const rows = this.orders().filter(order => selected.has(order.id));
|
||||
const header = 'Order Number,Status,Customer,Email,Total,Currency,Created At';
|
||||
const lines = rows.map(order => [order.orderNumber, order.status, order.customer.name, order.customer.email, order.total, order.currency, order.createdAt].join(','));
|
||||
const csv = [header, ...lines].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'orders-export.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Dashboard reflects the whole order book, not just the current filtered/paginated page. */
|
||||
loadDashboardStats(): void {
|
||||
this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1))
|
||||
.subscribe({ next: result => this.dashboardStats.set(this.computeDashboardStats(result.items)) });
|
||||
}
|
||||
|
||||
private computeDashboardStats(orders: AdminOrder[]): AdminOrdersDashboardStats {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const ordersToday = orders.filter(order => new Date(order.createdAt) >= startOfToday).length;
|
||||
|
||||
const byEmail = new Map<string, AdminOrder[]>();
|
||||
for (const order of orders) {
|
||||
const list = byEmail.get(order.customer.email) ?? [];
|
||||
list.push(order);
|
||||
byEmail.set(order.customer.email, list);
|
||||
}
|
||||
const returningCustomers = [...byEmail.values()].filter(list => list.length > 1).length;
|
||||
|
||||
const total = orders.reduce((sum, order) => sum + order.total, 0);
|
||||
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 })))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 8);
|
||||
|
||||
const stuckPending = orders.filter(order => order.status === 'pending' && Date.now() - new Date(order.createdAt).getTime() > 48 * 60 * 60 * 1000).length;
|
||||
const refundRequested = orders.filter(order => order.payment.status === 'refund_requested').length;
|
||||
const alerts: AdminOrdersDashboardStats['alerts'] = [
|
||||
...(stuckPending > 0 ? [{ labelKey: 'adminOrders.alertStuckPending', count: stuckPending }] : []),
|
||||
...(refundRequested > 0 ? [{ labelKey: 'adminOrders.alertRefundRequested', count: refundRequested }] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
ordersToday,
|
||||
pending: orders.filter(order => order.status === 'pending').length,
|
||||
paid: orders.filter(order => order.payment.status === 'paid').length,
|
||||
cancelled: orders.filter(order => order.status === 'cancelled').length,
|
||||
refunded: orders.filter(order => order.status === 'refunded').length,
|
||||
customers: byEmail.size,
|
||||
returningCustomers,
|
||||
averageOrder,
|
||||
currency: orders[0]?.currency ?? '',
|
||||
recentActivity,
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface AdminOrder {
|
||||
notes: string;
|
||||
internalNotes: string;
|
||||
timeline: AdminOrderTimelineEntry[];
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,28 @@
|
||||
<app-button variant="secondary" (click)="printInvoice()">{{ 'adminOrders.printInvoice' | translate }}</app-button>
|
||||
</header>
|
||||
|
||||
@if (!isTerminal()) {
|
||||
<ol class="status-stepper no-print" aria-label="Order status">
|
||||
@for (step of workflowSteps; track step; let i = $index) {
|
||||
<li
|
||||
class="status-stepper__step"
|
||||
[class.status-stepper__step--done]="i < currentStepIndex()"
|
||||
[class.status-stepper__step--current]="i === currentStepIndex()"
|
||||
>
|
||||
<span class="status-stepper__dot" aria-hidden="true"></span>
|
||||
<span class="status-stepper__label">{{ ('adminOrders.status.' + step) | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p class="status-stepper__terminal no-print">{{ ('adminOrders.status.' + order.status) | translate }}</p>
|
||||
}
|
||||
|
||||
<section class="grid two">
|
||||
<div class="card">
|
||||
<h3>{{ 'adminOrders.customer' | translate }}</h3>
|
||||
<p>{{ order.customer.name }}</p>
|
||||
<button type="button" class="link-button no-print" (click)="viewCustomer(order.customer.email)">{{ order.customer.name }}</button>
|
||||
<p class="print-only">{{ order.customer.name }}</p>
|
||||
<p>{{ order.customer.email }}</p>
|
||||
<p>{{ order.customer.phone }}</p>
|
||||
</div>
|
||||
@@ -21,10 +39,10 @@
|
||||
<p>{{ order.payment.amount }} {{ order.payment.currency }}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>{{ 'adminOrders.shipping' | translate }}</h3>
|
||||
<h3>{{ 'adminOrders.delivery' | translate }}</h3>
|
||||
<p>{{ order.shipping.address }}</p>
|
||||
<p>{{ order.shipping.method }}</p>
|
||||
@if (order.shipping.trackingNumber) { <p>{{ order.shipping.trackingNumber }}</p> }
|
||||
@if (order.shipping.trackingNumber) { <p>{{ 'adminOrders.trackingNumber' | translate }}: {{ order.shipping.trackingNumber }}</p> }
|
||||
</div>
|
||||
<div class="card no-print">
|
||||
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
|
||||
@@ -50,9 +68,7 @@
|
||||
|
||||
<section class="card">
|
||||
<h3>{{ 'adminOrders.timeline' | translate }}</h3>
|
||||
@for (entry of order.timeline; track $index) {
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminOrders.status.' + entry.status) | translate }} — {{ entry.note }}</p>
|
||||
}
|
||||
<app-order-timeline [entries]="order.timeline" />
|
||||
</section>
|
||||
|
||||
<section class="grid two no-print">
|
||||
|
||||
@@ -11,4 +11,65 @@
|
||||
select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 8px; font: inherit; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
@media (max-width: 700px) { .grid.two { grid-template-columns: 1fr; } }
|
||||
@media print { .no-print { display: none !important; } }
|
||||
@media print { .no-print { display: none !important; } .print-only { display: block !important; } }
|
||||
|
||||
.link-button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--brand-primary, #1e8a6e);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover, &:focus-visible { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.print-only { display: none; margin: 0; font-weight: 600; }
|
||||
|
||||
.status-stepper {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.status-stepper__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.status-stepper__dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted, #eef2f0);
|
||||
border: 2px solid var(--border-subtle, #e7ece9);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-stepper__step--done .status-stepper__dot,
|
||||
.status-stepper__step--current .status-stepper__dot {
|
||||
background: var(--brand-primary, #1e8a6e);
|
||||
border-color: var(--brand-primary, #1e8a6e);
|
||||
}
|
||||
|
||||
.status-stepper__label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
}
|
||||
|
||||
.status-stepper__step--done .status-stepper__label,
|
||||
.status-stepper__step--current .status-stepper__label {
|
||||
color: var(--text-primary, #1e3c38);
|
||||
}
|
||||
|
||||
.status-stepper__terminal {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1e3c38);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -9,11 +9,15 @@ 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';
|
||||
|
||||
const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
|
||||
const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-order-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent],
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
|
||||
templateUrl: './admin-order-detail-page.component.html',
|
||||
styleUrls: ['./admin-order-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -26,9 +30,21 @@ export class AdminOrderDetailPageComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
||||
readonly workflowSteps = WORKFLOW_STEPS;
|
||||
readonly noteDraft = signal('');
|
||||
readonly internalNoteDraft = signal('');
|
||||
|
||||
readonly isTerminal = computed(() => {
|
||||
const order = this.facade.selected();
|
||||
return !!order && TERMINAL_STATUSES.includes(order.status);
|
||||
});
|
||||
|
||||
readonly currentStepIndex = computed(() => {
|
||||
const order = this.facade.selected();
|
||||
if (!order) return -1;
|
||||
return WORKFLOW_STEPS.indexOf(order.status);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
@@ -40,6 +56,10 @@ export class AdminOrderDetailPageComponent {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'orders']);
|
||||
}
|
||||
|
||||
viewCustomer(email: string): void {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'customers', encodeURIComponent(email)]);
|
||||
}
|
||||
|
||||
setStatus(id: string, status: AdminOrderStatus): void {
|
||||
this.facade.setStatus(id, status, `Status changed to ${status}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<section class="admin-orders-card">
|
||||
@if (facade.dashboardStats(); as stats) {
|
||||
<app-orders-dashboard [stats]="stats" />
|
||||
}
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
|
||||
@@ -8,8 +12,42 @@
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar__view-controls">
|
||||
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.density' | translate">
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.density() === 'comfortable'" (click)="facade.setDensity('comfortable')">{{ 'adminProducts.densityComfortable' | translate }}</app-button>
|
||||
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.density() === 'compact'" (click)="facade.setDensity('compact')">{{ 'adminProducts.densityCompact' | translate }}</app-button>
|
||||
</div>
|
||||
<app-button variant="ghost" size="sm" (click)="columnsPanelOpen.set(!columnsPanelOpen())">{{ 'adminProducts.columns' | translate }}</app-button>
|
||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (columnsPanelOpen()) {
|
||||
<app-card padding="sm" class="columns-panel">
|
||||
@for (column of allColumns; track column) {
|
||||
<label class="check">
|
||||
<input type="checkbox" [checked]="isColumnVisible(column)" (change)="facade.setColumnVisible(column, $any($event.target).checked)" />
|
||||
<span>{{ ('adminOrders.column_' + column) | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</app-card>
|
||||
}
|
||||
|
||||
@if (facade.selectedIds().length > 0) {
|
||||
<div class="bulk-actions">
|
||||
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
|
||||
@for (status of statuses; track status) {
|
||||
@if (status !== 'all') { <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> }
|
||||
}
|
||||
</select>
|
||||
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="facade.applyBulkArchive(true)">{{ 'adminOrders.archiveSelected' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="printSelection()">{{ 'adminOrders.printSelected' | translate }}</app-button>
|
||||
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (facade.loading()) {
|
||||
<div class="skeleton-rows">
|
||||
@@ -18,30 +56,36 @@
|
||||
} @else if (facade.orders().length === 0) {
|
||||
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<div class="table-scroll" [class.density-compact]="facade.density() === 'compact'">
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" (change)="facade.toggleAll($any($event.target).checked)" /></th>
|
||||
<th>{{ 'adminOrders.orderNumber' | translate }}</th>
|
||||
<th>{{ 'adminOrders.customer' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'backoffice.price' | translate }}</th>
|
||||
<th>{{ 'adminOrders.createdAt' | translate }}</th>
|
||||
@if (isColumnVisible('customer')) { <th>{{ 'adminOrders.customer' | translate }}</th> }
|
||||
@if (isColumnVisible('status')) { <th>{{ 'backoffice.status' | translate }}</th> }
|
||||
@if (isColumnVisible('payment')) { <th>{{ 'adminOrders.payment' | translate }}</th> }
|
||||
@if (isColumnVisible('total')) { <th>{{ 'backoffice.price' | translate }}</th> }
|
||||
@if (isColumnVisible('created')) { <th>{{ 'adminOrders.createdAt' | translate }}</th> }
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (order of facade.orders(); track order.id) {
|
||||
<tr>
|
||||
<td><input type="checkbox" [checked]="isSelected(order.id)" (change)="facade.toggleSelection(order.id, $any($event.target).checked)" /></td>
|
||||
<td>{{ order.orderNumber }}</td>
|
||||
<td>{{ order.customer.name }}</td>
|
||||
<td><app-badge variant="neutral">{{ ('adminOrders.status.' + order.status) | translate }}</app-badge></td>
|
||||
<td>{{ order.total }} {{ order.currency }}</td>
|
||||
<td>{{ order.createdAt | date:'short' }}</td>
|
||||
@if (isColumnVisible('customer')) { <td>{{ order.customer.name }}</td> }
|
||||
@if (isColumnVisible('status')) { <td><app-badge variant="neutral">{{ ('adminOrders.status.' + order.status) | translate }}</app-badge></td> }
|
||||
@if (isColumnVisible('payment')) { <td><app-badge [variant]="order.payment.status === 'paid' ? 'success' : order.payment.status === 'refund_requested' ? 'warning' : 'neutral'">{{ ('adminOrders.paymentStatus.' + order.payment.status) | translate }}</app-badge></td> }
|
||||
@if (isColumnVisible('total')) { <td>{{ order.total }} {{ order.currency }}</td> }
|
||||
@if (isColumnVisible('created')) { <td>{{ order.createdAt | date:'short' }}</td> }
|
||||
<td><app-button variant="secondary" size="sm" (click)="view(order.id)">{{ 'adminOrders.view' | translate }}</app-button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
|
||||
@@ -5,3 +5,23 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }
|
||||
|
||||
.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.view-toggle { display: flex; gap: 2px; }
|
||||
.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.bulk-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.table-scroll {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.density-compact td, .density-compact th { padding: 4px 8px; }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AdminOrdersFacade } from '../facade/admin-orders.facade';
|
||||
import { AdminOrdersFacade, AdminOrderColumn, ALL_ORDER_COLUMNS } from '../facade/admin-orders.facade';
|
||||
import { AdminOrderStatus } from '../models/admin-order.model';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
@@ -12,11 +13,13 @@ 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 { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||
import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-orders-list-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent],
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, OrdersDashboardComponent],
|
||||
templateUrl: './admin-orders-list-page.component.html',
|
||||
styleUrls: ['./admin-orders-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -27,9 +30,13 @@ export class AdminOrdersListPageComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const;
|
||||
readonly allColumns = ALL_ORDER_COLUMNS;
|
||||
protected readonly columnsPanelOpen = signal(false);
|
||||
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
this.facade.loadDashboardStats();
|
||||
}
|
||||
|
||||
totalPages(): number {
|
||||
@@ -40,6 +47,22 @@ export class AdminOrdersListPageComponent {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'orders', id]);
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.facade.selectedIds().includes(id);
|
||||
}
|
||||
|
||||
isColumnVisible(column: AdminOrderColumn): boolean {
|
||||
return this.facade.visibleColumns().includes(column);
|
||||
}
|
||||
|
||||
applyBulkStatus(): void {
|
||||
this.facade.applyBulkStatus(this.bulkStatusValue());
|
||||
}
|
||||
|
||||
printSelection(): void {
|
||||
window.print();
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const csv = this.facade.exportCsv();
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
|
||||
@@ -7,4 +7,7 @@ export interface AdminOrdersGateway {
|
||||
updateStatus(id: string, status: AdminOrderStatus, note: string): 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>;
|
||||
restoreOrder(id: string): Observable<AdminOrder | null>;
|
||||
deleteOrder(id: string): Observable<void>;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
}));
|
||||
}
|
||||
|
||||
archiveOrder(id: string): Observable<AdminOrder | null> {
|
||||
return this.mutate(id, order => ({ ...order, archived: true, updatedAt: new Date().toISOString() }));
|
||||
}
|
||||
|
||||
restoreOrder(id: string): Observable<AdminOrder | null> {
|
||||
return this.mutate(id, order => ({ ...order, archived: false, updatedAt: new Date().toISOString() }));
|
||||
}
|
||||
|
||||
deleteOrder(id: string): Observable<void> {
|
||||
this.cache = this.ensureData().filter(order => order.id !== id);
|
||||
return of(void 0).pipe(delay(50));
|
||||
}
|
||||
|
||||
private mutate(id: string, update: (order: AdminOrder) => AdminOrder): Observable<AdminOrder | null> {
|
||||
const all = this.ensureData();
|
||||
const existing = all.find(order => order.id === id);
|
||||
@@ -112,6 +125,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
{ status: 'pending', timestamp: createdAt, note: 'Order created' },
|
||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, note: `Status set to ${status}` }] : []),
|
||||
],
|
||||
archived: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
||||
{ type: 'link', id: 'products', icon: 'pi-box', labelKey: 'adminShell.nav.products', path: ['products'] },
|
||||
{ type: 'link', id: 'categories', icon: 'pi-tags', labelKey: 'adminShell.nav.categories', path: ['categories'] },
|
||||
{ type: 'link', id: 'orders', icon: 'pi-shopping-cart', labelKey: 'adminShell.nav.orders', path: ['orders'] },
|
||||
{ type: 'link', id: 'customers', icon: 'pi-user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
||||
{ type: 'link', id: 'transactions', icon: 'pi-credit-card', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
||||
{ type: 'link', id: 'reviews', icon: 'pi-star', labelKey: 'adminShell.nav.reviews', comingSoon: true },
|
||||
{ type: 'link', id: 'reports', icon: 'pi-chart-bar', labelKey: 'adminShell.nav.reports', comingSoon: true },
|
||||
|
||||
@@ -1140,6 +1140,82 @@ export const en: Translations = {
|
||||
optionalDataMissingDescription: 'Optional configuration data is absent.',
|
||||
optionalDataMissingResolution: 'Add optional value if UX depends on it.',
|
||||
},
|
||||
adminOrders: {
|
||||
back: 'Back to orders',
|
||||
search: 'Search by order number, name, email, or phone…',
|
||||
export: 'Export',
|
||||
view: 'View',
|
||||
orderNumber: 'Order',
|
||||
customer: 'Customer',
|
||||
payment: 'Payment',
|
||||
delivery: 'Delivery',
|
||||
trackingNumber: 'Tracking number',
|
||||
createdAt: 'Placed',
|
||||
items: 'Items',
|
||||
timeline: 'Timeline',
|
||||
notes: 'Notes to customer',
|
||||
internalNotes: 'Internal notes',
|
||||
addNote: 'Add note',
|
||||
changeStatus: 'Change status',
|
||||
applyStatus: 'Apply',
|
||||
archiveSelected: 'Archive',
|
||||
printSelected: 'Print',
|
||||
printInvoice: 'Print invoice',
|
||||
requestRefund: 'Request refund',
|
||||
cancelOrder: 'Cancel order',
|
||||
confirmCancel: 'Cancel this order?',
|
||||
confirmRefund: 'Request a refund for this order?',
|
||||
emptyTitle: 'No orders yet',
|
||||
emptyDescription: 'Orders will show up here as customers check out.',
|
||||
column_customer: 'Customer',
|
||||
column_status: 'Status',
|
||||
column_payment: 'Payment',
|
||||
column_total: 'Total',
|
||||
column_created: 'Placed',
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
processing: 'Packing',
|
||||
shipped: 'Shipping',
|
||||
delivered: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
refunded: 'Refunded',
|
||||
},
|
||||
paymentStatus: {
|
||||
unpaid: 'Unpaid',
|
||||
paid: 'Paid',
|
||||
refund_requested: 'Refund requested',
|
||||
refunded: 'Refunded',
|
||||
},
|
||||
ordersToday: 'Orders today',
|
||||
paidCount: 'Paid',
|
||||
customersCount: 'Customers',
|
||||
returningCustomers: 'Returning customers',
|
||||
averageOrder: 'Average order',
|
||||
recentActivity: 'Recent activity',
|
||||
noRecentActivity: 'No activity yet.',
|
||||
alertStuckPending: 'orders have been pending for over 48 hours',
|
||||
alertRefundRequested: 'orders have a refund request waiting',
|
||||
},
|
||||
adminCustomers: {
|
||||
search: 'Search by name, email, or phone…',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
orderCount: 'Orders',
|
||||
lifetimeValue: 'Lifetime value',
|
||||
returning: 'Returning',
|
||||
profile: 'Profile',
|
||||
statistics: 'Statistics',
|
||||
firstOrder: 'First order',
|
||||
lastOrder: 'Last order',
|
||||
addresses: 'Addresses',
|
||||
notes: 'Notes',
|
||||
notesUnavailable: 'No customer-level notes yet — add a note on one of their orders instead.',
|
||||
orders: 'Orders',
|
||||
activity: 'Activity',
|
||||
emptyTitle: 'No customers yet',
|
||||
emptyDescription: 'Customers appear here once they place their first order.',
|
||||
},
|
||||
mediaLibrary: {
|
||||
title: 'Media Library',
|
||||
upload: 'Upload',
|
||||
@@ -1464,6 +1540,7 @@ export const en: Translations = {
|
||||
products: 'Products',
|
||||
categories: 'Categories',
|
||||
orders: 'Orders',
|
||||
customers: 'Customers',
|
||||
transactions: 'Transactions',
|
||||
reviews: 'Reviews',
|
||||
reports: 'Reports',
|
||||
@@ -1502,6 +1579,8 @@ export const en: Translations = {
|
||||
categoryEdit: { title: 'Edit category', description: 'Change this category\'s details' },
|
||||
orders: { title: 'Orders', description: 'View and process customer orders' },
|
||||
orderDetail: { title: 'Order', description: 'Order details and status' },
|
||||
customers: { title: 'Customers', description: 'Everyone who has placed an order' },
|
||||
customerDetail: { title: 'Customer', description: 'Customer profile, orders, and activity' },
|
||||
transactions: { title: 'Transactions', description: 'Payments, refunds, and fraud review' },
|
||||
media: { title: 'Media Library', description: 'Images and files for products and pages' },
|
||||
users: { title: 'Users', description: 'Admin team members and permissions' },
|
||||
|
||||
@@ -1135,6 +1135,82 @@ export const hy: Translations = {
|
||||
optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։',
|
||||
optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։',
|
||||
},
|
||||
adminOrders: {
|
||||
back: 'Վերադառնալ պատվերներին',
|
||||
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
|
||||
export: 'Արտահանել',
|
||||
view: 'Դիտել',
|
||||
orderNumber: 'Պատվեր',
|
||||
customer: 'Հաճախորդ',
|
||||
payment: 'Վճարում',
|
||||
delivery: 'Առաքում',
|
||||
trackingNumber: 'Հետևման համար',
|
||||
createdAt: 'Ստեղծված է',
|
||||
items: 'Ապրանքներ',
|
||||
timeline: 'Ժամանակագրություն',
|
||||
notes: 'Նշումներ հաճախորդի համար',
|
||||
internalNotes: 'Ներքին նշումներ',
|
||||
addNote: 'Ավելացնել նշում',
|
||||
changeStatus: 'Փոխել կարգավիճակը',
|
||||
applyStatus: 'Կիրառել',
|
||||
archiveSelected: 'Արխիվացնել',
|
||||
printSelected: 'Տպել',
|
||||
printInvoice: 'Տպել հաշիվ-ապրանքագիրը',
|
||||
requestRefund: 'Հայցել վերադարձ',
|
||||
cancelOrder: 'Չեղարկել պատվերը',
|
||||
confirmCancel: 'Չեղարկե՞լ այս պատվերը։',
|
||||
confirmRefund: 'Հայցե՞լ վերադարձ այս պատվերի համար։',
|
||||
emptyTitle: 'Պատվերներ դեռ չկան',
|
||||
emptyDescription: 'Պատվերները կհայտնվեն այստեղ հաճախորդների գնումներից հետո։',
|
||||
column_customer: 'Հաճախորդ',
|
||||
column_status: 'Կարգավիճակ',
|
||||
column_payment: 'Վճարում',
|
||||
column_total: 'Գումար',
|
||||
column_created: 'Ստեղծված է',
|
||||
status: {
|
||||
pending: 'Սպասման մեջ',
|
||||
processing: 'Փաթեթավորում',
|
||||
shipped: 'Առաքվում է',
|
||||
delivered: 'Ավարտված',
|
||||
cancelled: 'Չեղարկված',
|
||||
refunded: 'Վերադարձված',
|
||||
},
|
||||
paymentStatus: {
|
||||
unpaid: 'Չվճարված',
|
||||
paid: 'Վճարված',
|
||||
refund_requested: 'Հայցվել է վերադարձ',
|
||||
refunded: 'Վերադարձված',
|
||||
},
|
||||
ordersToday: 'Այսօրվա պատվերներ',
|
||||
paidCount: 'Վճարված',
|
||||
customersCount: 'Հաճախորդներ',
|
||||
returningCustomers: 'Կրկնվող հաճախորդներ',
|
||||
averageOrder: 'Միջին պատվեր',
|
||||
recentActivity: 'Վերջին ակտիվությունը',
|
||||
noRecentActivity: 'Ակտիվություն դեռ չկա։',
|
||||
alertStuckPending: 'պատվեր սպասման մեջ է 48 ժամից ավելի',
|
||||
alertRefundRequested: 'պատվեր սպասում է վերադարձի հայցի',
|
||||
},
|
||||
adminCustomers: {
|
||||
search: 'Փնտրել ըստ անվան, էլ. փոստի կամ հեռախոսի…',
|
||||
name: 'Անուն',
|
||||
email: 'Էլ. փոստ',
|
||||
phone: 'Հեռախոս',
|
||||
orderCount: 'Պատվերներ',
|
||||
lifetimeValue: 'Ընդհանուր գնումների գումար',
|
||||
returning: 'Կրկնվող',
|
||||
profile: 'Պրոֆիլ',
|
||||
statistics: 'Վիճակագրություն',
|
||||
firstOrder: 'Առաջին պատվեր',
|
||||
lastOrder: 'Վերջին պատվեր',
|
||||
addresses: 'Հասցեներ',
|
||||
notes: 'Նշումներ',
|
||||
notesUnavailable: 'Հաճախորդի մակարդակի նշումներ դեռ չկան․ ավելացրեք նշում նրա որևէ պատվերի վրա։',
|
||||
orders: 'Պատվերներ',
|
||||
activity: 'Ակտիվություն',
|
||||
emptyTitle: 'Հաճախորդներ դեռ չկան',
|
||||
emptyDescription: 'Հաճախորդները կհայտնվեն այստեղ առաջին պատվերից հետո։',
|
||||
},
|
||||
mediaLibrary: {
|
||||
title: 'Մեդիագրադարան',
|
||||
upload: 'Վերբեռնել',
|
||||
@@ -1459,6 +1535,7 @@ export const hy: Translations = {
|
||||
products: 'Ապրանքներ',
|
||||
categories: 'Կատեգորիաներ',
|
||||
orders: 'Պատվերներ',
|
||||
customers: 'Հաճախորդներ',
|
||||
transactions: 'Գործարքներ',
|
||||
reviews: 'Կարծիքներ',
|
||||
reports: 'Հաշվետվություններ',
|
||||
@@ -1497,6 +1574,8 @@ export const hy: Translations = {
|
||||
categoryEdit: { title: 'Կատեգորիայի խմբագրում', description: 'Փոփոխեք կատեգորիայի տվյալները' },
|
||||
orders: { title: 'Պատվերներ', description: 'Դիտեք և մշակեք գնորդների պատվերները' },
|
||||
orderDetail: { title: 'Պատվեր', description: 'Պատվերի մանրամասներն ու կարգավիճակը' },
|
||||
customers: { title: 'Հաճախորդներ', description: 'Բոլորը, ովքեր պատվեր են կատարել' },
|
||||
customerDetail: { title: 'Հաճախորդ', description: 'Հաճախորդի պրոֆիլ, պատվերներ և ակտիվություն' },
|
||||
transactions: { title: 'Գործարքներ', description: 'Վճարումներ, վերադարձներ և կեղծիքի ստուգում' },
|
||||
media: { title: 'Մեդիադարան', description: 'Ապրանքների և էջերի պատկերներ ու ֆայլեր' },
|
||||
users: { title: 'Օգտատերեր', description: 'Ադմինիստրատորների թիմը և իրավունքները' },
|
||||
|
||||
@@ -1135,6 +1135,82 @@ export const ru: Translations = {
|
||||
optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.',
|
||||
optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.',
|
||||
},
|
||||
adminOrders: {
|
||||
back: 'Назад к заказам',
|
||||
search: 'Поиск по номеру заказа, имени, email или телефону…',
|
||||
export: 'Экспорт',
|
||||
view: 'Просмотр',
|
||||
orderNumber: 'Заказ',
|
||||
customer: 'Клиент',
|
||||
payment: 'Оплата',
|
||||
delivery: 'Доставка',
|
||||
trackingNumber: 'Номер отслеживания',
|
||||
createdAt: 'Оформлен',
|
||||
items: 'Товары',
|
||||
timeline: 'Хронология',
|
||||
notes: 'Заметки для клиента',
|
||||
internalNotes: 'Внутренние заметки',
|
||||
addNote: 'Добавить заметку',
|
||||
changeStatus: 'Изменить статус',
|
||||
applyStatus: 'Применить',
|
||||
archiveSelected: 'В архив',
|
||||
printSelected: 'Печать',
|
||||
printInvoice: 'Печать накладной',
|
||||
requestRefund: 'Запросить возврат',
|
||||
cancelOrder: 'Отменить заказ',
|
||||
confirmCancel: 'Отменить этот заказ?',
|
||||
confirmRefund: 'Запросить возврат по этому заказу?',
|
||||
emptyTitle: 'Заказов пока нет',
|
||||
emptyDescription: 'Заказы появятся здесь после оформления покупок.',
|
||||
column_customer: 'Клиент',
|
||||
column_status: 'Статус',
|
||||
column_payment: 'Оплата',
|
||||
column_total: 'Сумма',
|
||||
column_created: 'Оформлен',
|
||||
status: {
|
||||
pending: 'Ожидает',
|
||||
processing: 'Сборка',
|
||||
shipped: 'Доставляется',
|
||||
delivered: 'Завершён',
|
||||
cancelled: 'Отменён',
|
||||
refunded: 'Возвращён',
|
||||
},
|
||||
paymentStatus: {
|
||||
unpaid: 'Не оплачен',
|
||||
paid: 'Оплачен',
|
||||
refund_requested: 'Запрошен возврат',
|
||||
refunded: 'Возвращён',
|
||||
},
|
||||
ordersToday: 'Заказов сегодня',
|
||||
paidCount: 'Оплачено',
|
||||
customersCount: 'Клиенты',
|
||||
returningCustomers: 'Повторные клиенты',
|
||||
averageOrder: 'Средний чек',
|
||||
recentActivity: 'Недавняя активность',
|
||||
noRecentActivity: 'Активности пока нет.',
|
||||
alertStuckPending: 'заказов ожидают более 48 часов',
|
||||
alertRefundRequested: 'заказов ждут запроса на возврат',
|
||||
},
|
||||
adminCustomers: {
|
||||
search: 'Поиск по имени, email или телефону…',
|
||||
name: 'Имя',
|
||||
email: 'Email',
|
||||
phone: 'Телефон',
|
||||
orderCount: 'Заказы',
|
||||
lifetimeValue: 'Общая сумма покупок',
|
||||
returning: 'Повторный',
|
||||
profile: 'Профиль',
|
||||
statistics: 'Статистика',
|
||||
firstOrder: 'Первый заказ',
|
||||
lastOrder: 'Последний заказ',
|
||||
addresses: 'Адреса',
|
||||
notes: 'Заметки',
|
||||
notesUnavailable: 'Заметок по клиенту пока нет — добавьте заметку к одному из его заказов.',
|
||||
orders: 'Заказы',
|
||||
activity: 'Активность',
|
||||
emptyTitle: 'Клиентов пока нет',
|
||||
emptyDescription: 'Клиенты появятся здесь после первого заказа.',
|
||||
},
|
||||
mediaLibrary: {
|
||||
title: 'Медиатека',
|
||||
upload: 'Загрузить',
|
||||
@@ -1459,6 +1535,7 @@ export const ru: Translations = {
|
||||
products: 'Товары',
|
||||
categories: 'Категории',
|
||||
orders: 'Заказы',
|
||||
customers: 'Клиенты',
|
||||
transactions: 'Транзакции',
|
||||
reviews: 'Отзывы',
|
||||
reports: 'Отчёты',
|
||||
@@ -1497,6 +1574,8 @@ export const ru: Translations = {
|
||||
categoryEdit: { title: 'Редактирование категории', description: 'Измените данные категории' },
|
||||
orders: { title: 'Заказы', description: 'Просматривайте и обрабатывайте заказы покупателей' },
|
||||
orderDetail: { title: 'Заказ', description: 'Детали и статус заказа' },
|
||||
customers: { title: 'Клиенты', description: 'Все, кто оформлял заказ' },
|
||||
customerDetail: { title: 'Клиент', description: 'Профиль клиента, заказы и активность' },
|
||||
transactions: { title: 'Транзакции', description: 'Платежи, возвраты и проверка мошенничества' },
|
||||
media: { title: 'Медиатека', description: 'Изображения и файлы для товаров и страниц' },
|
||||
users: { title: 'Пользователи', description: 'Команда администраторов и права доступа' },
|
||||
|
||||
@@ -1138,6 +1138,82 @@ export interface Translations {
|
||||
optionalDataMissingDescription: string;
|
||||
optionalDataMissingResolution: string;
|
||||
};
|
||||
adminOrders: {
|
||||
back: string;
|
||||
search: string;
|
||||
export: string;
|
||||
view: string;
|
||||
orderNumber: string;
|
||||
customer: string;
|
||||
payment: string;
|
||||
delivery: string;
|
||||
trackingNumber: string;
|
||||
createdAt: string;
|
||||
items: string;
|
||||
timeline: string;
|
||||
notes: string;
|
||||
internalNotes: string;
|
||||
addNote: string;
|
||||
changeStatus: string;
|
||||
applyStatus: string;
|
||||
archiveSelected: string;
|
||||
printSelected: string;
|
||||
printInvoice: string;
|
||||
requestRefund: string;
|
||||
cancelOrder: string;
|
||||
confirmCancel: string;
|
||||
confirmRefund: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
column_customer: string;
|
||||
column_status: string;
|
||||
column_payment: string;
|
||||
column_total: string;
|
||||
column_created: string;
|
||||
status: {
|
||||
pending: string;
|
||||
processing: string;
|
||||
shipped: string;
|
||||
delivered: string;
|
||||
cancelled: string;
|
||||
refunded: string;
|
||||
};
|
||||
paymentStatus: {
|
||||
unpaid: string;
|
||||
paid: string;
|
||||
refund_requested: string;
|
||||
refunded: string;
|
||||
};
|
||||
ordersToday: string;
|
||||
paidCount: string;
|
||||
customersCount: string;
|
||||
returningCustomers: string;
|
||||
averageOrder: string;
|
||||
recentActivity: string;
|
||||
noRecentActivity: string;
|
||||
alertStuckPending: string;
|
||||
alertRefundRequested: string;
|
||||
};
|
||||
adminCustomers: {
|
||||
search: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
orderCount: string;
|
||||
lifetimeValue: string;
|
||||
returning: string;
|
||||
profile: string;
|
||||
statistics: string;
|
||||
firstOrder: string;
|
||||
lastOrder: string;
|
||||
addresses: string;
|
||||
notes: string;
|
||||
notesUnavailable: string;
|
||||
orders: string;
|
||||
activity: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
};
|
||||
mediaLibrary: {
|
||||
title: string;
|
||||
upload: string;
|
||||
@@ -1471,6 +1547,7 @@ export interface Translations {
|
||||
products: string;
|
||||
categories: string;
|
||||
orders: string;
|
||||
customers: string;
|
||||
transactions: string;
|
||||
reviews: string;
|
||||
reports: string;
|
||||
@@ -1509,6 +1586,8 @@ export interface Translations {
|
||||
categoryEdit: { title: string; description: string };
|
||||
orders: { title: string; description: string };
|
||||
orderDetail: { title: string; description: string };
|
||||
customers: { title: string; description: string };
|
||||
customerDetail: { title: string; description: string };
|
||||
transactions: { title: string; description: string };
|
||||
media: { title: string; description: string };
|
||||
users: { title: string; description: string };
|
||||
|
||||
Reference in New Issue
Block a user