Browse Source

feat(admin-ui): Implement order process state chart view

Michael Bromley 5 years ago
parent
commit
72832588e9
24 changed files with 503 additions and 20 deletions
  1. 12 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 4 0
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  3. 8 3
      packages/admin-ui/src/lib/core/src/data/server-config.ts
  4. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.html
  5. 0 14
      packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.ts
  6. 34 0
      packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.spec.ts
  7. 31 0
      packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.ts
  8. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  9. 5 1
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html
  10. 17 0
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts
  11. 3 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph-dialog/order-process-graph-dialog.component.html
  12. 0 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph-dialog/order-process-graph-dialog.component.scss
  13. 27 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph-dialog/order-process-graph-dialog.component.ts
  14. 1 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/constants.ts
  15. 8 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-edge.component.html
  16. 23 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-edge.component.scss
  17. 45 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-edge.component.ts
  18. 12 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-graph.component.html
  19. 13 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-graph.component.scss
  20. 116 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-graph.component.ts
  21. 16 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-node.component.html
  22. 61 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-node.component.scss
  23. 55 0
      packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-node.component.ts
  24. 8 0
      packages/admin-ui/src/lib/order/src/order.module.ts

+ 12 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -2567,6 +2567,12 @@ export type OrderListOptions = {
   filter?: Maybe<OrderFilterParameter>;
   filter?: Maybe<OrderFilterParameter>;
 };
 };
 
 
+export type OrderProcessState = {
+   __typename?: 'OrderProcessState';
+  name: Scalars['String'];
+  to: Array<Scalars['String']>;
+};
+
 export type OrderSortParameter = {
 export type OrderSortParameter = {
   id?: Maybe<SortOrder>;
   id?: Maybe<SortOrder>;
   createdAt?: Maybe<SortOrder>;
   createdAt?: Maybe<SortOrder>;
@@ -3378,6 +3384,7 @@ export type SearchResultSortParameter = {
 
 
 export type ServerConfig = {
 export type ServerConfig = {
    __typename?: 'ServerConfig';
    __typename?: 'ServerConfig';
+  orderProcess: Array<OrderProcessState>;
   customFieldConfig: CustomFields;
   customFieldConfig: CustomFields;
 };
 };
 
 
@@ -6309,7 +6316,10 @@ export type GetServerConfigQuery = (
     { __typename?: 'GlobalSettings' }
     { __typename?: 'GlobalSettings' }
     & { serverConfig: (
     & { serverConfig: (
       { __typename?: 'ServerConfig' }
       { __typename?: 'ServerConfig' }
-      & { customFieldConfig: (
+      & { orderProcess: Array<(
+        { __typename?: 'OrderProcessState' }
+        & Pick<OrderProcessState, 'name' | 'to'>
+      )>, customFieldConfig: (
         { __typename?: 'CustomFields' }
         { __typename?: 'CustomFields' }
         & { Address: Array<(
         & { Address: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           { __typename?: 'StringCustomFieldConfig' }
@@ -7897,6 +7907,7 @@ export namespace GetServerConfig {
   export type Query = GetServerConfigQuery;
   export type Query = GetServerConfigQuery;
   export type GlobalSettings = GetServerConfigQuery['globalSettings'];
   export type GlobalSettings = GetServerConfigQuery['globalSettings'];
   export type ServerConfig = GetServerConfigQuery['globalSettings']['serverConfig'];
   export type ServerConfig = GetServerConfigQuery['globalSettings']['serverConfig'];
+  export type OrderProcess = (NonNullable<GetServerConfigQuery['globalSettings']['serverConfig']['orderProcess'][0]>);
   export type CustomFieldConfig = GetServerConfigQuery['globalSettings']['serverConfig']['customFieldConfig'];
   export type CustomFieldConfig = GetServerConfigQuery['globalSettings']['serverConfig']['customFieldConfig'];
   export type Address = CustomFieldsFragment;
   export type Address = CustomFieldsFragment;
   export type Collection = CustomFieldsFragment;
   export type Collection = CustomFieldsFragment;

+ 4 - 0
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -537,6 +537,10 @@ export const GET_SERVER_CONFIG = gql`
     query GetServerConfig {
     query GetServerConfig {
         globalSettings {
         globalSettings {
             serverConfig {
             serverConfig {
+                orderProcess {
+                    name
+                    to
+                }
                 customFieldConfig {
                 customFieldConfig {
                     Address {
                     Address {
                         ...CustomFields
                         ...CustomFields

+ 8 - 3
packages/admin-ui/src/lib/core/src/data/server-config.ts

@@ -6,6 +6,7 @@ import {
     CustomFields,
     CustomFields,
     GetGlobalSettings,
     GetGlobalSettings,
     GetServerConfig,
     GetServerConfig,
+    OrderProcessState,
     ServerConfig,
     ServerConfig,
 } from '../common/generated-types';
 } from '../common/generated-types';
 
 
@@ -46,10 +47,10 @@ export class ServerConfigService {
             .query<GetServerConfig.Query>(GET_SERVER_CONFIG)
             .query<GetServerConfig.Query>(GET_SERVER_CONFIG)
             .single$.toPromise()
             .single$.toPromise()
             .then(
             .then(
-                result => {
+                (result) => {
                     this._serverConfig = result.globalSettings.serverConfig;
                     this._serverConfig = result.globalSettings.serverConfig;
                 },
                 },
-                err => {
+                (err) => {
                     // Let the error fall through to be caught by the http interceptor.
                     // Let the error fall through to be caught by the http interceptor.
                 },
                 },
             );
             );
@@ -58,7 +59,7 @@ export class ServerConfigService {
     getAvailableLanguages() {
     getAvailableLanguages() {
         return this.baseDataService
         return this.baseDataService
             .query<GetGlobalSettings.Query>(GET_GLOBAL_SETTINGS, {}, 'cache-first')
             .query<GetGlobalSettings.Query>(GET_GLOBAL_SETTINGS, {}, 'cache-first')
-            .mapSingle(res => res.globalSettings.availableLanguages);
+            .mapSingle((res) => res.globalSettings.availableLanguages);
     }
     }
 
 
     /**
     /**
@@ -76,6 +77,10 @@ export class ServerConfigService {
         return this.serverConfig.customFieldConfig[type] || [];
         return this.serverConfig.customFieldConfig[type] || [];
     }
     }
 
 
+    getOrderProcessStates(): OrderProcessState[] {
+        return this.serverConfig.orderProcess;
+    }
+
     get serverConfig(): ServerConfig {
     get serverConfig(): ServerConfig {
         return this._serverConfig;
         return this._serverConfig;
     }
     }

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.html

@@ -2,5 +2,6 @@
     <clr-icon shape="success-standard" *ngIf="state === 'Fulfilled'" size="12"></clr-icon>
     <clr-icon shape="success-standard" *ngIf="state === 'Fulfilled'" size="12"></clr-icon>
     <clr-icon shape="success-standard" *ngIf="state === 'PartiallyFulfilled'" size="12"></clr-icon>
     <clr-icon shape="success-standard" *ngIf="state === 'PartiallyFulfilled'" size="12"></clr-icon>
     <clr-icon shape="ban" *ngIf="state === 'Cancelled'" size="12"></clr-icon>
     <clr-icon shape="ban" *ngIf="state === 'Cancelled'" size="12"></clr-icon>
-    {{ stateToken | translate }}
+    {{ state | orderStateI18nToken | translate }}
+    <ng-content></ng-content>
 </vdr-chip>
 </vdr-chip>

+ 0 - 14
packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.ts

@@ -1,5 +1,4 @@
 import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
 import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
-import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 
 
 @Component({
 @Component({
     selector: 'vdr-order-state-label',
     selector: 'vdr-order-state-label',
@@ -9,19 +8,6 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 })
 })
 export class OrderStateLabelComponent {
 export class OrderStateLabelComponent {
     @Input() state: string;
     @Input() state: string;
-    private readonly stateI18nTokens = {
-        AddingItems: _('order.state-adding-items'),
-        ArrangingPayment: _('order.state-arranging-payment'),
-        PaymentAuthorized: _('order.state-payment-authorized'),
-        PaymentSettled: _('order.state-payment-settled'),
-        PartiallyFulfilled: _('order.state-partially-fulfilled'),
-        Fulfilled: _('order.state-fulfilled'),
-        Cancelled: _('order.state-cancelled'),
-    };
-
-    get stateToken(): string {
-        return this.stateI18nTokens[this.state as any] || this.state;
-    }
 
 
     get chipColorType() {
     get chipColorType() {
         switch (this.state) {
         switch (this.state) {

+ 34 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.spec.ts

@@ -0,0 +1,34 @@
+import { OrderStateI18nTokenPipe } from './order-state-i18n-token.pipe';
+
+describe('orderStateI18nTokenPipe', () => {
+    const pipe = new OrderStateI18nTokenPipe();
+
+    it('works with default states', () => {
+        const result = pipe.transform('AddingItems');
+
+        expect(result).toBe('order.state-adding-items');
+    });
+
+    it('works with unknown states', () => {
+        const result = pipe.transform('ValidatingCustomer');
+
+        expect(result).toBe('order.state-validating-customer');
+    });
+
+    it('works with unknown states with various formatting', () => {
+        const result1 = pipe.transform('validating-Customer');
+        expect(result1).toBe('order.state-validating-customer');
+
+        const result2 = pipe.transform('validating-Customer');
+        expect(result2).toBe('order.state-validating-customer');
+
+        const result3 = pipe.transform('Validating Customer');
+        expect(result3).toBe('order.state-validating-customer');
+    });
+
+    it('passes through non-string values', () => {
+        expect(pipe.transform(null)).toBeNull();
+        expect(pipe.transform(1)).toBe(1);
+        expect(pipe.transform({})).toEqual({});
+    });
+});

+ 31 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.ts

@@ -0,0 +1,31 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+
+@Pipe({
+    name: 'orderStateI18nToken',
+})
+export class OrderStateI18nTokenPipe implements PipeTransform {
+    private readonly stateI18nTokens = {
+        AddingItems: _('order.state-adding-items'),
+        ArrangingPayment: _('order.state-arranging-payment'),
+        PaymentAuthorized: _('order.state-payment-authorized'),
+        PaymentSettled: _('order.state-payment-settled'),
+        PartiallyFulfilled: _('order.state-partially-fulfilled'),
+        Fulfilled: _('order.state-fulfilled'),
+        Cancelled: _('order.state-cancelled'),
+    };
+    transform<T extends unknown>(value: T): T {
+        if (typeof value === 'string') {
+            const defaultStateToken = this.stateI18nTokens[value as any];
+            if (defaultStateToken) {
+                return defaultStateToken;
+            }
+            return ('order.state-' +
+                value
+                    .replace(/([a-z])([A-Z])/g, '$1-$2')
+                    .replace(/ +/g, '-')
+                    .toLowerCase()) as any;
+        }
+        return value;
+    }
+}

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -82,6 +82,7 @@ import { CustomFieldLabelPipe } from './pipes/custom-field-label.pipe';
 import { DurationPipe } from './pipes/duration.pipe';
 import { DurationPipe } from './pipes/duration.pipe';
 import { FileSizePipe } from './pipes/file-size.pipe';
 import { FileSizePipe } from './pipes/file-size.pipe';
 import { HasPermissionPipe } from './pipes/has-permission.pipe';
 import { HasPermissionPipe } from './pipes/has-permission.pipe';
+import { OrderStateI18nTokenPipe } from './pipes/order-state-i18n-token.pipe';
 import { SentenceCasePipe } from './pipes/sentence-case.pipe';
 import { SentenceCasePipe } from './pipes/sentence-case.pipe';
 import { SortPipe } from './pipes/sort.pipe';
 import { SortPipe } from './pipes/sort.pipe';
 import { StringToColorPipe } from './pipes/string-to-color.pipe';
 import { StringToColorPipe } from './pipes/string-to-color.pipe';
@@ -172,6 +173,7 @@ const DECLARATIONS = [
     TimelineEntryComponent,
     TimelineEntryComponent,
     HistoryEntryDetailComponent,
     HistoryEntryDetailComponent,
     EditNoteDialogComponent,
     EditNoteDialogComponent,
+    OrderStateI18nTokenPipe,
 ];
 ];
 
 
 @NgModule({
 @NgModule({

+ 5 - 1
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html

@@ -2,7 +2,11 @@
     <vdr-ab-left>
     <vdr-ab-left>
         <div class="flex clr-align-items-center">
         <div class="flex clr-align-items-center">
             <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
             <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
-            <vdr-order-state-label [state]="order.state"></vdr-order-state-label>
+            <vdr-order-state-label [state]="order.state">
+                <button class="icon-button" (click)="openStateDiagram()" [title]="'order.order-state-diagram' | translate">
+                <clr-icon shape="list"></clr-icon>
+                </button>
+            </vdr-order-state-label>
         </div>
         </div>
     </vdr-ab-left>
     </vdr-ab-left>
 
 

+ 17 - 0
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts

@@ -23,6 +23,7 @@ import { map, startWith, switchMap, take } from 'rxjs/operators';
 
 
 import { CancelOrderDialogComponent } from '../cancel-order-dialog/cancel-order-dialog.component';
 import { CancelOrderDialogComponent } from '../cancel-order-dialog/cancel-order-dialog.component';
 import { FulfillOrderDialogComponent } from '../fulfill-order-dialog/fulfill-order-dialog.component';
 import { FulfillOrderDialogComponent } from '../fulfill-order-dialog/fulfill-order-dialog.component';
+import { OrderProcessGraphDialogComponent } from '../order-process-graph-dialog/order-process-graph-dialog.component';
 import { RefundOrderDialogComponent } from '../refund-order-dialog/refund-order-dialog.component';
 import { RefundOrderDialogComponent } from '../refund-order-dialog/refund-order-dialog.component';
 import { SettleRefundDialogComponent } from '../settle-refund-dialog/settle-refund-dialog.component';
 import { SettleRefundDialogComponent } from '../settle-refund-dialog/settle-refund-dialog.component';
 
 
@@ -114,6 +115,22 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         return ['/marketing', 'promotions', id];
         return ['/marketing', 'promotions', id];
     }
     }
 
 
+    openStateDiagram() {
+        this.entity$
+            .pipe(
+                take(1),
+                switchMap((order) =>
+                    this.modalService.fromComponent(OrderProcessGraphDialogComponent, {
+                        closable: true,
+                        locals: {
+                            activeState: order.state,
+                        },
+                    }),
+                ),
+            )
+            .subscribe();
+    }
+
     transitionToState(state: string) {
     transitionToState(state: string) {
         this.dataService.order.transitionToState(this.id, state).subscribe((val) => {
         this.dataService.order.transitionToState(this.id, state).subscribe((val) => {
             this.notificationService.success(_('order.transitioned-to-state-success'), { state });
             this.notificationService.success(_('order.transitioned-to-state-success'), { state });

+ 3 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph-dialog/order-process-graph-dialog.component.html

@@ -0,0 +1,3 @@
+<ng-template vdrDialogTitle>{{ 'order.order-state-diagram' | translate }}</ng-template>
+
+<vdr-order-process-graph [states]="states" [initialState]="activeState"></vdr-order-process-graph>

+ 0 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph-dialog/order-process-graph-dialog.component.scss


+ 27 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph-dialog/order-process-graph-dialog.component.ts

@@ -0,0 +1,27 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import {
+    CancelOrderInput,
+    DataService,
+    Dialog,
+    OrderProcessState,
+    ServerConfigService,
+} from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+
+@Component({
+    selector: 'vdr-order-process-graph-dialog',
+    templateUrl: './order-process-graph-dialog.component.html',
+    styleUrls: ['./order-process-graph-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderProcessGraphDialogComponent implements OnInit, Dialog<void> {
+    activeState: string;
+    states: OrderProcessState[] = [];
+    constructor(private serverConfigService: ServerConfigService) {}
+
+    ngOnInit(): void {
+        this.states = this.serverConfigService.getOrderProcessStates();
+    }
+
+    resolveWith: (result: void | undefined) => void;
+}

+ 1 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/constants.ts

@@ -0,0 +1 @@
+export const NODE_HEIGHT = 72;

+ 8 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-edge.component.html

@@ -0,0 +1,8 @@
+<div
+    [attr.data-from]="from.node.name"
+    [attr.data-to]="to.node.name"
+    [ngStyle]="getStyle()"
+    [class.active]="active$ | async"
+    class="edge">
+    <clr-icon shape="arrow" flip="vertical" class="arrow"></clr-icon>
+</div>

+ 23 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-edge.component.scss

@@ -0,0 +1,23 @@
+@import 'variables';
+
+.edge {
+    position: absolute;
+    border: 1px solid $color-grey-300;
+    background-color: $color-grey-300;
+    opacity: 0.3;
+    transition: border 0.2s, opacity 0.2s, background-color 0.2s;
+    &.active {
+        border-color: $color-primary-500;
+        background-color: $color-primary-500;
+        opacity: 1;
+        .arrow {
+            color: $color-primary-500;
+        }
+    }
+    .arrow {
+        position: absolute;
+        bottom: -4px;
+        left: -8px;
+        color: $color-grey-300;
+    }
+}

+ 45 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-edge.component.ts

@@ -0,0 +1,45 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { tap } from 'rxjs/operators';
+
+import { OrderProcessNodeComponent } from './order-process-node.component';
+
+@Component({
+    selector: 'vdr-order-process-edge',
+    templateUrl: './order-process-edge.component.html',
+    styleUrls: ['./order-process-edge.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderProcessEdgeComponent implements OnInit {
+    @Input() from: OrderProcessNodeComponent;
+    @Input() to: OrderProcessNodeComponent;
+    @Input() index: number;
+    active$: Observable<boolean>;
+
+    ngOnInit() {
+        this.active$ = this.from.active$
+            .asObservable()
+            .pipe(tap((active) => this.to.activeTarget$.next(active)));
+    }
+
+    getStyle() {
+        const direction = this.from.index < this.to.index ? 'down' : 'up';
+        const startPos = this.from.getPos(direction === 'down' ? 'bottom' : 'top');
+        const endPos = this.to.getPos(direction === 'down' ? 'top' : 'bottom');
+        const dX = Math.abs(startPos.x - endPos.x);
+        const dY = Math.abs(startPos.y - endPos.y);
+        const length = Math.sqrt(dX ** 2 + dY ** 2);
+        return {
+            'top.px': startPos.y,
+            'left.px': startPos.x + (direction === 'down' ? 10 : 40) + this.index * 12,
+            'height.px': length,
+            'width.px': 1,
+            ...(direction === 'up'
+                ? {
+                      transform: 'rotateZ(180deg)',
+                      'transform-origin': 'top',
+                  }
+                : {}),
+        };
+    }
+}

+ 12 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-graph.component.html

@@ -0,0 +1,12 @@
+<ng-container *ngFor="let state of nodes; let i = index">
+    <vdr-order-process-node
+        [node]="state"
+        [index]="i"
+        [active]="(activeState$ | async) === state.name"
+        (mouseenter)="onMouseOver(state.name)"
+        (mouseleave)="onMouseOut()"
+    ></vdr-order-process-node>
+</ng-container>
+<ng-container *ngFor="let edge of edges">
+    <vdr-order-process-edge [from]="edge.from" [to]="edge.to" [index]="edge.index"></vdr-order-process-edge>
+</ng-container>

+ 13 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-graph.component.scss

@@ -0,0 +1,13 @@
+@import "variables";
+
+:host {
+    display: block;
+    border: 1px hotpink;
+    margin: 20px;
+    padding: 12px;
+    position: relative;
+}
+
+.state-row {
+    display: flex;
+}

+ 116 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-graph.component.ts

@@ -0,0 +1,116 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    HostBinding,
+    Input,
+    OnChanges,
+    OnInit,
+    QueryList,
+    SimpleChanges,
+    ViewChildren,
+} from '@angular/core';
+import { OrderProcessState } from '@vendure/admin-ui/core';
+import { BehaviorSubject, Observable, Subject } from 'rxjs';
+import { debounceTime } from 'rxjs/operators';
+
+import { NODE_HEIGHT } from './constants';
+import { OrderProcessNodeComponent } from './order-process-node.component';
+
+export type StateNode = {
+    name: string;
+    to: StateNode[];
+};
+
+@Component({
+    selector: 'vdr-order-process-graph',
+    templateUrl: './order-process-graph.component.html',
+    styleUrls: ['./order-process-graph.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderProcessGraphComponent implements OnInit, OnChanges, AfterViewInit {
+    @Input() states: OrderProcessState[];
+    @Input() initialState?: string;
+    setActiveState$ = new BehaviorSubject<string | undefined>(undefined);
+    activeState$: Observable<string | undefined>;
+    nodes: StateNode[] = [];
+    edges: Array<{ from: OrderProcessNodeComponent; to: OrderProcessNodeComponent; index: number }> = [];
+
+    @ViewChildren(OrderProcessNodeComponent) nodeComponents: QueryList<OrderProcessNodeComponent>;
+
+    constructor(private changeDetector: ChangeDetectorRef) {}
+
+    @HostBinding('style.height.px')
+    get outerHeight(): number {
+        return this.nodes.length * NODE_HEIGHT;
+    }
+
+    ngOnInit() {
+        this.setActiveState$.next(this.initialState);
+        this.activeState$ = this.setActiveState$.pipe(debounceTime(150));
+    }
+
+    ngOnChanges(changes: SimpleChanges) {
+        this.populateNodes();
+    }
+
+    ngAfterViewInit() {
+        setTimeout(() => this.populateEdges());
+    }
+
+    onMouseOver(stateName: string) {
+        this.setActiveState$.next(stateName);
+    }
+
+    onMouseOut() {
+        this.setActiveState$.next(this.initialState);
+    }
+
+    getNodeFor(state: string): OrderProcessNodeComponent | undefined {
+        if (this.nodeComponents) {
+            return this.nodeComponents.find((n) => n.node.name === state);
+        }
+    }
+
+    private populateNodes() {
+        const stateNodeMap = new Map<string, StateNode>();
+        for (const state of this.states) {
+            stateNodeMap.set(state.name, {
+                name: state.name,
+                to: [],
+            });
+        }
+
+        for (const [name, stateNode] of stateNodeMap.entries()) {
+            const targets = this.states.find((s) => s.name === name)?.to ?? [];
+            for (const target of targets) {
+                const targetNode = stateNodeMap.get(target);
+                if (targetNode) {
+                    stateNode.to.push(targetNode);
+                }
+            }
+        }
+        this.nodes = [...stateNodeMap.values()].filter((n) => n.name !== 'Cancelled');
+    }
+
+    private populateEdges() {
+        for (const node of this.nodes) {
+            const nodeCmp = this.getNodeFor(node.name);
+            let index = 0;
+            for (const to of node.to) {
+                const toCmp = this.getNodeFor(to.name);
+                if (nodeCmp && toCmp && nodeCmp !== toCmp) {
+                    this.edges.push({
+                        to: toCmp,
+                        from: nodeCmp,
+                        index,
+                    });
+                    index++;
+                }
+            }
+        }
+        this.edges = [...this.edges];
+        this.changeDetector.markForCheck();
+    }
+}

+ 16 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-node.component.html

@@ -0,0 +1,16 @@
+<div class="node-wrapper" [ngStyle]="getStyle()" [class.active]="active$ | async">
+    <div
+        class="node"
+        [class.active-target]="activeTarget$ | async"
+    >
+        {{ node.name | orderStateI18nToken | translate }}
+    </div>
+    <div class="cancelled-wrapper" *ngIf="isCancellable">
+        <div class="cancelled-edge">
+        </div>
+        <clr-icon shape="dot-circle"></clr-icon>
+        <div class="cancelled-node">
+            {{ 'Cancelled' | orderStateI18nToken | translate }}
+        </div>
+    </div>
+</div>

+ 61 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-node.component.scss

@@ -0,0 +1,61 @@
+@import 'variables';
+:host {
+    display: block;
+}
+.node-wrapper {
+    position: absolute;
+    z-index: 1;
+    display: flex;
+    align-items: center;
+}
+.node {
+    display: inline-block;
+    border: 2px solid $color-grey-300;
+    border-radius: 3px;
+    padding: 3px 6px;
+    z-index: 1;
+    background-color: $color-grey-100;
+    opacity: 0.7;
+    transition: opacity 0.2s, background-color 0.2s, color 0.2s;
+    cursor: default;
+    &.active-target {
+        border-color: $color-primary-500;
+        opacity: 0.9;
+    }
+}
+.cancelled-wrapper {
+    display: flex;
+    align-items: center;
+    color: $color-grey-300;
+    transition: color 0.2s, opacity 0.2s;
+    opacity: 0.7;
+}
+.cancelled-edge {
+    width: 48px;
+    height: 2px;
+    background-color: $color-grey-300;
+    transition: background-color 0.2s;
+}
+clr-icon {
+    margin-left: -1px;
+}
+.cancelled-node {
+    margin-left: 6px;
+}
+.active {
+    .cancelled-wrapper {
+        opacity: 1;
+    }
+    .node {
+        opacity: 1;
+        background-color: $color-primary-600;
+        border-color: $color-primary-600;
+        color: $color-primary-100;
+    }
+    .cancelled-wrapper {
+        color: $color-error-500;
+    }
+    .cancelled-edge {
+        background-color: $color-error-500;
+    }
+}

+ 55 - 0
packages/admin-ui/src/lib/order/src/components/order-process-graph/order-process-node.component.ts

@@ -0,0 +1,55 @@
+import {
+    ChangeDetectionStrategy,
+    Component,
+    ElementRef,
+    Input,
+    OnChanges,
+    OnInit,
+    SimpleChanges,
+} from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { NODE_HEIGHT } from './constants';
+import { OrderProcessGraphComponent, StateNode } from './order-process-graph.component';
+
+@Component({
+    selector: 'vdr-order-process-node',
+    templateUrl: './order-process-node.component.html',
+    styleUrls: ['./order-process-node.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderProcessNodeComponent implements OnChanges {
+    @Input() node: StateNode;
+    @Input() index: number;
+    @Input() active: boolean;
+    active$ = new BehaviorSubject<boolean>(false);
+    activeTarget$ = new BehaviorSubject<boolean>(false);
+    isCancellable = false;
+
+    constructor(private graph: OrderProcessGraphComponent, private elementRef: ElementRef<HTMLDivElement>) {}
+
+    ngOnChanges(changes: SimpleChanges) {
+        this.isCancellable = !!this.node.to.find((s) => s.name === 'Cancelled');
+        if (changes.active) {
+            this.active$.next(this.active);
+        }
+    }
+
+    getPos(origin: 'top' | 'bottom' = 'top'): { x: number; y: number } {
+        const rect = this.elementRef.nativeElement.getBoundingClientRect();
+        const nodeHeight =
+            this.elementRef.nativeElement.querySelector('.node')?.getBoundingClientRect().height ?? 0;
+        return {
+            x: 10,
+            y: this.index * NODE_HEIGHT + (origin === 'bottom' ? nodeHeight : 0),
+        };
+    }
+
+    getStyle() {
+        const pos = this.getPos();
+        return {
+            'top.px': pos.y,
+            'left.px': pos.x,
+        };
+    }
+}

+ 8 - 0
packages/admin-ui/src/lib/order/src/order.module.ts

@@ -12,6 +12,10 @@ import { OrderDetailComponent } from './components/order-detail/order-detail.com
 import { OrderHistoryComponent } from './components/order-history/order-history.component';
 import { OrderHistoryComponent } from './components/order-history/order-history.component';
 import { OrderListComponent } from './components/order-list/order-list.component';
 import { OrderListComponent } from './components/order-list/order-list.component';
 import { OrderPaymentCardComponent } from './components/order-payment-card/order-payment-card.component';
 import { OrderPaymentCardComponent } from './components/order-payment-card/order-payment-card.component';
+import { OrderProcessGraphDialogComponent } from './components/order-process-graph-dialog/order-process-graph-dialog.component';
+import { OrderProcessEdgeComponent } from './components/order-process-graph/order-process-edge.component';
+import { OrderProcessGraphComponent } from './components/order-process-graph/order-process-graph.component';
+import { OrderProcessNodeComponent } from './components/order-process-graph/order-process-node.component';
 import { PaymentDetailComponent } from './components/payment-detail/payment-detail.component';
 import { PaymentDetailComponent } from './components/payment-detail/payment-detail.component';
 import { PaymentStateLabelComponent } from './components/payment-state-label/payment-state-label.component';
 import { PaymentStateLabelComponent } from './components/payment-state-label/payment-state-label.component';
 import { RefundOrderDialogComponent } from './components/refund-order-dialog/refund-order-dialog.component';
 import { RefundOrderDialogComponent } from './components/refund-order-dialog/refund-order-dialog.component';
@@ -39,6 +43,10 @@ import { orderRoutes } from './order.routes';
         PaymentDetailComponent,
         PaymentDetailComponent,
         SimpleItemListComponent,
         SimpleItemListComponent,
         OrderCustomFieldsCardComponent,
         OrderCustomFieldsCardComponent,
+        OrderProcessGraphComponent,
+        OrderProcessNodeComponent,
+        OrderProcessEdgeComponent,
+        OrderProcessGraphDialogComponent,
     ],
     ],
 })
 })
 export class OrderModule {}
 export class OrderModule {}