Browse Source

feat(admin-ui): Implement fulfillment controls

Relates to #119
Michael Bromley 6 years ago
parent
commit
a006545e66
19 changed files with 291 additions and 50 deletions
  1. 16 14
      admin-ui/src/app/common/generated-types.ts
  2. 2 0
      admin-ui/src/app/common/utilities/flatten-facet-values.spec.ts
  3. 15 12
      admin-ui/src/app/data/definitions/order-definitions.ts
  4. 8 3
      admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.html
  5. 3 0
      admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.scss
  6. 11 2
      admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.ts
  7. 48 0
      admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.html
  8. 18 0
      admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.scss
  9. 75 0
      admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.ts
  10. 47 18
      admin-ui/src/app/order/components/order-detail/order-detail.component.html
  11. 5 0
      admin-ui/src/app/order/components/order-detail/order-detail.component.scss
  12. 7 1
      admin-ui/src/app/order/order.module.ts
  13. 2 0
      admin-ui/src/app/shared/components/facet-value-chip/facet-value-chip.component.scss
  14. 2 0
      admin-ui/src/app/shared/components/labeled-data/labeled-data.component.html
  15. 12 0
      admin-ui/src/app/shared/components/labeled-data/labeled-data.component.scss
  16. 11 0
      admin-ui/src/app/shared/components/labeled-data/labeled-data.component.ts
  17. 1 0
      admin-ui/src/app/shared/components/order-state-label/order-state-label.component.html
  18. 2 0
      admin-ui/src/app/shared/shared.module.ts
  19. 6 0
      admin-ui/src/i18n-messages/en.json

+ 16 - 14
admin-ui/src/app/common/generated-types.ts

@@ -1551,7 +1551,6 @@ export type Mutation = {
   createProductOptionGroup: ProductOptionGroup,
   /** Update an existing ProductOptionGroup */
   updateProductOptionGroup: ProductOptionGroup,
-  reindex: JobInfo,
   /** Create a new Product */
   createProduct: Product,
   /** Update an existing Product */
@@ -1566,6 +1565,7 @@ export type Mutation = {
   generateVariantsForProduct: Product,
   /** Update existing ProductVariants */
   updateProductVariants: Array<Maybe<ProductVariant>>,
+  reindex: JobInfo,
   createPromotion: Promotion,
   updatePromotion: Promotion,
   deletePromotion: DeletionResponse,
@@ -1962,6 +1962,7 @@ export type Order = Node & {
   lines: Array<OrderLine>,
   adjustments: Array<Adjustment>,
   payments?: Maybe<Array<Payment>>,
+  fulfillments?: Maybe<Array<Fulfillment>>,
   subTotalBeforeTax: Scalars['Int'],
   subTotal: Scalars['Int'],
   currencyCode: CurrencyCode,
@@ -2437,10 +2438,10 @@ export type Query = {
   paymentMethod?: Maybe<PaymentMethod>,
   productOptionGroups: Array<ProductOptionGroup>,
   productOptionGroup?: Maybe<ProductOptionGroup>,
-  search: SearchResponse,
   products: ProductList,
   /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
   product?: Maybe<Product>,
+  search: SearchResponse,
   promotion?: Maybe<Promotion>,
   promotions: PromotionList,
   adjustmentOperations: AdjustmentOperations,
@@ -2578,11 +2579,6 @@ export type QueryProductOptionGroupArgs = {
 };
 
 
-export type QuerySearchArgs = {
-  input: SearchInput
-};
-
-
 export type QueryProductsArgs = {
   languageCode?: Maybe<LanguageCode>,
   options?: Maybe<ProductListOptions>
@@ -2596,6 +2592,11 @@ export type QueryProductArgs = {
 };
 
 
+export type QuerySearchArgs = {
+  input: SearchInput
+};
+
+
 export type QueryPromotionArgs = {
   id: Scalars['ID']
 };
@@ -3430,9 +3431,9 @@ export type ShippingAddressFragment = ({ __typename?: 'OrderAddress' } & Pick<Or
 
 export type OrderFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'total' | 'currencyCode'> & { customer: Maybe<({ __typename?: 'Customer' } & Pick<Customer, 'id' | 'firstName' | 'lastName'>)> });
 
-export type OrderWithLinesFragment = ({ __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'> & { fulfillment: Maybe<({ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method' | 'trackingCode'>)> })> })>, 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' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>)>> });
+export type FulfillmentFragment = ({ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'createdAt' | 'updatedAt' | 'method' | 'trackingCode'>);
 
-export type FulfillmentFragment = ({ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'updatedAt' | 'method' | 'trackingCode'>);
+export type OrderWithLinesFragment = ({ __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'> & { 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' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>)>>, fulfillments: Maybe<Array<({ __typename?: 'Fulfillment' } & FulfillmentFragment)>> });
 
 export type GetOrderListQueryVariables = {
   options?: Maybe<OrderListOptions>
@@ -4203,6 +4204,10 @@ export namespace Order {
   export type Customer = (NonNullable<OrderFragment['customer']>);
 }
 
+export namespace Fulfillment {
+  export type Fragment = FulfillmentFragment;
+}
+
 export namespace OrderWithLines {
   export type Fragment = OrderWithLinesFragment;
   export type Customer = (NonNullable<OrderWithLinesFragment['customer']>);
@@ -4210,15 +4215,12 @@ export namespace OrderWithLines {
   export type FeaturedAsset = (NonNullable<(NonNullable<OrderWithLinesFragment['lines'][0]>)['featuredAsset']>);
   export type ProductVariant = (NonNullable<OrderWithLinesFragment['lines'][0]>)['productVariant'];
   export type Items = (NonNullable<(NonNullable<OrderWithLinesFragment['lines'][0]>)['items'][0]>);
-  export type Fulfillment = (NonNullable<(NonNullable<(NonNullable<OrderWithLinesFragment['lines'][0]>)['items'][0]>)['fulfillment']>);
+  export type Fulfillment = FulfillmentFragment;
   export type Adjustments = AdjustmentFragment;
   export type ShippingMethod = (NonNullable<OrderWithLinesFragment['shippingMethod']>);
   export type ShippingAddress = ShippingAddressFragment;
   export type Payments = (NonNullable<(NonNullable<OrderWithLinesFragment['payments']>)[0]>);
-}
-
-export namespace Fulfillment {
-  export type Fragment = FulfillmentFragment;
+  export type Fulfillments = FulfillmentFragment;
 }
 
 export namespace GetOrderList {

+ 2 - 0
admin-ui/src/app/common/utilities/flatten-facet-values.spec.ts

@@ -56,6 +56,7 @@ describe('flattenFacetValues()', () => {
                 name: 'Brand',
                 translations: [],
                 values: [facetValue1, facetValue2, facetValue3],
+                updatedAt: '',
             },
             {
                 id: '2',
@@ -65,6 +66,7 @@ describe('flattenFacetValues()', () => {
                 name: 'Type',
                 translations: [],
                 values: [facetValue4, facetValue5],
+                updatedAt: '',
             },
         ];
 

+ 15 - 12
admin-ui/src/app/data/definitions/order-definitions.ts

@@ -40,6 +40,16 @@ export const ORDER_FRAGMENT = gql`
     }
 `;
 
+export const FULFILLMENT_FRAGMENT = gql`
+    fragment Fulfillment on Fulfillment {
+        id
+        createdAt
+        updatedAt
+        method
+        trackingCode
+    }
+`;
+
 export const ORDER_WITH_LINES_FRAGMENT = gql`
     fragment OrderWithLines on Order {
         id
@@ -73,9 +83,7 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
                 unitPriceWithTax
                 taxRate
                 fulfillment {
-                    id
-                    method
-                    trackingCode
+                    ...Fulfillment
                 }
             }
             totalPrice
@@ -104,19 +112,14 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
             state
             metadata
         }
+        fulfillments {
+            ...Fulfillment
+        }
         total
     }
     ${ADJUSTMENT_FRAGMENT}
     ${SHIPPING_ADDRESS_FRAGMENT}
-`;
-
-export const FULFILLMENT_FRAGMENT = gql`
-    fragment Fulfillment on Fulfillment {
-        id
-        updatedAt
-        method
-        trackingCode
-    }
+    ${FULFILLMENT_FRAGMENT}
 `;
 
 export const GET_ORDERS_LIST = gql`

+ 8 - 3
admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.html

@@ -8,19 +8,24 @@
                     <th></th>
                     <th>{{ 'order.product-name' | translate }}</th>
                     <th>{{ 'order.product-sku' | translate }}</th>
-                    <th>{{ 'order.quantity' | translate }}</th>
+                    <th>{{ 'order.unfulfilled' | translate }}</th>
                     <th>{{ 'order.fulfill' | translate }}</th>
                 </tr>
             </thead>
-            <tr *ngFor="let line of order.lines" class="order-line">
+            <tr
+                *ngFor="let line of order.lines"
+                class="order-line"
+                [class.ignore]="getUnfulfilledCount(line) === 0"
+            >
                 <td class="align-middle thumb">
                     <img [src]="line.featuredAsset.preview + '?preset=tiny'" />
                 </td>
                 <td class="align-middle name">{{ line.productVariant.name }}</td>
                 <td class="align-middle sku">{{ line.productVariant.sku }}</td>
-                <td class="align-middle quantity">{{ line.quantity }}</td>
+                <td class="align-middle quantity">{{ getUnfulfilledCount(line) }}</td>
                 <td class="align-middle fulfil">
                     <input
+                        [disabled]="getUnfulfilledCount(line) === 0"
                         [(ngModel)]="fulfillmentQuantities[line.id]"
                         type="number"
                         [max]="line.quantity"

+ 3 - 0
admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.scss

@@ -30,4 +30,7 @@
             margin-top: 0;
         }
     }
+    tr.ignore {
+        color: $color-grey-300;
+    }
 }

+ 11 - 2
admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.ts

@@ -1,6 +1,10 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 
-import { CreateFulfillmentInput, OrderWithLinesFragment } from '../../../common/generated-types';
+import {
+    CreateFulfillmentInput,
+    OrderWithLines,
+    OrderWithLinesFragment,
+} from '../../../common/generated-types';
 import { Dialog } from '../../../shared/providers/modal/modal.service';
 
 @Component({
@@ -20,7 +24,7 @@ export class FulfillOrderDialogComponent implements Dialog<CreateFulfillmentInpu
         this.fulfillmentQuantities = this.order.lines.reduce((result, line) => {
             return {
                 ...result,
-                [line.id]: line.quantity,
+                [line.id]: this.getUnfulfilledCount(line),
             };
         }, {});
         if (this.order.shippingMethod) {
@@ -28,6 +32,11 @@ export class FulfillOrderDialogComponent implements Dialog<CreateFulfillmentInpu
         }
     }
 
+    getUnfulfilledCount(line: OrderWithLines.Lines): number {
+        const fulfilled = line.items.reduce((sum, item) => sum + (item.fulfillment ? 1 : 0), 0);
+        return line.quantity - fulfilled;
+    }
+
     select() {
         const lines = Object.entries(this.fulfillmentQuantities).map(([orderLineId, quantity]) => ({
             orderLineId,

+ 48 - 0
admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.html

@@ -0,0 +1,48 @@
+{{ line.quantity }}
+<vdr-dropdown class="search-settings-menu" *ngIf="fulfilledCount || orderState === 'PartiallyFulfilled'">
+    <button type="button" class="icon-button" vdrDropdownTrigger>
+        <clr-icon *ngIf="fulfillmentStatus === 'full'" class="item-fulfilled" shape="check-circle"></clr-icon>
+        <clr-icon
+            *ngIf="fulfillmentStatus === 'partial'"
+            class="item-partially-fulfilled"
+            shape="check-circle"
+        ></clr-icon>
+        <clr-icon
+            *ngIf="fulfillmentStatus === 'none'"
+            class="item-not-fulfilled"
+            shape="exclamation-circle"
+        ></clr-icon>
+    </button>
+    <vdr-dropdown-menu vdrPosition="bottom-right">
+        <label class="dropdown-header" *ngIf="fulfillmentStatus === 'full'">
+            {{ 'order.line-fulfillment-all' | translate }}
+        </label>
+        <label class="dropdown-header" *ngIf="fulfillmentStatus === 'partial'">
+            {{
+                'order.line-fulfillment-partial' | translate: { total: line.quantity, count: fulfilledCount }
+            }}
+        </label>
+        <label class="dropdown-header" *ngIf="fulfillmentStatus === 'none'">
+            {{ 'order.line-fulfillment-none' | translate }}
+        </label>
+        <div class="fulfillment-detail" *ngFor="let item of fulfillments">
+            <div class="fulfillment-title">
+                {{ 'order.fulfillment' | translate }} #{{ item.fulfillment.id }} ({{
+                    'order.item-count' | translate: { count: item.count }
+                }})
+            </div>
+            <vdr-labeled-data [label]="'common.created-at' | translate">
+                {{ item.fulfillment.createdAt | date: 'medium' }}
+            </vdr-labeled-data>
+            <vdr-labeled-data [label]="'order.fulfillment-method' | translate">
+                {{ item.fulfillment.method }}
+            </vdr-labeled-data>
+            <vdr-labeled-data
+                *ngIf="item.fulfillment.trackingCode"
+                [label]="'order.tracking-code' | translate"
+            >
+                {{ item.fulfillment.trackingCode }}
+            </vdr-labeled-data>
+        </div>
+    </vdr-dropdown-menu>
+</vdr-dropdown>

+ 18 - 0
admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.scss

@@ -0,0 +1,18 @@
+@import "variables";
+
+.item-fulfilled {
+    color: $color-success-500;
+}
+.item-partially-fulfilled {
+    color: $color-warning-500;
+}
+.item-not-fulfilled {
+    color: $color-error-500;
+}
+
+.fulfillment-detail {
+    margin: 6px 12px;
+    &:not(:last-of-type) {
+        border-bottom: 1px dashed $color-grey-300;
+    }
+}

+ 75 - 0
admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.ts

@@ -0,0 +1,75 @@
+import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { unique } from 'shared/unique';
+
+import { OrderWithLines } from '../../../common/generated-types';
+
+export type FulfillmentStatus = 'full' | 'partial' | 'none';
+
+@Component({
+    selector: 'vdr-line-fulfillment',
+    templateUrl: './line-fulfillment.component.html',
+    styleUrls: ['./line-fulfillment.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LineFulfillmentComponent implements OnChanges {
+    @Input() line: OrderWithLines.Lines;
+    @Input() orderState: string;
+    fulfilledCount = 0;
+    fulfillmentStatus: FulfillmentStatus;
+    fulfillments: Array<{ count: number; fulfillment: OrderWithLines.Fulfillment }> = [];
+
+    ngOnChanges(changes: SimpleChanges): void {
+        if (this.line) {
+            this.fulfilledCount = this.getFulfilledCount(this.line);
+            this.fulfillmentStatus = this.getFulfillmentStatus(this.fulfilledCount, this.line.quantity);
+            this.fulfillments = this.getFulfillments(this.line);
+        }
+    }
+
+    /**
+     * Returns the number of items in an OrderLine which are fulfilled.
+     */
+    private getFulfilledCount(line: OrderWithLines.Lines): number {
+        return line.items.reduce((sum, item) => sum + (item.fulfillment ? 1 : 0), 0);
+    }
+
+    private getFulfillmentStatus(fulfilledCount: number, lineQuantity: number): FulfillmentStatus {
+        if (fulfilledCount === lineQuantity) {
+            return 'full';
+        }
+        if (0 < fulfilledCount && fulfilledCount < lineQuantity) {
+            return 'partial';
+        }
+        return 'none';
+    }
+
+    private getFulfillments(
+        line: OrderWithLines.Lines,
+    ): Array<{ count: number; fulfillment: OrderWithLines.Fulfillment }> {
+        const counts: { [fulfillmentId: string]: number } = {};
+
+        for (const item of line.items) {
+            if (item.fulfillment) {
+                if (counts[item.fulfillment.id] === undefined) {
+                    counts[item.fulfillment.id] = 1;
+                } else {
+                    counts[item.fulfillment.id]++;
+                }
+            }
+        }
+        const all = line.items.reduce(
+            (fulfillments, item) => {
+                return item.fulfillment ? [...fulfillments, item.fulfillment] : fulfillments;
+            },
+            [] as OrderWithLines.Fulfillment[],
+        );
+
+        return Object.entries(counts).map(([id, count]) => {
+            return {
+                count,
+                // tslint:disable-next-line:no-non-null-assertion
+                fulfillment: all.find(f => f.id === id)!,
+            };
+        });
+    }
+}

+ 47 - 18
admin-ui/src/app/order/components/order-detail/order-detail.component.html

@@ -3,7 +3,6 @@
         <div class="clr-row clr-flex-column">
             <vdr-order-state-label [state]="order.state"></vdr-order-state-label>
             <div class="date-detail">
-                <clr-icon shape="calendar"></clr-icon>
                 {{ 'common.updated' | translate }}:
                 <strong>{{ order.updatedAt | date: 'medium' }}</strong>
             </div>
@@ -44,7 +43,9 @@
                     <td class="align-middle unit-price">
                         {{ line.unitPriceWithTax / 100 | currency: order.currencyCode }}
                     </td>
-                    <td class="align-middle quantity">{{ line.quantity }}</td>
+                    <td class="align-middle quantity">
+                        <vdr-line-fulfillment [line]="line" [orderState]="order.state"></vdr-line-fulfillment>
+                    </td>
                     <td class="align-middle total">
                         {{ line.totalPrice / 100 | currency: order.currencyCode }}
                     </td>
@@ -98,31 +99,59 @@
                         {{ 'order.payment' | translate }}
                     </div>
                     <div class="card-block">
-                        <h6>{{ 'order.payment-state' | translate }}</h6>
-                        {{ payment.state }}
-                        <h6>{{ 'order.payment-method' | translate }}</h6>
-                        {{ payment.method }}
-                        <h6>{{ 'order.amount' | translate }}</h6>
-                        {{ payment.amount / 100 | currency: order.currencyCode }}
-                        <h6>{{ 'order.transaction-id' | translate }}</h6>
-                        {{ payment.transactionId }}
-                        <h6>{{ 'order.payment-metadata' | translate }}</h6>
-                        <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 [label]="'order.payment-state' | translate">
+                            {{ payment.state }}
+                        </vdr-labeled-data>
+                        <vdr-labeled-data [label]="'order.payment-method' | translate">
+                            {{ payment.method }}
+                        </vdr-labeled-data>
+                        <vdr-labeled-data [label]="'order.amount' | translate">
+                            {{ payment.amount / 100 | currency: order.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>
                     </div>
                     <div class="card-footer">
                         <ng-container *ngIf="payment.state === 'Authorized'">
-                            <button class="btn btn-sm btn-link" (click)="settlePayment(payment)">
+                            <button class="btn btn-sm btn-primary" (click)="settlePayment(payment)">
                                 {{ 'order.settle-payment' | translate }}
                             </button>
                         </ng-container>
                     </div>
                 </div>
             </ng-container>
+            <ng-container *ngIf="order.fulfillments && order.fulfillments.length">
+                <div class="card">
+                    <div class="card-header">
+                        {{ 'order.fulfillment' | translate }}
+                    </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>
+                        </div>
+                    </div>
+                </div>
+            </ng-container>
         </div>
     </div>
 </div>

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

@@ -35,3 +35,8 @@ ul.payment-metadata {
         color: $color-grey-500;
     }
 }
+
+.fulfillment-detail:not(:last-of-type) {
+    border-bottom: 1px dashed $color-grey-300;
+    margin-bottom: 12px;
+}

+ 7 - 1
admin-ui/src/app/order/order.module.ts

@@ -4,6 +4,7 @@ import { RouterModule } from '@angular/router';
 import { SharedModule } from '../shared/shared.module';
 
 import { FulfillOrderDialogComponent } from './components/fulfill-order-dialog/fulfill-order-dialog.component';
+import { LineFulfillmentComponent } from './components/line-fulfillment/line-fulfillment.component';
 import { OrderDetailComponent } from './components/order-detail/order-detail.component';
 import { OrderListComponent } from './components/order-list/order-list.component';
 import { orderRoutes } from './order.routes';
@@ -11,7 +12,12 @@ import { OrderResolver } from './providers/routing/order-resolver';
 
 @NgModule({
     imports: [SharedModule, RouterModule.forChild(orderRoutes)],
-    declarations: [OrderListComponent, OrderDetailComponent, FulfillOrderDialogComponent],
+    declarations: [
+        OrderListComponent,
+        OrderDetailComponent,
+        FulfillOrderDialogComponent,
+        LineFulfillmentComponent,
+    ],
     entryComponents: [FulfillOrderDialogComponent],
     providers: [OrderResolver],
 })

+ 2 - 0
admin-ui/src/app/shared/components/facet-value-chip/facet-value-chip.component.scss

@@ -8,4 +8,6 @@
     color: transparentize($color-grey-100, 0.5);
     text-transform: uppercase;
     font-size: 10px;
+    margin-right: 3px;
+    height: 11px;
 }

+ 2 - 0
admin-ui/src/app/shared/components/labeled-data/labeled-data.component.html

@@ -0,0 +1,2 @@
+<div class="label-title">{{ label }}</div>
+<ng-content></ng-content>

+ 12 - 0
admin-ui/src/app/shared/components/labeled-data/labeled-data.component.scss

@@ -0,0 +1,12 @@
+@import "variables";
+
+:host {
+    display: block;
+    margin-bottom: 6px;
+}
+.label-title {
+    font-size: 12px;
+    color: $color-grey-400;
+    line-height: 12px;
+    margin-bottom: -4px;
+}

+ 11 - 0
admin-ui/src/app/shared/components/labeled-data/labeled-data.component.ts

@@ -0,0 +1,11 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+@Component({
+    selector: 'vdr-labeled-data',
+    templateUrl: './labeled-data.component.html',
+    styleUrls: ['./labeled-data.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LabeledDataComponent {
+    @Input() label: string;
+}

+ 1 - 0
admin-ui/src/app/shared/components/order-state-label/order-state-label.component.html

@@ -1,4 +1,5 @@
 <vdr-chip [ngClass]="state">
     <clr-icon shape="check-circle" *ngIf="state === 'Fulfilled'"></clr-icon>
+    <clr-icon shape="check-circle" *ngIf="state === 'PartiallyFulfilled'"></clr-icon>
     {{ stateToken | translate }}
 </vdr-chip>

+ 2 - 0
admin-ui/src/app/shared/shared.module.ts

@@ -33,6 +33,7 @@ import { FormFieldComponent } from './components/form-field/form-field.component
 import { FormItemComponent } from './components/form-item/form-item.component';
 import { FormattedAddressComponent } from './components/formatted-address/formatted-address.component';
 import { ItemsPerPageControlsComponent } from './components/items-per-page-controls/items-per-page-controls.component';
+import { LabeledDataComponent } from './components/labeled-data/labeled-data.component';
 import { LanguageSelectorComponent } from './components/language-selector/language-selector.component';
 import { DialogButtonsDirective } from './components/modal-dialog/dialog-buttons.directive';
 import { DialogComponentOutletComponent } from './components/modal-dialog/dialog-component-outlet.component';
@@ -104,6 +105,7 @@ const DECLARATIONS = [
     DropdownItemDirective,
     OrderStateLabelComponent,
     FormattedAddressComponent,
+    LabeledDataComponent,
 ];
 
 @NgModule({

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

@@ -411,7 +411,12 @@
     "customer": "Customer",
     "fulfill": "Fulfill",
     "fulfill-order": "Fulfill order",
+    "fulfillment": "Fulfillment",
     "fulfillment-method": "Fulfillment method",
+    "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",
     "payment": "Payment",
     "payment-metadata": "Payment metadata",
     "payment-method": "Payment method",
@@ -437,6 +442,7 @@
     "total": "Total",
     "tracking-code": "Tracking code",
     "transaction-id": "Transaction ID",
+    "unfulfilled": "Unfulfilled",
     "unit-price": "Unit price"
   },
   "settings": {