diff --git a/apps/dash-salesforce-crm/.gitignore b/apps/dash-salesforce-crm/.gitignore new file mode 100644 index 000000000..41e8e8e6a --- /dev/null +++ b/apps/dash-salesforce-crm/.gitignore @@ -0,0 +1,7 @@ +venv/ +.env +__pycache__/ +apps/__pycache__/ +*.DS_Store +.vscode +secrets.sh \ No newline at end of file diff --git a/apps/dash-salesforce-crm/Procfile b/apps/dash-salesforce-crm/Procfile new file mode 100644 index 000000000..17da7384b --- /dev/null +++ b/apps/dash-salesforce-crm/Procfile @@ -0,0 +1 @@ +web: gunicorn --pythonpath apps/dash-salesforce-crm index:server \ No newline at end of file diff --git a/apps/dash-salesforce-crm/README.md b/apps/dash-salesforce-crm/README.md new file mode 100644 index 000000000..bda228686 --- /dev/null +++ b/apps/dash-salesforce-crm/README.md @@ -0,0 +1,64 @@ +# Dash Salesforce CRM + +This is a demo of the Dash interactive Python framework developed by [Plotly](https://plot.ly/). + +Dash abstracts away all of the technologies and protocols required to build an interactive web-based application and is a simple and effective way to bind a user interface around your Python code. + +To learn more check out our [documentation](https://plot.ly/dash). + +## Getting Started + +### Running the app locally + +First create a virtual environment with conda or venv inside a temp folder, then activate it. + +``` +virtualenv venv + +# Windows +venv\Scripts\activate +# Or Linux +source venv/bin/activate + +``` + +Clone the git repo, then install the requirements with pip + +``` + +git clone https://github.com/plotly/dash-sample-apps +cd dash-sample-apps/apps/dash-salesforce-crm +pip install -r requirements.txt + +``` + +To run the app, please create a SalesForce developer account (link is in the `About the App` section). There is an example of how the bash script should look like in the `secrets.example.sh` file. Be sure to create a new file named `secrets.sh` file and put your credentials in the file. Therefore, your credentials will not get pushed to github as the `secrets.sh` file is in the `.gitignore`. + +Run the app + +``` +source secrets.sh +python index.py + +``` + +## About the App + +This app uses Salesforce API in order to implement a custom CRM dashboard. The API is used via the module [Simple-Salesforce](https://pypi.org/project/simple-salesforce/). Create a free SalesForce developer trial account: [https://developer.salesforce.com/signup](https://developer.salesforce.com/signup) to utilize this API and run the app. + +## Built With + +- [Dash](https://dash.plot.ly/) - Main server and interactive components +- [Plotly Python](https://plot.ly/python/) - Used to create the interactive plots + +## Screenshots + +The following are screenshots for the app in this repo: + +![Screenshot1](screenshots/opportunities_screenshot.png) + +![Screenshot1](screenshots/leads_screenshot.png) + +![Screenshot1](screenshots/cases_screenshot.png) + +![Animated](screenshots/dash-salesforce-demo.gif) diff --git a/apps/dash-salesforce-crm/app.py b/apps/dash-salesforce-crm/app.py new file mode 100644 index 000000000..1e98313a2 --- /dev/null +++ b/apps/dash-salesforce-crm/app.py @@ -0,0 +1,50 @@ +import math +import dash +import dash_html_components as html + +from sfManager import sf_Manager + +app = dash.Dash( + __name__, meta_tags=[{"name": "viewport", "content": "width=device-width"}] +) + +app.config.suppress_callback_exceptions = True + +sf_manager = sf_Manager() + +millnames = ["", " K", " M", " B", " T"] # used to convert numbers + + +# return html Table with dataframe values +def df_to_table(df): + return html.Table( + [html.Tr([html.Th(col) for col in df.columns])] + + [ + html.Tr([html.Td(df.iloc[i][col]) for col in df.columns]) + for i in range(len(df)) + ] + ) + + +# returns most significant part of a number +def millify(n): + n = float(n) + millidx = max( + 0, + min( + len(millnames) - 1, int(math.floor(0 if n == 0 else math.log10(abs(n)) / 3)) + ), + ) + + return "{:.0f}{}".format(n / 10 ** (3 * millidx), millnames[millidx]) + + +# returns top indicator div +def indicator(color, text, id_value): + return html.Div( + [ + html.P(id=id_value, className="indicator_value"), + html.P(text, className="twelve columns indicator_text"), + ], + className="four columns indicator pretty_container", + ) diff --git a/apps/dash-salesforce-crm/assets/logo.png b/apps/dash-salesforce-crm/assets/logo.png new file mode 100644 index 000000000..3d446bf5e Binary files /dev/null and b/apps/dash-salesforce-crm/assets/logo.png differ diff --git a/apps/dash-salesforce-crm/assets/s1.css b/apps/dash-salesforce-crm/assets/s1.css new file mode 100644 index 000000000..927297c0c --- /dev/null +++ b/apps/dash-salesforce-crm/assets/s1.css @@ -0,0 +1,573 @@ +body { + font-size: 0.75rem; + margin: 0; + padding: 0; + background-color: #f9f9f9; + font-family: "Asap", sans-serif; + -webkit-user-select: none; + /* Chrome all / Safari all */ + -moz-user-select: none; + /* Firefox all */ + -ms-user-select: none; + /* IE 10+ */ + user-select: none; + /* Likely future */ +} + +.pretty_container { + border-radius: 5px; + background-color: white; + margin: 0.5rem; + padding: 1rem; + position: relative; + border: 1px solid #f1f1f1; +} + +input { + padding: 0.5rem 0; + border-radius: 5px; +} + +.button:hover { + box-shadow: 4px 4px 4px grey; + transition: box-shadow 0.5s; +} + +.row { + align-items: center; +} + +.modal { + display: block; + /*Hidden by default */ + position: fixed; + /* Stay in place */ + z-index: 1000; + /* Sit on top */ + left: 0; + top: 0; + width: 100%; + /* Full width */ + height: 100%; + /* Full height */ + overflow: auto; + /* Enable scroll if needed */ + background-color: rgb(0, 0, 0); + /* Fallback color */ + background-color: rgba(0, 0, 0, 0.4); + /* Black w/ opacity */ +} + +.modal-content { + background-color: white; + margin: 5% auto; + /* 15% from the top and centered */ + padding: 2rem; + width: 50%; + /* Could be more or less, depending on screen size */ + color: #506784; + border-radius: 10px; +} + +.button { + background-color: #3a7cef; + color: white; + display: block; + border-radius: 5px; + text-transform: uppercase; + border: none; + letter-spacing: 0.1rem; + box-shadow: 4px 4px 4px lightgrey; + transition: box-shadow 0.5s; + width: 50%; + padding: 0.75rem; + text-align: center; + margin-left: 25%; +} +.subtitle { + text-align: center; + color: #333333; + font-size: 20px; +} + +tr:nth-child(even) { + background-color: #f0f0f0; +} +tr:nth-child(odd) { + background-color: #fafafa; +} + +td, +th { + border: 0px solid #ddd; + padding: 8px; + /* float: left; */ +} + +.header { + margin: 0px; + background-color: white; + color: #333333; + padding-right: 3%; + display: flex; + flex-direction: row-reverse; + justify-content: space-between; +} + +.header img { + margin-left: auto; + height: 75px; + max-width: 33%; +} + +.app-title { + color: #333333; + padding-left: 5%; + font-size: 2.25rem; + letter-spacing: -0.1rem; + vertical-align: middle; + display: flex; + flex: 1; + flex-direction: row; +} + +#subtitle { + display: none; +} + +.indicator_value { + color: #333333; +} +.header button { + background-color: white; + box-shadow: none; + border-radius: 5px; + font-size: 1rem; + padding: 12px; + cursor: pointer; +} + +#learn_more { + display: none; +} + +#menu { + display: block; + border: none; + font-size: 2rem; +} + +#menu p { + margin-block-start: 0; + margin-block-end: 0; +} + +.tabs { + border-top: 1px solid lightgrey; + display: none; + flex-direction: column; + padding: 1rem 5%; + margin-bottom: 1rem; + background-color: white; +} +.dropdown-styles { + border-radius: 5px; + background-color: white; + margin: 0.5rem; + padding-top: 7px; + position: relative; + border: 1px solid #f1f1f1; +} +.dd-styles { + border-radius: 5px; + background-color: white; + margin: 0.5rem; + padding-top: 7px; + position: relative; + border: 1px solid #f1f1f1; +} +.tabs > a { + text-decoration: none; + color: #333333; + font-size: 1.25rem; + text-align: center; +} + +.control, +.Select-control { + border: none !important; +} + +table { + border: 1px; + font-size: 1rem; + width: 100%; + font-family: Ubuntu; +} + +.table { + overflow: scroll; +} + +.indicators { + display: flex; + align-items: stretch; +} + +.indicator { + flex: 1; + padding: 0.75rem 1.75rem; + text-align: center; +} + +.indicator_value { + font-size: 2rem; + margin: 1rem 0; +} + +.indicator_value p { + margin: 1rem 0; +} + +::-webkit-scrollbar { + width: 0px; /* Remove scrollbar space */ + background: transparent; /* Optional: just make scrollbar invisible */ +} + +.has-value.Select--single > .Select-control .Select-value .Select-value-label, +.has-value.is-pseudo-focused.Select--single + > .Select-control + .Select-value + .Select-value-label { + color: #848484; +} + +#cases_reasons { + height: 100%; +} + +/* Layouts for different screen sizes */ +@media (max-width: 350px) { + .app-title { + margin-left: 0 !important; + } +} + +@media (max-width: 450px) { + .dropdown-styles { + width: 28% !important; + display: inline-block !important; + } + .app-title { + font-size: 1.5rem !important; + padding-left: 4% !important; + margin-left: 5%; + } + .header img { + height: 35px !important; + padding-left: 3% !important; + } + .indicator_value { + font-size: 1.25rem !important; + } + .indicator { + padding: 0.5rem; + } + .button { + width: 25% !important; + } +} + +@media (max-width: 900px) { + .indicator { + align-content: center; + justify-items: center; + text-align: center; + } + + .indicator:first-child { + margin-right: 0; + } + + .indicator:last-child { + margin-left: 0; + } + + #opportunity_grid, + #lead_grid, + #cases_grid { + margin: 10px; + margin-bottom: 10%; + } + .app-title { + font-size: 2rem; + padding-left: 20%; + } + .header img { + height: 50px; + padding-left: 5%; + } + .button { + margin: 2% auto; + width: 20%; + } + .dropdown-styles { + width: 30%; + display: inline-block; + } + .dd-styles { + width: 44%; + display: inline-block; + } + .indicator_value { + font-size: 1.5rem; + } +} + +@media (min-width: 900px) { + .header { + flex-direction: row; + } + + #subtitle { + display: block; + } + #learn_more { + display: block; + } + + #menu { + display: none; + } + + .tabs { + display: flex; + flex-direction: row; + } + + .tabs > a { + width: 20%; + } + + .tabs > a:first-child { + border-right: 1px solid lightgrey; + } + + .tabs > a:last-child { + border-left: 1px solid lightgrey; + } + + #lead_grid { + display: -ms-grid; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; + -ms-grid-columns: 1fr 1fr 1fr 1fr 1fr 1fr; + grid-template-rows: auto; + -ms-grid-rows: auto; + width: 80%; + margin-bottom: 10%; + margin-left: auto; + margin-right: auto; + } + + #new_lead { + -ms-grid-column: 6; + -ms-grid-column-span: 1; + grid-column: 6 / 7; + -ms-grid-row: 1; + -ms-grid-row-span: 1; + grid-row: 1 / 2; + align-self: end; + } + + #leads_per_state { + -ms-grid-column: 1; + -ms-grid-column-span: 2; + grid-column: 1 / 3; + -ms-grid-row: 2; + -ms-grid-row-span: 3; + grid-row: 2 / 5; + } + + #lead_grid > .indicators { + -ms-grid-row: 2; + -ms-grid-row-span: 1; + grid-row: 2 / 3; + -ms-grid-column: 3; + -ms-grid-column-span: 4; + grid-column: 3 / 7; + } + + #submit_new_lead { + padding: 10px; + } + + #leads_source_container { + -ms-grid-row: 3; + -ms-grid-row-span: 2; + grid-row: 3 / 5; + -ms-grid-column: 3; + -ms-grid-column-span: 2; + grid-column: 3 / 5; + } + + #converted_leads_container { + -ms-grid-row: 3; + -ms-grid-row-span: 2; + grid-row: 3 / 5; + -ms-grid-column: 5; + -ms-grid-column-span: 2; + grid-column: 5 / 7; + } + + #leads_table { + -ms-grid-row: 6; + -ms-grid-row-span: 4; + grid-row: 6 / 10; + -ms-grid-column: 1; + -ms-grid-column-span: 6; + grid-column: 1 / 7; + max-height: 500px; + } + + #opportunity_grid { + display: -ms-grid; + display: grid; + width: 80%; + margin-bottom: 10%; + margin-left: auto; + margin-right: auto; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; + -ms-grid-columns: 1fr 1fr 1fr 1fr 1fr 1fr; + grid-template-rows: auto; + -ms-grid-rows: auto; + } + + #new_opportunity { + -ms-grid-column: 6; + -ms-grid-column-span: 1; + grid-column: 6 / 7; + -ms-grid-row: 1; + -ms-grid-row-span: 1; + grid-row: 1 / 1; + align-self: end; + } + + #converted_count_container { + -ms-grid-column: 1; + -ms-grid-column-span: 2; + grid-column: 1 / 3; + -ms-grid-row: 2; + -ms-grid-row-span: 4; + grid-row: 2 / 6; + } + + #opportunity_indicators { + -ms-grid-column: 3; + -ms-grid-column-span: 4; + grid-column: 3 / 7; + -ms-grid-row: 2; + -ms-grid-row-span: 1; + grid-row: 2 / 3; + } + + #opportunity_heatmap { + -ms-grid-column: 3; + -ms-grid-column-span: 4; + grid-column: 3 / 7; + -ms-grid-row: 3; + -ms-grid-row-span: 3; + grid-row: 3 / 6; + } + + #top_open_container { + -ms-grid-column: 1; + -ms-grid-column-span: 3; + grid-column: 1 / 4; + -ms-grid-row: 6; + -ms-grid-row-span: 4; + grid-row: 6 / 7; + } + + #top_lost_container { + -ms-grid-column: 4; + -ms-grid-column-span: 3; + grid-column: 4 / 7; + -ms-grid-row: 6; + -ms-grid-row-span: 4; + grid-row: 6 / 7; + } + + #cases_grid { + display: -ms-grid; + display: grid; + width: 80%; + margin-bottom: 10%; + margin-left: auto; + margin-right: auto; + -ms-grid-columns: 1fr 1fr 1fr 1fr 1fr 1fr; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; + -ms-grid-rows: auto; + grid-template-rows: auto; + } + + #new_case { + -ms-grid-column: 6; + -ms-grid-column-span: 1; + grid-column: 6 / 7; + -ms-grid-row: 1; + -ms-grid-row-span: 1; + grid-row: 1 / 1; + align-self: end; + } + + #cases_types_container { + -ms-grid-column: 1; + -ms-grid-column-span: 2; + grid-column: 1 / 3; + -ms-grid-row: 2; + -ms-grid-row-span: 3; + grid-row: 2 / 5; + } + + #cases_indicators { + -ms-grid-column: 3; + -ms-grid-column-span: 4; + grid-column: 3 / 7; + -ms-grid-row: 2; + -ms-grid-row-span: 1; + grid-row: 2 / 3; + } + + #cases_reasons_container { + -ms-grid-column: 3; + -ms-grid-column-span: 2; + grid-column: 3 / 5; + -ms-grid-row: 3; + -ms-grid-row-span: 2; + grid-row: 3 / 5; + } + + #cases_by_period_container { + -ms-grid-column: 5; + -ms-grid-column-span: 2; + grid-column: 5 / 7; + -ms-grid-row: 3; + -ms-grid-row-span: 2; + grid-row: 3 / 5; + } + + #cases_by_account_container { + -ms-grid-column: 1; + -ms-grid-column-span: 6; + grid-column: 1 / 7; + -ms-grid-row: 5; + -ms-grid-row-span: 1; + grid-row: 5 / 6; + } + + #cases_by_account { + display: block; + height: 100%; + } +} diff --git a/apps/dash-salesforce-crm/index.py b/apps/dash-salesforce-crm/index.py new file mode 100644 index 000000000..4cd6257a3 --- /dev/null +++ b/apps/dash-salesforce-crm/index.py @@ -0,0 +1,134 @@ +import dash +import dash_core_components as dcc +import dash_html_components as html +from dash.dependencies import Input, Output, State +from app import sf_manager, app +from panels import opportunities, cases, leads + + +server = app.server + +app.layout = html.Div( + [ + html.Div( + className="row header", + children=[ + html.Button(id="menu", children=dcc.Markdown("≡")), + html.Span( + className="app-title", + children=[ + dcc.Markdown("**CRM App**"), + html.Span( + id="subtitle", + children=dcc.Markdown("  using Salesforce API"), + style={"font-size": "1.8rem", "margin-top": "15px"}, + ), + ], + ), + html.Img(src=app.get_asset_url("logo.png")), + html.A( + id="learn_more", + children=html.Button("Learn More"), + href="https://plot.ly/dash/", + ), + ], + ), + html.Div( + id="tabs", + className="row tabs", + children=[ + dcc.Link("Opportunities", href="/"), + dcc.Link("Leads", href="/"), + dcc.Link("Cases", href="/"), + ], + ), + html.Div( + id="mobile_tabs", + className="row tabs", + style={"display": "none"}, + children=[ + dcc.Link("Opportunities", href="/"), + dcc.Link("Leads", href="/"), + dcc.Link("Cases", href="/"), + ], + ), + dcc.Store( # opportunities df + id="opportunities_df", + data=sf_manager.get_opportunities().to_json(orient="split"), + ), + dcc.Store( # leads df + id="leads_df", data=sf_manager.get_leads().to_json(orient="split") + ), + dcc.Store( + id="cases_df", data=sf_manager.get_cases().to_json(orient="split") + ), # cases df + dcc.Location(id="url", refresh=False), + html.Div(id="tab_content"), + html.Link( + href="https://use.fontawesome.com/releases/v5.2.0/css/all.css", + rel="stylesheet", + ), + html.Link( + href="https://fonts.googleapis.com/css?family=Dosis", rel="stylesheet" + ), + html.Link( + href="https://fonts.googleapis.com/css?family=Open+Sans", rel="stylesheet" + ), + html.Link( + href="https://fonts.googleapis.com/css?family=Ubuntu", rel="stylesheet" + ), + ], + className="row", + style={"margin": "0%"}, +) + +# Update the index + + +@app.callback( + [ + Output("tab_content", "children"), + Output("tabs", "children"), + Output("mobile_tabs", "children"), + ], + [Input("url", "pathname")], +) +def display_page(pathname): + tabs = [ + dcc.Link("Opportunities", href="/dash-salesforce-crm/opportunities"), + dcc.Link("Leads", href="/dash-salesforce-crm/leads"), + dcc.Link("Cases", href="/dash-salesforce-crm/cases"), + ] + if pathname == "/dash-salesforce-crm/opportunities": + tabs[0] = dcc.Link( + dcc.Markdown("**■ Opportunities**"), + href="/dash-salesforce-crm/opportunities", + ) + return opportunities.layout, tabs, tabs + elif pathname == "/dash-salesforce-crm/cases": + tabs[2] = dcc.Link( + dcc.Markdown("**■ Cases**"), href="/dash-salesforce-crm/cases" + ) + return cases.layout, tabs, tabs + tabs[1] = dcc.Link( + dcc.Markdown("**■ Leads**"), href="/dash-salesforce-crm/leads" + ) + return leads.layout, tabs, tabs + + +@app.callback( + Output("mobile_tabs", "style"), + [Input("menu", "n_clicks")], + [State("mobile_tabs", "style")], +) +def show_menu(n_clicks, tabs_style): + if n_clicks: + if tabs_style["display"] == "none": + tabs_style["display"] = "flex" + else: + tabs_style["display"] = "none" + return tabs_style + + +if __name__ == "__main__": + app.run_server(debug=True) diff --git a/apps/dash-salesforce-crm/panels/__init__.py b/apps/dash-salesforce-crm/panels/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dash-salesforce-crm/panels/cases.py b/apps/dash-salesforce-crm/panels/cases.py new file mode 100644 index 000000000..4a57224b3 --- /dev/null +++ b/apps/dash-salesforce-crm/panels/cases.py @@ -0,0 +1,684 @@ +# -*- coding: utf-8 -*- +import pandas as pd +from dash.dependencies import Input, Output, State +import dash_core_components as dcc +import dash_html_components as html +from plotly import graph_objs as go + +from app import app, indicator, sf_manager + +colors = {"background": "#F3F6FA", "background_div": "white"} + +accounts = sf_manager.get_accounts() +contacts = sf_manager.get_contacts() +users = sf_manager.get_users() + +# returns pie chart based on filters values +# column makes the function reusable + + +def pie_chart(df, column, priority, origin): + df = df.dropna(subset=["Type", "Reason", "Origin"]) + nb_cases = len(df.index) + types = [] + values = [] + + # filter priority and origin + if priority == "all_p": + if origin == "all": + types = df[column].unique().tolist() + else: + types = df[df["Origin"] == origin][column].unique().tolist() + else: + if origin == "all": + types = df[df["Priority"] == priority][column].unique().tolist() + else: + types = ( + df[(df["Priority"] == priority) & (df["Origin"] == origin)][column] + .unique() + .tolist() + ) + + # if no results were found + if types == []: + layout = dict( + autosize=True, annotations=[dict(text="No results found", showarrow=False)] + ) + return {"data": [], "layout": layout} + + for case_type in types: + nb_type = df.loc[df[column] == case_type].shape[0] + values.append(nb_type / nb_cases * 100) + + layout = go.Layout( + autosize=True, + margin=dict(l=0, r=0, b=0, t=4, pad=8), + paper_bgcolor="white", + plot_bgcolor="white", + ) + + trace = go.Pie( + labels=types, + values=values, + marker={"colors": ["#264e86", "#0074e4", "#74dbef", "#eff0f4"]}, + ) + + return {"data": [trace], "layout": layout} + + +def cases_by_period(df, period, priority, origin): + df = df.dropna(subset=["Type", "Reason", "Origin"]) + stages = df["Type"].unique() + + # priority filtering + if priority != "all_p": + df = df[df["Priority"] == priority] + + # period filtering + df["CreatedDate"] = pd.to_datetime(df["CreatedDate"], format="%Y-%m-%d") + if period == "W-MON": + df["CreatedDate"] = pd.to_datetime(df["CreatedDate"]) - pd.to_timedelta( + 7, unit="d" + ) + df = df.groupby([pd.Grouper(key="CreatedDate", freq=period), "Type"]).count() + + dates = df.index.get_level_values("CreatedDate").unique() + dates = [str(i) for i in dates] + + co = { # colors for stages + "Electrical": "#264e86", + "Other": "#0074e4", + "Structural": "#74dbef", + "Mechanical": "#eff0f4", + "Electronic": "rgb(255, 127, 14)", + } + + data = [] + for stage in stages: + stage_rows = [] + for date in dates: + try: + row = df.loc[(date, stage)] + stage_rows.append(row["IsDeleted"]) + except Exception as e: + stage_rows.append(0) + + data_trace = go.Bar( + x=dates, y=stage_rows, name=stage, marker=dict(color=co[stage]) + ) + data.append(data_trace) + + layout = go.Layout( + autosize=True, + barmode="stack", + margin=dict(l=40, r=25, b=40, t=0, pad=4), + paper_bgcolor="white", + plot_bgcolor="white", + ) + + return {"data": data, "layout": layout} + + +def cases_by_account(cases): + cases = cases.dropna(subset=["AccountId"]) + cases = pd.merge(cases, accounts, left_on="AccountId", right_on="Id") + cases = cases.groupby(["AccountId", "Name"]).count() + cases = cases.sort_values("IsDeleted") + data = [ + go.Bar( + y=cases.index.get_level_values("Name"), + x=cases["IsDeleted"], + orientation="h", + marker=dict(color="#0073e4"), + ) + ] # x could be any column value since its a count + + layout = go.Layout( + autosize=True, + barmode="stack", + margin=dict(l=210, r=25, b=20, t=0, pad=4), + paper_bgcolor="white", + plot_bgcolor="white", + ) + + return {"data": data, "layout": layout} + + +# returns modal (hidden by default) +def modal(): + contacts["Name"] = ( + contacts["Salutation"] + + " " + + contacts["FirstName"] + + " " + + contacts["LastName"] + ) + return html.Div( + html.Div( + [ + html.Div( + [ + html.Div( + [ + html.Span( + "New Case", + style={ + "color": "#506784", + "fontWeight": "bold", + "fontSize": "20", + }, + ), + html.Span( + "×", + id="cases_modal_close", + n_clicks=0, + style={ + "float": "right", + "cursor": "pointer", + "marginTop": "0", + "marginBottom": "17", + }, + ), + ], + className="row", + style={"borderBottom": "1px solid #C8D4E3"}, + ), + html.Div( + [ + html.Div( + [ + html.P( + "Account name", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + html.Div( + dcc.Dropdown( + id="new_case_account", + options=[ + { + "label": row["Name"], + "value": row["Id"], + } + for index, row in accounts.iterrows() + ], + clearable=False, + value=accounts.iloc[0].Id, + ) + ), + html.P( + "Priority", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_case_priority", + options=[ + {"label": "High", "value": "High"}, + {"label": "Medium", "value": "Medium"}, + {"label": "Low", "value": "Low"}, + ], + value="Medium", + clearable=False, + ), + html.P( + "Origin", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_case_origin", + options=[ + {"label": "Phone", "value": "Phone"}, + {"label": "Web", "value": "Web"}, + {"label": "Email", "value": "Email"}, + ], + value="Phone", + clearable=False, + ), + html.P( + "Reason", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_case_reason", + options=[ + { + "label": "Installation", + "value": "Installation", + }, + { + "label": "Equipment Complexity", + "value": "Equipment Complexity", + }, + { + "label": "Performance", + "value": "Performance", + }, + { + "label": "Breakdown", + "value": "Breakdown", + }, + { + "label": "Equipment Design", + "value": "Equipment Design", + }, + { + "label": "Feedback", + "value": "Feedback", + }, + {"label": "Other", "value": "Other"}, + ], + value="Installation", + clearable=False, + ), + html.P( + "Subject", + style={ + "float": "left", + "marginTop": "4", + "marginBottom": "2", + }, + className="row", + ), + dcc.Input( + id="new_case_subject", + placeholder="The Subject of the case", + type="text", + value="", + style={"width": "100%"}, + ), + ], + className="six columns", + style={"paddingRight": "15"}, + ), + html.Div( + [ + html.P( + "Contact name", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + html.Div( + dcc.Dropdown( + id="new_case_contact", + options=[ + { + "label": row["Name"], + "value": row["Id"], + } + for index, row in contacts.iterrows() + ], + clearable=False, + value=contacts.iloc[0].Id, + ) + ), + html.P( + "Type", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_case_type", + options=[ + { + "label": "Electrical", + "value": "Electrical", + }, + { + "label": "Mechanical", + "value": "Mechanical", + }, + { + "label": "Electronic", + "value": "Electronic", + }, + { + "label": "Structural", + "value": "Structural", + }, + {"label": "Other", "value": "Other"}, + ], + value="Electrical", + ), + html.P( + "Status", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_case_status", + options=[ + {"label": "New", "value": "New"}, + { + "label": "Working", + "value": "Working", + }, + { + "label": "Escalated", + "value": "Escalated", + }, + {"label": "Closed", "value": "Closed"}, + ], + value="New", + ), + html.P( + "Supplied Email", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Input( + id="new_case_email", + placeholder="email", + type="email", + value="", + style={"width": "100%"}, + ), + html.P( + "Description", + style={ + "float": "left", + "marginTop": "4", + "marginBottom": "2", + }, + className="row", + ), + dcc.Textarea( + id="new_case_description", + placeholder="Description of the case", + value="", + style={"width": "100%"}, + ), + ], + className="six columns", + style={"paddingLeft": "15"}, + ), + ], + style={"marginTop": "10", "textAlign": "center"}, + className="row", + ), + html.Span( + "Submit", + id="submit_new_case", + n_clicks=0, + className="button button--primary add pretty_container", + ), + ], + className="modal-content", + style={"textAlign": "center", "border": "1px solid #C8D4E3"}, + ) + ], + className="modal", + ), + id="cases_modal", + style={"display": "none"}, + ) + + +layout = [ + html.Div( + id="cases_grid", + children=[ + html.Div( + className="control dropdown-styles", + children=dcc.Dropdown( + id="cases_period_dropdown", + options=[ + {"label": "By day", "value": "D"}, + {"label": "By week", "value": "W-MON"}, + {"label": "By month", "value": "M"}, + ], + value="D", + clearable=False, + ), + ), + html.Div( + className="control dropdown-styles", + children=dcc.Dropdown( + id="priority_dropdown", + options=[ + {"label": "All priority", "value": "all_p"}, + {"label": "High priority", "value": "High"}, + {"label": "Medium priority", "value": "Medium"}, + {"label": "Low priority", "value": "Low"}, + ], + value="all_p", + clearable=False, + ), + ), + html.Div( + className="control dropdown-styles", + children=dcc.Dropdown( + id="origin_dropdown", + options=[ + {"label": "All origins", "value": "all"}, + {"label": "Phone", "value": "Phone"}, + {"label": "Web", "value": "Web"}, + {"label": "Email", "value": "Email"}, + ], + value="all", + clearable=False, + ), + ), + html.Span( + "Add new", + id="new_case", + n_clicks=0, + className="button button--primary add pretty_container", + ), + html.Div( + id="cases_indicators", + className="row indicators", + children=[ + indicator("#00cc96", "Low priority cases", "left_cases_indicator"), + indicator( + "#119DFF", "Medium priority cases", "middle_cases_indicator" + ), + indicator( + "#EF553B", "High priority cases", "right_cases_indicator" + ), + ], + ), + html.Div( + id="cases_types_container", + className="pretty_container chart_div", + children=[ + html.P("Cases Type"), + dcc.Graph( + id="cases_types", + config=dict(displayModeBar=False), + style={"height": "89%", "width": "98%"}, + ), + ], + ), + html.Div( + id="cases_reasons_container", + className="chart_div pretty_container", + children=[ + html.P("Cases Reasons"), + dcc.Graph(id="cases_reasons", config=dict(displayModeBar=False)), + ], + ), + html.Div( + id="cases_by_period_container", + className="pretty_container chart_div", + children=[ + html.P("Cases over Time"), + dcc.Graph(id="cases_by_period", config=dict(displayModeBar=False)), + ], + ), + html.Div( + id="cases_by_account_container", + className="pretty_container chart_div", + children=[ + html.P("Cases by Company"), + dcc.Graph(id="cases_by_account", config=dict(displayModeBar=False)), + ], + ), + ], + ), + modal(), +] + + +@app.callback(Output("left_cases_indicator", "children"), [Input("cases_df", "data")]) +def left_cases_indicator_callback(df): + df = pd.read_json(df, orient="split") + low = len(df[(df["Priority"] == "Low") & (df["Status"] == "New")]["Priority"].index) + return dcc.Markdown("**{}**".format(low)) + + +@app.callback(Output("middle_cases_indicator", "children"), [Input("cases_df", "data")]) +def middle_cases_indicator_callback(df): + df = pd.read_json(df, orient="split") + medium = len( + df[(df["Priority"] == "Medium") & (df["Status"] == "New")]["Priority"].index + ) + return dcc.Markdown("**{}**".format(medium)) + + +@app.callback(Output("right_cases_indicator", "children"), [Input("cases_df", "data")]) +def right_cases_indicator_callback(df): + df = pd.read_json(df, orient="split") + high = len( + df[(df["Priority"] == "High") & (df["Status"] == "New")]["Priority"].index + ) + return dcc.Markdown("**{}**".format(high)) + + +@app.callback( + Output("cases_reasons", "figure"), + [ + Input("priority_dropdown", "value"), + Input("origin_dropdown", "value"), + Input("cases_df", "data"), + ], +) +def cases_reasons_callback(priority, origin, df): + df = pd.read_json(df, orient="split") + chart = pie_chart(df, "Reason", priority, origin) + return chart + + +@app.callback( + Output("cases_types", "figure"), + [ + Input("priority_dropdown", "value"), + Input("origin_dropdown", "value"), + Input("cases_df", "data"), + ], +) +def cases_types_callback(priority, origin, df): + df = pd.read_json(df, orient="split") + chart = pie_chart(df, "Type", priority, origin) + chart["layout"]["legend"]["orientation"] = "h" + return chart + + +@app.callback( + Output("cases_by_period", "figure"), + [ + Input("cases_period_dropdown", "value"), + Input("origin_dropdown", "value"), + Input("priority_dropdown", "value"), + Input("cases_df", "data"), + ], +) +def cases_period_callback(period, origin, priority, df): + df = pd.read_json(df, orient="split") + return cases_by_period(df, period, priority, origin) + + +@app.callback(Output("cases_by_account", "figure"), [Input("cases_df", "data")]) +def cases_account_callback(df): + df = pd.read_json(df, orient="split") + return cases_by_account(df) + + +@app.callback(Output("cases_modal", "style"), [Input("new_case", "n_clicks")]) +def display_cases_modal_callback(n): + if n > 0: + return {"display": "block"} + return {"display": "none"} + + +@app.callback( + Output("new_case", "n_clicks"), + [Input("cases_modal_close", "n_clicks"), Input("submit_new_case", "n_clicks")], +) +def close_modal_callback(n, n2): + return 0 + + +@app.callback( + Output("cases_df", "data"), + [Input("submit_new_case", "n_clicks")], + [ + State("new_case_account", "value"), + State("new_case_origin", "value"), + State("new_case_reason", "value"), + State("new_case_subject", "value"), + State("new_case_contact", "value"), + State("new_case_type", "value"), + State("new_case_status", "value"), + State("new_case_description", "value"), + State("new_case_priority", "value"), + State("cases_df", "data"), + ], +) +def add_case_callback( + n_clicks, + account_id, + origin, + reason, + subject, + contact_id, + case_type, + status, + description, + priority, + current_df, +): + if n_clicks > 0: + query = { + "AccountId": account_id, + "Origin": origin, + "Reason": reason, + "Subject": subject, + "ContactId": contact_id, + "Type": case_type, + "Status": status, + "Description": description, + "Priority": priority, + } + + sf_manager.add_case(query) + df = sf_manager.get_cases() + return df.to_json(orient="split") + + return current_df diff --git a/apps/dash-salesforce-crm/panels/leads.py b/apps/dash-salesforce-crm/panels/leads.py new file mode 100644 index 000000000..05a8f16d8 --- /dev/null +++ b/apps/dash-salesforce-crm/panels/leads.py @@ -0,0 +1,531 @@ +# -*- coding: utf-8 -*- +import pandas as pd +from dash.dependencies import Input, Output, State +import dash_core_components as dcc +import dash_html_components as html +from plotly import graph_objs as go + +from app import app, indicator, df_to_table, sf_manager + +states = [ + "AL", + "AK", + "AZ", + "AR", + "CA", + "CO", + "CT", + "DC", + "DE", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "TX", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY", +] + + +# returns choropleth map figure based on status filter +def choropleth_map(status, df): + if status == "open": + df = df[ + (df["Status"] == "Open - Not Contacted") + | (df["Status"] == "Working - Contacted") + ] + + elif status == "converted": + df = df[df["Status"] == "Closed - Converted"] + + elif status == "lost": + df = df[df["Status"] == "Closed - Not Converted"] + + df = df.groupby("State").count() + + scl = [[0.0, "rgb(38, 78, 134)"], [1.0, "#0091D5"]] # colors scale + + data = [ + dict( + type="choropleth", + colorscale=scl, + locations=df.index, + z=df["Id"], + locationmode="USA-states", + marker=dict(line=dict(color="rgb(255,255,255)", width=2)), + colorbar=dict(len=0.8), + ) + ] + + layout = dict( + autosize=True, + geo=dict( + scope="usa", + projection=dict(type="albers usa"), + lakecolor="rgb(255, 255, 255)", + ), + margin=dict(l=10, r=10, t=0, b=0), + ) + return dict(data=data, layout=layout) + + +# returns pie chart that shows lead source repartition +def lead_source(status, df): + if status == "open": + df = df[ + (df["Status"] == "Open - Not Contacted") + | (df["Status"] == "Working - Contacted") + ] + + elif status == "converted": + df = df[df["Status"] == "Closed - Converted"] + + elif status == "lost": + df = df[df["Status"] == "Closed - Not Converted"] + + nb_leads = len(df.index) + types = df["LeadSource"].unique().tolist() + values = [] + + # compute % for each leadsource type + for case_type in types: + nb_type = df[df["LeadSource"] == case_type].shape[0] + values.append(nb_type / nb_leads * 100) + + trace = go.Pie( + labels=types, + values=values, + marker={"colors": ["#264e86", "#0074e4", "#74dbef", "#eff0f4"]}, + ) + + layout = dict(autosize=True, margin=dict(l=15, r=10, t=0, b=65)) + return dict(data=[trace], layout=layout) + + +def converted_leads_count(period, df): + df["CreatedDate"] = pd.to_datetime(df["CreatedDate"], format="%Y-%m-%d") + df = df[df["Status"] == "Closed - Converted"] + + df = ( + df.groupby([pd.Grouper(key="CreatedDate", freq=period)]) + .count() + .reset_index() + .sort_values("CreatedDate") + ) + + trace = go.Scatter( + x=df["CreatedDate"], + y=df["Id"], + name="converted leads", + fill="tozeroy", + fillcolor="#e6f2ff", + ) + + data = [trace] + + layout = go.Layout( + autosize=True, + xaxis=dict(showgrid=False), + margin=dict(l=33, r=25, b=37, t=5, pad=4), + paper_bgcolor="white", + plot_bgcolor="white", + ) + + return {"data": data, "layout": layout} + + +def modal(): + return html.Div( + html.Div( + [ + html.Div( + [ + html.Div( + [ + html.Span( + "New Lead", + style={ + "color": "#506784", + "fontWeight": "bold", + "fontSize": "20", + }, + ), + html.Span( + "×", + id="leads_modal_close", + n_clicks=0, + style={ + "float": "right", + "cursor": "pointer", + "marginTop": "0", + "marginBottom": "17", + }, + ), + ], + className="row", + style={"borderBottom": "1px solid #C8D4E3"}, + ), + html.Div( + [ + html.P( + ["Company Name"], + style={ + "float": "left", + "marginTop": "4", + "marginBottom": "2", + }, + className="row", + ), + dcc.Input( + id="new_lead_company", + placeholder="Enter company name", + type="text", + value="", + style={"width": "100%"}, + ), + html.P( + "Company State", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_lead_state", + options=[ + {"label": state, "value": state} + for state in states + ], + value="NY", + ), + html.P( + "Status", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_lead_status", + options=[ + { + "label": "Open - Not Contacted", + "value": "Open - Not Contacted", + }, + { + "label": "Working - Contacted", + "value": "Working - Contacted", + }, + { + "label": "Closed - Converted", + "value": "Closed - Converted", + }, + { + "label": "Closed - Not Converted", + "value": "Closed - Not Converted", + }, + ], + value="Open - Not Contacted", + ), + html.P( + "Source", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_lead_source", + options=[ + {"label": "Web", "value": "Web"}, + { + "label": "Phone Inquiry", + "value": "Phone Inquiry", + }, + { + "label": "Partner Referral", + "value": "Partner Referral", + }, + { + "label": "Purchased List", + "value": "Purchased List", + }, + {"label": "Other", "value": "Other"}, + ], + value="Web", + ), + ], + className="row", + style={"padding": "2% 8%"}, + ), + html.Span( + "Submit", + id="submit_new_lead", + n_clicks=0, + className="button button--primary add pretty_container", + ), + ], + className="modal-content", + style={"textAlign": "center"}, + ) + ], + className="modal", + ), + id="leads_modal", + style={"display": "none"}, + ) + + +layout = [ + html.Div( + id="lead_grid", + children=[ + html.Div( + className="two columns dd-styles", + children=dcc.Dropdown( + id="converted_leads_dropdown", + options=[ + {"label": "By day", "value": "D"}, + {"label": "By week", "value": "W-MON"}, + {"label": "By month", "value": "M"}, + ], + value="D", + clearable=False, + ), + ), + html.Div( + className="two columns dd-styles", + children=dcc.Dropdown( + id="lead_source_dropdown", + options=[ + {"label": "All status", "value": "all"}, + {"label": "Open leads", "value": "open"}, + {"label": "Converted leads", "value": "converted"}, + {"label": "Lost leads", "value": "lost"}, + ], + value="all", + clearable=False, + ), + ), + html.Span( + "Add new", + id="new_lead", + n_clicks=0, + className="button pretty_container", + ), + html.Div( + className="row indicators", + children=[ + indicator("#00cc96", "Converted Leads", "left_leads_indicator"), + indicator("#119DFF", "Open Leads", "middle_leads_indicator"), + indicator("#EF553B", "Conversion Rates", "right_leads_indicator"), + ], + ), + html.Div( + id="leads_per_state", + className="chart_div pretty_container", + children=[ + html.P("Leads count per state"), + dcc.Graph( + id="map", + style={"height": "90%", "width": "98%"}, + config=dict(displayModeBar=False), + ), + ], + ), + html.Div( + id="leads_source_container", + className="six columns chart_div pretty_container", + children=[ + html.P("Leads by source"), + dcc.Graph( + id="lead_source", + style={"height": "90%", "width": "98%"}, + config=dict(displayModeBar=False), + ), + ], + ), + html.Div( + id="converted_leads_container", + className="six columns chart_div pretty_container", + children=[ + html.P("Converted Leads count"), + dcc.Graph( + id="converted_leads", + style={"height": "90%", "width": "98%"}, + config=dict(displayModeBar=False), + ), + ], + ), + html.Div(id="leads_table", className="row pretty_container table"), + ], + ), + modal(), +] + + +# updates left indicator based on df updates +@app.callback(Output("left_leads_indicator", "children"), [Input("leads_df", "data")]) +def left_leads_indicator_callback(df): + df = pd.read_json(df, orient="split") + converted_leads = len(df[df["Status"] == "Closed - Converted"].index) + return dcc.Markdown("**{}**".format(converted_leads)) + + +# updates middle indicator based on df updates +@app.callback(Output("middle_leads_indicator", "children"), [Input("leads_df", "data")]) +def middle_leads_indicator_callback(df): + df = pd.read_json(df, orient="split") + open_leads = len( + df[ + (df["Status"] == "Open - Not Contacted") + | (df["Status"] == "Working - Contacted") + ].index + ) + return dcc.Markdown("**{}**".format(open_leads)) + + +# updates right indicator based on df updates +@app.callback(Output("right_leads_indicator", "children"), [Input("leads_df", "data")]) +def right_leads_indicator_callback(df): + df = pd.read_json(df, orient="split") + converted_leads = len(df[df["Status"] == "Closed - Converted"].index) + lost_leads = len(df[df["Status"] == "Closed - Not Converted"].index) + conversion_rates = converted_leads / (converted_leads + lost_leads) * 100 + conversion_rates = "%.2f" % conversion_rates + "%" + return dcc.Markdown("**{}**".format(conversion_rates)) + + +# update pie chart figure based on dropdown's value and df updates +@app.callback( + Output("lead_source", "figure"), + [Input("lead_source_dropdown", "value"), Input("leads_df", "data")], +) +def lead_source_callback(status, df): + df = pd.read_json(df, orient="split") + return lead_source(status, df) + + +# update heat map figure based on dropdown's value and df updates +@app.callback( + Output("map", "figure"), + [Input("lead_source_dropdown", "value"), Input("leads_df", "data")], +) +def map_callback(status, df): + df = pd.read_json(df, orient="split") + return choropleth_map(status, df) + + +# update table based on dropdown's value and df updates +@app.callback( + Output("leads_table", "children"), + [Input("lead_source_dropdown", "value"), Input("leads_df", "data")], +) +def leads_table_callback(status, df): + df = pd.read_json(df, orient="split") + if status == "open": + df = df[ + (df["Status"] == "Open - Not Contacted") + | (df["Status"] == "Working - Contacted") + ] + elif status == "converted": + df = df[df["Status"] == "Closed - Converted"] + elif status == "lost": + df = df[df["Status"] == "Closed - Not Converted"] + df = df[["CreatedDate", "Status", "Company", "State", "LeadSource"]] + return df_to_table(df) + + +# update pie chart figure based on dropdown's value and df updates +@app.callback( + Output("converted_leads", "figure"), + [Input("converted_leads_dropdown", "value"), Input("leads_df", "data")], +) +def converted_leads_callback(period, df): + df = pd.read_json(df, orient="split") + return converted_leads_count(period, df) + + +# hide/show modal +@app.callback(Output("leads_modal", "style"), [Input("new_lead", "n_clicks")]) +def display_leads_modal_callback(n): + if n > 0: + return {"display": "block"} + return {"display": "none"} + + +# reset to 0 add button n_clicks property +@app.callback( + Output("new_lead", "n_clicks"), + [Input("leads_modal_close", "n_clicks"), Input("submit_new_lead", "n_clicks")], +) +def close_modal_callback(n, n2): + return 0 + + +# add new lead to salesforce and stores new df in hidden div +@app.callback( + Output("leads_df", "data"), + [Input("submit_new_lead", "n_clicks")], + [ + State("new_lead_status", "value"), + State("new_lead_state", "value"), + State("new_lead_company", "value"), + State("new_lead_source", "value"), + State("leads_df", "data"), + ], +) +def add_lead_callback(n_clicks, status, state, company, source, current_df): + if n_clicks > 0: + if company == "": + company = "Not named yet" + query = { + "LastName": company, + "Company": company, + "Status": status, + "State": state, + "LeadSource": source, + } + sf_manager.add_lead(query) + df = sf_manager.get_leads() + return df.to_json(orient="split") + + return current_df diff --git a/apps/dash-salesforce-crm/panels/opportunities.py b/apps/dash-salesforce-crm/panels/opportunities.py new file mode 100644 index 000000000..f190ded6a --- /dev/null +++ b/apps/dash-salesforce-crm/panels/opportunities.py @@ -0,0 +1,615 @@ +# -*- coding: utf-8 -*- +from datetime import date +import pandas as pd +from dash.dependencies import Input, Output, State +import dash_core_components as dcc +import dash_html_components as html +from plotly import graph_objs as go + +from app import app, indicator, millify, df_to_table, sf_manager + + +def converted_opportunities(period, source, df): + df["CreatedDate"] = pd.to_datetime(df["CreatedDate"], format="%Y-%m-%d") + + # source filtering + if source == "all_s": + df = df[df["IsWon"] == 1] + else: + df = df[(df["LeadSource"] == source) & (df["IsWon"] == 1)] + + # period filtering + if period == "W-MON": + df["CreatedDate"] = pd.to_datetime(df["CreatedDate"]) - pd.to_timedelta( + 7, unit="d" + ) + df = ( + df.groupby([pd.Grouper(key="CreatedDate", freq=period)]) + .count() + .reset_index() + .sort_values("CreatedDate") + ) + + # if no results were found + if df.empty: + layout = dict( + autosize=True, annotations=[dict(text="No results found", showarrow=False)] + ) + return {"data": [], "layout": layout} + + trace = go.Scatter( + x=df["CreatedDate"], + y=df["IsWon"], + name="converted opportunities", + fill="tozeroy", + fillcolor="#e6f2ff", + ) + + data = [trace] + + layout = go.Layout( + autosize=True, + xaxis=dict(showgrid=False), + margin=dict(l=35, r=25, b=23, t=5, pad=4), + paper_bgcolor="white", + plot_bgcolor="white", + ) + + return {"data": data, "layout": layout} + + +# returns heat map figure +def heat_map_fig(df, x, y): + z = [] + for lead_type in y: + z_row = [] + for stage in x: + probability = df[(df["StageName"] == stage) & (df["Type"] == lead_type)][ + "Probability" + ].mean() + z_row.append(probability) + z.append(z_row) + + trace = dict( + type="heatmap", z=z, x=x, y=y, name="mean probability", colorscale="Blues" + ) + layout = dict( + autosize=True, + margin=dict(t=25, l=210, b=85, pad=4), + paper_bgcolor="white", + plot_bgcolor="white", + ) + + return go.Figure(data=[trace], layout=layout) + + +# returns top 5 open opportunities +def top_open_opportunities(df): + df = df.sort_values("Amount", ascending=True) + cols = ["CreatedDate", "Name", "Amount", "StageName"] + df = df[cols].iloc[:5] + # only display 21 characters + df["Name"] = df["Name"].apply(lambda x: x[:30]) + return df_to_table(df) + + +# returns top 5 lost opportunities +def top_lost_opportunities(df): + df = df[df["StageName"] == "Closed Lost"] + cols = ["CreatedDate", "Name", "Amount", "StageName"] + df = df[cols].sort_values("Amount", ascending=False).iloc[:5] + # only display 21 characters + df["Name"] = df["Name"].apply(lambda x: x[:30]) + return df_to_table(df) + + +# returns modal (hidden by default) +def modal(): + return html.Div( + html.Div( + [ + html.Div( + [ + html.Div( + [ + html.Span( + "New Opportunity", + style={ + "color": "#506784", + "fontWeight": "bold", + "fontSize": "20", + }, + ), + html.Span( + "×", + id="opportunities_modal_close", + n_clicks=0, + style={ + "float": "right", + "cursor": "pointer", + "marginTop": "0", + "marginBottom": "17", + }, + ), + ], + className="row", + style={"borderBottom": "1px solid #C8D4E3"}, + ), + html.Div( + [ + html.Div( + [ + html.P( + ["Name"], + style={ + "float": "left", + "marginTop": "4", + "marginBottom": "2", + }, + className="row", + ), + dcc.Input( + id="new_opportunity_name", + placeholder="Name of the opportunity", + type="text", + value="", + style={"width": "100%"}, + ), + html.P( + ["StageName"], + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_opportunity_stage", + options=[ + { + "label": "Prospecting", + "value": "Prospecting", + }, + { + "label": "Qualification", + "value": "Qualification", + }, + { + "label": "Needs Analysis", + "value": "Needs Analysis", + }, + { + "label": "Value Proposition", + "value": "Value Proposition", + }, + { + "label": "Id. Decision Makers", + "value": "Closed", + }, + { + "label": "Perception Analysis", + "value": "Perception Analysis", + }, + { + "label": "Proposal/Price Quote", + "value": "Proposal/Price Quote", + }, + { + "label": "Negotiation/Review", + "value": "Negotiation/Review", + }, + { + "label": "Closed/Won", + "value": "Closed Won", + }, + { + "label": "Closed/Lost", + "value": "Closed Lost", + }, + ], + clearable=False, + value="Prospecting", + ), + html.P( + "Source", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_opportunity_source", + options=[ + {"label": "Web", "value": "Web"}, + { + "label": "Phone Inquiry", + "value": "Phone Inquiry", + }, + { + "label": "Partner Referral", + "value": "Partner Referral", + }, + { + "label": "Purchased List", + "value": "Purchased List", + }, + {"label": "Other", "value": "Other"}, + ], + value="Web", + ), + html.P( + ["Close Date"], + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + html.Div( + dcc.DatePickerSingle( + id="new_opportunity_date", + min_date_allowed=date.today(), + # max_date_allowed=dt(2017, 9, 19), + initial_visible_month=date.today(), + date=date.today(), + ), + style={"textAlign": "left"}, + ), + ], + className="six columns", + style={"paddingRight": "15"}, + ), + html.Div( + [ + html.P( + "Type", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Dropdown( + id="new_opportunity_type", + options=[ + { + "label": "Existing Customer - Replacement", + "value": "Existing Customer - Replacement", + }, + { + "label": "New Customer", + "value": "New Customer", + }, + { + "label": "Existing Customer - Upgrade", + "value": "Existing Customer - Upgrade", + }, + { + "label": "Existing Customer - Downgrade", + "value": "Existing Customer - Downgrade", + }, + ], + value="New Customer", + ), + html.P( + "Amount", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Input( + id="new_opportunity_amount", + placeholder="0", + type="number", + value="", + style={"width": "100%"}, + ), + html.P( + "Probability", + style={ + "textAlign": "left", + "marginBottom": "2", + "marginTop": "4", + }, + ), + dcc.Input( + id="new_opportunity_probability", + placeholder="0", + type="number", + max=100, + step=1, + value="", + style={"width": "100%"}, + ), + ], + className="six columns", + style={"paddingLeft": "15"}, + ), + ], + className="row", + style={"paddingTop": "2%"}, + ), + html.Span( + "Submit", + id="submit_new_opportunity", + n_clicks=0, + className="button button--primary add pretty_container", + ), + ], + className="modal-content", + style={"textAlign": "center"}, + ) + ], + className="modal", + ), + id="opportunities_modal", + style={"display": "none"}, + ) + + +layout = [ + html.Div( + id="opportunity_grid", + children=[ + html.Div( + className="control dropdown-styles", + children=dcc.Dropdown( + id="converted_opportunities_dropdown", + options=[ + {"label": "By day", "value": "D"}, + {"label": "By week", "value": "W-MON"}, + {"label": "By month", "value": "M"}, + ], + value="D", + clearable=False, + ), + ), + html.Div( + className="control dropdown-styles", + children=dcc.Dropdown( + id="heatmap_dropdown", + options=[ + {"label": "All stages", "value": "all_s"}, + {"label": "Cold stages", "value": "cold"}, + {"label": "Warm stages", "value": "warm"}, + {"label": "Hot stages", "value": "hot"}, + ], + value="all_s", + clearable=False, + ), + ), + html.Div( + className="control dropdown-styles", + children=dcc.Dropdown( + id="source_dropdown", + options=[ + {"label": "All sources", "value": "all_s"}, + {"label": "Web", "value": "Web"}, + {"label": "Word of Mouth", "value": "Word of mouth"}, + {"label": "Phone Inquiry", "value": "Phone Inquiry"}, + {"label": "Partner Referral", "value": "Partner Referral"}, + {"label": "Purchased List", "value": "Purchased List"}, + {"label": "Other", "value": "Other"}, + ], + value="all_s", + clearable=False, + ), + ), + html.Span( + "Add new", + id="new_opportunity", + n_clicks=0, + className="button pretty_container", + ), + html.Div( + id="opportunity_indicators", + className="row indicators", + children=[ + indicator( + "#00cc96", "Won opportunities", "left_opportunities_indicator" + ), + indicator( + "#119DFF", + "Open opportunities", + "middle_opportunities_indicator", + ), + indicator( + "#EF553B", "Lost opportunities", "right_opportunities_indicator" + ), + ], + ), + html.Div( + id="converted_count_container", + className="chart_div pretty_container", + children=[ + html.P("Converted Opportunities count"), + dcc.Graph( + id="converted_count", + style={"height": "90%", "width": "98%"}, + config=dict(displayModeBar=False), + ), + ], + ), + html.Div( + id="opportunity_heatmap", + className="chart_div pretty_container", + children=[ + html.P("Probabilty heatmap per Stage and Type"), + dcc.Graph( + id="heatmap", + style={"height": "90%", "width": "98%"}, + config=dict(displayModeBar=False), + ), + ], + ), + html.Div( + id="top_open_container", + className="pretty_container", + children=[ + html.Div([html.P("Top Open opportunities")], className="subtitle"), + html.Div(id="top_open_opportunities", className="table"), + ], + ), + html.Div( + id="top_lost_container", + className="pretty_container", + children=[ + html.Div([html.P("Top Lost opportunities")], className="subtitle"), + html.Div(id="top_lost_opportunities", className="table"), + ], + ), + ], + ), + modal(), +] + + +# updates heatmap figure based on dropdowns values or df updates +@app.callback( + Output("heatmap", "figure"), + [Input("heatmap_dropdown", "value"), Input("opportunities_df", "data")], +) +def heat_map_callback(stage, df): + df = pd.read_json(df, orient="split") + df = df[pd.notnull(df["Type"])] + x = [] + y = df["Type"].unique() + if stage == "all_s": + x = df["StageName"].unique() + elif stage == "cold": + x = ["Needs Analysis", "Prospecting", "Qualification"] + elif stage == "warm": + x = ["Value Proposition", "Id. Decision Makers", "Perception Analysis"] + else: + x = ["Proposal/Price Quote", "Negotiation/Review", "Closed Won"] + return heat_map_fig(df, x, y) + + +# updates converted opportunity count graph based on dropdowns values or df updates +@app.callback( + Output("converted_count", "figure"), + [ + Input("converted_opportunities_dropdown", "value"), + Input("source_dropdown", "value"), + Input("opportunities_df", "data"), + ], +) +def converted_opportunity_callback(period, source, df): + df = pd.read_json(df, orient="split") + return converted_opportunities(period, source, df) + + +# updates left indicator value based on df updates +@app.callback( + Output("left_opportunities_indicator", "children"), + [Input("opportunities_df", "data")], +) +def left_opportunities_indicator_callback(df): + df = pd.read_json(df, orient="split") + won = millify(str(df[df["IsWon"] == 1]["Amount"].sum())) + return dcc.Markdown("**{}**".format(won)) + + +# updates middle indicator value based on df updates +@app.callback( + Output("middle_opportunities_indicator", "children"), + [Input("opportunities_df", "data")], +) +def middle_opportunities_indicator_callback(df): + df = pd.read_json(df, orient="split") + active = millify(str(df[(df["IsClosed"] == 0)]["Amount"].sum())) + return dcc.Markdown("**{}**".format(active)) + + +# updates right indicator value based on df updates +@app.callback( + Output("right_opportunities_indicator", "children"), + [Input("opportunities_df", "data")], +) +def right_opportunities_indicator_callback(df): + df = pd.read_json(df, orient="split") + lost = millify(str(df[(df["IsWon"] == 0) & (df["IsClosed"] == 1)]["Amount"].sum())) + return dcc.Markdown("**{}**".format(lost)) + + +# hide/show modal +@app.callback( + Output("opportunities_modal", "style"), [Input("new_opportunity", "n_clicks")] +) +def display_opportunities_modal_callback(n): + if n > 0: + return {"display": "block"} + return {"display": "none"} + + +# reset to 0 add button n_clicks property +@app.callback( + Output("new_opportunity", "n_clicks"), + [ + Input("opportunities_modal_close", "n_clicks"), + Input("submit_new_opportunity", "n_clicks"), + ], +) +def close_modal_callback(n, n2): + return 0 + + +# add new opportunity to salesforce and stores new df in hidden div +@app.callback( + Output("opportunities_df", "data"), + [Input("submit_new_opportunity", "n_clicks")], + [ + State("new_opportunity_name", "value"), + State("new_opportunity_stage", "value"), + State("new_opportunity_amount", "value"), + State("new_opportunity_probability", "value"), + State("new_opportunity_date", "date"), + State("new_opportunity_type", "value"), + State("new_opportunity_source", "value"), + State("opportunities_df", "data"), + ], +) +def add_opportunity_callback( + n_clicks, name, stage, amount, probability, date, o_type, source, current_df +): + if n_clicks > 0: + if name == "": + name = "Not named yet" + query = { + "Name": name, + "StageName": stage, + "Amount": amount, + "Probability": probability, + "CloseDate": date, + "Type": o_type, + "LeadSource": source, + } + + sf_manager.add_opportunity(query) + + df = sf_manager.get_opportunities() + + return df.to_json(orient="split") + + return current_df + + +# updates top open opportunities based on df updates +@app.callback( + Output("top_open_opportunities", "children"), [Input("opportunities_df", "data")] +) +def top_open_opportunities_callback(df): + df = pd.read_json(df, orient="split") + return top_open_opportunities(df) + + +# updates top lost opportunities based on df updates +@app.callback( + Output("top_lost_opportunities", "children"), [Input("opportunities_df", "data")] +) +def top_lost_opportunities_callback(df): + df = pd.read_json(df, orient="split") + return top_lost_opportunities(df) diff --git a/apps/dash-salesforce-crm/requirements.txt b/apps/dash-salesforce-crm/requirements.txt new file mode 100644 index 000000000..202c644fb --- /dev/null +++ b/apps/dash-salesforce-crm/requirements.txt @@ -0,0 +1,5 @@ +certifi==2019.6.16 +simple_salesforce==0.74.2 +pandas==0.24.2 +dash==1.0.0 +gunicorn==19.9.0 \ No newline at end of file diff --git a/apps/dash-salesforce-crm/screenshots/cases_screenshot.png b/apps/dash-salesforce-crm/screenshots/cases_screenshot.png new file mode 100644 index 000000000..707aeb4bc Binary files /dev/null and b/apps/dash-salesforce-crm/screenshots/cases_screenshot.png differ diff --git a/apps/dash-salesforce-crm/screenshots/dash-salesforce-demo.gif b/apps/dash-salesforce-crm/screenshots/dash-salesforce-demo.gif new file mode 100644 index 000000000..7367078fd Binary files /dev/null and b/apps/dash-salesforce-crm/screenshots/dash-salesforce-demo.gif differ diff --git a/apps/dash-salesforce-crm/screenshots/leads_screenshot.png b/apps/dash-salesforce-crm/screenshots/leads_screenshot.png new file mode 100644 index 000000000..5e7e68cd4 Binary files /dev/null and b/apps/dash-salesforce-crm/screenshots/leads_screenshot.png differ diff --git a/apps/dash-salesforce-crm/screenshots/opportunities_screenshot.png b/apps/dash-salesforce-crm/screenshots/opportunities_screenshot.png new file mode 100644 index 000000000..337264a47 Binary files /dev/null and b/apps/dash-salesforce-crm/screenshots/opportunities_screenshot.png differ diff --git a/apps/dash-salesforce-crm/secrets.example.sh b/apps/dash-salesforce-crm/secrets.example.sh new file mode 100644 index 000000000..60fefea60 --- /dev/null +++ b/apps/dash-salesforce-crm/secrets.example.sh @@ -0,0 +1,3 @@ +export USERNAME= +export PASSWORD= +export TOKEN= \ No newline at end of file diff --git a/apps/dash-salesforce-crm/sfManager.py b/apps/dash-salesforce-crm/sfManager.py new file mode 100644 index 000000000..d9d916d31 --- /dev/null +++ b/apps/dash-salesforce-crm/sfManager.py @@ -0,0 +1,130 @@ +from simple_salesforce import Salesforce +from simple_salesforce.exceptions import SalesforceExpiredSession +import pandas as pd +import os + + +class sf_Manager: + def __init__(self): + # Create a free SalesForce account: https://developer.salesforce.com/signup + self.sf = Salesforce( + username=os.getenv("USERNAME"), + password=os.getenv("PASSWORD"), + security_token=os.getenv("TOKEN"), + ) + + def login(self): + # Create a free SalesForce account: https://developer.salesforce.com/signup + self.sf = Salesforce( + username=os.getenv("USERNAME"), + password=os.getenv("PASSWORD"), + security_token=os.getenv("TOKEN"), + ) + return 0 + + def dict_to_df(self, query_result, date=True): + items = { + val: dict(query_result["records"][val]) + for val in range(query_result["totalSize"]) + } + df = pd.DataFrame.from_dict(items, orient="index").drop(["attributes"], axis=1) + + if date: # date indicates if the df contains datetime column + df["CreatedDate"] = pd.to_datetime( + df["CreatedDate"], format="%Y-%m-%d" + ) # convert to datetime + df["CreatedDate"] = df["CreatedDate"].dt.strftime( + "%Y-%m-%d" + ) # reset string + return df + + def get_leads(self): + try: + desc = self.sf.Lead.describe() + except SalesforceExpiredSession as e: + self.login() + desc = self.sf.Lead.describe() + + field_names = [field["name"] for field in desc["fields"]] + soql = "SELECT {} FROM Lead".format(",".join(field_names)) + query_result = self.sf.query_all(soql) + leads = self.dict_to_df(query_result) + return leads + + def get_opportunities(self): + query_text = "SELECT CreatedDate, Name, StageName, ExpectedRevenue, Amount, LeadSource, IsWon, IsClosed, Type, Probability FROM Opportunity" + try: + query_result = self.sf.query(query_text) + except SalesforceExpiredSession as e: + self.login() + query_result = self.sf.query(query_text) + opportunities = self.dict_to_df(query_result) + return opportunities + + def get_cases(self): + query_text = "SELECT CreatedDate, Type, Reason, Status, Origin, Subject, Priority, IsClosed, OwnerId, IsDeleted, AccountId FROM Case" + try: + query_result = self.sf.query(query_text) + except SalesforceExpiredSession as e: + self.login() + query_result = self.sf.query(query_text) + + cases = self.dict_to_df(query_result) + return cases + + def get_contacts(self): + query_text = "SELECT Id, Salutation, FirstName, LastName FROM Contact" + try: + query_result = self.sf.query(query_text) + except SalesforceExpiredSession as e: + self.login() + query_result = self.sf.query(query_text) + + contacts = self.dict_to_df(query_result, False) + return contacts + + def get_users(self): + query_text = "SELECT Id,FirstName, LastName FROM User" + try: + query_result = self.sf.query(query_text) + except SalesforceExpiredSession as e: + self.login() + query_result = self.sf.query(query_text) + + users = self.dict_to_df(query_result, False) + return users + + def get_accounts(self): + query_text = "SELECT Id, Name FROM Account" + try: + query_result = self.sf.query(query_text) + except SalesforceExpiredSession as e: + self.login() + query_result = self.sf.query(query_text) + + accounts = self.dict_to_df(query_result, False) + return accounts + + def add_lead(self, query): + try: + self.sf.Lead.create(query) + except SalesforceExpiredSession as e: + self.login() + self.sf.Lead.create(query) + return 0 + + def add_opportunity(self, query): + try: + self.sf.Opportunity.create(query) + except SalesforceExpiredSession as e: + self.login() + self.sf.Opportunity.create(query) + return 0 + + def add_case(self, query): + try: + self.sf.Case.create(query) + except SalesforceExpiredSession as e: + self.login() + self.sf.Case.create(query) + return 0