diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 9a37bdc..4fa923d 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -291,6 +291,29 @@ implementation) + `features/backoffice/media/` + the shared API and rebinding the provider; no consumer (`MediaLibraryFacade`, `MediaPickerComponent`, or any of the pickers above) changes. +## Sprint 23 - Orders (mock/local) + +`features/admin/orders/` (model/gateway/facade/pages), same +container/facade/service split as the rest of `admin/*`: + +- **No real data source exists for orders anywhere in this repo** (already + called out in Sprint 19's dashboard gap and `docs/BACKEND.md` item 7) - + `AdminOrdersLocalGateway` seeds 24 deterministic synthetic orders in + memory (cycling through all statuses/customers) rather than reading from + `BackofficeDataService`, since there is nothing there to read. This is + explicitly a placeholder to unblock the admin UI, not a real mock of + production order volume. +- List: search (order number/customer/email), status filter, pagination, + CSV export (client-side `Blob` download, no server round-trip). +- Detail: customer/payment/shipping info, itemized line items + total, + status timeline, change-status dropdown, refund request and cancel + (both `window.confirm`-gated), customer-visible notes vs internal-only + notes (two separate free-text logs), print invoice via `window.print()` + with a `@media print` rule hiding all non-invoice chrome (`.no-print`) - + no PDF generation library, deliberately minimal. +- Wired into `/​:lang/backoffice/orders` and `/​:lang/backoffice/orders/:id`, + replacing the coming-soon placeholder. + ## Known gaps / backend needs - **Dashboard metrics endpoint.** Categories/Products counts are computed diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 25ff982..8781d64 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -91,13 +91,13 @@ Backend must also support content moderation/validation on publish (disallow dan **Frontend files:** implement `AdminProductsApiGateway` alongside the existing `AdminProductsLocalGateway` and rebind the injection token — `features/admin/products/pages/*` and the facade do not change. -## 7. Orders / revenue (does not exist at all) +## 7. Orders / revenue -**Current behavior:** no backend or local data model for orders or revenue exists anywhere in the codebase. `features/backoffice/orders` is an empty placeholder. The Admin Dashboard's Orders and Revenue cards intentionally render a `pending-backend` state ("Awaiting backend integration") rather than fabricated numbers or a generic empty state. +**Current behavior (Sprint 23):** `features/admin/orders/` now exists as an admin CRUD-ish surface (list/detail, status changes, refund request, cancel, notes, CSV export, print invoice) but runs entirely against `AdminOrdersLocalGateway`, which fabricates 24 synthetic in-memory orders — there is still no real order data anywhere in this system. The dashboard's Orders/Revenue cards (Sprint 19) still correctly render `pending-backend` rather than reading from this mock (they're intentionally not wired to it — the mock is order-management scaffolding, not a real metrics source). -**Needed:** an order domain (creation, lifecycle, line items, totals) and revenue aggregation, plus endpoints to back a dashboard summary (see item 8) and an admin orders list/detail UI. +**Needed:** a real order domain — `AdminOrder` shape is in `src/app/features/admin/orders/models/admin-order.model.ts`. At minimum: order CRUD, status transitions with a timeline/audit trail, payment status, refund workflow, and a revenue aggregation endpoint for the dashboard cards. -**Frontend files:** `features/admin/dashboard/facade/admin-dashboard.facade.ts` (card status computation), a new `features/admin/orders/` module once the domain exists. +**Frontend files:** implement `AdminOrdersApiGateway` against `AdminOrdersGateway` (`services/admin-orders-gateway.interface.ts`) and rebind via an injection token — facade and pages don't change. ## 8. Dashboard metrics diff --git a/docs/SPRINT-PLAN.md b/docs/SPRINT-PLAN.md index 4b9ddba..d979b32 100644 --- a/docs/SPRINT-PLAN.md +++ b/docs/SPRINT-PLAN.md @@ -40,10 +40,11 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin - Commit: `feat(media): reusable media management` - Scoped down from ticket: no interactive crop UI (compression/resize only); folders are a flat tag, not a real folder entity/hierarchy. -## Sprint 23 — Orders (mock/local, flag backend gap) -- [ ] Orders model + local gateway (seed mock data) -- [ ] List: filters, search, statuses, timeline, export -- [ ] Detail: customer/payment/shipping info, notes/internal notes, refund request, cancel, print invoice +## Sprint 23 — Orders (mock/local, flag backend gap) ✅ done +- [x] AdminOrder model + local gateway (24 seeded synthetic orders, no real data source existed) +- [x] List: search, status filter, pagination, CSV export +- [x] Detail: customer/payment/shipping, itemized total, status timeline, change-status, refund request + cancel (confirm-gated), customer notes + internal notes, print invoice (window.print + @media print) +- [x] `docs/ADMIN.md` (new Sprint 23 section), `docs/BACKEND.md` item 7 rewritten - Commit: `feat(admin): order management` ## Sprint 24 — Transactions (mock/local) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1d87c39..dace8b1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -98,8 +98,11 @@ const coreRoutes: Routes = [ }, { path: 'orders', - loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), - data: { titleKey: 'dashboard.actionOrders' } + loadComponent: () => import('./features/admin/orders/pages/admin-orders-list-page.component').then(m => m.AdminOrdersListPageComponent) + }, + { + path: 'orders/:id', + loadComponent: () => import('./features/admin/orders/pages/admin-order-detail-page.component').then(m => m.AdminOrderDetailPageComponent) }, { path: 'media', diff --git a/src/app/features/admin/orders/facade/admin-orders.facade.ts b/src/app/features/admin/orders/facade/admin-orders.facade.ts new file mode 100644 index 0000000..15a9591 --- /dev/null +++ b/src/app/features/admin/orders/facade/admin-orders.facade.ts @@ -0,0 +1,65 @@ +import { Injectable, 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'; + +@Injectable({ providedIn: 'root' }) +export class AdminOrdersFacade { + private readonly gateway = inject(AdminOrdersLocalGateway); + + readonly filters = signal({ search: '', status: 'all', page: 1, pageSize: 10 }); + readonly orders = signal([]); + readonly total = signal(0); + readonly loading = signal(false); + readonly selected = signal(null); + + loadList(): void { + this.loading.set(true); + this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({ + next: result => { + this.orders.set(result.items); + this.total.set(result.total); + this.loading.set(false); + }, + error: () => { + this.orders.set([]); + this.total.set(0); + this.loading.set(false); + } + }); + } + + updateFilters(patch: Partial): void { + this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 })); + this.loadList(); + } + + loadDetail(id: string): void { + this.gateway.loadOrder(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) }); + } + + setStatus(id: string, status: AdminOrderStatus, note: string): void { + this.gateway.updateStatus(id, status, note).pipe(take(1)).subscribe({ next: order => this.selected.set(order) }); + } + + cancelOrder(id: string): void { + this.setStatus(id, 'cancelled', 'Cancelled by admin'); + } + + requestRefund(id: string): void { + this.gateway.requestRefund(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) }); + } + + addNote(id: string, note: string, internal: boolean): void { + if (!note.trim()) return; + this.gateway.addNote(id, note.trim(), internal).pipe(take(1)).subscribe({ next: order => this.selected.set(order) }); + } + + exportCsv(): string { + const header = 'Order Number,Status,Customer,Email,Total,Currency,Created At'; + const rows = this.orders().map(order => + [order.orderNumber, order.status, order.customer.name, order.customer.email, order.total, order.currency, order.createdAt].join(',') + ); + return [header, ...rows].join('\n'); + } +} diff --git a/src/app/features/admin/orders/models/admin-order.model.ts b/src/app/features/admin/orders/models/admin-order.model.ts new file mode 100644 index 0000000..3c6229b --- /dev/null +++ b/src/app/features/admin/orders/models/admin-order.model.ts @@ -0,0 +1,65 @@ +export type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'; +export type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded'; + +export interface AdminOrderCustomer { + name: string; + email: string; + phone: string; +} + +export interface AdminOrderPayment { + method: string; + status: AdminOrderPaymentStatus; + amount: number; + currency: string; +} + +export interface AdminOrderShipping { + address: string; + method: string; + trackingNumber: string; +} + +export interface AdminOrderItem { + productId: string; + name: string; + quantity: number; + price: number; +} + +export interface AdminOrderTimelineEntry { + status: AdminOrderStatus; + timestamp: string; + note: string; +} + +export interface AdminOrder { + id: string; + orderNumber: string; + status: AdminOrderStatus; + customer: AdminOrderCustomer; + payment: AdminOrderPayment; + shipping: AdminOrderShipping; + items: AdminOrderItem[]; + total: number; + currency: string; + notes: string; + internalNotes: string; + timeline: AdminOrderTimelineEntry[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminOrderListFilters { + search: string; + status: 'all' | AdminOrderStatus; + page: number; + pageSize: number; +} + +export interface AdminOrdersListResult { + items: AdminOrder[]; + total: number; + page: number; + pageSize: number; +} 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 new file mode 100644 index 0000000..a2fe8e9 --- /dev/null +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.html @@ -0,0 +1,75 @@ +@if (facade.selected(); as order) { +
+
+ {{ 'adminOrders.back' | translate }} +

{{ order.orderNumber }}

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

{{ 'adminOrders.customer' | translate }}

+

{{ order.customer.name }}

+

{{ order.customer.email }}

+

{{ order.customer.phone }}

+
+
+

{{ 'adminOrders.payment' | translate }}

+

{{ order.payment.method }} — {{ ('adminOrders.paymentStatus.' + order.payment.status) | translate }}

+

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

+
+
+

{{ 'adminOrders.shipping' | translate }}

+

{{ order.shipping.address }}

+

{{ order.shipping.method }}

+ @if (order.shipping.trackingNumber) {

{{ order.shipping.trackingNumber }}

} +
+
+

{{ 'adminOrders.changeStatus' | translate }}

+ +
+ {{ 'adminOrders.requestRefund' | translate }} + {{ 'adminOrders.cancelOrder' | translate }} +
+
+
+ +
+

{{ 'adminOrders.items' | translate }}

+ @for (item of order.items; track item.productId) { +

{{ item.name }} × {{ item.quantity }} — {{ item.price }} {{ order.currency }}

+ } +

{{ 'backoffice.price' | translate }}: {{ order.total }} {{ order.currency }}

+
+ +
+

{{ 'adminOrders.timeline' | translate }}

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

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

+ } +
+ +
+
+

{{ 'adminOrders.notes' | translate }}

+ @for (line of order.notes.split('\n'); track $index) { @if (line) {

{{ line }}

} } + + {{ 'adminOrders.addNote' | translate }} +
+
+

{{ 'adminOrders.internalNotes' | translate }}

+ @for (line of order.internalNotes.split('\n'); track $index) { @if (line) {

{{ line }}

} } + + {{ 'adminOrders.addNote' | translate }} +
+
+
+} @else { +

{{ 'common.loading' | translate }}

+} 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 new file mode 100644 index 0000000..70a363f --- /dev/null +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.scss @@ -0,0 +1,14 @@ +.order-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; } +.spacer { flex: 1; } +.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; } +.total { font-weight: 600; } +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; } } 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 new file mode 100644 index 0000000..5748e95 --- /dev/null +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AdminOrdersFacade } from '../facade/admin-orders.facade'; +import { AdminOrderStatus } from '../models/admin-order.model'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { TranslateService } from '../../../../i18n/translate.service'; +import { LanguageService } from '../../../../services/language.service'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; + +@Component({ + selector: 'app-admin-order-detail-page', + standalone: true, + imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent], + templateUrl: './admin-order-detail-page.component.html', + styleUrls: ['./admin-order-detail-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminOrderDetailPageComponent { + readonly facade = inject(AdminOrdersFacade); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly languageService = inject(LanguageService); + private readonly translate = inject(TranslateService); + + readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; + readonly noteDraft = signal(''); + readonly internalNoteDraft = signal(''); + + constructor() { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.facade.loadDetail(id); + } + } + + back(): void { + void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'orders']); + } + + setStatus(id: string, status: AdminOrderStatus): void { + this.facade.setStatus(id, status, `Status changed to ${status}`); + } + + cancel(id: string): void { + if (window.confirm(this.translate.t('adminOrders.confirmCancel'))) { + this.facade.cancelOrder(id); + } + } + + requestRefund(id: string): void { + if (window.confirm(this.translate.t('adminOrders.confirmRefund'))) { + this.facade.requestRefund(id); + } + } + + submitNote(id: string): void { + this.facade.addNote(id, this.noteDraft(), false); + this.noteDraft.set(''); + } + + submitInternalNote(id: string): void { + this.facade.addNote(id, this.internalNoteDraft(), true); + this.internalNoteDraft.set(''); + } + + printInvoice(): void { + window.print(); + } +} 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 new file mode 100644 index 0000000..1cdd0ba --- /dev/null +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.html @@ -0,0 +1,47 @@ +
+
+
+ + +
+ {{ 'adminOrders.export' | translate }} +
+ + @if (!facade.loading() && 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 }} + + } + + + +
+ {{ 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 new file mode 100644 index 0000000..106689c --- /dev/null +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.scss @@ -0,0 +1,6 @@ +.admin-orders-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; } +.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; } +.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; } +select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } +.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; } +@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } } 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 new file mode 100644 index 0000000..09b3540 --- /dev/null +++ b/src/app/features/admin/orders/pages/admin-orders-list-page.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, inject } 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 { 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 { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component'; +import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; + +@Component({ + selector: 'app-admin-orders-list-page', + standalone: true, + imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent], + templateUrl: './admin-orders-list-page.component.html', + styleUrls: ['./admin-orders-list-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminOrdersListPageComponent { + readonly facade = inject(AdminOrdersFacade); + private readonly router = inject(Router); + private readonly languageService = inject(LanguageService); + + readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const; + + constructor() { + this.facade.loadList(); + } + + totalPages(): number { + return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize)); + } + + view(id: string): void { + void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'orders', id]); + } + + exportCsv(): void { + const csv = this.facade.exportCsv(); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'orders.csv'; + link.click(); + URL.revokeObjectURL(url); + } +} 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 new file mode 100644 index 0000000..8bed28a --- /dev/null +++ b/src/app/features/admin/orders/services/admin-orders-gateway.interface.ts @@ -0,0 +1,10 @@ +import { Observable } from 'rxjs'; +import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model'; + +export interface AdminOrdersGateway { + loadOrders(filters: AdminOrderListFilters): Observable; + loadOrder(id: string): Observable; + updateStatus(id: string, status: AdminOrderStatus, note: string): Observable; + requestRefund(id: string): Observable; + addNote(id: string, note: string, internal: boolean): 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 new file mode 100644 index 0000000..79db506 --- /dev/null +++ b/src/app/features/admin/orders/services/admin-orders-local.gateway.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model'; +import { AdminOrdersGateway } from './admin-orders-gateway.interface'; + +const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; +const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan']; +const SEED_COUNT = 24; + +@Injectable({ providedIn: 'root' }) +export class AdminOrdersLocalGateway implements AdminOrdersGateway { + private cache: AdminOrder[] | null = null; + + loadOrders(filters: AdminOrderListFilters): Observable { + const all = this.ensureData(); + const filtered = all + .filter(order => !filters.search || `${order.orderNumber} ${order.customer.name} ${order.customer.email}`.toLowerCase().includes(filters.search.toLowerCase())) + .filter(order => filters.status === 'all' || order.status === filters.status) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + const start = (filters.page - 1) * filters.pageSize; + return of({ + items: filtered.slice(start, start + filters.pageSize), + total: filtered.length, + page: filters.page, + pageSize: filters.pageSize, + }).pipe(delay(50)); + } + + loadOrder(id: string): Observable { + return of(this.ensureData().find(order => order.id === id) ?? null).pipe(delay(50)); + } + + updateStatus(id: string, status: AdminOrderStatus, note: string): Observable { + return this.mutate(id, order => ({ + ...order, + status, + updatedAt: new Date().toISOString(), + timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), note }], + })); + } + + requestRefund(id: string): Observable { + return this.mutate(id, order => ({ + ...order, + payment: { ...order.payment, status: 'refund_requested' }, + updatedAt: new Date().toISOString(), + timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), note: 'Refund requested' }], + })); + } + + addNote(id: string, note: string, internal: boolean): Observable { + return this.mutate(id, order => ({ + ...order, + notes: internal ? order.notes : order.notes ? `${order.notes}\n${note}` : note, + internalNotes: internal ? (order.internalNotes ? `${order.internalNotes}\n${note}` : note) : order.internalNotes, + updatedAt: new Date().toISOString(), + })); + } + + private mutate(id: string, update: (order: AdminOrder) => AdminOrder): Observable { + const all = this.ensureData(); + const existing = all.find(order => order.id === id); + if (!existing) { + return of(null); + } + const updated = update(existing); + this.cache = all.map(order => order.id === id ? updated : order); + return of(updated).pipe(delay(50)); + } + + private ensureData(): AdminOrder[] { + if (!this.cache) { + this.cache = Array.from({ length: SEED_COUNT }, (_, index) => this.seedOrder(index)); + } + return this.cache; + } + + private seedOrder(index: number): AdminOrder { + const status = STATUSES[index % STATUSES.length]; + const customerName = CUSTOMER_NAMES[index % CUSTOMER_NAMES.length]; + const total = 1500 + (index * 137) % 8000; + const createdAt = new Date(Date.now() - index * 36 * 60 * 60 * 1000).toISOString(); + return { + id: `order-${index + 1}`, + orderNumber: `ORD-${1000 + index}`, + status, + customer: { + name: customerName, + email: `${customerName.toLowerCase().replace(/\s+/g, '.')}@example.com`, + phone: `+374${90000000 + index}`, + }, + payment: { + method: index % 2 === 0 ? 'card' : 'cash_on_delivery', + status: status === 'cancelled' ? 'refunded' : status === 'delivered' ? 'paid' : 'unpaid', + amount: total, + currency: 'RUB', + }, + shipping: { + address: `Sample Street ${index + 1}, Yerevan`, + method: index % 3 === 0 ? 'courier' : 'pickup', + trackingNumber: status === 'shipped' || status === 'delivered' ? `TRACK-${100000 + index}` : '', + }, + items: [ + { productId: `product-${(index % 5) + 1}`, name: `Sample Product ${(index % 5) + 1}`, quantity: 1 + (index % 3), price: Math.round(total / (1 + (index % 3))) }, + ], + total, + currency: 'RUB', + notes: '', + internalNotes: '', + timeline: [ + { status: 'pending', timestamp: createdAt, note: 'Order created' }, + ...(status !== 'pending' ? [{ status, timestamp: createdAt, note: `Status set to ${status}` }] : []), + ], + createdAt, + updatedAt: createdAt, + }; + } +}