From 097927f12468f84756161f932834f7966d1737c6 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 18 Jul 2026 21:47:54 +0400 Subject: [PATCH] feat(admin): implement order and customer operations experience 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). --- src/app/app.routes.ts | 18 +++ .../facade/admin-customers.facade.ts | 79 +++++++++ .../customers/models/admin-customer.model.ts | 19 +++ .../admin-customer-detail-page.component.html | 53 +++++++ .../admin-customer-detail-page.component.scss | 13 ++ .../admin-customer-detail-page.component.ts | 47 ++++++ .../admin-customers-list-page.component.html | 45 ++++++ .../admin-customers-list-page.component.scss | 3 + .../admin-customers-list-page.component.ts | 35 ++++ .../order-timeline.component.html | 19 +++ .../order-timeline.component.scss | 57 +++++++ .../order-timeline.component.ts | 24 +++ .../orders-dashboard.component.html | 33 ++++ .../orders-dashboard.component.scss | 51 ++++++ .../orders-dashboard.component.ts | 19 +++ .../orders/facade/admin-orders.facade.ts | 150 +++++++++++++++++- .../admin/orders/models/admin-order.model.ts | 1 + .../admin-order-detail-page.component.html | 28 +++- .../admin-order-detail-page.component.scss | 63 +++++++- .../admin-order-detail-page.component.ts | 24 ++- .../admin-orders-list-page.component.html | 90 ++++++++--- .../admin-orders-list-page.component.scss | 20 +++ .../pages/admin-orders-list-page.component.ts | 29 +++- .../admin-orders-gateway.interface.ts | 3 + .../services/admin-orders-local.gateway.ts | 14 ++ .../features/admin/shell/admin-nav.model.ts | 1 + src/app/i18n/en.ts | 79 +++++++++ src/app/i18n/hy.ts | 79 +++++++++ src/app/i18n/ru.ts | 79 +++++++++ src/app/i18n/translations.ts | 79 +++++++++ 30 files changed, 1218 insertions(+), 36 deletions(-) create mode 100644 src/app/features/admin/customers/facade/admin-customers.facade.ts create mode 100644 src/app/features/admin/customers/models/admin-customer.model.ts create mode 100644 src/app/features/admin/customers/pages/admin-customer-detail-page.component.html create mode 100644 src/app/features/admin/customers/pages/admin-customer-detail-page.component.scss create mode 100644 src/app/features/admin/customers/pages/admin-customer-detail-page.component.ts create mode 100644 src/app/features/admin/customers/pages/admin-customers-list-page.component.html create mode 100644 src/app/features/admin/customers/pages/admin-customers-list-page.component.scss create mode 100644 src/app/features/admin/customers/pages/admin-customers-list-page.component.ts create mode 100644 src/app/features/admin/orders/components/order-timeline/order-timeline.component.html create mode 100644 src/app/features/admin/orders/components/order-timeline/order-timeline.component.scss create mode 100644 src/app/features/admin/orders/components/order-timeline/order-timeline.component.ts create mode 100644 src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.html create mode 100644 src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.scss create mode 100644 src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 010b19f..99e679a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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), diff --git a/src/app/features/admin/customers/facade/admin-customers.facade.ts b/src/app/features/admin/customers/facade/admin-customers.facade.ts new file mode 100644 index 0000000..dbeab03 --- /dev/null +++ b/src/app/features/admin/customers/facade/admin-customers.facade.ts @@ -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([]); + readonly loading = signal(false); + readonly search = signal(''); + readonly selected = signal(null); + + private buildCustomers(orders: AdminOrder[]): AdminCustomer[] { + const byEmail = new Map(); + 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) + ); + } +} diff --git a/src/app/features/admin/customers/models/admin-customer.model.ts b/src/app/features/admin/customers/models/admin-customer.model.ts new file mode 100644 index 0000000..6cbdbb5 --- /dev/null +++ b/src/app/features/admin/customers/models/admin-customer.model.ts @@ -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[]; +} diff --git a/src/app/features/admin/customers/pages/admin-customer-detail-page.component.html b/src/app/features/admin/customers/pages/admin-customer-detail-page.component.html new file mode 100644 index 0000000..2a107e7 --- /dev/null +++ b/src/app/features/admin/customers/pages/admin-customer-detail-page.component.html @@ -0,0 +1,53 @@ +@if (facade.selected(); as customer) { +
+
+ {{ 'adminOrders.back' | translate }} +

{{ customer.name }}

+ @if (customer.orderCount > 1) { + {{ 'adminCustomers.returning' | translate }} + } +
+ +
+
+

{{ 'adminCustomers.profile' | translate }}

+

{{ customer.email }}

+

{{ customer.phone }}

+
+
+

{{ 'adminCustomers.statistics' | translate }}

+

{{ 'adminCustomers.orderCount' | translate }}: {{ customer.orderCount }}

+

{{ 'adminCustomers.lifetimeValue' | translate }}: {{ customer.totalSpent }} {{ customer.currency }}

+

{{ 'adminCustomers.firstOrder' | translate }}: {{ customer.firstOrderAt | date:'short' }}

+

{{ 'adminCustomers.lastOrder' | translate }}: {{ customer.lastOrderAt | date:'short' }}

+
+
+

{{ 'adminCustomers.addresses' | translate }}

+ @for (address of customer.addresses; track address) {

{{ address }}

} +
+
+

{{ 'adminCustomers.notes' | translate }}

+

{{ 'adminCustomers.notesUnavailable' | translate }}

+
+
+ +
+

{{ 'adminCustomers.orders' | translate }}

+ @for (order of customer.orders; track order.id) { +
+ + {{ ('adminOrders.status.' + order.status) | translate }} + {{ order.total }} {{ order.currency }} + {{ order.createdAt | date:'short' }} +
+ } +
+ +
+

{{ 'adminCustomers.activity' | translate }}

+ +
+
+} @else { +

{{ 'common.loading' | translate }}

+} diff --git a/src/app/features/admin/customers/pages/admin-customer-detail-page.component.scss b/src/app/features/admin/customers/pages/admin-customer-detail-page.component.scss new file mode 100644 index 0000000..e3f829b --- /dev/null +++ b/src/app/features/admin/customers/pages/admin-customer-detail-page.component.scss @@ -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; } } diff --git a/src/app/features/admin/customers/pages/admin-customer-detail-page.component.ts b/src/app/features/admin/customers/pages/admin-customer-detail-page.component.ts new file mode 100644 index 0000000..aa01ed0 --- /dev/null +++ b/src/app/features/admin/customers/pages/admin-customer-detail-page.component.ts @@ -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(() => { + 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]); + } +} diff --git a/src/app/features/admin/customers/pages/admin-customers-list-page.component.html b/src/app/features/admin/customers/pages/admin-customers-list-page.component.html new file mode 100644 index 0000000..2023b50 --- /dev/null +++ b/src/app/features/admin/customers/pages/admin-customers-list-page.component.html @@ -0,0 +1,45 @@ +
+
+ +
+ + @if (facade.loading()) { +
+ @for (i of [1,2,3,4]; track i) { } +
+ } @else if (facade.filteredCustomers().length === 0) { + + } @else { + + + + {{ 'adminCustomers.name' | translate }} + {{ 'adminCustomers.email' | translate }} + {{ 'adminCustomers.phone' | translate }} + {{ 'adminCustomers.orderCount' | translate }} + {{ 'adminCustomers.lifetimeValue' | translate }} + {{ 'adminOrders.createdAt' | translate }} + {{ 'adminProducts.actions' | translate }} + + + + @for (customer of facade.filteredCustomers(); track customer.email) { + + {{ customer.name }} + {{ customer.email }} + {{ customer.phone }} + + {{ customer.orderCount }} + @if (customer.orderCount > 1) { + {{ 'adminCustomers.returning' | translate }} + } + + {{ customer.totalSpent }} {{ customer.currency }} + {{ customer.lastOrderAt | date:'short' }} + {{ 'adminOrders.view' | translate }} + + } + + + } +
diff --git a/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss b/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss new file mode 100644 index 0000000..cf491b6 --- /dev/null +++ b/src/app/features/admin/customers/pages/admin-customers-list-page.component.scss @@ -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; } diff --git a/src/app/features/admin/customers/pages/admin-customers-list-page.component.ts b/src/app/features/admin/customers/pages/admin-customers-list-page.component.ts new file mode 100644 index 0000000..298fd19 --- /dev/null +++ b/src/app/features/admin/customers/pages/admin-customers-list-page.component.ts @@ -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)]); + } +} diff --git a/src/app/features/admin/orders/components/order-timeline/order-timeline.component.html b/src/app/features/admin/orders/components/order-timeline/order-timeline.component.html new file mode 100644 index 0000000..a98ae89 --- /dev/null +++ b/src/app/features/admin/orders/components/order-timeline/order-timeline.component.html @@ -0,0 +1,19 @@ +
    + @for (entry of entries(); track entry.timestamp + (entry.note || '')) { +
  1. + +
    +
    + + @if (entry.status) { + {{ ('adminOrders.status.' + entry.status) | translate }} + } + @if (showOrderNumber() && entry.orderNumber) { + {{ entry.orderNumber }} + } +
    +

    {{ entry.note }}

    +
    +
  2. + } +
diff --git a/src/app/features/admin/orders/components/order-timeline/order-timeline.component.scss b/src/app/features/admin/orders/components/order-timeline/order-timeline.component.scss new file mode 100644 index 0000000..957f647 --- /dev/null +++ b/src/app/features/admin/orders/components/order-timeline/order-timeline.component.scss @@ -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); +} diff --git a/src/app/features/admin/orders/components/order-timeline/order-timeline.component.ts b/src/app/features/admin/orders/components/order-timeline/order-timeline.component.ts new file mode 100644 index 0000000..1d93e50 --- /dev/null +++ b/src/app/features/admin/orders/components/order-timeline/order-timeline.component.ts @@ -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(); + readonly showOrderNumber = input(false); +} diff --git a/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.html b/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.html new file mode 100644 index 0000000..21a6c22 --- /dev/null +++ b/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.html @@ -0,0 +1,33 @@ +
+
+ + + + + + + + +
+ + @if (stats().alerts.length > 0) { + + @for (alert of stats().alerts; track alert.labelKey) { + {{ alert.count }} — {{ alert.labelKey | translate }} + } + + } + + +

{{ 'adminOrders.recentActivity' | translate }}

+ @if (stats().recentActivity.length === 0) { +

{{ 'adminOrders.noRecentActivity' | translate }}

+ } @else { +
    + @for (entry of stats().recentActivity; track entry.timestamp + entry.orderId) { +
  • {{ entry.timestamp | date:'short' }} — {{ entry.orderNumber }} — {{ entry.note }}
  • + } +
+ } +
+
diff --git a/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.scss b/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.scss new file mode 100644 index 0000000..6ce28c3 --- /dev/null +++ b/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.scss @@ -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); +} diff --git a/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.ts b/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.ts new file mode 100644 index 0000000..59a2d14 --- /dev/null +++ b/src/app/features/admin/orders/components/orders-dashboard/orders-dashboard.component.ts @@ -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(); +} diff --git a/src/app/features/admin/orders/facade/admin-orders.facade.ts b/src/app/features/admin/orders/facade/admin-orders.facade.ts index 15a9591..dbac20f 100644 --- a/src/app/features/admin/orders/facade/admin-orders.facade.ts +++ b/src/app/features/admin/orders/facade/admin-orders.facade.ts @@ -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({ search: '', status: 'all', page: 1, pageSize: 10 }); readonly orders = signal([]); @@ -13,6 +39,41 @@ export class AdminOrdersFacade { readonly loading = signal(false); readonly selected = signal(null); + readonly viewMode = signal((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table'); + readonly density = signal((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable'); + readonly visibleColumns = signal(this.localStorage.getJSON(COLUMNS_KEY) ?? [...ALL_ORDER_COLUMNS]); + readonly selectedIds = signal([]); + readonly hasSelection = computed(() => this.selectedIds().length > 0); + readonly dashboardStats = signal(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(); + 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, + }; + } } diff --git a/src/app/features/admin/orders/models/admin-order.model.ts b/src/app/features/admin/orders/models/admin-order.model.ts index 3c6229b..46f8a4d 100644 --- a/src/app/features/admin/orders/models/admin-order.model.ts +++ b/src/app/features/admin/orders/models/admin-order.model.ts @@ -46,6 +46,7 @@ export interface AdminOrder { notes: string; internalNotes: string; timeline: AdminOrderTimelineEntry[]; + archived: boolean; createdAt: string; updatedAt: string; } diff --git a/src/app/features/admin/orders/pages/admin-order-detail-page.component.html b/src/app/features/admin/orders/pages/admin-order-detail-page.component.html index bb22639..d929b20 100644 --- a/src/app/features/admin/orders/pages/admin-order-detail-page.component.html +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.html @@ -8,10 +8,28 @@ {{ 'adminOrders.printInvoice' | translate }} + @if (!isTerminal()) { +
    + @for (step of workflowSteps; track step; let i = $index) { +
  1. + + {{ ('adminOrders.status.' + step) | translate }} +
  2. + } +
+ } @else { +

{{ ('adminOrders.status.' + order.status) | translate }}

+ } +

{{ 'adminOrders.customer' | translate }}

-

{{ order.customer.name }}

+ +

{{ order.customer.email }}

{{ order.customer.phone }}

@@ -21,10 +39,10 @@

{{ order.payment.amount }} {{ order.payment.currency }}

-

{{ 'adminOrders.shipping' | translate }}

+

{{ 'adminOrders.delivery' | translate }}

{{ order.shipping.address }}

{{ order.shipping.method }}

- @if (order.shipping.trackingNumber) {

{{ order.shipping.trackingNumber }}

} + @if (order.shipping.trackingNumber) {

{{ 'adminOrders.trackingNumber' | translate }}: {{ order.shipping.trackingNumber }}

}

{{ 'adminOrders.changeStatus' | translate }}

@@ -50,9 +68,7 @@

{{ 'adminOrders.timeline' | translate }}

- @for (entry of order.timeline; track $index) { -

{{ entry.timestamp | date:'short' }} — {{ ('adminOrders.status.' + entry.status) | translate }} — {{ entry.note }}

- } +
diff --git a/src/app/features/admin/orders/pages/admin-order-detail-page.component.scss b/src/app/features/admin/orders/pages/admin-order-detail-page.component.scss index 70a363f..7189e85 100644 --- a/src/app/features/admin/orders/pages/admin-order-detail-page.component.scss +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.scss @@ -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); +} diff --git a/src/app/features/admin/orders/pages/admin-order-detail-page.component.ts b/src/app/features/admin/orders/pages/admin-order-detail-page.component.ts index 5748e95..f61e3ef 100644 --- a/src/app/features/admin/orders/pages/admin-order-detail-page.component.ts +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.ts @@ -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}`); } diff --git a/src/app/features/admin/orders/pages/admin-orders-list-page.component.html b/src/app/features/admin/orders/pages/admin-orders-list-page.component.html index 3fddf31..e838458 100644 --- a/src/app/features/admin/orders/pages/admin-orders-list-page.component.html +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.html @@ -1,4 +1,8 @@
+ @if (facade.dashboardStats(); as stats) { + + } +
@@ -8,9 +12,43 @@ }
- {{ 'adminOrders.export' | translate }} +
+
+ {{ 'adminProducts.densityComfortable' | translate }} + {{ 'adminProducts.densityCompact' | translate }} +
+ {{ 'adminProducts.columns' | translate }} + {{ 'adminOrders.export' | translate }} +
+ @if (columnsPanelOpen()) { + + @for (column of allColumns; track column) { + + } + + } + + @if (facade.selectedIds().length > 0) { +
+ {{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }} + + {{ 'adminOrders.applyStatus' | translate }} + {{ 'adminOrders.archiveSelected' | translate }} + {{ 'adminProducts.bulkExportAction' | translate }} + {{ 'adminOrders.printSelected' | translate }} + {{ 'adminProducts.bulkDelete' | translate }} +
+ } + @if (facade.loading()) {
@for (i of [1,2,3,4]; track i) { } @@ -18,30 +56,36 @@ } @else if (facade.orders().length === 0) { } @else { - - - - {{ 'adminOrders.orderNumber' | translate }} - {{ 'adminOrders.customer' | translate }} - {{ 'backoffice.status' | translate }} - {{ 'backoffice.price' | translate }} - {{ 'adminOrders.createdAt' | translate }} - {{ 'adminProducts.actions' | translate }} - - - - @for (order of facade.orders(); track order.id) { +
+ + - {{ order.orderNumber }} - {{ order.customer.name }} - {{ ('adminOrders.status.' + order.status) | translate }} - {{ order.total }} {{ order.currency }} - {{ order.createdAt | date:'short' }} - {{ 'adminOrders.view' | translate }} + + {{ 'adminOrders.orderNumber' | translate }} + @if (isColumnVisible('customer')) { {{ 'adminOrders.customer' | translate }} } + @if (isColumnVisible('status')) { {{ 'backoffice.status' | translate }} } + @if (isColumnVisible('payment')) { {{ 'adminOrders.payment' | translate }} } + @if (isColumnVisible('total')) { {{ 'backoffice.price' | translate }} } + @if (isColumnVisible('created')) { {{ 'adminOrders.createdAt' | translate }} } + {{ 'adminProducts.actions' | translate }} - } - - + + + @for (order of facade.orders(); track order.id) { + + + {{ order.orderNumber }} + @if (isColumnVisible('customer')) { {{ order.customer.name }} } + @if (isColumnVisible('status')) { {{ ('adminOrders.status.' + order.status) | translate }} } + @if (isColumnVisible('payment')) { {{ ('adminOrders.paymentStatus.' + order.payment.status) | translate }} } + @if (isColumnVisible('total')) { {{ order.total }} {{ order.currency }} } + @if (isColumnVisible('created')) { {{ order.createdAt | date:'short' }} } + {{ 'adminOrders.view' | translate }} + + } + + +
{{ facade.total() }} {{ 'adminProducts.items' | translate }} diff --git a/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss b/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss index 73a2c80..81df4b5 100644 --- a/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss @@ -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; } diff --git a/src/app/features/admin/orders/pages/admin-orders-list-page.component.ts b/src/app/features/admin/orders/pages/admin-orders-list-page.component.ts index 2f47ae0..9c1d430 100644 --- a/src/app/features/admin/orders/pages/admin-orders-list-page.component.ts +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.ts @@ -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('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' }); diff --git a/src/app/features/admin/orders/services/admin-orders-gateway.interface.ts b/src/app/features/admin/orders/services/admin-orders-gateway.interface.ts index 8bed28a..2bec462 100644 --- a/src/app/features/admin/orders/services/admin-orders-gateway.interface.ts +++ b/src/app/features/admin/orders/services/admin-orders-gateway.interface.ts @@ -7,4 +7,7 @@ export interface AdminOrdersGateway { updateStatus(id: string, status: AdminOrderStatus, note: string): Observable; requestRefund(id: string): Observable; addNote(id: string, note: string, internal: boolean): Observable; + archiveOrder(id: string): Observable; + restoreOrder(id: string): Observable; + deleteOrder(id: string): Observable; } diff --git a/src/app/features/admin/orders/services/admin-orders-local.gateway.ts b/src/app/features/admin/orders/services/admin-orders-local.gateway.ts index 79db506..7348b65 100644 --- a/src/app/features/admin/orders/services/admin-orders-local.gateway.ts +++ b/src/app/features/admin/orders/services/admin-orders-local.gateway.ts @@ -58,6 +58,19 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway { })); } + archiveOrder(id: string): Observable { + return this.mutate(id, order => ({ ...order, archived: true, updatedAt: new Date().toISOString() })); + } + + restoreOrder(id: string): Observable { + return this.mutate(id, order => ({ ...order, archived: false, updatedAt: new Date().toISOString() })); + } + + deleteOrder(id: string): Observable { + 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 { 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, }; diff --git a/src/app/features/admin/shell/admin-nav.model.ts b/src/app/features/admin/shell/admin-nav.model.ts index f56c48f..81fb9ed 100644 --- a/src/app/features/admin/shell/admin-nav.model.ts +++ b/src/app/features/admin/shell/admin-nav.model.ts @@ -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 }, diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index a7e1bb5..f9f063a 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -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' }, diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 314003e..499c85a 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -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: 'Ադմինիստրատորների թիմը և իրավունքները' }, diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 20c150d..64596da 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -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: 'Команда администраторов и права доступа' }, diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index aeeb62e..0eff8cb 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -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 };