Adding react-teams-tab-field-visit-mashup sample (#848)

This commit is contained in:
Bob German 2019-05-04 11:01:11 -04:00 committed by Vesa Juvonen
parent b0204bd14b
commit a8f4e4f0c2
80 changed files with 22569 additions and 0 deletions

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

View File

@ -0,0 +1,34 @@
constants.ts
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

View File

@ -0,0 +1,13 @@
{
"@microsoft/generator-sharepoint": {
"plusBeta": true,
"isCreatingSolution": false,
"environment": "spo",
"version": "1.7.0",
"libraryName": "field-visit-demo-tab",
"libraryId": "67407b5b-e3eb-480a-97a3-195810f83702",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Bob German
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,68 @@
# Teams Tab - Field Visit Demo (Mashup)
## Summary
A web part for use in Microsoft Teams that displays a mashup of information partaining to customer visits. Visits are obtained from the Team's shared calendar and displayed by user. When a visit is selected, the solution displays:
* customer information (from the Northwind database)
* documents (from SharePoint)
* recent transactions (mock)
* a map (Bing maps)
* current weather (Open Weather Map)
* photos (from SharePoint)
* a text box for sending messages to the Teams channel with deep links back to the selected visit
The solution demonstrates:
* A Teams tab using SharePoint Framework
* Accessing the hosting Team using the teams context and the Graph API
* Deep linking to a SharePoint Framework tab
* A mashup using React components
![Field Visit Demo](./documentation/FieldVisitDemo.png)
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/drop-1.8.0-green.svg)
## Applies to
* [SharePoint Framework Developer](http://dev.office.com/sharepoint/docs/spfx/sharepoint-framework-overview)
* [Building Microsoft Teams Tabs using SharePoint Framework](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/integrate-with-teams-introduction)
* [Office 365 developer tenant](http://dev.office.com/sharepoint/docs/spfx/set-up-your-developer-tenant)
## Solution
Solution|Author(s)
--------|---------
field-visit-demo-tab | Bob German ([@Bob1German](http://www.twitter.com/Bob1German))
Many thanks to [Arbindo Chattopadhyay](https://www.linkedin.com/in/arbindoc/) for writing the [detailed installation instructions ](./documentation/setup.md) and compiling [links to resources](./documentation/resources.md).
## Version history
Version|Date|Comments
-------|----|--------
1.1|April 20, 2019|Updated for SPFx 1.8, moved to sp-dev-fx-webparts
1.0|April 5, 2019|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
The quickest path to building the web part and running it with mock data:
* Obtain API keys for [Bing Maps](https://docs.microsoft.com/en-us/bingmaps/getting-started/bing-maps-dev-center-help/getting-a-bing-maps-key) and [Open Weather Maps](https://openweathermap.org/api). In src/webparts/fieldVisitTab/, copy constants.sample.ts to constants.ts and add the API keys.
* npm install
* gulp serve
To work with real data, the web part requires content to be in place including:
* A Microsoft Team
* Calendar items in the Team's shared calendar, encoded with Northwind database customer IDs
* Documents and photos in SharePoint
[Detailed setup instructions are here](./documentation/setup.md).

View File

@ -0,0 +1,18 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"field-visit-tab-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/fieldVisitTab/FieldVisitTabWebPart.js",
"manifest": "./src/webparts/fieldVisitTab/FieldVisitTabWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"FieldVisitTabWebPartStrings": "lib/webparts/fieldVisitTab/loc/{locale}.js",
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "field-visit-demo-tab",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,24 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "field-visit-demo-tab-client-side-solution",
"id": "67407b5b-e3eb-480a-97a3-195810f83702",
"version": "1.0.1.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "Calendars.Read"
},
{
"resource": "Microsoft Graph",
"scope": "Group.ReadWrite.All"
}
],
"isDomainIsolated": false
},
"paths": {
"zippedPackage": "solution/field-visit-demo-tab.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

View File

@ -0,0 +1,28 @@
# Resources
Here are some resources to help you understand the tooling used in this solution.
TypeScript
* [TypeScript Site](http://bit.ly/SPF-TypeScript)
* [TypeScript Playground](http://bit.ly/TSPlayground)
* [Bobs TS “Cheat Sheets”](http://bit.ly/LearnTypeScript)
Tools
* [WebPack](http://bit.ly/SPF-WebPack)
* [Gulp task runner](http://bit.ly/SPF-Gulp)
* [Visual Studio Code](http://bit.ly/SPF-VSCode)
Tutorials
* [SPFx Dev Setup](http://bit.ly/SPFx-DevSetup)
* [Build your first Web Part](http://bit.ly/SPFx-FirstWP)
* [SPFx Labs](http://bit.ly/SPFx-Labs)
Teams
* [Building Microsoft Teams Tabs using SharePoint Framework](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/integrate-with-teams-introduction)
* [Teams Labs](http://bit.ly/TeamsDevLabs)
APIs
* [Graph API](http://bit.ly/MSGraphAPI)
* [SharePoint REST API](http://bit.ly/MSSharePointAPI)

View File

@ -0,0 +1,118 @@
# Configuration - Field Visit Demo
## 1. Setting up your development environment
The FieldVisit demo is a SharePoint Framework (SPFx) implementation. It is intended to demonstrate key SPFx capabilities hosted in SharePoint as well as Microsoft Teams.
Follow instructions from articles below to setup your Office 365 tenant and development environment
* [Set up Office 365 Tenant](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-developer-tenant)
* [Set up development environment](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-development-environment)
## 2. SharePoint and Teams Configuration
The sample uses content from Teams and SharePoint to build the demo scenario. In this section we will walk through configuring Teams and SharePoint with demo content.
### Calendar Configuration
Select a Team in Microsoft Teams where you are owner and follow steps below
1. Browse to the Team where youd like this sample to be configured and navigate to General channel. **NOTE:** You can choose to configure the demo on any channel of your choice. For sake of simplicity, we will be referring to General channel throughout this document.
2. Create a scheduled meeting and name it “Visit The Big Cheese (THEBI)”.
* Ensure the meeting is scheduled in the General channel
* The demo shows the upcoming 1 week's appointments, so to keep the demo working, ensure the meeting is repeated once a week for however long you need the demo
* Set the meeting duration as you wish, such as 1 hour from 10 AM to 11 AM
* You may choose to add participants, but not mandatory. The meeting will be displayed under a tab for your username as well as any other users you add as participants.
Since the meeting is scheduled in the channel, all team members will be able to join this meeting
3. Log out and Log in using a different user login which has access to the same team. Repeat step 2 with a different name and time, for example "Building inspection (ANTON)”
**NOTE**: The meeting names have a 5 characters code in parenthesis, such as THEBI and ANTON. These are sample customer codes from Northwind database. If youd like to use any other customer code, you can select one from link [here](https://services.odata.org/V3/Northwind/Northwind.svc/Customers). Note that the mock transaction data is not provided for every customer; you can view or add the mock data in the [Activity Service Mock](../src/webparts/fieldVisitTab/services/ActivityService/ActivityServiceMock.ts) class.
## SharePoint Configuration
In this section we will walk through configuration of SharePoint site, which will hold documents and photos.
1. Browse to the SharePoint site collection of the Team configured above
* Navigate to Files tab within your Teams General channel
* Click on Open in SharePoint
* Navigate to root of the document library Documents
2. Add a new Column
* Click on Add column -> Single line of Text
* **Name:** Customer
* Click Save
3. Upload 3 sample documents / spreadsheets / PDF to Documents
4. Update properties of these 3 files with **Customer** attribute as THEBI
5. Upload additional 3 documents / spreadsheets / PDF to Documents
6. Update properties of these 3 files to ANTON
7. Create a new Picture Library
* Click on **Add an app** from the **Settings** menu
* Search for **Picture Library**
* Name the library **Photos**
8. Navigate to newly created **Photos** picture library
9. Create two new folders with names **THEBI** and **ANTON**
10. Upload 2 sample pictures (.jpg or .png) in each folder
## 3. Sample Project Updates
In this section we will make changes to the sample FieldVisit project
1. Download the FieldVisit project from GitHub (link here)
2. If you do not already have installed, download and install Visual Studio Code
3. Open the folder where you've placed the demo code
4. This sample uses Weather and Bing Map API to display Weather and Map within the web part. In order for the weather and map APIs to work, you will need to generate API keys using the links below:
* **[Open Weather Maps](https://openweathermap.org/api)**
* **[Bing Maps](https://docs.microsoft.com/en-us/bingmaps/getting-started/bing-maps-dev-center-help/getting-a-bing-maps-key)**
5. Copy file **constants.sample.ts** in src\webparts\fieldvisittab to **constants.ts**
6. Open constants.ts and update keys with your own keys. **NOTE:** These keys are used to retrieve map and weather from Bing. If you do not provide a valid key, maps and weather will not be displayed
7. In Visual Studio Code, click on menu **Terminal -> New Terminal**
8. Issue command **gulp bundle -ship**
9. Issue command **gulp trust-dev-cert** (**NOTE:** This step needs to be performed only once per development machine)
10. Issue command **gulp package-solution -ship**
11. Ensure there are no errors for any of these commands.
## 4. Deploying Solution in SharePoint
In this section we will deploy the SPFx solution package into SharePoint App Catalog.
1. Login as tenant admin in Office 365 and navigate to SharePoint Admin center at https://&lt;tenant&gt;-admin.sharepoint.com/_layouts/15/online/SiteCollections.aspx
2. On the left menu bar, click on **apps**
3. Click on **App Catalog**
4. On the left menu bar, click on **Apps for SharePoint**
5. Click on Upload and browse to folder where you have deployed the same code
6. Navigate to **FieldVisitDemo\SPFx\sharepoint\solution** and select the **field-visit-demo-tab.spppkg** file
7. Click on OK
8. In the popup window **check** on **Make this solution available to all sites in organization** and click on **Deploy**
9. Navigate to new SharePoint Admin Center at the following link: https://&lt;tenant&gt;-admin.sharepoint.com/_layouts/15/online/AdminHome.aspx
10. From the left menu bar, click on **API Management**
11. Select the permission for the field visit solution click on the **Approve or Reject** button
12. Click **Approve** to approve the permissions
## 5. Using the Solution in SharePoint
In this section we will configure the web part on a SharePoint page.
1. Navigate to SharePoint site collection configured in section SharePoint Configuration
2. Add a new page
3. Provide title of the page **Field Visit**
4. Add a new web part and select **FieldVisitTab** web part
5. Edit the properties of the web part and fill in as following
* **Group Name:** Name of the Team where the sample is installed. Refer to Teams Configuration section
* **Group/Team ID:** Here you need ID of the Team. Use the [Microsoft Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer) to find the ID of the Team
* **Channel ID:** ID of the General channel. Use the [Microsoft Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer) to find the channel ID as well
6. Save the web part configuration
7. Publish this page
8. Navigate to the site collection home page and then navigate back to the page
9. You should see 2 appointments. Click on each appointment to see the documents, pictures, map and weather
## 6. Deploying Solution in Teams
In this section we will deploy the SPFx FieldVisit TAB web part in Microsoft Teams as a tab. For simplicity sake, we will sideload the solution to a particular Team. Ensure Teams is [configured to allow side loading](https://docs.microsoft.com/en-us/microsoftteams/enable-features-office-365).
1. Open File Explorer and navigate to **FieldVisitDemo\SPFx\teams** folder
2. You will find 3 files manifest.json, tab20x20.png and tab96x96.png. Select all 3 files and add them to a new compressed zip file
3. Name the zip file **FieldVisitTeamsManifest.zip**
4. Navigate to Team in which the solution is configured. Refer to Teams Configuration section
5. Navigate to **Manage Team -> Apps**
6. Click on **Upload a custom app** in the bottom right corner
7. Select the Zip file you created, **FieldVisitTeamsManifest.zip**
8. Navigate to the **General** channel and click on + to add a new TAB
9. Select **FieldVisitTab**
10. Click on **Save** in the TAB configuration screen. **NOTE:** We do not need to provide Team name, ID and Channel ID when configuring TAB in Team because the web part retrieves this information from the Teams context.
11. Your new FIeldVisit Tab should now be configured showing 2 appointments. Click on each appointment to view further details such as documents, pictures, map and weather
## Acknowledgements
Many thanks to [Arbindo Chattopadhyay](https://www.linkedin.com/in/arbindoc/) for writing these instructions.

View File

@ -0,0 +1,7 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"name": "field-visit-demo-tab",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.8.0",
"@microsoft/sp-lodash-subset": "1.8.0",
"@microsoft/sp-office-ui-fabric-core": "1.8.0",
"@microsoft/sp-property-pane": "1.8.0",
"@microsoft/sp-webpart-base": "1.8.0",
"@microsoft/sp-http": "1.8.0",
"@microsoft/teams-js": "^1.4.1",
"@types/es6-promise": "0.0.33",
"@types/react": "16.4.2",
"@types/react-dom": "16.0.5",
"@types/webpack-env": "1.13.1",
"react": "16.7.0",
"react-dom": "16.7.0"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^1.5.0",
"@microsoft/sp-build-web": "1.8.0",
"@microsoft/sp-module-interfaces": "1.8.0",
"@microsoft/sp-tslint-rules": "1.8.0",
"@microsoft/sp-webpart-workbench": "1.8.0",
"@microsoft/rush-stack-compiler-3.3": "0.1.7",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"gulp": "~3.9.1",
"@microsoft/rush-stack-compiler-2.7": "0.4.0"
},
"resolutions": {
"@types/react": "16.4.2"
}
}

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,26 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "ed686d10-9382-4c3b-be6b-1957d4ec9692",
"alias": "FieldVisitTabWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "FieldVisitTab" },
"description": { "default": "Displays details of upcoming field visits in a Microsoft Teams tab" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "FieldVisitTab"
}
}]
}

View File

@ -0,0 +1,148 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version, Environment } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-webpart-base';
import * as microsoftTeams from '@microsoft/teams-js';
import * as strings from 'FieldVisitTabWebPartStrings';
import { IFieldVisitsProps, FieldVisits }
from './components/FieldVisits';
import ServiceFactory from './services/ServiceFactory';
export interface IFieldVisitTabWebPartProps {
groupName: string;
groupId: string;
channelId: string;
}
export default class FieldVisitTabWebPart extends BaseClientSideWebPart<IFieldVisitTabWebPartProps> {
private teamsContext?: microsoftTeams.Context;
private groupName?: string;
private groupId?: string;
private channelId?: string;
protected onInit(): Promise<any> {
let p: Promise<any> = Promise.resolve();
if (this.context.microsoftTeams &&
this.context.microsoftTeams.getContext) {
// Get configuration from the Teams SDK
p = new Promise((resolve, reject) => {
if (this.context.microsoftTeams &&
this.context.microsoftTeams.getContext) {
this.context.microsoftTeams.getContext(context => {
this.teamsContext = context;
this.groupName = context.teamName;
this.groupId = context.groupId;
this.channelId = context.channelId;
resolve();
});
}
});
} else {
// Get configuration from web part settings
this.groupName = this.properties.groupName;
this.groupId = this.properties.groupId;
this.channelId = this.properties.channelId;
}
return p;
}
public render(): void {
// Get services needed for the mashup
const visitService = ServiceFactory.getVisitService(
Environment.type, this.context, this.context.serviceScope
);
const weatherService = ServiceFactory.getWeatherService(
Environment.type, this.context, this.context.serviceScope);
const mapService = ServiceFactory.getMapService(
Environment.type, this.context, this.context.serviceScope);
const documentService = ServiceFactory.getDocumentService(
Environment.type, this.context, this.context.serviceScope
);
const activityService = ServiceFactory.getActivityService(
Environment.type, this.context, this.context.serviceScope
);
const conversationService = ServiceFactory.getConversationService(
Environment.type, this.context, this.context.serviceScope,
this.groupId, this.channelId
);
const photoService = ServiceFactory.getPhotoService(
Environment.type, this.context, this.context.serviceScope
);
// Render the mashup
const element: React.ReactElement<IFieldVisitsProps> = React.createElement(
FieldVisits,
{
visitService: visitService,
weatherService: weatherService,
mapService: mapService,
documentService: documentService,
activityService: activityService,
conversationService: conversationService,
photoService: photoService,
groupName: this.groupName,
groupId: this.groupId,
channelId: this.channelId,
entityId: this.teamsContext ? this.teamsContext.entityId : "",
subEntityId: this.teamsContext ? this.teamsContext.subEntityId : "",
teamsApplicationId: 'ed686d10-9382-4c3b-be6b-1957d4ec9692',
currentUserEmail: this.context.pageContext.user.email
}
);
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('groupName', {
label: strings.GroupNameLabel
}),
PropertyPaneTextField('groupId', {
label: strings.GroupIdLabel
}),
PropertyPaneTextField('channelId', {
label: strings.ChannelIdLabel
})
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,92 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IActivityService } from '../services/ActivityService/IActivityService';
import { IActivity } from '../model/IActivity';
export interface IActivityProps {
service: IActivityService;
customerId: string;
}
export interface IActivityState {
activities?: IActivity[];
currentCustomerId?: string;
}
export class Activities extends React.Component<IActivityProps, IActivityState> {
constructor(props: IActivityProps) {
super(props);
this.state = {
activities: undefined,
currentCustomerId: undefined
};
}
public render(): React.ReactElement<IActivityProps> {
if (this.props.customerId) {
if (this.state.currentCustomerId == this.props.customerId) {
if (this.state.activities && this.state.activities.length > 0) {
return (
<div className={styles.activities}>
<div className={styles.activitiesHeadingRow}>
<div className={styles.activitiesDateColumn}>
Date
</div>
<div className={styles.activitiesNameColumn}>
Activity
</div>
<div className={styles.activitiesAmountColumn}>
Amount
</div>
<div className={styles.activitiesDescriptionColumn}>
Description
</div>
</div>
{this.state.activities.map(a => (
<div className={styles.activitiesRow}>
<div className={styles.activitiesDateColumn}>
{a.date.toDateString()}
</div>
<div className={styles.activitiesNameColumn}>
{a.name}
</div>
<div className={styles.activitiesAmountColumn}>
{a.amount}
</div>
<div className={styles.activitiesDescriptionColumn}>
{a.description}
</div>
</div>
))}
</div>);
} else {
return (<div>No documents found</div>);
}
} else {
this.props.service.getDocuments(this.props.customerId)
.then((activities: IActivity[]) => {
this.setState({
activities: activities,
currentCustomerId: this.props.customerId
});
});
return (<div>Loading</div>);
}
} else {
return (
<div>No visit selected</div>
);
}
}
}

View File

@ -0,0 +1,36 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IVisit } from '../model/IVisit';
export interface ICompanyInfoProps {
visit?: IVisit;
}
export class CompanyInfo extends React.Component<ICompanyInfoProps, {}> {
public render(): React.ReactElement<ICompanyInfoProps> {
if (this.props.visit) {
return (
<div className={styles.documents}>
<div className={styles.documentsHeadingRow}>{ this.props.visit.customer.CompanyName }</div>
<div className={styles.documentsRow}>
<div>{ this.props.visit.customer.Address }</div>
<div>{ this.props.visit.customer.City },&nbsp;
{ this.props.visit.customer.Region }&nbsp;
{ this.props.visit.customer.PostalCode }</div>
<div>{ this.props.visit.customer.Phone }</div>
<div>{ this.props.visit.customer.ContactName },&nbsp;
{ this.props.visit.customer.ContactTitle }</div>
</div>
</div>
);
} else {
return (
<div></div>
);
}
}
}

View File

@ -0,0 +1,90 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IDocumentService } from '../services/DocumentService/IDocumentService';
import { IDocument } from '../model/IDocument';
export interface IDocumentProps {
service: IDocumentService;
customerId: string;
}
export interface IDocumentState {
documents: IDocument[] | null;
currentCustomerId: string | null;
}
export class Documents extends React.Component<IDocumentProps, IDocumentState> {
constructor(props: IDocumentProps) {
super(props);
this.state = {
documents: null,
currentCustomerId: null
};
}
public render(): React.ReactElement<IDocumentProps> {
if (this.props.customerId) {
if (this.state.currentCustomerId == this.props.customerId) {
if (this.state.documents && this.state.documents.length > 0) {
return (
<div className={styles.documents}>
<div className={styles.documentsHeadingRow}>
<div className={styles.documentsNameColumn}>
Document
</div>
<div className={styles.documentsAuthorColumn}>
Author
</div>
<div className={styles.documentsDateColumn}>
Modified date
</div>
</div>
{this.state.documents.map(doc => (
<div className={styles.documentsRow}>
<div className={styles.documentsNameColumn}>
<a href={`${doc.url}?web=1`} target='blank'>{doc.name}</a>
</div>
<div className={styles.documentsAuthorColumn}>
{doc.author}
</div>
<div className={styles.documentsDateColumn}>
{doc.date.toDateString()}&nbsp;
{doc.date.getHours() % 12}:
{doc.date.getMinutes()<10 ? "0" : ""}
{doc.date.getMinutes()}&nbsp;
{doc.date.getHours() < 12 ? 'am' : 'pm'}
</div>
</div>
))}
</div>);
} else {
return (<div>No documents found</div>);
}
} else {
this.props.service.getDocuments(this.props.customerId)
.then((docs: IDocument[]) => {
this.setState({
documents: docs,
currentCustomerId: this.props.customerId
});
});
return (<div>Loading</div>);
}
} else {
return (
<div></div>
);
}
}
}

View File

@ -0,0 +1,281 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.fieldVisits {
.fieldVisitsRow {
@include ms-Grid-row;
}
.fieldVisitsLeftColumn {
@include ms-Grid-col;
@include ms-lg8;
padding: 10px;
}
.fieldVisitsRightColumn {
@include ms-Grid-col;
@include ms-lg4;
padding: 10px;
}
}
.userTabs {
margin: 0 0 5px 0;
padding: 0;
.userTab {
display: inline;
border: solid;
border-width: 1px 1px 0 1px;
padding: 5px 10px;
font-size: x-large;
margin: 0 10px 0 0;
}
.userTabSelected {
background-color: $ms-color-themeLight;
}
}
.visitList {
border-top-style: solid;
border-width: 1px;
.visitListRow {
@include ms-Grid-row;
border-color: black;
border-top-style: hidden;
border-bottom-style: solid;
border-width: 1px;
}
.visitListRow:hover {
background-color: $ms-color-neutralLight
}
.visitListRowSelected,
.visitListRowSelected:hover {
background-color: $ms-color-themeLight;
}
.visitListDateColumn {
@include ms-Grid-col;
@include ms-sm4;
@include ms-md3;
@include ms-lg2;
};
.visitListDetailColumn {
@include ms-Grid-col;
@include ms-sm8;
@include ms-md9;
@include ms-lg10;
}
.visitListTime {
@include ms-font-xl;
}
.visitListDate {
@include ms-font-m;
}
.visitListTitle {
@include ms-font-l;
}
.visitListLocation {
@include ms-font-s;
}
.visitListContact {
@include ms-font-s;
}
}
.weather {
.weatherContainer {
max-width: 700px;
margin: 10px auto;
}
.weatherrow {
@include ms-Grid-row;
@include ms-fontColor-white;
background-color: $ms-color-themeDark;
padding: 10px;
}
.weathercolumn1 {
@include ms-Grid-col;
@include ms-xl2;
@include ms-lg3;
@include ms-md5;
@include ms-sm12;
}
.weathercolumn2 {
@include ms-Grid-col;
@include ms-xl10;
@include ms-lg9;
@include ms-md7;
@include ms-sm12;
}
.weatherTemp {
@include ms-font-xxl;
@include ms-fontColor-white;
}
}
.map {
.mapImage {
width: 100%;
height: auto;
}
}
.documents {
border-top-style: solid;
border-width: 1px;
padding: 10px;
.documentsRow {
@include ms-Grid-row;
border-color: $ms-color-neutralLight;
border-bottom-style: solid;
border-width: 1px;
}
.documentsHeadingRow {
@include ms-Grid-row;
color: $ms-color-white;
background-color: $ms-color-themeDark;
padding: 5px;
}
.documentsRow:hover {
background-color: $ms-color-neutralLight
}
.documentsNameColumn {
@include ms-Grid-col;
@include ms-sm8;
@include ms-md6;
@include ms-lg5;
};
.documentsAuthorColumn {
@include ms-Grid-col;
@include ms-sm1;
@include ms-md3;
@include ms-lg2;
};
.documentsDateColumn {
@include ms-Grid-col;
@include ms-sm4;
@include ms-md3;
@include ms-lg5;
};
}
.activities {
border-top-style: solid;
border-width: 1px;
padding: 10px;
.activitiesRow {
@include ms-Grid-row;
border-color: $ms-color-neutralLight;
border-bottom-style: solid;
border-width: 1px;
}
.activitiesHeadingRow {
@include ms-Grid-row;
color: $ms-color-white;
background-color: $ms-color-themeDark;
padding: 5px;
}
.activitiesRow:hover {
background-color: $ms-color-neutralLight
}
.activitiesDateColumn {
@include ms-Grid-col;
@include ms-sm4;
@include ms-md3;
@include ms-lg2;
};
.activitiesNameColumn {
@include ms-Grid-col;
@include ms-sm4;
@include ms-md3;
@include ms-lg3;
};
.activitiesAmountColumn {
@include ms-Grid-col;
@include ms-sm4;
@include ms-md3;
@include ms-lg2;
};
.activitiesDescriptionColumn {
@include ms-Grid-col;
@include ms-sm12;
@include ms-md3;
@include ms-lg5;
};
}
.postToChannel {
margin: 5px;
@include ms-Grid;
.postToChannelRow {
@include ms-Grid-row;
border-color: $ms-color-neutralLight;
border-top-style: solid;
border-bottom-style: solid;
border-width: 2px;
}
.postToChannelTextColumn {
@include ms-Grid-col;
@include ms-sm9;
@include ms-md9;
@include ms-lg9;
}
.postToChannelButtonColumn {
@include ms-Grid-col;
@include ms-sm3;
@include ms-md3;
@include ms-lg3;
}
.postToChannelTextArea {
width: 100%;
padding: 2px;
height: 60px;
}
.postToChannelButton {
width: 50px;
padding: 2px;
height: 60px;
}
}

View File

@ -0,0 +1,259 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IVisitService } from '../services/VisitService/IVisitService';
import { IWeatherService } from '../services/WeatherService/IWeatherService';
import { IMapService } from '../services/MapService/IMapService';
import { IDocumentService } from '../services/DocumentService/IDocumentService';
import { IActivityService } from '../services/ActivityService/IActivityService';
import { IConversationService } from '../services/ConversationService/IConversationService';
import { IPhotoService } from '../services/PhotoService/IPhotoService';
import { IVisit } from '../model/IVisit';
import { IUser } from '../model/IUser';
import { UserTabs } from './UserTabs';
import { VisitList } from './VisitList';
import { CompanyInfo } from './CompanyInfo';
import { Weather } from './Weather';
import { Map } from './Map';
import { Documents } from './Documents';
import { Activities } from './Activities';
import { PostToChannel } from './PostToChannel';
import { Photos } from './Photos';
export interface IFieldVisitsProps {
visitService: IVisitService;
weatherService: IWeatherService;
mapService: IMapService;
documentService: IDocumentService;
activityService: IActivityService;
conversationService: IConversationService;
photoService: IPhotoService;
currentUserEmail: string;
groupName?: string;
groupId?: string;
channelId?: string;
teamsApplicationId: string;
entityId: string;
subEntityId?: string;
}
export interface IFieldVisitsState {
dataFetched?: boolean;
users?: IUser[];
allVisits?: IVisit[];
filteredVisits?: IVisit[];
selectedVisit?: IVisit;
subEntityId?: string;
}
export class FieldVisits extends React.Component<IFieldVisitsProps, IFieldVisitsState> {
constructor(props: IFieldVisitsProps) {
super(props);
this.state = {
dataFetched: false,
users: [],
allVisits: [],
filteredVisits: [],
selectedVisit: undefined, // NOTE If defined, selectedVisit should reference a member of visits[]
subEntityId: props.subEntityId
};
}
public render(): React.ReactElement<IFieldVisitsProps> {
if (!this.state.dataFetched) {
this.props.visitService.getGroupVisits(this.props.groupId, this.props.groupName)
.then((visits) => {
var u = this.getUsersFromVisits(visits);
var fv = this.filterVisitsBySelectedUsers(visits, u);
this.setState({
users: u,
allVisits: visits,
filteredVisits: fv,
selectedVisit: undefined,
dataFetched: true
});
});
}
if (this.state.dataFetched) {
// Unpack data
let address: string = "";
let city: string = "";
let state: string = "";
let country: string = "";
let postalCode: string = "";
let customerId: string = "";
let customerName: string = "";
if (this.state.selectedVisit && this.state.selectedVisit.customer) {
address = this.state.selectedVisit.customer.Address;
city = this.state.selectedVisit.customer.City;
state = this.state.selectedVisit.customer.Region;
country = this.state.selectedVisit.customer.Country;
postalCode = this.state.selectedVisit.customer.PostalCode;
customerId = this.state.selectedVisit.customer.CustomerID;
customerName = this.state.selectedVisit.customer.CompanyName;
}
// Handle deep link, if any
let userChanged = false;
if (this.state.subEntityId) {
let [deeplinkUser, deeplinkCustomerId] = this.props.subEntityId ?
this.props.subEntityId.split(':') : ["", ""];
if (this.state.users) {
this.state.users.forEach(user => {
if (user.email == deeplinkUser && !user.isSelected) {
userChanged = true;
this.handleUserSelectionChanged(user);
}
});
}
if (!userChanged) {
if (this.state.filteredVisits) {
this.state.filteredVisits.forEach(visit => {
if (visit.customer.CustomerID == deeplinkCustomerId) {
this.handleVisitSelectionChanged(visit);
}
this.setState({
subEntityId: undefined
});
});
}
}
}
// Get currently selected user
let selectedUser = "";
if (this.state.users) {
this.state.users.forEach((user) => { if (user.isSelected) { selectedUser = user.email; } });
}
return (
<div className={styles.fieldVisits}>
<div className={styles.fieldVisitsRow}>
<div className={styles.fieldVisitsLeftColumn}>
<UserTabs users={this.state.users}
userSelectionChanged={this.handleUserSelectionChanged.bind(this)}
/>
<VisitList visits={this.state.filteredVisits}
selectedVisit={this.state.selectedVisit}
visitSelectionChanged={this.handleVisitSelectionChanged.bind(this)}
/>
<Activities service={this.props.activityService}
customerId={customerId} />
<Documents service={this.props.documentService}
customerId={customerId} />
<Photos service={this.props.photoService}
customerId={customerId} />
</div>
<div className={styles.fieldVisitsRightColumn}>
<Weather service={this.props.weatherService}
country={country} postalCode={postalCode} />
<CompanyInfo visit={this.state.selectedVisit} />
<PostToChannel channelId={this.props.channelId}
entityId={this.props.entityId}
teamsApplicationId={this.props.teamsApplicationId}
customerId={customerId}
customerName={customerName}
selectedUser={selectedUser}
address={address}
city={city}
state={state}
country={country}
postalCode={postalCode}
conversationService={this.props.conversationService}
mapService={this.props.mapService} />
<Map service={this.props.mapService}
address={address} city={city} state={state}
country={country} postalCode={postalCode} />
</div>
</div>
</div>
);
} else {
return (<div>Loading...</div>);
}
}
private handleUserSelectionChanged(user: IUser) {
var oldUsers = this.state.users;
var newUsers: IUser[] = [];
// ** use this code to allow only one user to be selected **
if (oldUsers) {
oldUsers.forEach((u) => {
let newUser = u;
newUser.isSelected = u.email == user.email;
newUsers.push(newUser);
});
}
// ** use this code to allow multuple users to be selected **
// oldUsers.forEach((u) => {
// let newUser = u;
// if (u.email == user.email) {
// newUser.isSelected = !u.isSelected;
// }
// newUsers.push(newUser);
// });
var fv = this.filterVisitsBySelectedUsers(this.state.allVisits, newUsers);
var sv = fv.filter((v) => (v == this.state.selectedVisit)).length > 0 ?
this.state.selectedVisit : undefined;
this.setState({
users: newUsers,
filteredVisits: fv,
selectedVisit: sv
});
}
private handleVisitSelectionChanged(visit: IVisit) {
this.setState({
selectedVisit: visit
});
}
private filterVisitsBySelectedUsers(visits: IVisit[] | undefined, users: IUser[]): IVisit[] {
var result: IVisit[] = [];
if (visits) {
visits.forEach((visit) => {
let showVisit = false;
visit.calendarItem.Attendees.forEach((attendee) => {
if (users.filter((u) => (u.isSelected && u.email == attendee.email)).length > 0) {
showVisit = true;
}
});
if (showVisit) {
result.push(visit);
}
});
}
return result;
}
private getUsersFromVisits(visits: IVisit[]) {
var result: IUser[] = [];
visits.forEach((visit) => {
if (visit.calendarItem.Attendees) {
visit.calendarItem.Attendees.forEach((attendee) => {
if ((attendee.email != "?? GROUP EMAIL ??") &&
(result.filter((i: IUser) => (i.email == attendee.email)).length == 0)) {
result.push({
fullName: attendee.fullName,
email: attendee.email,
isSelected: attendee.email == this.props.currentUserEmail
});
}
});
}
});
return result.sort((a, b) => (a.fullName < b.fullName ? -1 : 1));
}
}

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IMapService } from '../services/MapService/IMapService';
export interface IMapProps {
service: IMapService;
address: string;
city: string;
state: string;
country: string;
postalCode: string;
}
export interface IMapState {
locationSignature?: string;
mapUrl?: string;
}
export class Map extends React.Component<IMapProps, IMapState> {
constructor(props: IMapProps) {
super(props);
this.state = {
locationSignature: undefined,
mapUrl: undefined
};
}
public render(): React.ReactElement<IMapProps> {
if (this.props.country &&
this.props.country.toLowerCase() == "usa" &&
this.props.postalCode) {
const locationSignature = this.getLocationSignature(
this.props.address, this.props.city,
this.props.state, this.props.country,
this.props.postalCode);
if (locationSignature === this.state.locationSignature) {
if (this.state.mapUrl !== '#') {
return (
<div className={styles.map}>
<img className={styles.mapImage}
src={this.state.mapUrl} />
<br />{`Map at ${this.props.address}, ${this.props.city}, ${this.props.state}`}
</div>);
} else {
return (<div>Map not found</div>);
}
} else {
this.props.service.getMapImageUrl(this.props.address,
this.props.city, this.props.state, this.props.country,
this.props.postalCode)
.then((mapUrl) => {
this.setState({
locationSignature: locationSignature,
mapUrl: mapUrl
});
});
return (<div>Loading</div>);
}
} else {
return (
<div></div>
);
}
}
private getLocationSignature(address: string, city: string, state: string,
country: string, postalCode: string) {
return `${address}**${city}**${state}**${country}**${postalCode}`;
}
}

View File

@ -0,0 +1,67 @@
import * as React from 'react';
import { IPhoto } from '../model/IPhoto';
import { IPhotoService } from '../services/PhotoService/IPhotoService';
export interface IPhotosProps {
customerId: string;
service: IPhotoService;
}
export interface IPhotosState {
photos?: IPhoto[];
currentCustomerId?: string;
}
export class Photos extends React.Component<IPhotosProps, IPhotosState> {
constructor(props: IPhotosProps) {
super(props);
this.state = {
photos: undefined,
currentCustomerId: undefined
};
}
public render(): React.ReactElement<IPhotosProps> {
if (this.props.customerId) {
if (this.state.currentCustomerId == this.props.customerId) {
if (this.state.photos && this.state.photos.length > 0) {
return (
<div>
{this.state.photos.map(photo => (
<div>
<img src={photo.url} width='200px' /><br />
{photo.name}
</div>
))}
</div>
);
} else {
return (<div>No photos for this property</div>);
}
} else {
this.props.service.getPhotos(this.props.customerId)
.then ((photos) => {
this.setState({
photos: photos,
currentCustomerId: this.props.customerId
});
});
return (<div>Loading...</div>);
}
} else {
return (<div></div>);
}
}
}

View File

@ -0,0 +1,103 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { ContentType } from '../model/IConversation';
import { IConversationService } from '../services/ConversationService/IConversationService';
import { IMapService } from '../services/MapService/IMapService';
export interface IPostToChannelProps {
channelId?: string;
entityId: string;
teamsApplicationId: string;
selectedUser: string;
customerId: string;
customerName: string;
address: string;
city: string;
state: string;
country: string;
postalCode: string;
conversationService: IConversationService;
mapService: IMapService;
}
export interface IPostToChannelState {
value: string;
}
export class PostToChannel extends React.Component<IPostToChannelProps, IPostToChannelState> {
constructor(props: IPostToChannelProps) {
super(props);
this.state = { value: '' };
}
public render(): React.ReactElement<IPostToChannelProps> {
if (this.props.customerId && this.props.customerName) {
return (
<div className={styles.postToChannel}>
<div className={styles.postToChannelRow}>
<div className={styles.postToChannelTextColumn}>
<textarea className={styles.postToChannelTextArea}
onChange={this.handleChange.bind(this)}
value={this.state.value}
/>
</div>
<div className={styles.postToChannelButtonColumn}>
<input type='button' value='Send'
onClick={this.handleClick.bind(this)}
className={styles.postToChannelButton}
{...this.props.entityId ? '' : 'disabled'} />
</div>
</div>
</div>
);
} else {
return (<div />);
}
}
private handleChange(event) {
this.setState({ value: event.target.value });
}
// Attempting to post the map - getting an undefined URL
// SO it's commented out for now...
private handleClick(event) {
// Build a deep link to the current user tab and customer
const url = encodeURI(
'https://teams.microsoft.com/l/entity/' +
this.props.teamsApplicationId + '/' +
this.props.entityId +
'?label=Vi32&' +
'context={"subEntityId": "' +
this.props.selectedUser + ':' +
this.props.customerId +
'", "channelId": "' + this.props.channelId + '"}');
var message =
`
<div style="border-style:solid; border-width:1px; padding:10px;">
<div>${this.state.value}</div>
<hr />
<div style="background: #eaeaff; font-weight: bold ">
<a href="${url}">${this.props.customerName}</a>
</div>
${this.props.address}<br />
${this.props.city}, ${this.props.state} ${this.props.postalCode}<br />
</div><br />
`
;
this.props.conversationService
.createChatThread(message, ContentType.html)
.then(() => {
this.setState({ value: '' });
});
}
}

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IUser } from '../model/IUser';
export interface IUserTabsProps {
users?: IUser[];
userSelectionChanged: (IUser) => {};
}
export class UserTabs extends React.Component<IUserTabsProps, {}> {
public render(): React.ReactElement<IUserTabsProps> {
return (
<ul className={styles.userTabs}>
{this.props.users? this.props.users.map(user => (
<li className={user.isSelected ? styles.userTab + " " + styles.userTabSelected :
styles.userTab}
onClick={ () => { this.props.userSelectionChanged(user); }} >
{user.fullName}
</li>
)) : <li></li> }
</ul>
);
}
}

View File

@ -0,0 +1,56 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IVisit } from '../model/IVisit';
export interface IVisitListProps {
visits?: IVisit[];
selectedVisit?: IVisit;
visitSelectionChanged: (IVisit) => {};
}
export class VisitList extends React.Component<IVisitListProps, {}> {
public render(): React.ReactElement<IVisitListProps> {
return (
<div>
<div className={styles.visitList}>
{this.props.visits ? this.props.visits.map(item => (
<div className={ (item == this.props.selectedVisit) ?
styles.visitListRow + ' ' + styles.visitListRowSelected : styles.visitListRow }
onClick={ () => { this.props.visitSelectionChanged(item); }}
>
<div className={styles.visitListDateColumn}>
<div className={styles.visitListTime}>
{item.calendarItem.DateTime.getHours() % 12}:
{item.calendarItem.DateTime.getMinutes()<10 ? "0" : ""}
{item.calendarItem.DateTime.getMinutes()}&nbsp;
{item.calendarItem.DateTime.getHours() < 12 ? 'am' : 'pm'}
</div>
<div className={styles.visitListDate}>
{item.calendarItem.DateTime.toDateString()}
</div>
</div>
<div className={styles.visitListDetailColumn}>
<div className={styles.visitListTitle}>{item.calendarItem.Title}</div>
<div className={styles.visitListContact}>
{item.customer.CompanyName}&nbsp;
({item.customer.ContactName})
</div>
<div className={styles.visitListLocation}>
{item.customer.Address},
{item.customer.City},
{item.customer.Region}&nbsp;
{item.customer.Country}&nbsp;
{item.customer.PostalCode}
</div>
</div>
</div>
)) : <div></div> }
</div>
</div>
);
}
}

View File

@ -0,0 +1,83 @@
import * as React from 'react';
import styles from './FieldVisits.module.scss';
import { IWeatherService } from '../services/WeatherService/IWeatherService';
import { IWeatherConditions } from '../model/IWeatherConditions';
export interface IWeatherProps {
service: IWeatherService;
country: string;
postalCode: string;
}
export interface IWeatherState {
conditions?: IWeatherConditions;
locationSignature?: string;
}
export class Weather extends React.Component<IWeatherProps, IWeatherState> {
constructor(props: IWeatherProps) {
super(props);
this.state = {
conditions: undefined,
locationSignature: undefined
};
}
public render(): React.ReactElement<IWeatherProps> {
if (this.props.country &&
this.props.country.toLowerCase() == "usa" &&
this.props.postalCode) {
const locationSignature = this.getLocationSignature(
this.props.country, this.props.postalCode);
if (this.state.conditions &&
this.state.locationSignature === locationSignature ) {
const c = this.state.conditions;
const tempC = c.main.temp-273;
const tempF = Math.round(9/5*tempC+32);
return (
<div className={styles.weather}>
<div className={styles.weatherContainer}>
<div className={styles.weatherrow}>
Weather conditions in {c.name}<hr />
<div className={styles.weathercolumn1 + ' ' + styles.weatherTemp}>
{tempF}&deg; F<br />
<img src={`http://openweathermap.org/img/w/${c.weather[0].icon}.png`} />
</div>
<div className={styles.weathercolumn2}>
{`${c.weather[0].main}`}<br />
{`Barometric pressure ${c.main.pressure}`}<br />
{`Humidity ${c.main.humidity}%`}<br />
{`Wind at ${c.wind.speed} MPH`}<br />
</div>
</div>
</div>
</div>);
} else {
this.props.service.getConditions(this.props.postalCode)
.then((conditions: IWeatherConditions) => {
this.setState({
conditions: conditions,
locationSignature: locationSignature
});
});
return (<div>Loading</div>);
}
} else {
return (
<div></div>
);
}
}
private getLocationSignature(country: string, postalCode: string) {
return `${country}**${postalCode}`;
}
}

View File

@ -0,0 +1,3 @@
export const owmApiKey: string = "";
export const mapApiKey: string = "";

View File

@ -0,0 +1,9 @@
define([], function() {
return {
"PropertyPaneDescription": "Web part to display team calendar and field visit details",
"BasicGroupName": "These settings are automatic in a Teams tab",
"GroupNameLabel": "Group Name",
"GroupIdLabel": "Group/Team ID",
"ChannelIdLabel": "Channel ID"
}
});

View File

@ -0,0 +1,12 @@
declare interface IFieldVisitTabWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
GroupNameLabel: string;
GroupIdLabel: string;
ChannelIdLabel: string;
}
declare module 'FieldVisitTabWebPartStrings' {
const strings: IFieldVisitTabWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,7 @@
export interface IActivity {
customerId: string;
date: Date;
name: string;
description: string;
amount: number;
}

View File

@ -0,0 +1,7 @@
import { IUser } from './IUser';
export interface ICalendarItem {
Title?: string;
DateTime: Date;
Attendees: IUser[];
}

View File

@ -0,0 +1,19 @@
// For use with this API:
// https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/api/channel_post_chatthreads
// TODO: Replace with something more official
export const enum ContentType {
text = 0,
html = 1
}
export interface IChatMessage {
body: {
contentType: ContentType;
content: string;
};
}
export interface INewChatThread {
rootMessage: IChatMessage;
}

View File

@ -0,0 +1,14 @@
export interface ICustomer {
CustomerID: string;
CompanyName: string;
ContactName: string;
ContactTitle: string;
Address: string;
City: string;
Region: string;
PostalCode: string;
Country: string;
Phone: string;
Fax: string;
}

View File

@ -0,0 +1,6 @@
export interface IDocument {
name: string;
url: string;
author: string;
date: Date;
}

View File

@ -0,0 +1,48 @@
export interface IPoint {
type: string;
coordinates: number[];
}
export interface IAddress {
addressLine: string;
adminDistrict: string;
adminDistrict2: string;
countryRegion: string;
formattedAddress: string;
locality: string;
postalCode: string;
}
export interface IGeocodePoint {
type: string;
coordinates: number[];
calculationMethod: string;
usageTypes: string[];
}
export interface IResource {
__type: string;
bbox: number[];
name: string;
point: IPoint;
address: IAddress;
confidence: string;
entityType: string;
geocodePoints: IGeocodePoint[];
matchCodes: string[];
}
export interface IResourceSet {
estimatedTotal: number;
resources: IResource[];
}
export interface IMapLocation {
authenticationResultCode: string;
brandLogoUri: string;
copyright: string;
resourceSets: IResourceSet[];
statusCode: number;
statusDescription: string;
traceId: string;
}

View File

@ -0,0 +1,4 @@
export interface IPhoto {
name: string;
url: string;
}

View File

@ -0,0 +1,5 @@
export interface IUser {
fullName: string;
email: string;
isSelected? : boolean;
}

View File

@ -0,0 +1,7 @@
import { ICalendarItem } from './ICalendarItem';
import { ICustomer } from './ICustomer';
export interface IVisit {
calendarItem: ICalendarItem;
customer: ICustomer;
}

View File

@ -0,0 +1,53 @@
export interface IWeatherCoord {
lon: number;
lat: number;
}
export interface IWeather {
id: number;
main: string;
description: string;
icon: string;
}
export interface IGauges {
temp: number;
pressure: number;
humidity: number;
temp_min: number;
temp_max: number;
}
export interface IWind {
speed: number;
deg: number;
gust: number;
}
export interface IClouds {
all: number;
}
export interface ISys {
type: number;
id: number;
message: number;
country: string;
sunrise: number;
sunset: number;
}
export interface IWeatherConditions {
coord: IWeatherCoord;
weather: IWeather[];
base: string;
main: IGauges;
visibility: number;
wind: IWind;
clouds: IClouds;
dt: number;
sys: ISys;
id: number;
name: string;
cod: number;
}

View File

@ -0,0 +1,218 @@
import { IActivity } from '../../model/IActivity';
import { IActivityService } from './IActivityService';
export default class ActivityServiceMock implements IActivityService {
// US customers from Northwind database
private mockItems: IActivity[] = [
{
customerId: 'THEBI',
date: new Date(2018, 6, 21),
name: 'Payment',
description: 'Paid in full',
amount: 3253.22
},
{
customerId: 'GREAL',
date: new Date(2018, 6, 17),
name: 'Payment',
description: 'Paid in full',
amount: 244.62
},
{
customerId: 'HUNGC',
date: new Date(2018, 6, 30),
name: 'Payment',
description: 'Paid in full',
amount: 1644.00
},
{
customerId: 'LAZYK',
date: new Date(2018, 6, 15),
name: 'Payment',
description: 'Paid in full',
amount: 4033.75
},
{
customerId: 'LETSS',
date: new Date(2018, 6, 8),
name: 'Payment',
description: 'Paid in full',
amount: 516.70
},
{
customerId: 'LONEP',
date: new Date(2018, 6, 25),
name: 'Payment',
description: 'Paid in full',
amount: 2050.39
},
{
customerId: 'OLDWO',
date: new Date(2018, 6, 11),
name: 'Payment',
description: 'Paid in full',
amount: 1677.06
},
{
customerId: 'RATTC',
date: new Date(2018, 6, 20),
name: 'Payment',
description: 'Partial payment',
amount: 1400.00
},
{
customerId: 'SAVEA',
date: new Date(2018, 6, 27),
name: 'Payment',
description: 'Paid in full',
amount: 2555.51
},
{
customerId: 'SPLIR',
date: new Date(2018, 6, 29),
name: 'Payment',
description: 'Paid in full',
amount: 877.11
},
{
customerId: 'THECR',
date: new Date(2018, 6, 21),
name: 'Payment',
description: 'Paid in full',
amount: 1205.63
},
{
customerId: 'TRAIH',
date: new Date(2018, 6, 18),
name: 'Payment',
description: 'Paid in full',
amount: 4020.55
},
{
customerId: 'WHITC',
date: new Date(2018, 6, 24),
name: 'Payment',
description: 'Paid in full',
amount: 309.22
},
{
customerId: 'THEBI',
date: new Date(2018, 7, 21),
name: 'Payment',
description: 'Paid in full',
amount: 3253.22
},
{
customerId: 'GREAL',
date: new Date(2018, 7, 17),
name: 'Payment',
description: 'Paid in full',
amount: 244.62
},
{
customerId: 'HUNGC',
date: new Date(2018, 7, 30),
name: 'Payment',
description: 'Paid in full',
amount: 1644.00
},
{
customerId: 'LAZYK',
date: new Date(2018, 7, 15),
name: 'Payment',
description: 'Paid in full',
amount: 4033.75
},
{
customerId: 'LETSS',
date: new Date(2018, 7, 8),
name: 'Payment',
description: 'Paid in full',
amount: 516.70
},
{
customerId: 'LONEP',
date: new Date(2018, 7, 25),
name: 'Payment',
description: 'Paid in full',
amount: 2050.39
},
{
customerId: 'OLDWO',
date: new Date(2018, 7, 11),
name: 'Payment',
description: 'Paid in full',
amount: 1677.06
},
{
customerId: 'RATTC',
date: new Date(2018, 7, 20),
name: 'Payment',
description: 'Partial payment',
amount: 1400.00
},
{
customerId: 'SAVEA',
date: new Date(2018, 7, 27),
name: 'Payment',
description: 'Paid in full',
amount: 2555.51
},
{
customerId: 'SPLIR',
date: new Date(2018, 7, 29),
name: 'Payment',
description: 'Paid in full',
amount: 877.11
},
{
customerId: 'THECR',
date: new Date(2018, 7, 21),
name: 'Payment',
description: 'Paid in full',
amount: 1205.63
},
{
customerId: 'TRAIH',
date: new Date(2018, 7, 18),
name: 'Payment',
description: 'Paid in full',
amount: 4020.55
},
{
customerId: 'WHITC',
date: new Date(2018, 7, 24),
name: 'Payment',
description: 'Paid in full',
amount: 309.22
},
{
customerId: 'LAZYK',
date: new Date(2018, 7, 29),
name: 'Claim',
description: 'Fire damage',
amount: 30000
},
{
customerId: 'THEBI',
date: new Date(2018, 8, 1),
name: 'Policy expiration',
description: 'First warning',
amount: 0
}
];
public getDocuments (customerId: string):
Promise<IActivity[]> {
var result: IActivity[] = [];
result = this.mockItems.filter(a => a.customerId == customerId);
return new Promise<IActivity[]>((resolve) => {
resolve(result);
});
}
}

View File

@ -0,0 +1,7 @@
import { IActivity } from '../../model/IActivity';
// US only for now
export interface IActivityService {
getDocuments (customerId: string):
Promise<IActivity[]>;
}

View File

@ -0,0 +1,91 @@
import { ICalendarService } from './ICalendarService';
import { ICalendarItem } from '../../model/ICalendarItem';
import { IUser } from '../../model/IUser';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { MSGraphClient } from '@microsoft/sp-http';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
interface CalendarView { value: MicrosoftGraph.Event[]; }
export default class CalendarService implements ICalendarService {
private context: WebPartContext;
constructor(context: WebPartContext, serviceScope: ServiceScope) {
this.context = context;
}
public getGroupCalendarItems(groupId?: string, groupName?: string) {
if (groupId && groupName) {
var result = new Promise<ICalendarItem[]>((resolve, reject) => {
const now = Date.now();
const startDateTime = this.formatDateForRest(new Date(now));
const endDateTime = this.formatDateForRest(new Date(now + 7 * 24 * 60 * 60 * 1000));
this.context.msGraphClientFactory
.getClient()
.then((graphClient: MSGraphClient): void => {
graphClient.api(`/groups/${groupId}/calendarview?startdatetime=${startDateTime}&enddatetime=${endDateTime}`)
.get((error, data: CalendarView, rawResponse?: any) => {
let calendarItems: ICalendarItem[] = [];
data.value.forEach((event) => {
let attendees: IUser[] = [];
if (event.attendees) {
event.attendees.forEach((user) => {
if (user.emailAddress &&
user.emailAddress.name &&
user.emailAddress.address &&
user.emailAddress.name.toLowerCase() != groupName.toLowerCase()) {
attendees.push({
fullName: user.emailAddress.name,
email: user.emailAddress.address
});
}
});
if (event.start && event.start.dateTime) {
calendarItems.push({
Title: event.subject,
DateTime: new Date(event.start.dateTime),
Attendees: attendees
});
}
}
});
resolve(calendarItems);
});
});
});
return result;
} else {
// If here, either group id or name were blank
return Promise.resolve([]);
}
}
// formatDateForRest() - The O365 REST API wants ISO format with the milliseconds not present,
// in UTC. This function will return the correctly formatted time for midnight, local time, on
// the date specified. Example of a correctly formatted time: 2015-09-06T00:00:00Z
private formatDateForRest(date) {
var midnightLocalTime = date;
midnightLocalTime.setHours(0, 0, 0, 0);
var utcDate = new Date(midnightLocalTime.getTime() + midnightLocalTime.getTimezoneOffset() * 60 * 1000);
return utcDate.getFullYear() + "-" +
('0' + (utcDate.getMonth() + 1)).substr(-2) + "-" +
('0' + utcDate.getDate()).substr(-2) + "T" +
('0' + utcDate.getHours()).substr(-2) + ":00:00Z";
}
}

View File

@ -0,0 +1,38 @@
import { ICalendarService } from './ICalendarService';
import { ICalendarItem } from '../../model/ICalendarItem';
export default class CalendarServiceMock implements ICalendarService {
private mockItems: ICalendarItem[] =
[
{
Title: "Damage assessment: Lonseome Pine follow-up (LONEP)",
DateTime: new Date(2018, 6, 30, 9, 30, 0),
Attendees: [
{ fullName: "User 2", email: "user2@contoso.com" }
]
},
{
Title: "Damage assessment: Big Cheese annual inspection (THEBI)",
DateTime: new Date(2018, 6, 30, 11, 0, 0),
Attendees: [
{ fullName: "User 1", email: "user1@contoso.com" }
]
},
{
Title: "Fire inspection: Lazy K Kountry Store (LAZYK)",
DateTime: new Date(2018, 6, 30, 15, 30, 0),
Attendees: [
{ fullName: "User 3", email: "user3@contoso.com" },
{ fullName: "User 2", email: "user2@contoso.com" },
{ fullName: "User 1", email: "user1@contoso.com" }
]
}
];
public getGroupCalendarItems(groupId?: string) {
return new Promise<ICalendarItem[]>((resolve) => {
resolve(this.mockItems);
});
}
}

View File

@ -0,0 +1,7 @@
import { ICalendarItem } from '../../model/ICalendarItem';
export interface ICalendarService {
getGroupCalendarItems (groupId?: string, groupEmail?: string) : Promise<ICalendarItem[]>;
}

View File

@ -0,0 +1,15 @@
import { IConversationService } from './IConversationService';
import { ContentType } from '../../model/IConversation';
export default class ConversationServiceMock implements IConversationService {
public createChatThread(content: string, contentType: ContentType) {
console.log(`New chat thread: ${content}`);
return new Promise<void>((resolve) => {
resolve();
});
}
}

View File

@ -0,0 +1,50 @@
import { INewChatThread, ContentType } from '../../model/IConversation';
import { IConversationService } from '../../services/ConversationService/IConversationService';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { MSGraphClient } from '@microsoft/sp-http';
export default class ConversationServiceTeams implements IConversationService {
private context: WebPartContext;
private teamId: string;
private channelId: string;
constructor(context: WebPartContext, serviceScope: ServiceScope,
teamId: string, channelId: string) {
this.context = context;
this.teamId = teamId;
this.channelId = channelId;
}
public createChatThread(content: string, contentType: ContentType) {
const result = new Promise<void>((resolve, reject) => {
const postContent: INewChatThread =
{
rootMessage: {
body: {
content: content,
contentType: contentType
}
}
};
this.context.msGraphClientFactory
.getClient()
.then((graphClient: MSGraphClient): void => {
graphClient.api(`https://graph.microsoft.com/beta/teams/${this.teamId}/channels/${this.channelId}/chatThreads`)
.post(postContent, ((err, res) => {
resolve();
}));
});
});
return result;
}
}

View File

@ -0,0 +1,8 @@
import { ContentType } from '../../model/IConversation';
export interface IConversationService {
createChatThread(content: string, contentType: ContentType):
Promise<void>;
}

View File

@ -0,0 +1,45 @@
import { ICustomerService } from './ICustomerService';
import { ICustomer } from '../../model/ICustomer';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { HttpClient } from '@microsoft/sp-http';
export default class CustomerService implements ICustomerService {
private context: IWebPartContext;
constructor(context: IWebPartContext, serviceScope: ServiceScope) {
this.context = context;
}
public getCustomer(customerID: string): Promise<ICustomer> {
var result: Promise<ICustomer> = new Promise<ICustomer>
((resolve, reject) => {
this.context.httpClient
.fetch(`https://services.odata.org/V3/Northwind/Northwind.svc/Customers?$filter=CustomerID eq '${customerID}'`,
HttpClient.configurations.v1,
{
method: 'GET',
headers: {"accept": "application/json"},
mode: 'cors',
cache: 'default'
})
.then ((response) => {
if (response.ok) {
return response.json();
} else {
throw (`Error ${response.status}: ${response.statusText}`);
}
})
// TODO: Kill the any
.then ((o: any) => {
resolve(o.value[0]);
});
// TODO: Handle exception
});
return result;
}
}

View File

@ -0,0 +1,189 @@
import { ICustomerService } from './ICustomerService';
import { ICustomer } from '../../model/ICustomer';
export default class CustomerServiceMock implements ICustomerService {
// US customers from Northwind database
private mockItems: ICustomer[] =
[
{
"CustomerID": "GREAL",
"CompanyName": "Great Lakes Food Market",
"ContactName": "Howard Snyder",
"ContactTitle": "Marketing Manager",
"Address": "2732 Baker Blvd.",
"City": "Eugene",
"Region": "OR",
"PostalCode": "97403",
"Country": "USA",
"Phone": "(503) 555-7555",
"Fax": ""
},
{
"CustomerID": "HUNGC",
"CompanyName": "Hungry Coyote Import Store",
"ContactName": "Yoshi Latimer",
"ContactTitle": "Sales Representative",
"Address": "City Center Plaza 516 Main St.",
"City": "Elgin",
"Region": "OR",
"PostalCode": "97827",
"Country": "USA",
"Phone": "(503) 555-6874",
"Fax": "(503) 555-2376"
},
{
"CustomerID": "LAZYK",
"CompanyName": "Lazy K Kountry Store",
"ContactName": "John Steel",
"ContactTitle": "Marketing Manager",
"Address": "12 Orchestra Terrace",
"City": "Walla Walla",
"Region": "WA",
"PostalCode": "99362",
"Country": "USA",
"Phone": "(509) 555-7969",
"Fax": "(509) 555-6221"
},
{
"CustomerID": "LETSS",
"CompanyName": "Let's Stop N Shop",
"ContactName": "Jaime Yorres",
"ContactTitle": "Owner",
"Address": "87 Polk St. Suite 5",
"City": "San Francisco",
"Region": "CA",
"PostalCode": "94117",
"Country": "USA",
"Phone": "(415) 555-5938",
"Fax": ""
},
{
"CustomerID": "LONEP",
"CompanyName": "Lonesome Pine Restaurant",
"ContactName": "Fran Wilson",
"ContactTitle": "Sales Manager",
"Address": "89 Chiaroscuro Rd.",
"City": "Portland",
"Region": "OR",
"PostalCode": "97219",
"Country": "USA",
"Phone": "(503) 555-9573",
"Fax": "(503) 555-9646"
},
{
"CustomerID": "OLDWO",
"CompanyName": "Old World Delicatessen",
"ContactName": "Rene Phillips",
"ContactTitle": "Sales Representative",
"Address": "2743 Bering St.",
"City": "Anchorage",
"Region": "AK",
"PostalCode": "99508",
"Country": "USA",
"Phone": "(907) 555-7584",
"Fax": "(907) 555-2880"
},
{
"CustomerID": "RATTC",
"CompanyName": "Rattlesnake Canyon Grocery",
"ContactName": "Paula Wilson",
"ContactTitle": "Assistant Sales Representative",
"Address": "2817 Milton Dr.",
"City": "Albuquerque",
"Region": "NM",
"PostalCode": "87110",
"Country": "USA",
"Phone": "(505) 555-5939",
"Fax": "(505) 555-3620"
},
{
"CustomerID": "SAVEA",
"CompanyName": "Save-a-lot Markets",
"ContactName": "Jose Pavarotti",
"ContactTitle": "Sales Representative",
"Address": "187 Suffolk Ln.",
"City": "Boise",
"Region": "ID",
"PostalCode": "83720",
"Country": "USA",
"Phone": "(208) 555-8097",
"Fax": ""
},
{
"CustomerID": "SPLIR",
"CompanyName": "Split Rail Beer & Ale",
"ContactName": "Art Braunschweiger",
"ContactTitle": "Sales Manager",
"Address": "P.O. Box 555",
"City": "Lander",
"Region": "WY",
"PostalCode": "82520",
"Country": "USA",
"Phone": "(307) 555-4680",
"Fax": "(307) 555-6525"
},
{
"CustomerID": "THEBI",
"CompanyName": "The Big Cheese",
"ContactName": "Liz Nixon",
"ContactTitle": "Marketing Manager",
"Address": "89 Jefferson Way Suite 2",
"City": "Portland",
"Region": "OR",
"PostalCode": "97201",
"Country": "USA",
"Phone": "(503) 555-3612",
"Fax": ""
},
{
"CustomerID": "THECR",
"CompanyName": "The Cracker Box",
"ContactName": "Liu Wong",
"ContactTitle": "Marketing Assistant",
"Address": "55 Grizzly Peak Rd.",
"City": "Butte",
"Region": "MT",
"PostalCode": "59801",
"Country": "USA",
"Phone": "(406) 555-5834",
"Fax": "(406) 555-8083"
},
{
"CustomerID": "TRAIH",
"CompanyName": "Trail's Head Gourmet Provisioners",
"ContactName": "Helvetius Nagy",
"ContactTitle": "Sales Associate",
"Address": "722 DaVinci Blvd.",
"City": "Kirkland",
"Region": "WA",
"PostalCode": "98034",
"Country": "USA",
"Phone": "(206) 555-8257",
"Fax": "(206) 555-2174"
},
{
"CustomerID": "WHITC",
"CompanyName": "White Clover Markets",
"ContactName": "Karl Jablonski",
"ContactTitle": "Owner",
"Address": "305 - 14th Ave. S. Suite 3B",
"City": "Seattle",
"Region": "WA",
"PostalCode": "98128",
"Country": "USA",
"Phone": "(206) 555-4112",
"Fax": "(206) 555-4115"
}
];
public getCustomer(customerID: string):Promise<ICustomer> {
var result: ICustomer;
result = this.mockItems.filter(c => c.CustomerID == customerID)[0];
return new Promise<ICustomer>((resolve) => {
resolve(result);
});
}
}

View File

@ -0,0 +1,5 @@
import { ICustomer } from '../../model/ICustomer';
export interface ICustomerService {
getCustomer (customerID: string) : Promise<ICustomer>;
}

View File

@ -0,0 +1,58 @@
import { IDocument } from '../../model/IDocument';
import { IDocumentService } from './IDocumentService';
import { IDocumentsResponse } from './IDocumentsResponse';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { SPHttpClient } from '@microsoft/sp-http';
export default class DocumentService implements IDocumentService {
private context: IWebPartContext;
constructor(context: IWebPartContext, serviceScope: ServiceScope) {
this.context = context;
}
public getDocuments(customerId: string):
Promise<IDocument[]> {
var result = new Promise<IDocument[]>((resolve, reject) => {
const siteUrl = this.context.pageContext.web.absoluteUrl;
this.context.spHttpClient
.fetch(`${siteUrl}/_api/lists/GetByTitle('Documents')/items?$filter=Customer eq '${customerId}'&$select=Title,FileLeafRef,FileRef,UniqueId,Modified,Author/Name,Author/Title&$expand=Author/Id&$orderby=Title`,
SPHttpClient.configurations.v1,
{
method: 'GET',
headers: { "accept": "application/json" },
mode: 'cors',
cache: 'default'
})
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw (`Error ${response.status}: ${response.statusText}`);
}
})
.then((o: IDocumentsResponse) => {
let docs: IDocument[] = [];
o.value.forEach((doc) => {
docs.push({
name: doc.FileLeafRef,
url: doc.FileRef,
author: doc.Author.Title,
date: new Date(doc.Modified)
});
});
resolve(docs);
});
// TODO: Handle exception
});
return result;
}
}

View File

@ -0,0 +1,37 @@
import { IDocument } from '../../model/IDocument';
import { IDocumentService } from './IDocumentService';
export default class DocumentServiceMock implements IDocumentService {
// US customers from Northwind database
private mockData: IDocument[] = [
{
name: "Document A",
url: "#",
author: "Ada Lovelace",
date: new Date()
},
{
name: "Document B",
url: "#",
author: "Charles Babbage",
date: new Date()
},
{
name: "Document C",
url: "#",
author: "Grace Hopper",
date: new Date()
}
];
public getDocuments (customerId: string):
Promise<IDocument[]> {
var result = this.mockData;
return new Promise<IDocument[]>((resolve) => {
resolve(result);
});
}
}

View File

@ -0,0 +1,7 @@
import { IDocument } from '../../model/IDocument';
// US only for now
export interface IDocumentService {
getDocuments (customerId: string):
Promise<IDocument[]>;
}

View File

@ -0,0 +1,17 @@
export interface Author {
Name: string;
Title: string;
}
export interface Value {
Modified: string;
FileLeafRef: string;
Title: string;
FileRef: string;
UniqueId: string;
Author: Author;
}
export interface IDocumentsResponse {
value: Value[];
}

View File

@ -0,0 +1,11 @@
import { IMapLocation } from '../../model/IMapLocation';
// US only for now
export interface IMapService {
getLocation (address: string, city: string, state: string, zip: string):
Promise<IMapLocation>;
getMapApiKey (): string;
getMapImageUrl (address: string, city: string, state: string,
country: string, postalCode: string):
Promise<string>;
}

View File

@ -0,0 +1,109 @@
import { IMapService } from './IMapService';
import { IMapLocation } from '../../model/IMapLocation';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { HttpClient } from '@microsoft/sp-http';
import * as constants from '../../constants';
export default class MapService implements IMapService {
private context: IWebPartContext;
constructor(context: IWebPartContext, serviceScope: ServiceScope) {
this.context = context;
}
public getLocation(address: string, city: string, state: string, zip: string):
Promise<IMapLocation> {
// Remove "." and trim address to make Bing maps happy
const adjustedAddress = address.replace('.',' ').trim();
var result = new Promise<IMapLocation>((resolve, reject) => {
this.context.httpClient
.fetch(`https://dev.virtualearth.net/REST/v1/Locations/US/${state}/${zip}/${city}/${adjustedAddress}?key=${constants.mapApiKey}`,
HttpClient.configurations.v1,
{
method: 'GET',
headers: { "accept": "application/json" },
mode: 'cors',
cache: 'default'
})
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw (`Error ${response.status}: ${response.statusText}`);
}
})
.then((o: IMapLocation) => {
resolve(o);
})
.catch((error: any) => {
reject(error);
});
});
return result;
}
public getMapApiKey(): string {
return constants.mapApiKey;
}
public getMapImageUrl(address: string, city: string, state: string,
country: string, postalCode: string):
Promise<string> {
return new Promise<string>((resolve, reject) => {
if (country &&
country.toLowerCase() == "usa" &&
postalCode) {
const locationSignature = this.getLocationSignature(
address, city, state, country, postalCode);
if (this.locationSignature === locationSignature) {
// If here, the cached location is valid
resolve (this.getMapImageUrlFromLocation(this.location));
} else {
// If here we have no location cached; call the web service
this.getLocation(address, city, state, postalCode)
.then((location: IMapLocation) => {
this.location = location;
this.locationSignature = locationSignature;
resolve (this.getMapImageUrlFromLocation(this.location));
})
.catch ((error: string) => {
resolve('#');
});
}
}
});
}
private locationSignature: string;
private location: IMapLocation;
private getLocationSignature(address: string, city: string, state: string,
country: string, postalCode: string) {
return `${address}**${city}**${state}**${country}**${postalCode}`;
}
private getMapImageUrlFromLocation(location: IMapLocation) {
const coordinates =
location.resourceSets[0].resources[0].point.coordinates;
const latitude = coordinates[0];
const longitude = coordinates[1];
const apiKey = this.getMapApiKey();
return `https://dev.virtualearth.net/REST/v1/Imagery/Map/Road/${latitude},${longitude}/16?mapSize=450,600&pp=${latitude},${longitude}&key=${apiKey}`;
}
}

View File

@ -0,0 +1,106 @@
import { IMapService } from './IMapService';
import { IMapLocation } from '../../model/IMapLocation';
import * as constants from '../../constants';
export default class MapServiceMock implements IMapService {
// US customers from Northwind database
private mockData: IMapLocation =
{
"authenticationResultCode": "ValidCredentials",
"brandLogoUri": "http://dev.virtualearth.net/Branding/logo_powered_by.png",
"copyright": "Copyright © 2018 Microsoft and its suppliers. All rights reserved. This API cannot be accessed and the content and any results may not be used, reproduced or transmitted in any manner without express written permission from Microsoft Corporation.",
"resourceSets": [
{
"estimatedTotal": 1,
"resources": [
{
"__type": "Location:http://schemas.microsoft.com/search/local/ws/rest/v1",
"bbox": [
42.3640072824293,
-71.1962504127409,
42.3717327175707,
-71.1823095872591
],
"name": "1 Main St, Watertown, MA 02472",
"point": {
"type": "Point",
"coordinates": [
42.36787,
-71.18928
]
},
"address": {
"addressLine": "1 Main St",
"adminDistrict": "MA",
"adminDistrict2": "Middlesex County",
"countryRegion": "United States",
"formattedAddress": "1 Main St, Watertown, MA 02472",
"locality": "Watertown",
"postalCode": "02472"
},
"confidence": "High",
"entityType": "Address",
"geocodePoints": [
{
"type": "Point",
"coordinates": [
42.36787,
-71.18928
],
"calculationMethod": "Rooftop",
"usageTypes": [
"Display"
]
},
{
"type": "Point",
"coordinates": [
42.3682298194267,
-71.1888233197147
],
"calculationMethod": "Rooftop",
"usageTypes": [
"Route"
]
}
],
"matchCodes": [
"Good"
]
}
]
}
],
"statusCode": 200,
"statusDescription": "OK",
"traceId": "98ce3776abf44779a9b55622ef8b4be7|BN1CD9C1AB|7.7.0.0|Ref A: 0409F12560074D0B806E088436EF5946 Ref B: BN3EDGE0208 Ref C: 2018-07-26T01:03:06Z"
};
public getLocation (address: string, city: string, state: string, zip: string):
Promise<IMapLocation> {
var result = this.mockData;
return new Promise<IMapLocation>((resolve) => {
resolve(result);
});
}
public getMapApiKey(): string {
return constants.mapApiKey;
}
public getMapImageUrl(address: string, city: string, state: string,
country: string, postalCode: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const apiKey = this.getMapApiKey();
const latitude = 42.36787;
const longitude = -71.18928;
resolve (`https://dev.virtualearth.net/REST/v1/Imagery/Map/Road/${latitude},${longitude}/16?mapSize=450,600&pp=${latitude},${longitude}&key=${apiKey}`);
});
}
}

View File

@ -0,0 +1,7 @@
import { IPhoto } from '../../model/IPhoto';
export interface IPhotoService {
getPhotos(customerId: string): Promise<IPhoto[]>;
}

View File

@ -0,0 +1,27 @@
export interface Value {
CheckInComment: string;
CheckOutType: number;
ContentTag: string;
CustomizedPageStatus: number;
ETag: string;
Exists: boolean;
IrmEnabled: boolean;
Length: number;
Level: number;
LinkingUri?: any;
LinkingUrl: string;
MajorVersion: number;
MinorVersion: number;
Name: string;
ServerRelativeUrl: string;
TimeCreated: Date;
TimeLastModified: Date;
Title?: any;
UIVersion: number;
UIVersionLabel: string;
UniqueId: string;
}
export interface IPhotosResponse {
value: Value[];
}

View File

@ -0,0 +1,55 @@
import { IPhotoService } from './IPhotoService';
import { IPhotosResponse } from './IPhotosResponse';
import { IPhoto } from '../../model/IPhoto';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { SPHttpClient } from '@microsoft/sp-http';
export default class PhotoService implements IPhotoService {
private context: IWebPartContext;
constructor(context: IWebPartContext, serviceScope: ServiceScope) {
this.context = context;
}
public getPhotos(customerId: string): Promise<IPhoto[]> {
var result = new Promise<IPhoto[]>((resolve, reject) => {
const absoluteUrl = this.context.pageContext.web.absoluteUrl;
const serverRelativeUrl = this.context.pageContext.web.serverRelativeUrl;
this.context.spHttpClient
.fetch(`${absoluteUrl}/_api/web/GetFolderByServerRelativeUrl('${serverRelativeUrl}/Photos/${customerId}')/Files`,
SPHttpClient.configurations.v1,
{
method: 'GET',
headers: { "accept": "application/json" },
mode: 'cors',
cache: 'default'
})
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw (`Error ${response.status}: ${response.statusText}`);
}
})
.then((o: IPhotosResponse) => {
let files: IPhoto[] = [];
o.value.forEach((file) => {
files.push({
name: file.Name,
url: file.ServerRelativeUrl
});
});
resolve(files);
});
// TODO: Handle exception
});
return result;
}
}

View File

@ -0,0 +1,27 @@
import { IPhotoService } from './IPhotoService';
import { IPhoto } from '../../model/IPhoto';
export default class PhotoServiceMock implements IPhotoService {
// US customers from Northwind database
private mockData: IPhoto[] =
[
{
name: "Surface 1",
url: "https://c.s-microsoft.com/en-au/CMSImages/Windows_Homepage_Hero_RS4_1920.jpg?version=85d2e084-dab6-5d93-619a-2af2c228e9a2"
},
{
name: "Surface 2",
url: "https://c.s-microsoft.com/en-au/CMSImages/1920_panel2_hero_EdgeSurface.jpg?version=c9920e78-cb6c-64d4-c967-fd1d34791f6e"
}
];
public getPhotos(customerID: string): Promise<IPhoto[]> {
var result = this.mockData;
return new Promise<IPhoto[]>((resolve) => {
resolve(result);
});
}
}

View File

@ -0,0 +1,114 @@
import { IVisitService } from './VisitService/IVisitService';
import VisitService from './VisitService/VisitService';
import { ICustomerService } from './CustomerService/ICustomerService';
import CustomerService from './CustomerService/CustomerService';
import CustomerServiceMock from './CustomerService/CustomerServiceMock';
import { ICalendarService } from './CalendarService/ICalendarService';
import CalendarService from './CalendarService/CalendarService';
import CalendarServiceMock from './CalendarService/CalendarServiceMock';
import { IWeatherService } from './WeatherService/IWeatherService';
import WeatherService from './WeatherService/WeatherService';
import WeatherServiceMock from './WeatherService/WeatherServiceMock';
import { IMapService } from './MapService/IMapService';
import MapService from './MapService/MapService';
import MapServiceMock from './MapService/MapServiceMock';
import { IDocumentService } from './DocumentService/IDocumentService';
import DocumentService from './DocumentService/DocumentService';
import DocumentServiceMock from './DocumentService/DocumentServiceMock';
import { IActivityService } from './ActivityService/IActivityService';
import ActivityServiceMock from './ActivityService/ActivityServiceMock';
import { IConversationService } from './ConversationService/IConversationService';
import ConversationServiceMock from './ConversationService/ConversationServiceMock';
import ConversationServiceTeams from './ConversationService/ConversationServiceTeams';
import { IPhotoService } from './PhotoService/IPhotoService';
import PhotoServiceMock from './PhotoService/PhotoServiceMock';
import PhotoService from './PhotoService/PhotoService';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { EnvironmentType } from '@microsoft/sp-core-library';
export default class ServiceFactory {
public static getVisitService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope): IVisitService {
var calendarService: ICalendarService;
var customerService: ICustomerService;
if (environmentType === EnvironmentType.Local) {
calendarService = new CalendarServiceMock();
customerService = new CustomerServiceMock();
} else {
calendarService = new CalendarService(context, serviceScope);
customerService = new CustomerService(context, serviceScope);
}
return new VisitService(calendarService, customerService);
}
public static getWeatherService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope): IWeatherService {
return (environmentType === EnvironmentType.Local) ?
new WeatherServiceMock() :
new WeatherService(context, serviceScope);
}
public static getMapService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope): IMapService {
return (environmentType === EnvironmentType.Local) ?
new MapServiceMock() :
new MapService(context, serviceScope);
}
public static getDocumentService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope): IDocumentService {
return (environmentType === EnvironmentType.Local) ?
new DocumentServiceMock() :
new DocumentService(context, serviceScope);
}
public static getActivityService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope): IActivityService {
return (environmentType === EnvironmentType.Local) ?
new ActivityServiceMock() :
new ActivityServiceMock();
}
public static getConversationService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope,
teamId?: string,
channelId?: string): IConversationService {
return (environmentType === EnvironmentType.Local ||
!teamId || !channelId ) ?
new ConversationServiceMock() :
new ConversationServiceTeams(context, serviceScope, teamId, channelId);
}
public static getPhotoService(
environmentType: EnvironmentType,
context: WebPartContext,
serviceScope: ServiceScope): IPhotoService {
return (environmentType === EnvironmentType.Local) ?
new PhotoServiceMock() :
new PhotoService(context, serviceScope);
}
}

View File

@ -0,0 +1,5 @@
import { IVisit } from '../../model/IVisit';
export interface IVisitService {
getGroupVisits (groupId?: string, groupEmail?: string) : Promise<IVisit[]>;
}

View File

@ -0,0 +1,59 @@
import { IVisitService } from './IVisitService';
import { IVisit } from '../../model/IVisit';
import { ICalendarService } from '../CalendarService/ICalendarService';
import { ICustomerService } from '../CustomerService/ICustomerService';
export default class VisitService implements IVisitService {
private calendarService: ICalendarService;
private customerService: ICustomerService;
constructor(calendarService: ICalendarService,
customerService: ICustomerService) {
this.calendarService = calendarService;
this.customerService = customerService;
}
public getGroupVisits(groupId?: string, groupName?: string) {
return new Promise<IVisit[]>((resolve, reject) => {
this.calendarService.getGroupCalendarItems(groupId, groupName)
.then((calendarItems) => {
var items: IVisit[] = new Array<IVisit>();
let outstandingPromises = 0;
calendarItems.forEach(element => {
// Parse title looking for customer ID
let regex = /\(([^)]+)\)/;
if (element.Title) {
let matches = regex.exec(element.Title);
if (matches && matches.length > 1) {
// If here, we found a potential customer ID
let customerId = matches[1];
outstandingPromises++;
this.customerService.getCustomer(customerId)
.then((customer) => {
if (customer) {
// If here, we found an actual customer; add it to the list
items.push({
calendarItem: element,
customer: customer
});
}
if (!--outstandingPromises) {
resolve(items.sort((i, j) =>
(i.calendarItem.DateTime.getTime() -
j.calendarItem.DateTime.getTime())));
}
});
}
}
});
});
});
}
}

View File

@ -0,0 +1,6 @@
import { IWeatherConditions } from '../../model/IWeatherConditions';
// US only for now
export interface IWeatherService {
getConditions (zip: string) : Promise<IWeatherConditions>;
}

View File

@ -0,0 +1,44 @@
import { IWeatherService } from './IWeatherService';
import { IWeatherConditions } from '../../model/IWeatherConditions';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { ServiceScope } from '@microsoft/sp-core-library';
import { HttpClient } from '@microsoft/sp-http';
import * as constants from '../../constants';
export default class WeatherService implements IWeatherService {
private context: IWebPartContext;
constructor(context: IWebPartContext, serviceScope: ServiceScope) {
this.context = context;
}
public getConditions (zip: string) : Promise<IWeatherConditions> {
var result: Promise<IWeatherConditions> = new Promise<IWeatherConditions>
((resolve, reject) => {
this.context.httpClient
.fetch(`https://api.openweathermap.org/data/2.5/weather?zip=${zip},us&appid=${constants.owmApiKey}`,
HttpClient.configurations.v1,
{
method: 'GET',
headers: {"accept": "application/json"},
mode: 'cors',
cache: 'default'
})
.then ((response) => {
if (response.ok) {
return response.json();
} else {
throw (`Error ${response.status}: ${response.statusText}`);
}
})
.then ((o: IWeatherConditions) => {
resolve(o);
});
// TODO: Handle exception
});
return result;
}
}

View File

@ -0,0 +1,72 @@
import { IWeatherService } from './IWeatherService';
import { IWeatherConditions } from '../../model/IWeatherConditions';
export default class WeatherServiceMock implements IWeatherService {
// US customers from Northwind database
private mockData: IWeatherConditions =
{
"coord": {
"lon": -71.24,
"lat": 42.4
},
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
},
{
"id": 701,
"main": "Mist",
"description": "mist",
"icon": "50d"
},
{
"id": 721,
"main": "Haze",
"description": "haze",
"icon": "50d"
}
],
"base": "stations",
"main": {
"temp": 300.61,
"pressure": 1016,
"humidity": 66,
"temp_min": 297.15,
"temp_max": 304.15
},
"visibility": 16093,
"wind": {
"speed": 7.2,
"deg": 180,
"gust": 10.8
},
"clouds": {
"all": 75
},
"dt": 1532542500,
"sys": {
"type": 1,
"id": 1272,
"message": 0.0051,
"country": "US",
"sunrise": 1532511070,
"sunset": 1532563874
},
"id": 420016955,
"name": "Boston",
"cod": 200
};
public getConditions(customerID: string): Promise<IWeatherConditions> {
var result = this.mockData;
return new Promise<IWeatherConditions>((resolve) => {
resolve(result);
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,47 @@
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.2/MicrosoftTeams.schema.json",
"manifestVersion": "1.2",
"packageName": "FieldVisitTab",
"id": "ed686d10-9382-4c3b-be6b-1957d4ec9692",
"version": "0.1",
"developer": {
"name": "SPFx + Teams Dev",
"websiteUrl": "https://products.office.com/en-us/sharepoint/collaboration",
"privacyUrl": "https://privacy.microsoft.com/en-us/privacystatement",
"termsOfUseUrl": "https://www.microsoft.com/en-us/servicesagreement"
},
"name": {
"short": "FieldVisitTab"
},
"description": {
"short": "Displays details of upcoming field visits in a Microsoft Teams tab",
"full": "Displays details of upcoming field visits in a Microsoft Teams tab"
},
"icons": {
"outline": "tab20x20.png",
"color": "tab96x96.png"
},
"accentColor": "#004578",
"configurableTabs": [
{
"configurationUrl": "https://{teamSiteDomain}{teamSitePath}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest={teamSitePath}/_layouts/15/teamshostedapp.aspx%3FopenPropertyPane=true%26teams%26componentId=ed686d10-9382-4c3b-be6b-1957d4ec9692",
"canUpdateConfiguration": false,
"scopes": [
"team"
]
}
],
"validDomains": [
"*.login.microsoftonline.com",
"*.sharepoint.com",
"*.sharepoint-df.com",
"spoppe-a.akamaihd.net",
"spoprod-a.akamaihd.net",
"resourceseng.blob.core.windows.net",
"msft.spoppe.com"
],
"webApplicationInfo": {
"resource": "https://{teamSiteDomain}",
"id": "00000003-0000-0ff1-ce00-000000000000"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,35 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"lib"
]
}

View File

@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}