feat(admin): order management
Sprint 23. New features/admin/orders/ module, same container/facade/service split as admin/products and admin/categories. - AdminOrder model + AdminOrdersLocalGateway seeding 24 deterministic synthetic orders (no real order data source exists anywhere in this repo - explicitly a placeholder, not a mock of production volume) - list: search, status filter, pagination, CSV export (client-side Blob download) - detail: customer/payment/shipping, itemized total, status timeline, change-status dropdown, refund request + cancel (window.confirm-gated), separate customer-facing vs internal notes, print invoice via window.print() with @media print hiding non-invoice chrome - wired into /:lang/backoffice/orders(/:id), replacing the coming-soon placeholder docs/ADMIN.md + docs/BACKEND.md updated; dashboard's Orders/Revenue cards (Sprint 19) remain intentionally un-wired to this mock and still render pending-backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -291,6 +291,29 @@ implementation) + `features/backoffice/media/` + the shared
|
|||||||
API and rebinding the provider; no consumer (`MediaLibraryFacade`,
|
API and rebinding the provider; no consumer (`MediaLibraryFacade`,
|
||||||
`MediaPickerComponent`, or any of the pickers above) changes.
|
`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
|
## Known gaps / backend needs
|
||||||
|
|
||||||
- **Dashboard metrics endpoint.** Categories/Products counts are computed
|
- **Dashboard metrics endpoint.** Categories/Products counts are computed
|
||||||
|
|||||||
@@ -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.
|
**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
|
## 8. Dashboard metrics
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin
|
|||||||
- Commit: `feat(media): reusable media management`
|
- 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.
|
- 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)
|
## Sprint 23 — Orders (mock/local, flag backend gap) ✅ done
|
||||||
- [ ] Orders model + local gateway (seed mock data)
|
- [x] AdminOrder model + local gateway (24 seeded synthetic orders, no real data source existed)
|
||||||
- [ ] List: filters, search, statuses, timeline, export
|
- [x] List: search, status filter, pagination, CSV export
|
||||||
- [ ] Detail: customer/payment/shipping info, notes/internal notes, refund request, cancel, print invoice
|
- [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`
|
- Commit: `feat(admin): order management`
|
||||||
|
|
||||||
## Sprint 24 — Transactions (mock/local)
|
## Sprint 24 — Transactions (mock/local)
|
||||||
|
|||||||
@@ -98,8 +98,11 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'orders',
|
path: 'orders',
|
||||||
loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent),
|
loadComponent: () => import('./features/admin/orders/pages/admin-orders-list-page.component').then(m => m.AdminOrdersListPageComponent)
|
||||||
data: { titleKey: 'dashboard.actionOrders' }
|
},
|
||||||
|
{
|
||||||
|
path: 'orders/:id',
|
||||||
|
loadComponent: () => import('./features/admin/orders/pages/admin-order-detail-page.component').then(m => m.AdminOrderDetailPageComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'media',
|
path: 'media',
|
||||||
|
|||||||
65
src/app/features/admin/orders/facade/admin-orders.facade.ts
Normal file
65
src/app/features/admin/orders/facade/admin-orders.facade.ts
Normal file
@@ -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<AdminOrderListFilters>({ search: '', status: 'all', page: 1, pageSize: 10 });
|
||||||
|
readonly orders = signal<AdminOrder[]>([]);
|
||||||
|
readonly total = signal(0);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly selected = signal<AdminOrder | null>(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<AdminOrderListFilters>): 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/app/features/admin/orders/models/admin-order.model.ts
Normal file
65
src/app/features/admin/orders/models/admin-order.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@if (facade.selected(); as order) {
|
||||||
|
<main class="order-detail">
|
||||||
|
<header class="toolbar no-print">
|
||||||
|
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
|
||||||
|
<h1>{{ order.orderNumber }}</h1>
|
||||||
|
<app-badge variant="neutral">{{ ('adminOrders.status.' + order.status) | translate }}</app-badge>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<app-button variant="secondary" (click)="printInvoice()">{{ 'adminOrders.printInvoice' | translate }}</app-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid two">
|
||||||
|
<div class="card">
|
||||||
|
<h3>{{ 'adminOrders.customer' | translate }}</h3>
|
||||||
|
<p>{{ order.customer.name }}</p>
|
||||||
|
<p>{{ order.customer.email }}</p>
|
||||||
|
<p>{{ order.customer.phone }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>{{ 'adminOrders.payment' | translate }}</h3>
|
||||||
|
<p>{{ order.payment.method }} — {{ ('adminOrders.paymentStatus.' + order.payment.status) | translate }}</p>
|
||||||
|
<p>{{ order.payment.amount }} {{ order.payment.currency }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>{{ 'adminOrders.shipping' | translate }}</h3>
|
||||||
|
<p>{{ order.shipping.address }}</p>
|
||||||
|
<p>{{ order.shipping.method }}</p>
|
||||||
|
@if (order.shipping.trackingNumber) { <p>{{ order.shipping.trackingNumber }}</p> }
|
||||||
|
</div>
|
||||||
|
<div class="card no-print">
|
||||||
|
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
|
||||||
|
<select [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
|
||||||
|
@for (status of statuses; track status) {
|
||||||
|
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)">{{ 'adminOrders.requestRefund' | translate }}</app-button>
|
||||||
|
<app-button variant="danger" size="sm" (click)="cancel(order.id)">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>{{ 'adminOrders.items' | translate }}</h3>
|
||||||
|
@for (item of order.items; track item.productId) {
|
||||||
|
<p>{{ item.name }} × {{ item.quantity }} — {{ item.price }} {{ order.currency }}</p>
|
||||||
|
}
|
||||||
|
<p class="total">{{ 'backoffice.price' | translate }}: {{ order.total }} {{ order.currency }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid two no-print">
|
||||||
|
<div class="card">
|
||||||
|
<h3>{{ 'adminOrders.notes' | translate }}</h3>
|
||||||
|
@for (line of order.notes.split('\n'); track $index) { @if (line) { <p>{{ line }}</p> } }
|
||||||
|
<textarea rows="2" [ngModel]="noteDraft()" (ngModelChange)="noteDraft.set($event)"></textarea>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="submitNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>{{ 'adminOrders.internalNotes' | translate }}</h3>
|
||||||
|
@for (line of order.internalNotes.split('\n'); track $index) { @if (line) { <p>{{ line }}</p> } }
|
||||||
|
<textarea rows="2" [ngModel]="internalNoteDraft()" (ngModelChange)="internalNoteDraft.set($event)"></textarea>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
} @else {
|
||||||
|
<p>{{ 'common.loading' | translate }}</p>
|
||||||
|
}
|
||||||
@@ -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; } }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<section class="admin-orders-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filters">
|
||||||
|
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
|
||||||
|
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
|
||||||
|
@for (status of statuses; track status) {
|
||||||
|
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!facade.loading() && facade.orders().length === 0) {
|
||||||
|
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
|
||||||
|
} @else {
|
||||||
|
<app-table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (order of facade.orders(); track order.id) {
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
<td><app-button variant="secondary" size="sm" (click)="view(order.id)">{{ 'adminOrders.view' | translate }}</app-button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</app-table>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||||
|
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -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; } }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminOrdersListResult>;
|
||||||
|
loadOrder(id: string): Observable<AdminOrder | null>;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -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<AdminOrdersListResult> {
|
||||||
|
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<AdminOrder | null> {
|
||||||
|
return of(this.ensureData().find(order => order.id === id) ?? null).pipe(delay(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(id: string, status: AdminOrderStatus, note: string): Observable<AdminOrder | null> {
|
||||||
|
return this.mutate(id, order => ({
|
||||||
|
...order,
|
||||||
|
status,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), note }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRefund(id: string): Observable<AdminOrder | null> {
|
||||||
|
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<AdminOrder | null> {
|
||||||
|
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<AdminOrder | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user