Forráskód Böngészése

feat(admin-ui): Display order history timeline

Relates to #118
Michael Bromley 6 éve
szülő
commit
3f5745dbfe
26 módosított fájl, 657 hozzáadás és 77 törlés
  1. 47 29
      admin-ui/src/app/common/generated-types.ts
  2. 23 0
      admin-ui/src/app/data/definitions/order-definitions.ts
  3. 13 0
      admin-ui/src/app/data/providers/order-data.service.ts
  4. 12 0
      admin-ui/src/app/order/components/fulfillment-detail/fulfillment-detail.component.html
  5. 1 0
      admin-ui/src/app/order/components/fulfillment-detail/fulfillment-detail.component.scss
  6. 35 0
      admin-ui/src/app/order/components/fulfillment-detail/fulfillment-detail.component.ts
  7. 11 0
      admin-ui/src/app/order/components/history-entry-detail/history-entry-detail.component.html
  8. 6 0
      admin-ui/src/app/order/components/history-entry-detail/history-entry-detail.component.scss
  9. 9 0
      admin-ui/src/app/order/components/history-entry-detail/history-entry-detail.component.ts
  10. 6 12
      admin-ui/src/app/order/components/order-detail/order-detail.component.html
  11. 119 0
      admin-ui/src/app/order/components/order-history/order-history.component.html
  12. 129 0
      admin-ui/src/app/order/components/order-history/order-history.component.scss
  13. 127 0
      admin-ui/src/app/order/components/order-history/order-history.component.ts
  14. 5 18
      admin-ui/src/app/order/components/order-payment-card/order-payment-card.component.html
  15. 0 7
      admin-ui/src/app/order/components/order-payment-card/order-payment-card.component.scss
  16. 3 7
      admin-ui/src/app/order/components/order-payment-card/order-payment-card.component.ts
  17. 17 0
      admin-ui/src/app/order/components/payment-detail/payment-detail.component.html
  18. 10 0
      admin-ui/src/app/order/components/payment-detail/payment-detail.component.scss
  19. 18 0
      admin-ui/src/app/order/components/payment-detail/payment-detail.component.ts
  20. 9 0
      admin-ui/src/app/order/components/simple-item-list/simple-item-list.component.html
  21. 16 0
      admin-ui/src/app/order/components/simple-item-list/simple-item-list.component.scss
  22. 11 0
      admin-ui/src/app/order/components/simple-item-list/simple-item-list.component.ts
  23. 12 2
      admin-ui/src/app/order/order.module.ts
  24. 3 2
      admin-ui/src/app/shared/components/order-state-label/order-state-label.component.html
  25. 4 0
      admin-ui/src/app/shared/components/order-state-label/order-state-label.component.scss
  26. 11 0
      admin-ui/src/i18n-messages/en.json

+ 47 - 29
admin-ui/src/app/common/generated-types.ts

@@ -1552,6 +1552,12 @@ export type Mutation = {
   updateCollection: Collection,
   /** Move a Collection to a different parent or index */
   moveCollection: Collection,
+  /** Create a new Country */
+  createCountry: Country,
+  /** Update an existing Country */
+  updateCountry: Country,
+  /** Delete a Country */
+  deleteCountry: DeletionResponse,
   /** Create a new CustomerGroup */
   createCustomerGroup: CustomerGroup,
   /** Update an existing CustomerGroup */
@@ -1560,12 +1566,6 @@ export type Mutation = {
   addCustomersToGroup: CustomerGroup,
   /** Remove Customers from a CustomerGroup */
   removeCustomersFromGroup: CustomerGroup,
-  /** Create a new Country */
-  createCountry: Country,
-  /** Update an existing Country */
-  updateCountry: Country,
-  /** Delete a Country */
-  deleteCountry: DeletionResponse,
   /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
   createCustomer: Customer,
   /** Update an existing Customer */
@@ -1709,6 +1709,21 @@ export type MutationMoveCollectionArgs = {
 };
 
 
+export type MutationCreateCountryArgs = {
+  input: CreateCountryInput
+};
+
+
+export type MutationUpdateCountryArgs = {
+  input: UpdateCountryInput
+};
+
+
+export type MutationDeleteCountryArgs = {
+  id: Scalars['ID']
+};
+
+
 export type MutationCreateCustomerGroupArgs = {
   input: CreateCustomerGroupInput
 };
@@ -1731,21 +1746,6 @@ export type MutationRemoveCustomersFromGroupArgs = {
 };
 
 
-export type MutationCreateCountryArgs = {
-  input: CreateCountryInput
-};
-
-
-export type MutationUpdateCountryArgs = {
-  input: UpdateCountryInput
-};
-
-
-export type MutationDeleteCountryArgs = {
-  id: Scalars['ID']
-};
-
-
 export type MutationCreateCustomerArgs = {
   input: CreateCustomerInput,
   password?: Maybe<Scalars['String']>
@@ -2508,10 +2508,10 @@ export type Query = {
   collections: CollectionList,
   collection?: Maybe<Collection>,
   collectionFilters: Array<ConfigurableOperation>,
-  customerGroups: Array<CustomerGroup>,
-  customerGroup?: Maybe<CustomerGroup>,
   countries: CountryList,
   country?: Maybe<Country>,
+  customerGroups: Array<CustomerGroup>,
+  customerGroup?: Maybe<CustomerGroup>,
   customers: CustomerList,
   customer?: Maybe<Customer>,
   facets: FacetList,
@@ -2587,11 +2587,6 @@ export type QueryCollectionArgs = {
 };
 
 
-export type QueryCustomerGroupArgs = {
-  id: Scalars['ID']
-};
-
-
 export type QueryCountriesArgs = {
   options?: Maybe<CountryListOptions>
 };
@@ -2602,6 +2597,11 @@ export type QueryCountryArgs = {
 };
 
 
+export type QueryCustomerGroupArgs = {
+  id: Scalars['ID']
+};
+
+
 export type QueryCustomersArgs = {
   options?: Maybe<CustomerListOptions>
 };
@@ -2745,6 +2745,7 @@ export type Refund = Node & {
   method?: Maybe<Scalars['String']>,
   state: Scalars['String'],
   transactionId?: Maybe<Scalars['String']>,
+  reason?: Maybe<Scalars['String']>,
   orderItems: Array<OrderItem>,
   paymentId: Scalars['ID'],
   metadata?: Maybe<Scalars['JSON']>,
@@ -3552,7 +3553,7 @@ export type OrderFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'crea
 
 export type FulfillmentFragment = ({ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'createdAt' | 'updatedAt' | 'method' | 'trackingCode'>);
 
-export type OrderDetailFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'active' | 'subTotal' | 'subTotalBeforeTax' | 'totalBeforeTax' | 'currencyCode' | 'shipping' | 'total'> & { customer: Maybe<({ __typename?: 'Customer' } & Pick<Customer, 'id' | 'firstName' | 'lastName'>)>, lines: Array<({ __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'totalPrice'> & { featuredAsset: Maybe<({ __typename?: 'Asset' } & Pick<Asset, 'preview'>)>, productVariant: ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'name' | 'sku'>), items: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate' | 'refundId' | 'cancelled'> & { fulfillment: Maybe<({ __typename?: 'Fulfillment' } & FulfillmentFragment)> })> })>, adjustments: Array<({ __typename?: 'Adjustment' } & AdjustmentFragment)>, shippingMethod: Maybe<({ __typename?: 'ShippingMethod' } & Pick<ShippingMethod, 'id' | 'code' | 'description'>)>, shippingAddress: Maybe<({ __typename?: 'OrderAddress' } & ShippingAddressFragment)>, payments: Maybe<Array<({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'createdAt' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'> & { refunds: Array<({ __typename?: 'Refund' } & Pick<Refund, 'id' | 'createdAt' | 'state' | 'items' | 'adjustment' | 'total' | 'paymentId' | 'transactionId' | 'method'> & { orderItems: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>)> })> })>>, fulfillments: Maybe<Array<({ __typename?: 'Fulfillment' } & FulfillmentFragment)>> });
+export type OrderDetailFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'active' | 'subTotal' | 'subTotalBeforeTax' | 'totalBeforeTax' | 'currencyCode' | 'shipping' | 'total'> & { customer: Maybe<({ __typename?: 'Customer' } & Pick<Customer, 'id' | 'firstName' | 'lastName'>)>, lines: Array<({ __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'totalPrice'> & { featuredAsset: Maybe<({ __typename?: 'Asset' } & Pick<Asset, 'preview'>)>, productVariant: ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'name' | 'sku'>), items: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate' | 'refundId' | 'cancelled'> & { fulfillment: Maybe<({ __typename?: 'Fulfillment' } & FulfillmentFragment)> })> })>, adjustments: Array<({ __typename?: 'Adjustment' } & AdjustmentFragment)>, shippingMethod: Maybe<({ __typename?: 'ShippingMethod' } & Pick<ShippingMethod, 'id' | 'code' | 'description'>)>, shippingAddress: Maybe<({ __typename?: 'OrderAddress' } & ShippingAddressFragment)>, payments: Maybe<Array<({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'createdAt' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'> & { refunds: Array<({ __typename?: 'Refund' } & Pick<Refund, 'id' | 'createdAt' | 'state' | 'items' | 'adjustment' | 'total' | 'paymentId' | 'reason' | 'transactionId' | 'method'> & { orderItems: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>)> })> })>>, fulfillments: Maybe<Array<({ __typename?: 'Fulfillment' } & FulfillmentFragment)>> });
 
 export type GetOrderListQueryVariables = {
   options?: Maybe<OrderListOptions>
@@ -3603,6 +3604,14 @@ export type SettleRefundMutationVariables = {
 
 export type SettleRefundMutation = ({ __typename?: 'Mutation' } & { settleRefund: ({ __typename?: 'Refund' } & RefundFragment) });
 
+export type GetOrderHistoryQueryVariables = {
+  id: Scalars['ID'],
+  options?: Maybe<HistoryEntryListOptions>
+};
+
+
+export type GetOrderHistoryQuery = ({ __typename?: 'Query' } & { order: Maybe<({ __typename?: 'Order' } & Pick<Order, 'id'> & { history: ({ __typename?: 'HistoryEntryList' } & Pick<HistoryEntryList, 'totalItems'> & { items: Array<({ __typename?: 'HistoryEntry' } & Pick<HistoryEntry, 'id' | 'type' | 'createdAt' | 'data'> & { administrator: Maybe<({ __typename?: 'Administrator' } & Pick<Administrator, 'id' | 'firstName' | 'lastName'>)> })> }) })> });
+
 export type AssetFragment = ({ __typename?: 'Asset' } & Pick<Asset, 'id' | 'createdAt' | 'name' | 'fileSize' | 'mimeType' | 'type' | 'preview' | 'source'>);
 
 export type ProductVariantFragment = ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'enabled' | 'languageCode' | 'name' | 'price' | 'currencyCode' | 'priceIncludesTax' | 'priceWithTax' | 'stockOnHand' | 'trackInventory' | 'sku'> & { taxRateApplied: ({ __typename?: 'TaxRate' } & Pick<TaxRate, 'id' | 'name' | 'value'>), taxCategory: ({ __typename?: 'TaxCategory' } & Pick<TaxCategory, 'id' | 'name'>), options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'code' | 'languageCode' | 'name'>)>, facetValues: Array<({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'code' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) })>, featuredAsset: Maybe<({ __typename?: 'Asset' } & AssetFragment)>, assets: Array<({ __typename?: 'Asset' } & AssetFragment)>, translations: Array<({ __typename?: 'ProductVariantTranslation' } & Pick<ProductVariantTranslation, 'id' | 'languageCode' | 'name'>)> });
@@ -4412,6 +4421,15 @@ export namespace SettleRefund {
   export type SettleRefund = RefundFragment;
 }
 
+export namespace GetOrderHistory {
+  export type Variables = GetOrderHistoryQueryVariables;
+  export type Query = GetOrderHistoryQuery;
+  export type Order = (NonNullable<GetOrderHistoryQuery['order']>);
+  export type History = (NonNullable<GetOrderHistoryQuery['order']>)['history'];
+  export type Items = (NonNullable<(NonNullable<GetOrderHistoryQuery['order']>)['history']['items'][0]>);
+  export type Administrator = (NonNullable<(NonNullable<(NonNullable<GetOrderHistoryQuery['order']>)['history']['items'][0]>)['administrator']>);
+}
+
 export namespace Asset {
   export type Fragment = AssetFragment;
 }

+ 23 - 0
admin-ui/src/app/data/definitions/order-definitions.ts

@@ -134,6 +134,7 @@ export const ORDER_DETAIL_FRAGMENT = gql`
                 adjustment
                 total
                 paymentId
+                reason
                 transactionId
                 method
                 orderItems {
@@ -220,3 +221,25 @@ export const SETTLE_REFUND = gql`
     }
     ${REFUND_FRAGMENT}
 `;
+
+export const GET_ORDER_HISTORY = gql`
+    query GetOrderHistory($id: ID!, $options: HistoryEntryListOptions) {
+        order(id: $id) {
+            id
+            history(options: $options) {
+                totalItems
+                items {
+                    id
+                    type
+                    createdAt
+                    administrator {
+                        id
+                        firstName
+                        lastName
+                    }
+                    data
+                }
+            }
+        }
+    }
+`;

+ 13 - 0
admin-ui/src/app/data/providers/order-data.service.ts

@@ -4,7 +4,9 @@ import {
     CreateFulfillment,
     FulfillOrderInput,
     GetOrder,
+    GetOrderHistory,
     GetOrderList,
+    HistoryEntryListOptions,
     RefundOrder,
     RefundOrderInput,
     SettlePayment,
@@ -15,6 +17,7 @@ import {
     CANCEL_ORDER,
     CREATE_FULFILLMENT,
     GET_ORDER,
+    GET_ORDER_HISTORY,
     GET_ORDERS_LIST,
     REFUND_ORDER,
     SETTLE_PAYMENT,
@@ -39,6 +42,16 @@ export class OrderDataService {
         return this.baseDataService.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id });
     }
 
+    getOrderHistory(id: string, options?: HistoryEntryListOptions) {
+        return this.baseDataService.query<GetOrderHistory.Query, GetOrderHistory.Variables>(
+            GET_ORDER_HISTORY,
+            {
+                id,
+                options,
+            },
+        );
+    }
+
     settlePayment(id: string) {
         return this.baseDataService.mutate<SettlePayment.Mutation, SettlePayment.Variables>(SETTLE_PAYMENT, {
             id,

+ 12 - 0
admin-ui/src/app/order/components/fulfillment-detail/fulfillment-detail.component.html

@@ -0,0 +1,12 @@
+<vdr-labeled-data [label]="'common.created-at' | translate">
+    {{ fulfillment?.createdAt | date: 'medium' }}
+</vdr-labeled-data>
+<vdr-labeled-data [label]="'order.fulfillment-method' | translate">
+    {{ fulfillment?.method }}
+</vdr-labeled-data>
+<vdr-labeled-data *ngIf="fulfillment?.trackingCode" [label]="'order.tracking-code' | translate">
+    {{ fulfillment?.trackingCode }}
+</vdr-labeled-data>
+<vdr-labeled-data [label]="'order.contents' | translate">
+    <vdr-simple-item-list [items]="items"></vdr-simple-item-list>
+</vdr-labeled-data>

+ 1 - 0
admin-ui/src/app/order/components/fulfillment-detail/fulfillment-detail.component.scss

@@ -0,0 +1 @@
+@import "variables";

+ 35 - 0
admin-ui/src/app/order/components/fulfillment-detail/fulfillment-detail.component.ts

@@ -0,0 +1,35 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+import { OrderDetail } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-fulfillment-detail',
+    templateUrl: './fulfillment-detail.component.html',
+    styleUrls: ['./fulfillment-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FulfillmentDetailComponent {
+    @Input() fulfillmentId: string;
+    @Input() order: OrderDetail.Fragment;
+
+    get fulfillment(): OrderDetail.Fulfillment | undefined | null {
+        return this.order.fulfillments && this.order.fulfillments.find(f => f.id === this.fulfillmentId);
+    }
+
+    get items(): Array<{ name: string; quantity: number }> {
+        const itemMap = new Map<string, number>();
+        for (const line of this.order.lines) {
+            for (const item of line.items) {
+                if (item.fulfillment && item.fulfillment.id === this.fulfillmentId) {
+                    const count = itemMap.get(line.productVariant.name);
+                    if (count != null) {
+                        itemMap.set(line.productVariant.name, count + 1);
+                    } else {
+                        itemMap.set(line.productVariant.name, 1);
+                    }
+                }
+            }
+        }
+        return Array.from(itemMap.entries()).map(([name, quantity]) => ({ name, quantity }));
+    }
+}

+ 11 - 0
admin-ui/src/app/order/components/history-entry-detail/history-entry-detail.component.html

@@ -0,0 +1,11 @@
+<vdr-dropdown>
+    <button class="btn btn-link btn-sm details-button" vdrDropdownTrigger>
+        <clr-icon shape="details" size="12"></clr-icon>
+        {{ 'order.details' | translate }}
+    </button>
+    <vdr-dropdown-menu>
+        <div class="entry-dropdown">
+            <ng-content></ng-content>
+        </div>
+    </vdr-dropdown-menu>
+</vdr-dropdown>

+ 6 - 0
admin-ui/src/app/order/components/history-entry-detail/history-entry-detail.component.scss

@@ -0,0 +1,6 @@
+.entry-dropdown {
+    margin: 0 12px;
+}
+.details-button {
+    margin: 0;
+}

+ 9 - 0
admin-ui/src/app/order/components/history-entry-detail/history-entry-detail.component.ts

@@ -0,0 +1,9 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+
+@Component({
+    selector: 'vdr-history-entry-detail',
+    templateUrl: './history-entry-detail.component.html',
+    styleUrls: ['./history-entry-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class HistoryEntryDetailComponent {}

+ 6 - 12
admin-ui/src/app/order/components/order-detail/order-detail.component.html

@@ -103,6 +103,8 @@
                     <td>{{ order.total / 100 | currency: order.currencyCode }}</td>
                 </tr>
             </table>
+
+            <vdr-order-history [order]="order"></vdr-order-history>
         </div>
         <div class="clr-col-lg-4 order-cards">
             <div class="card">
@@ -135,18 +137,10 @@
                     </div>
                     <div class="card-block">
                         <div class="fulfillment-detail" *ngFor="let fulfillment of order.fulfillments">
-                            <vdr-labeled-data [label]="'common.created-at' | translate">
-                                {{ fulfillment.createdAt | date: 'medium' }}
-                            </vdr-labeled-data>
-                            <vdr-labeled-data [label]="'order.fulfillment-method' | translate">
-                                {{ fulfillment.method }}
-                            </vdr-labeled-data>
-                            <vdr-labeled-data
-                                *ngIf="fulfillment.trackingCode"
-                                [label]="'order.tracking-code' | translate"
-                            >
-                                {{ fulfillment.trackingCode }}
-                            </vdr-labeled-data>
+                            <vdr-fulfillment-detail
+                                [fulfillmentId]="fulfillment.id"
+                                [order]="order"
+                            ></vdr-fulfillment-detail>
                         </div>
                     </div>
                 </div>

+ 119 - 0
admin-ui/src/app/order/components/order-history/order-history.component.html

@@ -0,0 +1,119 @@
+<h4>{{ 'order.order-history' | translate }}</h4>
+<div class="entry-list">
+    <div class="entry"></div>
+    <div
+        *ngFor="let entry of history$ | async"
+        [ngClass]="getClassForEntry(entry)"
+        [class.has-custom-icon]="!!getTimelineIcon(entry)"
+        class="entry"
+    >
+        <div class="timeline">
+            <div class="custom-icon">
+                <clr-icon
+                    *ngIf="getTimelineIcon(entry) === 'complete'"
+                    shape="success-standard"
+                    size="24"
+                    class="complete is-solid"
+                ></clr-icon>
+                <clr-icon *ngIf="getTimelineIcon(entry) === 'fulfillment'" shape="truck" size="24"></clr-icon>
+                <clr-icon
+                    *ngIf="getTimelineIcon(entry) === 'payment'"
+                    shape="credit-card"
+                    size="24"
+                ></clr-icon>
+                <clr-icon
+                    *ngIf="getTimelineIcon(entry) === 'cancelled'"
+                    shape="ban"
+                    size="24"
+                    class="cancelled"
+                ></clr-icon>
+            </div>
+        </div>
+        <div class="entry-body" [ngSwitch]="entry.type">
+            <div class="detail">
+                <div class="time">
+                    {{ entry.createdAt | date: 'short' }}
+                </div>
+                <div class="name">
+                    {{ getName(entry) }}
+                </div>
+            </div>
+            <ng-container *ngSwitchCase="type.ORDER_STATE_TRANSITION">
+                <div class="featured-entry" *ngIf="entry.data.to === 'Fulfilled'">
+                    <div class="title">
+                        {{ 'order.history-order-fulfilled' | translate }}
+                    </div>
+                </div>
+                <div class="featured-entry" *ngIf="entry.data.to === 'Cancelled'">
+                    <div class="title">
+                        {{ 'order.history-order-cancelled' | translate }}
+                    </div>
+                </div>
+                <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Fulfilled'">
+                    {{
+                        'order.history-order-transition'
+                            | translate: { from: entry.data.from, to: entry.data.to }
+                    }}
+                </ng-template>
+            </ng-container>
+            <ng-container *ngSwitchCase="type.ORDER_PAYMENT_TRANSITION">
+                <div
+                    class="featured-entry"
+                    *ngIf="entry.data.to === 'Settled'; else regularPaymentTransition"
+                >
+                    <div class="title">
+                        {{ 'order.history-payment-settled' | translate }}
+                    </div>
+                    {{ 'order.transaction-id' | translate }}: {{ getPayment(entry)?.transactionId }}
+                    <vdr-history-entry-detail *ngIf="getPayment(entry) as payment">
+                        <vdr-payment-detail
+                            [payment]="payment"
+                            [currencyCode]="order.currencyCode"
+                        ></vdr-payment-detail>
+                    </vdr-history-entry-detail>
+                </div>
+                <ng-template #regularPaymentTransition>
+                    {{
+                        'order.history-payment-transition'
+                            | translate: { from: entry.data.from, to: entry.data.to }
+                    }}
+                </ng-template>
+            </ng-container>
+            <ng-container *ngSwitchCase="type.ORDER_REFUND_TRANSITION">
+                {{
+                    'order.history-refund-transition'
+                        | translate: { from: entry.data.from, to: entry.data.to }
+                }}
+            </ng-container>
+            <ng-container *ngSwitchCase="type.ORDER_CANCELLATION">
+                {{ 'order.history-items-cancelled' | translate: { count: entry.data.orderItemIds.length } }}
+                <vdr-history-entry-detail *ngIf="getCancelledItems(entry) as items">
+                    <vdr-labeled-data [label]="'order.cancellation-reason' | translate">
+                        {{ entry.data.reason }}
+                    </vdr-labeled-data>
+                    <vdr-labeled-data [label]="'order.contents' | translate">
+                        <div class="items-list">
+                            <ul>
+                                <li *ngFor="let item of items">{{ item.name }} x {{ item.quantity }}</li>
+                            </ul>
+                        </div>
+                    </vdr-labeled-data>
+                </vdr-history-entry-detail>
+            </ng-container>
+            <ng-container *ngSwitchCase="type.ORDER_FULLFILLMENT">
+                <div class="featured-entry">
+                    <div class="title">
+                        {{ 'order.history-fulfillment-created' | translate }}
+                    </div>
+                    {{ 'order.tracking-code' | translate }}: {{ getFullfillment(entry)?.trackingCode }}
+                    <vdr-history-entry-detail *ngIf="getFullfillment(entry) as fulfillment">
+                        <vdr-fulfillment-detail
+                            [fulfillmentId]="fulfillment.id"
+                            [order]="order"
+                        ></vdr-fulfillment-detail>
+                    </vdr-history-entry-detail>
+                </div>
+            </ng-container>
+        </div>
+    </div>
+</div>

+ 129 - 0
admin-ui/src/app/order/components/order-history/order-history.component.scss

@@ -0,0 +1,129 @@
+@import "variables";
+
+:host {
+    margin-top: 48px;
+    display: block;
+}
+.entry-list {
+    margin-top: 24px;
+    margin-left: 24px;
+    margin-right: 12px;
+}
+.entry {
+    display: flex;
+}
+.timeline {
+    border-left: 2px solid $color-primary-100;
+    padding-bottom: 24px;
+    position: relative;
+
+    &:before {
+        content: '';
+        position: absolute;
+        width: 2px;
+        height: 10px;
+        left: -2px;
+        border-left: 2px solid $color-primary-100;
+    }
+    &:after {
+        content: '';
+        display: block;
+        border-radius: 50%;
+        width: 8px;
+        height: 8px;
+        background-color: $color-grey-200;
+        border: 1px solid $color-grey-400;
+        position: absolute;
+        left: -5px;
+        top: 4px;
+    }
+
+    .custom-icon {
+        position: absolute;
+        width: 32px;
+        height: 32px;
+        left: -17px;
+        top: -4px;
+        align-items: center;
+        justify-content: center;
+        border-radius: 50%;
+        color: $color-primary-600;
+        background-color: $color-grey-100;
+        border: 1px solid $color-grey-300;
+        display: none;
+    }
+
+    .complete {
+        color: $color-success-400;
+    }
+    .cancelled {
+        color: $color-error-400;
+    }
+}
+.entry.has-custom-icon {
+    .timeline:after {
+        display: none;
+    }
+    .custom-icon {
+        display: flex;
+    }
+}
+.entry:last-of-type {
+    .timeline {
+        border-left-color: transparent;
+    }
+}
+
+.entry-body {
+    flex: 1;
+    padding-bottom: 18px;
+    padding-left: 12px;
+    line-height: 16px;
+    margin-left: 12px;
+}
+
+.featured-entry {
+    .title {
+        font-size: 16px;
+        line-height: 26px;
+    }
+}
+
+.detail {
+    display: flex;
+    color: $color-grey-400;
+    font-size: 12px;
+    .time {
+    }
+    .name {
+        margin-left: 12px;
+    }
+}
+
+.success {
+    .timeline:before {
+        background-color: $color-success-200;
+        border: 1px solid $color-success-400;
+    }
+}
+
+.error {
+    .timeline:before {
+        background-color: $color-error-200;
+        border: 1px solid $color-error-400;
+    }
+}
+
+.warning {
+    .timeline:before {
+        background-color: $color-warning-200;
+        border: 1px solid $color-warning-400;
+    }
+}
+.items-list {
+    font-size: 12px;
+
+    ul {
+        list-style-type: none;
+    }
+}

+ 127 - 0
admin-ui/src/app/order/components/order-history/order-history.component.ts

@@ -0,0 +1,127 @@
+import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+import { startWith, switchMap } from 'rxjs/operators';
+
+import {
+    GetOrderHistory,
+    HistoryEntryType,
+    OrderDetail,
+    OrderDetailFragment,
+    SortOrder,
+} from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+
+@Component({
+    selector: 'vdr-order-history',
+    templateUrl: './order-history.component.html',
+    styleUrls: ['./order-history.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderHistoryComponent implements OnInit, OnChanges {
+    @Input() order: OrderDetailFragment;
+    history$: Observable<GetOrderHistory.Items[] | null>;
+    readonly type = HistoryEntryType;
+    private orderChange = new Subject<void>();
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit() {
+        this.history$ = this.orderChange.pipe(
+            startWith(null),
+            switchMap(() => {
+                return this.dataService.order
+                    .getOrderHistory(this.order.id, {
+                        sort: {
+                            createdAt: SortOrder.DESC,
+                        },
+                    })
+                    .mapStream(data => data.order && data.order.history.items);
+            }),
+        );
+    }
+
+    ngOnChanges(changes: SimpleChanges): void {
+        this.orderChange.next();
+    }
+
+    getClassForEntry(entry: GetOrderHistory.Items): 'success' | 'error' | 'warning' | undefined {
+        if (entry.type === HistoryEntryType.ORDER_PAYMENT_TRANSITION) {
+            if (entry.data.to === 'Settled') {
+                return 'success';
+            } else if (entry.data.to === 'Declined') {
+                return 'error';
+            }
+        }
+        if (entry.type === HistoryEntryType.ORDER_CANCELLATION) {
+            return 'error';
+        }
+        if (entry.type === HistoryEntryType.ORDER_REFUND_TRANSITION) {
+            return 'warning';
+        }
+    }
+
+    getTimelineIcon(
+        entry: GetOrderHistory.Items,
+    ): 'complete' | 'note' | 'fulfillment' | 'payment' | 'cancelled' | undefined {
+        if (entry.type === HistoryEntryType.ORDER_STATE_TRANSITION) {
+            if (entry.data.to === 'Fulfilled') {
+                return 'complete';
+            }
+            if (entry.data.to === 'Cancelled') {
+                return 'cancelled';
+            }
+        }
+        if (entry.type === HistoryEntryType.ORDER_PAYMENT_TRANSITION && entry.data.to === 'Settled') {
+            return 'payment';
+        }
+        if (entry.type === HistoryEntryType.ORDER_NOTE) {
+            return 'note';
+        }
+        if (entry.type === HistoryEntryType.ORDER_FULLFILLMENT) {
+            return 'fulfillment';
+        }
+    }
+
+    getFullfillment(entry: GetOrderHistory.Items): OrderDetail.Fulfillment | undefined {
+        if (entry.type === HistoryEntryType.ORDER_FULLFILLMENT && this.order.fulfillments) {
+            return this.order.fulfillments.find(f => f.id === entry.data.fulfillmentId);
+        }
+    }
+
+    getPayment(entry: GetOrderHistory.Items): OrderDetail.Payments | undefined {
+        if (entry.type === HistoryEntryType.ORDER_PAYMENT_TRANSITION && this.order.payments) {
+            return this.order.payments.find(p => p.id === entry.data.paymentId);
+        }
+    }
+
+    getCancelledItems(entry: GetOrderHistory.Items): Array<{ name: string; quantity: number }> {
+        const itemMap = new Map<string, number>();
+        const cancelledItemIds: string[] = entry.data.orderItemIds;
+        for (const line of this.order.lines) {
+            for (const item of line.items) {
+                if (cancelledItemIds.includes(item.id)) {
+                    const count = itemMap.get(line.productVariant.name);
+                    if (count != null) {
+                        itemMap.set(line.productVariant.name, count + 1);
+                    } else {
+                        itemMap.set(line.productVariant.name, 1);
+                    }
+                }
+            }
+        }
+        return Array.from(itemMap.entries()).map(([name, quantity]) => ({ name, quantity }));
+    }
+
+    getName(entry: GetOrderHistory.Items): string {
+        const { administrator } = entry;
+        if (administrator) {
+            return `${administrator.firstName} ${administrator.lastName}`;
+        } else {
+            const customer = this.order.customer;
+            if (customer) {
+                return `${customer.firstName} ${customer.lastName}`;
+            }
+        }
+        return '';
+    }
+}

+ 5 - 18
admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.html → admin-ui/src/app/order/components/order-payment-card/order-payment-card.component.html

@@ -1,28 +1,12 @@
 <div class="card">
     <div class="card-header payment-header">
-        <div>{{ 'order.payment' | translate }}</div>
+        <div>{{ 'order.payment' | translate }} #{{ payment.id }}</div>
         <div class="payment-state">
             <vdr-payment-state-label [state]="payment.state"></vdr-payment-state-label>
         </div>
     </div>
     <div class="card-block">
-        <vdr-labeled-data [label]="'order.payment-method' | translate">
-            {{ payment.method }}
-        </vdr-labeled-data>
-        <vdr-labeled-data [label]="'order.amount' | translate">
-            {{ payment.amount / 100 | currency: currencyCode }}
-        </vdr-labeled-data>
-        <vdr-labeled-data [label]="'order.transaction-id' | translate">
-            {{ payment.transactionId }}
-        </vdr-labeled-data>
-        <vdr-labeled-data [label]="'order.payment-metadata' | translate">
-            <ul class="payment-metadata">
-                <li *ngFor="let entry of getPaymentMetadata(payment)">
-                    <span class="metadata-prop">{{ entry[0] }}:</span>
-                    {{ entry[1] }}
-                </li>
-            </ul>
-        </vdr-labeled-data>
+        <vdr-payment-detail [payment]="payment" [currencyCode]="currencyCode"></vdr-payment-detail>
     </div>
     <ng-container *ngFor="let refund of payment.refunds">
         <div class="card-header payment-header">
@@ -41,6 +25,9 @@
             <vdr-labeled-data [label]="'order.transaction-id' | translate" *ngIf="refund.transactionId">
                 {{ refund.transactionId }}
             </vdr-labeled-data>
+            <vdr-labeled-data [label]="'order.refund-reason' | translate" *ngIf="refund.reason">
+                {{ refund.reason }}
+            </vdr-labeled-data>
         </div>
         <div class="card-footer" *ngIf="refund.state === 'Pending'">
             <button class="btn btn-sm btn-primary" (click)="settleRefund.emit(refund)">

+ 0 - 7
admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.scss → admin-ui/src/app/order/components/order-payment-card/order-payment-card.component.scss

@@ -6,13 +6,6 @@
     align-items: center;
 }
 
-ul.payment-metadata {
-    list-style-type: none;
-}
-.metadata-prop {
-    color: $color-grey-500;
-}
-
 .refund-icon {
     margin-right: 6px;
     color: $color-grey-400;

+ 3 - 7
admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.ts → admin-ui/src/app/order/components/order-payment-card/order-payment-card.component.ts

@@ -5,17 +5,13 @@ import { OrderDetail } from '../../../common/generated-types';
 
 @Component({
     selector: 'vdr-order-payment-detail',
-    templateUrl: './order-payment-detail.component.html',
-    styleUrls: ['./order-payment-detail.component.scss'],
+    templateUrl: './order-payment-card.component.html',
+    styleUrls: ['./order-payment-card.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class OrderPaymentDetailComponent {
+export class OrderPaymentCardComponent {
     @Input() payment: OrderDetail.Payments;
     @Input() currencyCode: CurrencyCode;
     @Output() settlePayment = new EventEmitter<OrderDetail.Payments>();
     @Output() settleRefund = new EventEmitter<OrderDetail.Refunds>();
-
-    getPaymentMetadata(payment: OrderDetail.Payments) {
-        return Object.entries(payment.metadata);
-    }
 }

+ 17 - 0
admin-ui/src/app/order/components/payment-detail/payment-detail.component.html

@@ -0,0 +1,17 @@
+<vdr-labeled-data [label]="'order.payment-method' | translate">
+    {{ payment.method }}
+</vdr-labeled-data>
+<vdr-labeled-data [label]="'order.amount' | translate">
+    {{ payment.amount / 100 | currency: currencyCode }}
+</vdr-labeled-data>
+<vdr-labeled-data [label]="'order.transaction-id' | translate">
+    {{ payment.transactionId }}
+</vdr-labeled-data>
+<vdr-labeled-data [label]="'order.payment-metadata' | translate">
+    <ul class="payment-metadata">
+        <li *ngFor="let entry of paymentMetadata">
+            <span class="metadata-prop">{{ entry[0] }}:</span>
+            {{ entry[1] }}
+        </li>
+    </ul>
+</vdr-labeled-data>

+ 10 - 0
admin-ui/src/app/order/components/payment-detail/payment-detail.component.scss

@@ -0,0 +1,10 @@
+@import "variables";
+
+ul.payment-metadata {
+    list-style-type: none;
+    line-height: 16px;
+    margin-top: 8px;
+}
+.metadata-prop {
+    color: $color-grey-400;
+}

+ 18 - 0
admin-ui/src/app/order/components/payment-detail/payment-detail.component.ts

@@ -0,0 +1,18 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+import { CurrencyCode, OrderDetail } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-payment-detail',
+    templateUrl: './payment-detail.component.html',
+    styleUrls: ['./payment-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PaymentDetailComponent {
+    @Input() payment: OrderDetail.Payments;
+    @Input() currencyCode: CurrencyCode;
+
+    get paymentMetadata() {
+        return Object.entries(this.payment.metadata);
+    }
+}

+ 9 - 0
admin-ui/src/app/order/components/simple-item-list/simple-item-list.component.html

@@ -0,0 +1,9 @@
+<div class="items-list">
+    <ul>
+        <li *ngFor="let item of items">
+            <div class="quantity">{{ item.quantity }}</div>
+            <clr-icon shape="times" size="12"></clr-icon>
+            {{ item.name }}
+        </li>
+    </ul>
+</div>

+ 16 - 0
admin-ui/src/app/order/components/simple-item-list/simple-item-list.component.scss

@@ -0,0 +1,16 @@
+.items-list {
+    font-size: 12px;
+
+    ul {
+        margin-top: 6px;
+        list-style-type: none;
+        margin-left: 2px;
+        li {
+            line-height: 14px;
+        }
+    }
+    .quantity {
+        min-width: 16px;
+        display: inline-block;
+    }
+}

+ 11 - 0
admin-ui/src/app/order/components/simple-item-list/simple-item-list.component.ts

@@ -0,0 +1,11 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+@Component({
+    selector: 'vdr-simple-item-list',
+    templateUrl: './simple-item-list.component.html',
+    styleUrls: ['./simple-item-list.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SimpleItemListComponent {
+    @Input() items: Array<{ name: string; quantity: number }>;
+}

+ 12 - 2
admin-ui/src/app/order/order.module.ts

@@ -5,15 +5,20 @@ import { SharedModule } from '../shared/shared.module';
 
 import { CancelOrderDialogComponent } from './components/cancel-order-dialog/cancel-order-dialog.component';
 import { FulfillOrderDialogComponent } from './components/fulfill-order-dialog/fulfill-order-dialog.component';
+import { FulfillmentDetailComponent } from './components/fulfillment-detail/fulfillment-detail.component';
+import { HistoryEntryDetailComponent } from './components/history-entry-detail/history-entry-detail.component';
 import { LineFulfillmentComponent } from './components/line-fulfillment/line-fulfillment.component';
 import { LineRefundsComponent } from './components/line-refunds/line-refunds.component';
 import { OrderDetailComponent } from './components/order-detail/order-detail.component';
+import { OrderHistoryComponent } from './components/order-history/order-history.component';
 import { OrderListComponent } from './components/order-list/order-list.component';
-import { OrderPaymentDetailComponent } from './components/order-payment-detail/order-payment-detail.component';
+import { OrderPaymentCardComponent } from './components/order-payment-card/order-payment-card.component';
+import { PaymentDetailComponent } from './components/payment-detail/payment-detail.component';
 import { PaymentStateLabelComponent } from './components/payment-state-label/payment-state-label.component';
 import { RefundOrderDialogComponent } from './components/refund-order-dialog/refund-order-dialog.component';
 import { RefundStateLabelComponent } from './components/refund-state-label/refund-state-label.component';
 import { SettleRefundDialogComponent } from './components/settle-refund-dialog/settle-refund-dialog.component';
+import { SimpleItemListComponent } from './components/simple-item-list/simple-item-list.component';
 import { orderRoutes } from './order.routes';
 import { OrderResolver } from './providers/routing/order-resolver';
 
@@ -28,9 +33,14 @@ import { OrderResolver } from './providers/routing/order-resolver';
         CancelOrderDialogComponent,
         PaymentStateLabelComponent,
         LineRefundsComponent,
-        OrderPaymentDetailComponent,
+        OrderPaymentCardComponent,
         RefundStateLabelComponent,
         SettleRefundDialogComponent,
+        OrderHistoryComponent,
+        FulfillmentDetailComponent,
+        PaymentDetailComponent,
+        HistoryEntryDetailComponent,
+        SimpleItemListComponent,
     ],
     entryComponents: [
         FulfillOrderDialogComponent,

+ 3 - 2
admin-ui/src/app/shared/components/order-state-label/order-state-label.component.html

@@ -1,5 +1,6 @@
 <vdr-chip [ngClass]="state" [colorType]="chipColorType">
-    <clr-icon shape="check-circle" *ngIf="state === 'Fulfilled'"></clr-icon>
-    <clr-icon shape="check-circle" *ngIf="state === 'PartiallyFulfilled'"></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="ban" *ngIf="state === 'Cancelled'" size="12"></clr-icon>
     {{ stateToken | translate }}
 </vdr-chip>

+ 4 - 0
admin-ui/src/app/shared/components/order-state-label/order-state-label.component.scss

@@ -1 +1,5 @@
 @import "variables";
+
+clr-icon {
+    margin-right: 3px;
+}

+ 11 - 0
admin-ui/src/i18n-messages/en.json

@@ -413,17 +413,28 @@
     "cancel-selected-items": "Cancel selected items",
     "cancellation-reason": "Cancellation reason",
     "cancelled-order-success": "Successfully cancelled order",
+    "contents": "Contents",
     "create-fulfillment": "Create fulfillment",
     "create-fulfillment-success": "Created fulfillment",
     "customer": "Customer",
+    "details": "Details",
     "fulfill": "Fulfill",
     "fulfill-order": "Fulfill order",
     "fulfillment": "Fulfillment",
     "fulfillment-method": "Fulfillment method",
+    "history-fulfillment-created": "Fulfillment created",
+    "history-items-cancelled": "{count} {count, plural, one {item} other {items}} cancelled",
+    "history-order-cancelled": "Order cancelled",
+    "history-order-fulfilled": "Order fulfilled",
+    "history-order-transition": "Order transitioned from {from} to {to}",
+    "history-payment-settled": "Payment #{id} settled",
+    "history-payment-transition": "Payment #{id} transitioned from {from} to {to}",
+    "history-refund-transition": "Refund #{id} transitioned from {from} to {to}",
     "item-count": "{count} {count, plural, one {item} other {items}}",
     "line-fulfillment-all": "All items fulfilled",
     "line-fulfillment-none": "No items fulfilled",
     "line-fulfillment-partial": "{ count } of { total } items fulfilled",
+    "order-history": "Order history",
     "order-total": "Order total",
     "payment": "Payment",
     "payment-metadata": "Payment metadata",