Browse Source

feat(admin-ui): Implement creating fulfillment for orders

Relates to #119
Michael Bromley 6 years ago
parent
commit
1a22d0dfad

+ 1 - 0
admin-ui/src/app/catalog/providers/routing/facet-resolver.ts

@@ -16,6 +16,7 @@ export class FacetResolver extends BaseEntityResolver<FacetWithValues.Fragment>
                 languageCode: getDefaultLanguage(),
                 name: '',
                 code: '',
+                updatedAt: '',
                 translations: [],
                 values: [],
             },

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

@@ -450,8 +450,7 @@ export type CreateFacetValueWithFacetInput = {
 };
 
 export type CreateFulfillmentInput = {
-  orderItemIds?: Maybe<Array<Scalars['ID']>>,
-  orderId?: Maybe<Scalars['ID']>,
+  lines: Array<FulfillmentLineInput>,
   method: Scalars['String'],
   trackingCode?: Maybe<Scalars['String']>,
 };
@@ -1051,6 +1050,11 @@ export type Fulfillment = Node & {
   trackingCode?: Maybe<Scalars['String']>,
 };
 
+export type FulfillmentLineInput = {
+  orderLineId: Scalars['ID'],
+  quantity: Scalars['Int'],
+};
+
 export type GlobalSettings = {
   __typename?: 'GlobalSettings',
   id: Scalars['ID'],
@@ -1573,14 +1577,14 @@ export type Mutation = {
   createShippingMethod: ShippingMethod,
   /** Update an existing ShippingMethod */
   updateShippingMethod: ShippingMethod,
-  /** Create a new TaxRate */
-  createTaxRate: TaxRate,
-  /** Update an existing TaxRate */
-  updateTaxRate: TaxRate,
   /** Create a new TaxCategory */
   createTaxCategory: TaxCategory,
   /** Update an existing TaxCategory */
   updateTaxCategory: TaxCategory,
+  /** Create a new TaxRate */
+  createTaxRate: TaxRate,
+  /** Update an existing TaxRate */
+  updateTaxRate: TaxRate,
   /** Create a new Zone */
   createZone: Zone,
   /** Update an existing Zone */
@@ -1863,23 +1867,23 @@ export type MutationUpdateShippingMethodArgs = {
 };
 
 
-export type MutationCreateTaxRateArgs = {
-  input: CreateTaxRateInput
+export type MutationCreateTaxCategoryArgs = {
+  input: CreateTaxCategoryInput
 };
 
 
-export type MutationUpdateTaxRateArgs = {
-  input: UpdateTaxRateInput
+export type MutationUpdateTaxCategoryArgs = {
+  input: UpdateTaxCategoryInput
 };
 
 
-export type MutationCreateTaxCategoryArgs = {
-  input: CreateTaxCategoryInput
+export type MutationCreateTaxRateArgs = {
+  input: CreateTaxRateInput
 };
 
 
-export type MutationUpdateTaxCategoryArgs = {
-  input: UpdateTaxCategoryInput
+export type MutationUpdateTaxRateArgs = {
+  input: UpdateTaxRateInput
 };
 
 
@@ -2446,10 +2450,10 @@ export type Query = {
   shippingMethod?: Maybe<ShippingMethod>,
   shippingEligibilityCheckers: Array<ConfigurableOperation>,
   shippingCalculators: Array<ConfigurableOperation>,
-  taxRates: TaxRateList,
-  taxRate?: Maybe<TaxRate>,
   taxCategories: Array<TaxCategory>,
   taxCategory?: Maybe<TaxCategory>,
+  taxRates: TaxRateList,
+  taxRate?: Maybe<TaxRate>,
   zones: Array<Zone>,
   zone?: Maybe<Zone>,
   networkStatus: NetworkStatus,
@@ -2622,17 +2626,17 @@ export type QueryShippingMethodArgs = {
 };
 
 
-export type QueryTaxRatesArgs = {
-  options?: Maybe<TaxRateListOptions>
+export type QueryTaxCategoryArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryTaxRateArgs = {
-  id: Scalars['ID']
+export type QueryTaxRatesArgs = {
+  options?: Maybe<TaxRateListOptions>
 };
 
 
-export type QueryTaxCategoryArgs = {
+export type QueryTaxRateArgs = {
   id: Scalars['ID']
 };
 
@@ -3358,7 +3362,7 @@ export type UpdateCustomerAddressMutation = ({ __typename?: 'Mutation' } & { upd
 
 export type FacetValueFragment = ({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'languageCode' | 'code' | 'name'> & { translations: Array<({ __typename?: 'FacetValueTranslation' } & Pick<FacetValueTranslation, 'id' | 'languageCode' | 'name'>)>, facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) });
 
-export type FacetWithValuesFragment = ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'languageCode' | 'isPrivate' | 'code' | 'name'> & { translations: Array<({ __typename?: 'FacetTranslation' } & Pick<FacetTranslation, 'id' | 'languageCode' | 'name'>)>, values: Array<({ __typename?: 'FacetValue' } & FacetValueFragment)> });
+export type FacetWithValuesFragment = ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'updatedAt' | 'languageCode' | 'isPrivate' | 'code' | 'name'> & { translations: Array<({ __typename?: 'FacetTranslation' } & Pick<FacetTranslation, 'id' | 'languageCode' | 'name'>)>, values: Array<({ __typename?: 'FacetValue' } & FacetValueFragment)> });
 
 export type CreateFacetMutationVariables = {
   input: CreateFacetInput
@@ -3426,7 +3430,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'>)> })>, 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 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' | 'updatedAt' | 'method' | 'trackingCode'>);
 
 export type GetOrderListQueryVariables = {
   options?: Maybe<OrderListOptions>
@@ -3449,6 +3455,13 @@ export type SettlePaymentMutationVariables = {
 
 export type SettlePaymentMutation = ({ __typename?: 'Mutation' } & { settlePayment: Maybe<({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>)> });
 
+export type CreateFulfillmentMutationVariables = {
+  input: CreateFulfillmentInput
+};
+
+
+export type CreateFulfillmentMutation = ({ __typename?: 'Mutation' } & { createFulfillment: Maybe<({ __typename?: 'Fulfillment' } & FulfillmentFragment)> });
+
 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'>)> });
@@ -4197,12 +4210,17 @@ 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 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 namespace GetOrderList {
   export type Variables = GetOrderListQueryVariables;
   export type Query = GetOrderListQuery;
@@ -4222,6 +4240,12 @@ export namespace SettlePayment {
   export type SettlePayment = (NonNullable<SettlePaymentMutation['settlePayment']>);
 }
 
+export namespace CreateFulfillment {
+  export type Variables = CreateFulfillmentMutationVariables;
+  export type Mutation = CreateFulfillmentMutation;
+  export type CreateFulfillment = FulfillmentFragment;
+}
+
 export namespace Asset {
   export type Fragment = AssetFragment;
 }

+ 1 - 0
admin-ui/src/app/customer/customer.module.ts

@@ -19,5 +19,6 @@ import { CustomerResolver } from './providers/routing/customer-resolver';
         AddressCardComponent,
     ],
     providers: [CustomerResolver],
+    exports: [AddressCardComponent],
 })
 export class CustomerModule {}

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

@@ -72,6 +72,11 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
                 unitPriceIncludesTax
                 unitPriceWithTax
                 taxRate
+                fulfillment {
+                    id
+                    method
+                    trackingCode
+                }
             }
             totalPrice
         }
@@ -105,6 +110,15 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
     ${SHIPPING_ADDRESS_FRAGMENT}
 `;
 
+export const FULFILLMENT_FRAGMENT = gql`
+    fragment Fulfillment on Fulfillment {
+        id
+        updatedAt
+        method
+        trackingCode
+    }
+`;
+
 export const GET_ORDERS_LIST = gql`
     query GetOrderList($options: OrderListOptions) {
         orders(options: $options) {
@@ -138,3 +152,12 @@ export const SETTLE_PAYMENT = gql`
         }
     }
 `;
+
+export const CREATE_FULFILLMENT = gql`
+    mutation CreateFulfillment($input: CreateFulfillmentInput!) {
+        createFulfillment(input: $input) {
+            ...Fulfillment
+        }
+    }
+    ${FULFILLMENT_FRAGMENT}
+`;

+ 22 - 2
admin-ui/src/app/data/providers/order-data.service.ts

@@ -1,5 +1,16 @@
-import { GetOrder, GetOrderList, SettlePayment } from '../../common/generated-types';
-import { GET_ORDER, GET_ORDERS_LIST, SETTLE_PAYMENT } from '../definitions/order-definitions';
+import {
+    CreateFulfillment,
+    CreateFulfillmentInput,
+    GetOrder,
+    GetOrderList,
+    SettlePayment,
+} from '../../common/generated-types';
+import {
+    CREATE_FULFILLMENT,
+    GET_ORDER,
+    GET_ORDERS_LIST,
+    SETTLE_PAYMENT,
+} from '../definitions/order-definitions';
 
 import { BaseDataService } from './base-data.service';
 
@@ -24,4 +35,13 @@ export class OrderDataService {
             id,
         });
     }
+
+    createFullfillment(input: CreateFulfillmentInput) {
+        return this.baseDataService.mutate<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
+            CREATE_FULFILLMENT,
+            {
+                input,
+            },
+        );
+    }
 }

+ 54 - 0
admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.html

@@ -0,0 +1,54 @@
+<ng-template vdrDialogTitle>{{ 'order.fulfill-order' | translate }}</ng-template>
+
+<div class="fulfillment-wrapper">
+    <div class="order-lines">
+        <table class="table">
+            <thead>
+                <tr>
+                    <th></th>
+                    <th>{{ 'order.product-name' | translate }}</th>
+                    <th>{{ 'order.product-sku' | translate }}</th>
+                    <th>{{ 'order.quantity' | translate }}</th>
+                    <th>{{ 'order.fulfill' | translate }}</th>
+                </tr>
+            </thead>
+            <tr *ngFor="let line of order.lines" class="order-line">
+                <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 fulfil">
+                    <input
+                        [(ngModel)]="fulfillmentQuantities[line.id]"
+                        type="number"
+                        [max]="line.quantity"
+                        min="0"
+                    />
+                </td>
+            </tr>
+        </table>
+    </div>
+    <div class="shipping-details">
+        <vdr-formatted-address [address]="order.shippingAddress"></vdr-formatted-address>
+        <h6>{{ 'order.shipping-method' | translate }}</h6>
+        {{ order.shippingMethod?.description }}
+        <strong>{{ order.shipping / 100 | currency: order.currencyCode }}</strong>
+        <clr-input-container>
+            <label>{{ 'order.fulfillment-method' | translate }}</label>
+            <input clrInput placeholder="" name="method" [(ngModel)]="method" required />
+        </clr-input-container>
+        <clr-input-container>
+            <label>{{ 'order.tracking-code' | translate }}</label>
+            <input clrInput placeholder="" name="trackingCode" [(ngModel)]="trackingCode" />
+        </clr-input-container>
+    </div>
+</div>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit" (click)="select()" [disabled]="!method" class="btn btn-primary">
+        {{ 'order.create-fulfillment' | translate }}
+    </button>
+</ng-template>

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

@@ -0,0 +1,33 @@
+@import "variables";
+
+:host {
+    height: 100%;
+    display: flex;
+    min-height: 64vh;
+}
+.fulfillment-wrapper {
+    flex: 1;
+    @media screen and (min-width: $breakpoint-small) {
+        display: flex;
+        flex-direction: row;
+    }
+
+    .shipping-details {
+        margin-top: 24px;
+        @media screen and (min-width: $breakpoint-small) {
+            margin-top: 0;
+            margin-left: 24px;
+            width: 250px;
+        }
+        clr-input-container {
+            margin-top: 24px;
+        }
+    }
+    .order-lines {
+        flex: 1;
+        overflow-y: auto;
+        table {
+            margin-top: 0;
+        }
+    }
+}

+ 46 - 0
admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.ts

@@ -0,0 +1,46 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+
+import { CreateFulfillmentInput, OrderWithLinesFragment } from '../../../common/generated-types';
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-fulfill-order-dialog',
+    templateUrl: './fulfill-order-dialog.component.html',
+    styleUrls: ['./fulfill-order-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FulfillOrderDialogComponent implements Dialog<CreateFulfillmentInput>, OnInit {
+    order: OrderWithLinesFragment;
+    resolveWith: (result?: CreateFulfillmentInput) => void;
+    method = '';
+    trackingCode = '';
+    fulfillmentQuantities: { [lineId: string]: number } = {};
+
+    ngOnInit(): void {
+        this.fulfillmentQuantities = this.order.lines.reduce((result, line) => {
+            return {
+                ...result,
+                [line.id]: line.quantity,
+            };
+        }, {});
+        if (this.order.shippingMethod) {
+            this.method = this.order.shippingMethod.description;
+        }
+    }
+
+    select() {
+        const lines = Object.entries(this.fulfillmentQuantities).map(([orderLineId, quantity]) => ({
+            orderLineId,
+            quantity,
+        }));
+        this.resolveWith({
+            lines,
+            trackingCode: this.trackingCode,
+            method: this.method,
+        });
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+}

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

@@ -10,7 +10,15 @@
         </div>
     </vdr-ab-left>
 
-    <vdr-ab-right></vdr-ab-right>
+    <vdr-ab-right>
+        <button
+            class="btn btn-primary"
+            (click)="fulfillOrder()"
+            [disabled]="order.state !== 'PaymentSettled' && order.state !== 'PartiallyFulfilled'"
+        >
+            {{ 'order.fulfill-order' | translate }}
+        </button>
+    </vdr-ab-right>
 </vdr-action-bar>
 
 <div *ngIf="entity$ | async as order">
@@ -28,14 +36,18 @@
                     </tr>
                 </thead>
                 <tr *ngFor="let line of order.lines" class="order-line">
-                    <td class="thumb"><img [src]="line.featuredAsset.preview + '?preset=tiny'" /></td>
-                    <td class="name">{{ line.productVariant.name }}</td>
-                    <td class="sku">{{ line.productVariant.sku }}</td>
-                    <td class="unit-price">
+                    <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 unit-price">
                         {{ line.unitPriceWithTax / 100 | currency: order.currencyCode }}
                     </td>
-                    <td class="quantity">{{ line.quantity }}</td>
-                    <td class="total">{{ line.totalPrice / 100 | currency: order.currencyCode }}</td>
+                    <td class="align-middle quantity">{{ line.quantity }}</td>
+                    <td class="align-middle total">
+                        {{ line.totalPrice / 100 | currency: order.currencyCode }}
+                    </td>
                 </tr>
                 <tr class="sub-total">
                     <td class="left">{{ 'order.sub-total' | translate }}</td>
@@ -76,11 +88,7 @@
                         <h6 *ngIf="getShippingAddressLines(order.shippingAddress).length">
                             {{ 'order.shipping-address' | translate }}
                         </h6>
-                        <ul class="shipping-address">
-                            <li *ngFor="let line of getShippingAddressLines(order.shippingAddress)">
-                                {{ line }}
-                            </li>
-                        </ul>
+                        <vdr-formatted-address [address]="order.shippingAddress"></vdr-formatted-address>
                     </div>
                 </div>
             </div>

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

@@ -1,6 +1,8 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormGroup } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
+import { of } from 'rxjs';
+import { switchMap, take } from 'rxjs/operators';
 import { _ } from 'src/app/core/providers/i18n/mark-for-extraction';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
@@ -8,6 +10,8 @@ import { Order, OrderWithLines } from '../../../common/generated-types';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';
 import { ServerConfigService } from '../../../data/server-config';
+import { ModalService } from '../../../shared/providers/modal/modal.service';
+import { FulfillOrderDialogComponent } from '../fulfill-order-dialog/fulfill-order-dialog.component';
 
 @Component({
     selector: 'vdr-order-detail',
@@ -25,6 +29,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderWithLines.Fra
         private changeDetector: ChangeDetectorRef,
         private dataService: DataService,
         private notificationService: NotificationService,
+        private modalService: ModalService,
     ) {
         super(route, router, serverConfigService);
     }
@@ -63,6 +68,40 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderWithLines.Fra
         });
     }
 
+    fulfillOrder() {
+        this.entity$
+            .pipe(
+                take(1),
+                switchMap(order => {
+                    return this.modalService.fromComponent(FulfillOrderDialogComponent, {
+                        size: 'xl',
+                        locals: {
+                            order,
+                        },
+                    });
+                }),
+                switchMap(input => {
+                    if (input) {
+                        return this.dataService.order.createFullfillment(input);
+                    } else {
+                        return of(undefined);
+                    }
+                }),
+                switchMap(result => {
+                    if (result) {
+                        return this.dataService.order.getOrder(this.id).single$;
+                    } else {
+                        return of(undefined);
+                    }
+                }),
+            )
+            .subscribe(result => {
+                if (result) {
+                    this.notificationService.success(_('order.create-fulfillment-success'));
+                }
+            });
+    }
+
     protected setFormValues(entity: Order.Fragment): void {
         // empty
     }

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

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

+ 4 - 0
admin-ui/src/app/shared/components/chip/chip.component.scss

@@ -18,6 +18,10 @@
 .chip-label {
     padding: 3px 6px;
     line-height: 1em;
+    border-radius: 3px;
+    white-space: nowrap;
+    display: flex;
+    align-items: center;
 }
 
 .chip-icon {

+ 0 - 4
admin-ui/src/app/shared/components/data-table/data-table.component.scss

@@ -33,7 +33,3 @@ thead th {
         font-size: 22px;
     }
 }
-
-::ng-deep .align-middle {
-    vertical-align: middle!important;
-}

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

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

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

@@ -2,31 +2,31 @@
 
 :host {
     ::ng-deep vdr-chip {
-        &.PaymentAuthorized, &.PaymentSettled {
+        &.PaymentAuthorized, &.PaymentSettled, &.PartiallyFulfilled {
             .wrapper {
-                color: $color-warning-700;
+                border-color: $color-warning-200;
             }
             .chip-label {
-                color: $color-warning-700;
-                background-color: $color-warning-400;
+                color: $color-warning-600;
+                background-color: $color-warning-100;
             }
         }
         &.Fulfilled {
             .wrapper {
-                color: $color-success-700;
+                border-color: $color-success-200;
             }
             .chip-label {
-                color: $color-success-700;
-                background-color: $color-success-400;
+                color: $color-success-600;
+                background-color: $color-success-100;
             }
         }
         &.Cancelled {
             .wrapper {
-                color: $color-error-700;
+                border-color: $color-error-200;
             }
             .chip-label {
-                color: $color-error-700;
-                background-color: $color-error-400;
+                color: $color-error-600;
+                background-color: $color-error-100;
             }
         }
     }

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

@@ -406,7 +406,12 @@
   },
   "order": {
     "amount": "Amount",
+    "create-fulfillment": "Create fulfillment",
+    "create-fulfillment-success": "Created fulfillment",
     "customer": "Customer",
+    "fulfill": "Fulfill",
+    "fulfill-order": "Fulfill order",
+    "fulfillment-method": "Fulfillment method",
     "payment": "Payment",
     "payment-metadata": "Payment metadata",
     "payment-method": "Payment method",
@@ -419,6 +424,7 @@
     "settle-payment-success": "Sucessfully settled payment",
     "shipping": "Shipping",
     "shipping-address": "Shipping address",
+    "shipping-method": "Shipping method",
     "state": "State",
     "state-adding-items": "Adding items",
     "state-arranging-payment": "Arranging payment",
@@ -429,6 +435,7 @@
     "state-payment-settled": "Payment settled",
     "sub-total": "Sub total",
     "total": "Total",
+    "tracking-code": "Tracking code",
     "transaction-id": "Transaction ID",
     "unit-price": "Unit price"
   },

+ 4 - 0
admin-ui/src/styles/theme/_theme.scss

@@ -23,6 +23,10 @@ a:focus, button:focus {
 
 .table {
     border-color: $color-grey-200;
+
+    td.align-middle {
+        vertical-align: middle!important;
+    }
 }
 
 .full-label, .compact-label {