From 5aef65fa3567143878ff1a37cf253cbbfec11e66 Mon Sep 17 00:00:00 2001 From: admin <51248046+danton267@users.noreply.github.com> Date: Tue, 24 May 2022 10:29:31 +0100 Subject: [PATCH 1/3] refactor v1 --- apps/dash-salesforce-crm/.gitignore | 197 ++++- apps/dash-salesforce-crm/Procfile | 2 +- apps/dash-salesforce-crm/README.md | 8 +- apps/dash-salesforce-crm/app.py | 73 +- apps/dash-salesforce-crm/assets/css/app.css | 64 ++ .../github}/cases_screenshot.png | Bin .../github}/dash-salesforce-demo.gif | Bin .../github}/leads_screenshot.png | Bin .../github}/opportunities_screenshot.png | Bin .../assets/images/plotly-logo.png | Bin 0 -> 23021 bytes apps/dash-salesforce-crm/assets/logo.png | Bin 9903 -> 0 bytes apps/dash-salesforce-crm/assets/s1.css | 573 -------------- apps/dash-salesforce-crm/constants.py | 61 ++ apps/dash-salesforce-crm/index.py | 134 ---- apps/dash-salesforce-crm/panels/cases.py | 706 ++---------------- apps/dash-salesforce-crm/panels/leads.py | 546 ++------------ .../panels/opportunities.py | 647 ++-------------- apps/dash-salesforce-crm/requirements.txt | 10 +- apps/dash-salesforce-crm/utils/components.py | 566 ++++++++++++++ apps/dash-salesforce-crm/utils/graphs.py | 347 +++++++++ .../utils/helper_functions.py | 36 + .../salesforce_manager.py} | 3 +- apps/dash-svm/README.md | 1 + 23 files changed, 1481 insertions(+), 2493 deletions(-) create mode 100644 apps/dash-salesforce-crm/assets/css/app.css rename apps/dash-salesforce-crm/{screenshots => assets/github}/cases_screenshot.png (100%) rename apps/dash-salesforce-crm/{screenshots => assets/github}/dash-salesforce-demo.gif (100%) rename apps/dash-salesforce-crm/{screenshots => assets/github}/leads_screenshot.png (100%) rename apps/dash-salesforce-crm/{screenshots => assets/github}/opportunities_screenshot.png (100%) create mode 100644 apps/dash-salesforce-crm/assets/images/plotly-logo.png delete mode 100644 apps/dash-salesforce-crm/assets/logo.png delete mode 100644 apps/dash-salesforce-crm/assets/s1.css create mode 100644 apps/dash-salesforce-crm/constants.py delete mode 100644 apps/dash-salesforce-crm/index.py create mode 100644 apps/dash-salesforce-crm/utils/components.py create mode 100644 apps/dash-salesforce-crm/utils/graphs.py create mode 100644 apps/dash-salesforce-crm/utils/helper_functions.py rename apps/dash-salesforce-crm/{sfManager.py => utils/salesforce_manager.py} (99%) diff --git a/apps/dash-salesforce-crm/.gitignore b/apps/dash-salesforce-crm/.gitignore index 41e8e8e6a..1f65cd953 100644 --- a/apps/dash-salesforce-crm/.gitignore +++ b/apps/dash-salesforce-crm/.gitignore @@ -1,7 +1,192 @@ -venv/ -.env +# .gitignore specifies the files that shouldn't be included +# in version control and therefore shouldn't be included when +# deploying an application to Dash Enterprise +# This is a very exhaustive list! +# This list was based off of https://github.com/github/gitignore + +# Ignore data that is generated during the runtime of an application +# This folder is used by the "Large Data" sample applications +runtime_data/ +data/ + +# Omit SQLite databases that may be produced by dash-snapshots in development +*.db + +# Byte-compiled / optimized / DLL files __pycache__/ -apps/__pycache__/ -*.DS_Store -.vscode -secrets.sh \ No newline at end of file +*.py[cod] +*$py.class + + +# Jupyter Notebook + +.ipynb_checkpoints +*/.ipynb_checkpoints/* + +# IPython +profile_default/ +ipython_config.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +secrets.sh + +# Spyder project settings +.spyderproject +.spyproject + +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + + +# macOS General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# History files +.Rhistory +.Rapp.history + +# Session Data files +.RData + +# User-specific files +.Ruserdata + +# Example code in package build process +*-Ex.R + +# Output files from R CMD check +/*.Rcheck/ + +# RStudio files +.Rproj.user/ + +# produced vignettes +vignettes/*.html +vignettes/*.pdf + +# OAuth2 token, see https://github.com/hadley/httr/releases/tag/v0.3 +.httr-oauth + +# knitr and R markdown default cache directories +*_cache/ +/cache/ + +# Temporary files created by R markdown +*.utf8.md +*.knit.md + +# R Environment Variables +.Renviron + +# Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# SublineText +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings \ No newline at end of file diff --git a/apps/dash-salesforce-crm/Procfile b/apps/dash-salesforce-crm/Procfile index 626d012fa..38371ebbf 100644 --- a/apps/dash-salesforce-crm/Procfile +++ b/apps/dash-salesforce-crm/Procfile @@ -1 +1 @@ -web: gunicorn index:server +web: gunicorn app:server diff --git a/apps/dash-salesforce-crm/README.md b/apps/dash-salesforce-crm/README.md index cc4396e90..4ed09c357 100644 --- a/apps/dash-salesforce-crm/README.md +++ b/apps/dash-salesforce-crm/README.md @@ -55,13 +55,13 @@ This app uses Salesforce API in order to implement a custom CRM dashboard. The A The following are screenshots for the app in this repo: -![Screenshot1](screenshots/opportunities_screenshot.png) +![Screenshot1](assets/github/opportunities_screenshot.png) -![Screenshot1](screenshots/leads_screenshot.png) +![Screenshot1](assets/github/leads_screenshot.png) -![Screenshot1](screenshots/cases_screenshot.png) +![Screenshot1](assets/github/cases_screenshot.png) -![Animated](screenshots/dash-salesforce-demo.gif) +![Animated](assets/github/dash-salesforce-demo.gif) diff --git a/apps/dash-salesforce-crm/app.py b/apps/dash-salesforce-crm/app.py index 1e98313a2..007f2b81e 100644 --- a/apps/dash-salesforce-crm/app.py +++ b/apps/dash-salesforce-crm/app.py @@ -1,50 +1,31 @@ -import math -import dash -import dash_html_components as html +from dash import Dash, html, dcc, Input, Output +import dash_bootstrap_components as dbc +from panels import opportunities, cases, leads -from sfManager import sf_Manager +from constants import salesforce_manager +from utils.components import Header -app = dash.Dash( - __name__, meta_tags=[{"name": "viewport", "content": "width=device-width"}] +app = Dash( + __name__, + external_stylesheets=[dbc.themes.CYBORG], + title="CRM Salesforce" +) +server = app.server + +app.layout = dbc.Container([ + Header(app), + dbc.Tabs([ + dbc.Tab(opportunities.layout, label="Opportunities"), + dbc.Tab(leads.layout, label="Leads"), + dbc.Tab(cases.layout, label="Cases"), + ]), + + dcc.Store(id="opportunities_df", data=salesforce_manager.get_opportunities().to_json(orient="split")), + dcc.Store(id="leads_df", data=salesforce_manager.get_leads().to_json(orient="split")), + dcc.Store(id="cases_df", data=salesforce_manager.get_cases().to_json(orient="split")), + ], + fluid=True, ) -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", - ) +if __name__ == "__main__": + app.run_server(debug=True) diff --git a/apps/dash-salesforce-crm/assets/css/app.css b/apps/dash-salesforce-crm/assets/css/app.css new file mode 100644 index 000000000..1c383617b --- /dev/null +++ b/apps/dash-salesforce-crm/assets/css/app.css @@ -0,0 +1,64 @@ +.header { + padding: 5px 15px 5px 10px; +} + +.header-logos { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: 5%; +} + + +.tab-content { + padding: 10px 10rem 0 10rem; +} + +.main-svg { + background-color: transparent !important; +} + +.row { + margin-top: 20px; +} + +.card { + margin-bottom: 20px; +} + + +/* Demo button css */ +.demo-button { + font-family: Open Sans,sans-serif; + text-decoration: none; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + border-radius: 8px; + font-weight: 700; + height: 2.5rem; + font-size: 13px; + -webkit-padding-start: 1rem; + padding-inline-start: 1rem; + -webkit-padding-end: 1rem; + padding-inline-end: 1rem; + color: #ffffff; + letter-spacing: 2px; + border: solid 1.5px transparent; + box-shadow: 2px 1000px 1px #0c0c0c inset; + background-image: linear-gradient(135deg, #7A76FF, #7A76FF, #7FE4FF); + -webkit-background-size: 200% 100%; + background-size: 200% 100%; + -webkit-background-position: 99%; + background-position: 99%; + background-origin: border-box; + transition: all .4s ease-in-out; + padding-top: 15px; + padding-bottom: 15px; +} +.demo-button:hover { + color: #7A76FF; +} \ No newline at end of file diff --git a/apps/dash-salesforce-crm/screenshots/cases_screenshot.png b/apps/dash-salesforce-crm/assets/github/cases_screenshot.png similarity index 100% rename from apps/dash-salesforce-crm/screenshots/cases_screenshot.png rename to apps/dash-salesforce-crm/assets/github/cases_screenshot.png diff --git a/apps/dash-salesforce-crm/screenshots/dash-salesforce-demo.gif b/apps/dash-salesforce-crm/assets/github/dash-salesforce-demo.gif similarity index 100% rename from apps/dash-salesforce-crm/screenshots/dash-salesforce-demo.gif rename to apps/dash-salesforce-crm/assets/github/dash-salesforce-demo.gif diff --git a/apps/dash-salesforce-crm/screenshots/leads_screenshot.png b/apps/dash-salesforce-crm/assets/github/leads_screenshot.png similarity index 100% rename from apps/dash-salesforce-crm/screenshots/leads_screenshot.png rename to apps/dash-salesforce-crm/assets/github/leads_screenshot.png diff --git a/apps/dash-salesforce-crm/screenshots/opportunities_screenshot.png b/apps/dash-salesforce-crm/assets/github/opportunities_screenshot.png similarity index 100% rename from apps/dash-salesforce-crm/screenshots/opportunities_screenshot.png rename to apps/dash-salesforce-crm/assets/github/opportunities_screenshot.png diff --git a/apps/dash-salesforce-crm/assets/images/plotly-logo.png b/apps/dash-salesforce-crm/assets/images/plotly-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..984dd57ab53b7f9fa47df219e25df91a0885613d GIT binary patch literal 23021 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

E2KG?|%lGhzU$ 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 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/constants.py b/apps/dash-salesforce-crm/constants.py new file mode 100644 index 000000000..6500af68b --- /dev/null +++ b/apps/dash-salesforce-crm/constants.py @@ -0,0 +1,61 @@ + +from utils.salesforce_manager import SalesforceManager + +salesforce_manager = SalesforceManager() + +## used to convert numbers +millnames = ["", " K", " M", " B", " T"] + +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", +] \ No newline at end of file diff --git a/apps/dash-salesforce-crm/index.py b/apps/dash-salesforce-crm/index.py deleted file mode 100644 index 4cd6257a3..000000000 --- a/apps/dash-salesforce-crm/index.py +++ /dev/null @@ -1,134 +0,0 @@ -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/cases.py b/apps/dash-salesforce-crm/panels/cases.py index 4a57224b3..5f230cc51 100644 --- a/apps/dash-salesforce-crm/panels/cases.py +++ b/apps/dash-salesforce-crm/panels/cases.py @@ -1,669 +1,75 @@ -# -*- coding: utf-8 -*- +from dash import html, dcc, Input, Output, State, callback +import dash_bootstrap_components as dbc 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 +from constants import salesforce_manager +from utils.components import cases_modal, cases_controls, cases_data_cards, cases_graphs +from utils.graphs import cases_by_account, cases_by_period, cases_pie_chart -colors = {"background": "#F3F6FA", "background_div": "white"} -accounts = sf_manager.get_accounts() -contacts = sf_manager.get_contacts() -users = sf_manager.get_users() +layout = html.Div([ + dbc.Row(cases_controls), + dbc.Row(cases_data_cards), + dbc.Row(cases_graphs), + cases_modal(salesforce_manager), +]) -# 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( +@callback( + Output("left_cases_indicator", "children"), + Output("middle_cases_indicator", "children"), + Output("right_cases_indicator", "children"), + Output("cases_by_account", "figure"), 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"), - ], + Input("cases_df", "data"), + Input("cases_period_dropdown", "value"), + Input("origin_dropdown", "value"), + Input("priority_dropdown", "value"), ) -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): +def update_graphs(df, period, origin, priority): df = pd.read_json(df, orient="split") - return cases_by_account(df) + ## Data cards + left_cases_indicator = len(df[(df["Priority"] == "Low") & (df["Status"] == "New")]["Priority"].index) + middle_cases_indicator = len(df[(df["Priority"] == "Medium") & (df["Status"] == "New")]["Priority"].index) + right_cases_indicator = len(df[(df["Priority"] == "High") & (df["Status"] == "New")]["Priority"].index) -@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"} + ## Figures + fig_by_account = cases_by_account(df, salesforce_manager) + fig_pie_chart = cases_pie_chart(df, "Reason", priority, origin) + fig_pie_chart_h = cases_pie_chart(df, "Type", priority, origin, h_orientation=True) + fig_by_period = cases_by_period(df, period, priority) + return left_cases_indicator, middle_cases_indicator, right_cases_indicator, \ + fig_by_account, fig_pie_chart, fig_pie_chart_h, fig_by_period -@app.callback( - Output("new_case", "n_clicks"), - [Input("cases_modal_close", "n_clicks"), Input("submit_new_case", "n_clicks")], +@callback( + Output("cases_modal", "is_open"), + Input("new_case", "n_clicks"), + State("cases_modal", "is_open"), ) -def close_modal_callback(n, n2): - return 0 - +def toggle_cases_modal(n1, is_open): + if n1: + return not is_open + return is_open -@app.callback( +@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"), - ], + 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"), + prevent_initial_call=True, ) -def add_case_callback( - n_clicks, - account_id, - origin, - reason, - subject, - contact_id, - case_type, - status, - description, - priority, - current_df, -): +def add_new_case(n_clicks, account_id, origin, reason, subject, contact_id, case_type, status, description, priority, current_df): if n_clicks > 0: query = { "AccountId": account_id, @@ -677,8 +83,8 @@ def add_case_callback( "Priority": priority, } - sf_manager.add_case(query) - df = sf_manager.get_cases() + salesforce_manager.add_case(query) + df = salesforce_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 index 05a8f16d8..c35cf25a7 100644 --- a/apps/dash-salesforce-crm/panels/leads.py +++ b/apps/dash-salesforce-crm/panels/leads.py @@ -1,466 +1,48 @@ -# -*- coding: utf-8 -*- +from dash import html, Input, Output, State, callback +import dash_bootstrap_components as dbc 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 +from constants import salesforce_manager +from utils.graphs import leads_choropleth_map, lead_source, converted_leads_count +from utils.components import leads_modal, leads_controls, leads_data_cards, leads_graphs -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", -] +layout = html.Div([ + dbc.Row(leads_controls), + dbc.Row(leads_data_cards), + dbc.Row(leads_graphs), + leads_modal(), +]) -# 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( +@callback( + Output("left_leads_indicator", "children"), + Output("middle_leads_indicator", "children"), + Output("right_leads_indicator", "children"), Output("lead_source", "figure"), - [Input("lead_source_dropdown", "value"), Input("leads_df", "data")], + Output("leads_map", "figure"), + Output("converted_leads", "figure"), + Output("leads_table", "data"), + Output("leads_table", "columns"), + Input("leads_df", "data"), + Input("lead_source_dropdown", "value"), + Input("converted_leads_dropdown", "value"), ) -def lead_source_callback(status, df): +def update_graphs(df, status, period): df = pd.read_json(df, orient="split") - return lead_source(status, df) + ## Data Cards + left_leads_indicator = len(df[df["Status"] == "Closed - Converted"].index) + middle_leads_indicator = len( df[ (df["Status"] == "Open - Not Contacted") | (df["Status"] == "Working - Contacted") ].index) -# 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) + lost_leads = len(df[df["Status"] == "Closed - Not Converted"].index) + right_leads_indicator = left_leads_indicator / (left_leads_indicator + lost_leads) * 100 + ## Figures + fig_lead_source = lead_source(status, df) + fig_leads_choropleth_map = leads_choropleth_map(status, df) + fig_converted_leads_count = converted_leads_count(period, 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") + ## Table if status == "open": df = df[ (df["Status"] == "Open - Not Contacted") @@ -471,49 +53,35 @@ def leads_table_callback(status, df): 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"} + table_data = df.to_dict('records') + table_cols = [{"name": i, "id": i} for i in df.columns] + return left_leads_indicator, middle_leads_indicator, f"{right_leads_indicator:.2f}%", \ + fig_lead_source, fig_leads_choropleth_map, fig_converted_leads_count, \ + table_data, table_cols -# 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")], +@callback( + Output("leads_modal", "is_open"), + Input("new_lead", "n_clicks"), + State("leads_modal", "is_open"), ) -def close_modal_callback(n, n2): - return 0 +def toggle_cases_modal(n1, is_open): + if n1: + return not is_open + return is_open -# add new lead to salesforce and stores new df in hidden div -@app.callback( +@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"), - ], + 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"), + prevent_initial_call=True, ) -def add_lead_callback(n_clicks, status, state, company, source, current_df): +def add_new_lead(n_clicks, status, state, company, source, current_df): if n_clicks > 0: if company == "": company = "Not named yet" @@ -524,8 +92,8 @@ def add_lead_callback(n_clicks, status, state, company, source, current_df): "State": state, "LeadSource": source, } - sf_manager.add_lead(query) - df = sf_manager.get_leads() + salesforce_manager.add_lead(query) + df = salesforce_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 index f190ded6a..4d3677600 100644 --- a/apps/dash-salesforce-crm/panels/opportunities.py +++ b/apps/dash-salesforce-crm/panels/opportunities.py @@ -1,578 +1,80 @@ -# -*- coding: utf-8 -*- -from datetime import date +from dash import html, dcc, Input, Output, State, callback +import dash_bootstrap_components as dbc 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 +from constants import salesforce_manager +from utils.helper_functions import millify, top_open_opportunities, top_lost_opportunities +from utils.components import opportunities_modal, opportunities_controls, opportunities_data_cards, opportunities_graphs +from utils.graphs import converted_opportunities, opportunities_heat_map_fig +layout = html.Div([ + dbc.Row(opportunities_controls), + dbc.Row(opportunities_data_cards), + dbc.Row(opportunities_graphs), + opportunities_modal(), +]) -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( +@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")], + Output("opportunities_heatmap", "figure"), + Output("converted_count", "figure"), + Output("top_open_opportunities", "data"), + Output("top_open_opportunities", "columns"), + Output("top_lost_opportunities", "data"), + Output("top_lost_opportunities", "columns"), + Input("opportunities_df", "data"), + Input("heatmap_dropdown", "value"), + Input("converted_opportunities_dropdown", "value"), + Input("source_dropdown", "value"), ) -def right_opportunities_indicator_callback(df): +def update_graphs(df, stage, period, source): 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"), - ], + ## Data Cards + left_opportunities_indicator = millify(str(df[df["IsWon"] == 1]["Amount"].sum())) + middle_opportunities_indicator = millify(str(df[(df["IsClosed"] == 0)]["Amount"].sum())) + right_opportunities_indicator = millify(str(df[(df["IsWon"] == 0) & (df["IsClosed"] == 1)]["Amount"].sum())) + + ## Figures + fig_converted_opportunities = converted_opportunities(period, source, df) + fig_heatmap = opportunities_heat_map_fig(df, stage) + + ## Table + table_data_top_ten, table_cols_top_ten = top_open_opportunities(df) + table_data_top_lost, table_cols_top_lost = top_lost_opportunities(df) + + return left_opportunities_indicator, middle_opportunities_indicator, right_opportunities_indicator, \ + fig_heatmap, fig_converted_opportunities, \ + table_data_top_ten, table_cols_top_ten, table_data_top_lost, table_cols_top_lost + +@callback( + Output("opportunities_modal", "is_open"), + Input("new_opportunity", "n_clicks"), + State("opportunities_modal", "is_open"), ) -def close_modal_callback(n, n2): - return 0 +def toggle_opportunities_modal(n1, is_open): + if n1: + return not is_open + return is_open -# add new opportunity to salesforce and stores new df in hidden div -@app.callback( +@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"), - ], + 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"), + prevent_initial_call=True, ) -def add_opportunity_callback( +def add_new_opportunity( n_clicks, name, stage, amount, probability, date, o_type, source, current_df ): if n_clicks > 0: @@ -587,29 +89,8 @@ def add_opportunity_callback( "Type": o_type, "LeadSource": source, } - - sf_manager.add_opportunity(query) - - df = sf_manager.get_opportunities() - + salesforce_manager.add_opportunity(query) + df = salesforce_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) + return current_df \ No newline at end of file diff --git a/apps/dash-salesforce-crm/requirements.txt b/apps/dash-salesforce-crm/requirements.txt index 202c644fb..c915af0ad 100644 --- a/apps/dash-salesforce-crm/requirements.txt +++ b/apps/dash-salesforce-crm/requirements.txt @@ -1,5 +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 +dash==2.4.1 +pandas==1.4.2 +gunicorn==20.1.0 +cryptography==2.8 +simple_salesforce==1.11 \ No newline at end of file diff --git a/apps/dash-salesforce-crm/utils/components.py b/apps/dash-salesforce-crm/utils/components.py new file mode 100644 index 000000000..fdcfdab19 --- /dev/null +++ b/apps/dash-salesforce-crm/utils/components.py @@ -0,0 +1,566 @@ +from dash import html, dcc, dash_table +from constants import states +from datetime import date +import dash_bootstrap_components as dbc + + +def Header(app): + name = [ + html.Span("CRM App"), + html.Span(" using Salesforce API", style={"font-size": "1.8rem", "margin-top": "15px"}), + ] + title = html.H2(name, style={"margin-top": 5}) + logo = html.Img(src=app.get_asset_url("images/plotly-logo.png"), style={"float": "right", "height": 60}) + link = html.A(logo, href="https://plotly.com/dash/", target="_blank") + demo_link = html.A("ENTERPRISE DEMO", href="https://plotly.com/get-demo/", target="_blank", className="demo-button") + return dbc.Row([dbc.Col(title, md=8), dbc.Col([demo_link, link], md=4, className="header-logos")], className="header") + +def dbc_indicator(text, id_value, width): + return dbc.Col( + dbc.Card([ + html.P(id=id_value, className="indicator_value"), + html.P(text, className="twelve columns indicator_text"), + ]), width = width + ) + +def dbc_card(header, child_id, width, table=None): + if table is None: + child = dcc.Graph(id=child_id, config=dict(displayModeBar=False)) + else: + child = dash_table.DataTable(id=child_id) + return dbc.Col( + dbc.Card( + dbc.CardBody([ + html.H4(header, className="card-title"), + child + ]) + ), width = width + ) + +### Leads page components ### ### ### ### + + +leads_controls = [ + dbc.Col([ + 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, + ) + ], width=2), + dbc.Col([ + 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, + ) + ], width=2), + dbc.Col(width=6), + dbc.Col([ + dbc.Button("Add New Lead", color="primary", size="lg", id="new_lead"), + ], width=2) + ] +leads_data_cards = [ + dbc_indicator("Converted Leads", "left_leads_indicator", 4), + dbc_indicator("Open Leads", "middle_leads_indicator", 4), + dbc_indicator("Conversion Rates", "right_leads_indicator", 4), +] + +leads_graphs = [ + dbc_card("Leads Count per State", "leads_map", width=4), + dbc_card("Leads by Source", "lead_source", width=4), + dbc_card("Converted Leads Count", "converted_leads", width=4), + dbc_card("Table of Leads", "leads_table", width=12, table=True), +] + + +def leads_modal(): + return dbc.Modal([ + dbc.ModalHeader(dbc.ModalTitle("New Lead")), + dbc.ModalBody(dbc.Row([ + dbc.Col([ + dbc.Label("Company Name"), + dcc.Input( + id="new_lead_company", + placeholder="Enter company name", + type="text", + value="", + style={"width": "100%"}, + ), + ], width=6), + dbc.Col([ + dbc.Label("Company State"), + dcc.Dropdown( + id="new_lead_state", + options=states, + value="NY", + ), + ], width=6), + dbc.Col([ + dbc.Label("Status"), + dcc.Dropdown( + id="new_lead_status", + options=["Open - Not Contacted", "Working - Contacted", "Closed - Converted", "Closed - Not Converted"], + value="Open - Not Contacted", + ), + ], width=6), + dbc.Col([ + dbc.Label("Source"), + dcc.Dropdown( + id="new_lead_source", + options=["Web", "Phone Inquiry", "Partner Referral", "Purchased List", "Other"], + value="Web", + ), + ], width=6), + ])), + dbc.ModalFooter(dbc.Button("Submit", id="submit_new_lead", className="ms-auto")), + ], + id="leads_modal", + is_open=False, +) + + + +### Cases page components ### ### ### ### + +cases_controls = [ + dbc.Col([ + 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, + ), + ], width=2), + dbc.Col([ + 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, + ), + ], width=2), + dbc.Col([ + 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, + ), + ], width=2), + dbc.Col(width=4), + dbc.Col([ + dbc.Button("Add New Case", color="primary", size="lg", id="new_case"), + ], width=2) + ] +cases_data_cards = [ + dbc_indicator("Low priority cases", "left_cases_indicator", 4), + dbc_indicator("Medium priority cases", "middle_cases_indicator", 4), + dbc_indicator("High priority cases", "right_cases_indicator", 4), +] + +cases_graphs = [ + dbc_card("Cases Type", "cases_types", width=6), + dbc_card("Cases Reasons", "cases_reasons", width=6), + dbc_card("Cases over Time", "cases_by_period", width=6), + dbc_card("Cases by Company", "cases_by_account", width=6), +] + +def cases_modal(salesforce_manager): + accounts = salesforce_manager.get_accounts() + contacts = salesforce_manager.get_contacts() + + contacts["Name"] = ( + contacts["Salutation"] + + " " + + contacts["FirstName"] + + " " + + contacts["LastName"] + ) + return dbc.Modal([ + dbc.ModalHeader(dbc.ModalTitle("New Case")), + dbc.ModalBody(dbc.Row([ + dbc.Col([ + dbc.Label("Account Name"), + 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, + ) + ], width=6), + dbc.Col([ + dbc.Label("Contact Name"), + 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, + ) + ], width=6), + dbc.Col([ + dbc.Label("Priority"), + dcc.Dropdown( + id="new_case_priority", + options=[ + {"label": "High", "value": "High"}, + {"label": "Medium", "value": "Medium"}, + {"label": "Low", "value": "Low"}, + ], + value="Medium", + clearable=False, + ), + ], width=6), + dbc.Col([ + dbc.Label("Type"), + 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", + ), + ], width=6), + dbc.Col([ + dbc.Label("Origin"), + dcc.Dropdown( + id="new_case_origin", + options=[ + {"label": "Phone", "value": "Phone"}, + {"label": "Web", "value": "Web"}, + {"label": "Email", "value": "Email"}, + ], + value="Phone", + clearable=False, + ), + ], width=6), + dbc.Col([ + dbc.Label("Status"), + 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", + ), + ], width=6), + dbc.Col([ + dbc.Label("Reason"), + 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, + ), + ], width=6), + dbc.Col([ + dbc.Label("Supplied Email"), + dcc.Input( + id="new_case_email", + placeholder="email", + type="email", + value="", + style={"width": "100%"}, + ), + ], width=6), + dbc.Col([ + dbc.Label("Subject"), + dcc.Input( + id="new_case_subject", + placeholder="The Subject of the case", + type="text", + value="", + style={"width": "100%"}, + ), + ], width=6), + dbc.Col([ + dbc.Label("Description"), + dcc.Textarea( + id="new_case_description", + placeholder="Description of the case", + value="", + style={"width": "100%"}, + ), + ], width=6), + ])), + dbc.ModalFooter(dbc.Button("Submit", id="submit_new_case", className="ms-auto")), + ], + id="cases_modal", + is_open=False, +) + + +### Opportunities page components ### ### ### ### + +opportunities_controls = [ + dbc.Col([ + 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, + ), + ], width=2), + dbc.Col([ + 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, + ), + ], width=2), + dbc.Col([ + 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, + ), + ], width=2), + dbc.Col(width=4), + dbc.Col([ + dbc.Button("Add New Opportunity", color="primary", size="lg", id="new_opportunity"), + ], width=2) + ] +opportunities_data_cards = [ + dbc_indicator("Won opportunities", "left_opportunities_indicator", 4), + dbc_indicator("Open opportunities", "middle_opportunities_indicator", 4), + dbc_indicator("Lost opportunities", "right_opportunities_indicator", 4), +] + +opportunities_graphs = [ + dbc_card("Converted Opportunities count", "converted_count", width=4), + dbc_card("Probabilty heatmap per Stage and Type", "opportunities_heatmap", width=8), + dbc_card("Top Open opportunities", "top_open_opportunities", width=6, table=True), + dbc_card("Top Lost opportunities", "top_lost_opportunities", width=6, table=True), +] + + +def opportunities_modal(): + return dbc.Modal([ + dbc.ModalHeader(dbc.ModalTitle("New Opportunity")), + dbc.ModalBody(dbc.Row([ + dbc.Col([ + dbc.Label("Name"), + dcc.Input( + id="new_opportunity_name", + placeholder="Name of the opportunity", + type="text", + value="", + style={"width": "100%"}, + ), + ], width=6), + dbc.Col([ + dbc.Label("StageName"), + 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", + ) + ], width=6), + dbc.Col([ + dbc.Label("Source"), + dcc.Dropdown( + id="new_opportunity_source", + options=["Web", "Phone Inquiry", "Partner Referral", "Purchased List", "Other"], + value="Web", + ), + ], width=6), + dbc.Col([ + dbc.Label("Close Date"), + 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"}, + ), + ], width=6), + dbc.Col([ + dbc.Label("Type"), + dcc.Dropdown( + id="new_opportunity_type", + options=["Existing Customer - Replacement", "New Customer", "Existing Customer - Upgrade", "Existing Customer - Downgrade"], + value="New Customer", + ), + ], width=6), + dbc.Col([ + dbc.Label("Amount"), + dcc.Input( + id="new_opportunity_amount", + placeholder="0", + type="number", + value="", + style={"width": "100%"}, + ), + ], width=6), + dbc.Col([ + dbc.Label("Probability"), + dcc.Input( + id="new_opportunity_probability", + placeholder="0", + type="number", + max=100, + step=1, + value="", + style={"width": "100%"}, + ), + ], width=6), + ])), + dbc.ModalFooter(dbc.Button("Submit", id="submit_new_opportunity", className="ms-auto")), + ], + id="opportunities_modal", + is_open=False, +) + + \ No newline at end of file diff --git a/apps/dash-salesforce-crm/utils/graphs.py b/apps/dash-salesforce-crm/utils/graphs.py new file mode 100644 index 000000000..62677ea8a --- /dev/null +++ b/apps/dash-salesforce-crm/utils/graphs.py @@ -0,0 +1,347 @@ + +from plotly import graph_objs as go +import pandas as pd + + + + + +### Opportunity page graphs ### ### ### ### + + + + +def leads_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) + + +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} + + + + + +### Opportunity page graphs ### ### ### ### + + + + +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} + + +def opportunities_heat_map_fig(df, stage): + df = df[pd.notnull(df["Type"])] + x, y, z = [], 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"] + + 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) + + + + + +### Cases page graphs ### ### ### ### + + + + +def cases_pie_chart(df, column, priority, origin, h_orientation=None): + 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"]}, + ) + + if h_orientation: + layout["legend"]["orientation"] = "h" + + return {"data": [trace], "layout": layout} + + +def cases_by_period(df, period, priority): + 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, salesforce_manager): + accounts = salesforce_manager.get_accounts() + 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} diff --git a/apps/dash-salesforce-crm/utils/helper_functions.py b/apps/dash-salesforce-crm/utils/helper_functions.py new file mode 100644 index 000000000..d1286ce36 --- /dev/null +++ b/apps/dash-salesforce-crm/utils/helper_functions.py @@ -0,0 +1,36 @@ +from constants import millnames +import math + + +# 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 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_dict('records'), [{"name": i, "id": i} for i in df.columns] + + +# 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_dict('records'), [{"name": i, "id": i} for i in df.columns] + diff --git a/apps/dash-salesforce-crm/sfManager.py b/apps/dash-salesforce-crm/utils/salesforce_manager.py similarity index 99% rename from apps/dash-salesforce-crm/sfManager.py rename to apps/dash-salesforce-crm/utils/salesforce_manager.py index d9d916d31..bd6977d48 100644 --- a/apps/dash-salesforce-crm/sfManager.py +++ b/apps/dash-salesforce-crm/utils/salesforce_manager.py @@ -3,8 +3,7 @@ import pandas as pd import os - -class sf_Manager: +class SalesforceManager: def __init__(self): # Create a free SalesForce account: https://developer.salesforce.com/signup self.sf = Salesforce( diff --git a/apps/dash-svm/README.md b/apps/dash-svm/README.md index abd26f12d..57e73c28a 100644 --- a/apps/dash-svm/README.md +++ b/apps/dash-svm/README.md @@ -64,6 +64,7 @@ An SVM is a popular Machine Learning model used in many different fields. You ca * **Matthew Chan** - *Code Review* - [@matthewchan15](https://github.com/matthewchan15) * **Yunke Xiao** - *Redesign* - [@YunkeXiao](https://github.com/YunkeXiao) * **celinehuang** - *Code Review* - [@celinehuang](https://github.com/celinehuang) +* **Daniel Anton S** - *2022 complete refactor* - [@danton267](https://github.com/danton267) ## Acknowledgments From 92ffbaadcabfdc25f03e08854403fac6cda3cb5c Mon Sep 17 00:00:00 2001 From: admin <51248046+danton267@users.noreply.github.com> Date: Wed, 25 May 2022 18:54:34 +0100 Subject: [PATCH 2/3] style update --- .../assets/css/boostrap_extra.css | 27 ++ apps/dash-salesforce-crm/pages/__init__.py | 0 apps/dash-salesforce-crm/pages/cases.py | 89 +++++ apps/dash-salesforce-crm/pages/leads.py | 98 +++++ .../pages/opportunities.py | 96 +++++ apps/dash-salesforce-crm/utils/figures.py | 349 ++++++++++++++++++ 6 files changed, 659 insertions(+) create mode 100644 apps/dash-salesforce-crm/assets/css/boostrap_extra.css create mode 100644 apps/dash-salesforce-crm/pages/__init__.py create mode 100644 apps/dash-salesforce-crm/pages/cases.py create mode 100644 apps/dash-salesforce-crm/pages/leads.py create mode 100644 apps/dash-salesforce-crm/pages/opportunities.py create mode 100644 apps/dash-salesforce-crm/utils/figures.py diff --git a/apps/dash-salesforce-crm/assets/css/boostrap_extra.css b/apps/dash-salesforce-crm/assets/css/boostrap_extra.css new file mode 100644 index 000000000..c22edb190 --- /dev/null +++ b/apps/dash-salesforce-crm/assets/css/boostrap_extra.css @@ -0,0 +1,27 @@ +.Select-control { + border: none !important; + box-shadow: inset 1px 1px rgb(255 255 255 / 20%), inset -1px -1px rgb(255 255 255 / 10%), 1px 3px 24px -1px rgb(0 0 0 / 15%) !important; + background-color: transparent !important; + background-image: linear-gradient(125deg,rgba(255,255,255,.3),rgba(255,255,255,.2) 70%) !important; + -webkit-backdrop-filter: blur(5px) !important; + backdrop-filter: blur(5px) !important; + --bs-secondary-rgb: transparent !important; +} +.Select.has-value.Select--single > .Select-control .Select-value .Select-value-label { color: #FFFF !important; } + +.Select-menu-outer { + border: none !important; + box-shadow: inset 1px 1px rgb(255 255 255 / 20%), inset -1px -1px rgb(255 255 255 / 10%), 1px 3px 24px -1px rgb(0 0 0 / 15%) !important; + background-color: transparent !important; + background-image: linear-gradient(125deg,rgba(255,255,255,.5),rgba(255,255,255,.5) 70%) !important; + -webkit-backdrop-filter: blur(5px) !important; + backdrop-filter: blur(5px) !important; + --bs-secondary-rgb: transparent !important; +} +.Select-control .Select-input:focus { + background: transparent !important; +} +.modal-body .Select-menu-outer { + background-color: #CA417B !important; +} +.CalendarDay__selected, .CalendarDay__default:hover { color:#CA417B !important; } \ No newline at end of file diff --git a/apps/dash-salesforce-crm/pages/__init__.py b/apps/dash-salesforce-crm/pages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dash-salesforce-crm/pages/cases.py b/apps/dash-salesforce-crm/pages/cases.py new file mode 100644 index 000000000..bdb8fe399 --- /dev/null +++ b/apps/dash-salesforce-crm/pages/cases.py @@ -0,0 +1,89 @@ +from dash import html, dcc, Input, Output, State, callback +import dash_bootstrap_components as dbc +import pandas as pd + +from constants import salesforce_manager +from utils.components import cases_modal, cases_controls, cases_data_cards, cases_graphs +import utils.figures as figs + +layout = html.Div([ + dbc.Row(cases_controls), + dbc.Row(cases_data_cards), + dbc.Row(cases_graphs), + cases_modal(salesforce_manager), +]) + +@callback( + Output("left_cases_indicator", "children"), + Output("middle_cases_indicator", "children"), + Output("right_cases_indicator", "children"), + Output("cases_by_account", "figure"), + Output("cases_reasons", "figure"), + Output("cases_types", "figure"), + Output("cases_by_period", "figure"), + Input("cases_df", "data"), + Input("cases_period_dropdown", "value"), + Input("origin_dropdown", "value"), + Input("priority_dropdown", "value"), +) +def update_graphs(df, period, origin, priority): + df = pd.read_json(df, orient="split") + + ## Data cards + left_cases_indicator = len(df[(df["Priority"] == "Low") & (df["Status"] == "New")]["Priority"].index) + middle_cases_indicator = len(df[(df["Priority"] == "Medium") & (df["Status"] == "New")]["Priority"].index) + right_cases_indicator = len(df[(df["Priority"] == "High") & (df["Status"] == "New")]["Priority"].index) + + ## Figures + fig_by_account = figs.cases_by_account(df, salesforce_manager) + fig_pie_chart = figs.cases_pie_chart(df, "Reason", priority, origin) + fig_pie_chart_h = figs.cases_pie_chart(df, "Type", priority, origin, h_orientation=True) + fig_by_period = figs.cases_by_period(df, period, priority) + + return left_cases_indicator, middle_cases_indicator, right_cases_indicator, \ + fig_by_account, fig_pie_chart, fig_pie_chart_h, fig_by_period + +@callback( + Output("cases_modal", "is_open"), + Input("new_case", "n_clicks"), + State("cases_modal", "is_open"), +) +def toggle_cases_modal(n1, is_open): + if n1: + return not is_open + return is_open + +@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"), + prevent_initial_call=True, +) +def add_new_case(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, + } + + salesforce_manager.add_case(query) + df = salesforce_manager.get_cases() + return df.to_json(orient="split") + + return current_df diff --git a/apps/dash-salesforce-crm/pages/leads.py b/apps/dash-salesforce-crm/pages/leads.py new file mode 100644 index 000000000..063aa77f0 --- /dev/null +++ b/apps/dash-salesforce-crm/pages/leads.py @@ -0,0 +1,98 @@ +from dash import html, Input, Output, State, callback +import dash_bootstrap_components as dbc +import pandas as pd + +from constants import salesforce_manager +from utils.components import leads_modal, leads_controls, leads_data_cards, leads_graphs +import utils.figures as figs + +layout = html.Div([ + dbc.Row(leads_controls), + dbc.Row(leads_data_cards), + dbc.Row(leads_graphs), + leads_modal(), +]) + +@callback( + Output("left_leads_indicator", "children"), + Output("middle_leads_indicator", "children"), + Output("right_leads_indicator", "children"), + Output("lead_source", "figure"), + Output("leads_map", "figure"), + Output("converted_leads", "figure"), + Output("leads_table", "data"), + Output("leads_table", "columns"), + Input("leads_df", "data"), + Input("lead_source_dropdown", "value"), + Input("converted_leads_dropdown", "value"), +) +def update_graphs(df, status, period): + df = pd.read_json(df, orient="split") + + ## Data Cards + left_leads_indicator = len(df[df["Status"] == "Closed - Converted"].index) + middle_leads_indicator = len( df[ (df["Status"] == "Open - Not Contacted") | (df["Status"] == "Working - Contacted") ].index) + + lost_leads = len(df[df["Status"] == "Closed - Not Converted"].index) + right_leads_indicator = left_leads_indicator / (left_leads_indicator + lost_leads) * 100 + + ## Figures + fig_lead_source = figs.lead_source(status, df) + fig_leads_choropleth_map = figs.leads_choropleth_map(status, df) + fig_converted_leads_count = figs.converted_leads_count(period, df) + + ## Table + 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"]] + table_data = df.to_dict('records') + table_cols = [{"name": i, "id": i} for i in df.columns] + + return left_leads_indicator, middle_leads_indicator, f"{right_leads_indicator:.2f}%", \ + fig_lead_source, fig_leads_choropleth_map, fig_converted_leads_count, \ + table_data, table_cols + +@callback( + Output("leads_modal", "is_open"), + Input("new_lead", "n_clicks"), + State("leads_modal", "is_open"), +) +def toggle_cases_modal(n1, is_open): + if n1: + return not is_open + return is_open + + +@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"), + prevent_initial_call=True, +) +def add_new_lead(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, + } + salesforce_manager.add_lead(query) + df = salesforce_manager.get_leads() + return df.to_json(orient="split") + + return current_df diff --git a/apps/dash-salesforce-crm/pages/opportunities.py b/apps/dash-salesforce-crm/pages/opportunities.py new file mode 100644 index 000000000..fa8f8a35c --- /dev/null +++ b/apps/dash-salesforce-crm/pages/opportunities.py @@ -0,0 +1,96 @@ +from dash import html, dcc, Input, Output, State, callback +import dash_bootstrap_components as dbc +import pandas as pd + +from constants import salesforce_manager +from utils.helper_functions import millify, top_open_opportunities, top_lost_opportunities +from utils.components import opportunities_modal, opportunities_controls, opportunities_data_cards, opportunities_graphs +import utils.figures as figs + +layout = html.Div([ + dbc.Row(opportunities_controls), + dbc.Row(opportunities_data_cards), + dbc.Row(opportunities_graphs), + opportunities_modal(), +]) + + +@callback( + Output("left_opportunities_indicator", "children"), + Output("middle_opportunities_indicator", "children"), + Output("right_opportunities_indicator", "children"), + Output("opportunities_heatmap", "figure"), + Output("converted_count", "figure"), + Output("top_open_opportunities", "data"), + Output("top_open_opportunities", "columns"), + Output("top_lost_opportunities", "data"), + Output("top_lost_opportunities", "columns"), + Input("opportunities_df", "data"), + Input("heatmap_dropdown", "value"), + Input("converted_opportunities_dropdown", "value"), + Input("source_dropdown", "value"), +) +def update_graphs(df, stage, period, source): + df = pd.read_json(df, orient="split") + + ## Data Cards + left_opportunities_indicator = millify(str(df[df["IsWon"] == 1]["Amount"].sum())) + middle_opportunities_indicator = millify(str(df[(df["IsClosed"] == 0)]["Amount"].sum())) + right_opportunities_indicator = millify(str(df[(df["IsWon"] == 0) & (df["IsClosed"] == 1)]["Amount"].sum())) + + ## Figures + fig_converted_opportunities = figs.converted_opportunities(period, source, df) + fig_heatmap = figs.opportunities_heat_map_fig(df, stage) + + ## Table + table_data_top_ten, table_cols_top_ten = top_open_opportunities(df) + table_data_top_lost, table_cols_top_lost = top_lost_opportunities(df) + + return left_opportunities_indicator, middle_opportunities_indicator, right_opportunities_indicator, \ + fig_heatmap, fig_converted_opportunities, \ + table_data_top_ten, table_cols_top_ten, table_data_top_lost, table_cols_top_lost + +@callback( + Output("opportunities_modal", "is_open"), + Input("new_opportunity", "n_clicks"), + State("opportunities_modal", "is_open"), +) +def toggle_opportunities_modal(n1, is_open): + if n1: + return not is_open + return is_open + + +@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"), + prevent_initial_call=True, +) +def add_new_opportunity( + 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, + } + salesforce_manager.add_opportunity(query) + df = salesforce_manager.get_opportunities() + return df.to_json(orient="split") + + return current_df \ No newline at end of file diff --git a/apps/dash-salesforce-crm/utils/figures.py b/apps/dash-salesforce-crm/utils/figures.py new file mode 100644 index 000000000..1cac3c277 --- /dev/null +++ b/apps/dash-salesforce-crm/utils/figures.py @@ -0,0 +1,349 @@ + +from plotly import graph_objs as go +import pandas as pd + + + + + +### Opportunity page graphs ### ### ### ### + + + + +def leads_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) + + +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} + + + + + +### Opportunity page graphs ### ### ### ### + + + + +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} + + +def opportunities_heat_map_fig(df, stage): + df = df[pd.notnull(df["Type"])] + x, y, z = [], 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"] + + 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", + xaxis=dict(zeroline=False, showgrid=False), + yaxis=dict(zeroline=False, showgrid=False) + ) + + return go.Figure(data=[trace], layout=layout) + + + + + +### Cases page graphs ### ### ### ### + + + + +def cases_pie_chart(df, column, priority, origin, h_orientation=None): + 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"]}, + ) + + if h_orientation: + layout["legend"]["orientation"] = "h" + + return {"data": [trace], "layout": layout} + + +def cases_by_period(df, period, priority): + 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, salesforce_manager): + accounts = salesforce_manager.get_accounts() + 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} From c92bf6fad79069119f07607ca06041dda41f2af6 Mon Sep 17 00:00:00 2001 From: admin <51248046+danton267@users.noreply.github.com> Date: Wed, 25 May 2022 18:55:17 +0100 Subject: [PATCH 3/3] style finish --- apps/dash-salesforce-crm/app.py | 4 +- apps/dash-salesforce-crm/assets/css/app.css | 29 +- ...rap_extra.css => extra_dropdown_style.css} | 0 apps/dash-salesforce-crm/panels/__init__.py | 0 apps/dash-salesforce-crm/panels/cases.py | 90 ----- apps/dash-salesforce-crm/panels/leads.py | 99 ----- .../panels/opportunities.py | 96 ----- apps/dash-salesforce-crm/utils/components.py | 63 ++-- apps/dash-salesforce-crm/utils/figures.py | 3 +- apps/dash-salesforce-crm/utils/graphs.py | 347 ------------------ 10 files changed, 70 insertions(+), 661 deletions(-) rename apps/dash-salesforce-crm/assets/css/{boostrap_extra.css => extra_dropdown_style.css} (100%) delete mode 100644 apps/dash-salesforce-crm/panels/__init__.py delete mode 100644 apps/dash-salesforce-crm/panels/cases.py delete mode 100644 apps/dash-salesforce-crm/panels/leads.py delete mode 100644 apps/dash-salesforce-crm/panels/opportunities.py delete mode 100644 apps/dash-salesforce-crm/utils/graphs.py diff --git a/apps/dash-salesforce-crm/app.py b/apps/dash-salesforce-crm/app.py index 007f2b81e..6d9806732 100644 --- a/apps/dash-salesforce-crm/app.py +++ b/apps/dash-salesforce-crm/app.py @@ -1,13 +1,13 @@ from dash import Dash, html, dcc, Input, Output import dash_bootstrap_components as dbc -from panels import opportunities, cases, leads +from pages import opportunities, cases, leads from constants import salesforce_manager from utils.components import Header app = Dash( __name__, - external_stylesheets=[dbc.themes.CYBORG], + external_stylesheets=[dbc.themes.QUARTZ], title="CRM Salesforce" ) server = app.server diff --git a/apps/dash-salesforce-crm/assets/css/app.css b/apps/dash-salesforce-crm/assets/css/app.css index 1c383617b..d2e87d9eb 100644 --- a/apps/dash-salesforce-crm/assets/css/app.css +++ b/apps/dash-salesforce-crm/assets/css/app.css @@ -10,21 +10,40 @@ gap: 5%; } +.row { + margin-top: 20px; +} + +.card { + margin-bottom: 20px; +} .tab-content { padding: 10px 10rem 0 10rem; } +/* graph background color */ .main-svg { background-color: transparent !important; } - -.row { - margin-top: 20px; +.layer.bg * { + fill: none !important; +} +/* Graph legend */ +.infolayer .legend .bg { + fill: none !important; } -.card { - margin-bottom: 20px; +/* table */ +td { + background-color: transparent !important; +} +th span { + font-weight: bold; + text-align: center; +} +th { + background-color: rgba(202, 65, 123, 0.7) !important; } diff --git a/apps/dash-salesforce-crm/assets/css/boostrap_extra.css b/apps/dash-salesforce-crm/assets/css/extra_dropdown_style.css similarity index 100% rename from apps/dash-salesforce-crm/assets/css/boostrap_extra.css rename to apps/dash-salesforce-crm/assets/css/extra_dropdown_style.css diff --git a/apps/dash-salesforce-crm/panels/__init__.py b/apps/dash-salesforce-crm/panels/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/dash-salesforce-crm/panels/cases.py b/apps/dash-salesforce-crm/panels/cases.py deleted file mode 100644 index 5f230cc51..000000000 --- a/apps/dash-salesforce-crm/panels/cases.py +++ /dev/null @@ -1,90 +0,0 @@ -from dash import html, dcc, Input, Output, State, callback -import dash_bootstrap_components as dbc -import pandas as pd - -from constants import salesforce_manager -from utils.components import cases_modal, cases_controls, cases_data_cards, cases_graphs -from utils.graphs import cases_by_account, cases_by_period, cases_pie_chart - - -layout = html.Div([ - dbc.Row(cases_controls), - dbc.Row(cases_data_cards), - dbc.Row(cases_graphs), - cases_modal(salesforce_manager), -]) - -@callback( - Output("left_cases_indicator", "children"), - Output("middle_cases_indicator", "children"), - Output("right_cases_indicator", "children"), - Output("cases_by_account", "figure"), - Output("cases_reasons", "figure"), - Output("cases_types", "figure"), - Output("cases_by_period", "figure"), - Input("cases_df", "data"), - Input("cases_period_dropdown", "value"), - Input("origin_dropdown", "value"), - Input("priority_dropdown", "value"), -) -def update_graphs(df, period, origin, priority): - df = pd.read_json(df, orient="split") - - ## Data cards - left_cases_indicator = len(df[(df["Priority"] == "Low") & (df["Status"] == "New")]["Priority"].index) - middle_cases_indicator = len(df[(df["Priority"] == "Medium") & (df["Status"] == "New")]["Priority"].index) - right_cases_indicator = len(df[(df["Priority"] == "High") & (df["Status"] == "New")]["Priority"].index) - - ## Figures - fig_by_account = cases_by_account(df, salesforce_manager) - fig_pie_chart = cases_pie_chart(df, "Reason", priority, origin) - fig_pie_chart_h = cases_pie_chart(df, "Type", priority, origin, h_orientation=True) - fig_by_period = cases_by_period(df, period, priority) - - return left_cases_indicator, middle_cases_indicator, right_cases_indicator, \ - fig_by_account, fig_pie_chart, fig_pie_chart_h, fig_by_period - -@callback( - Output("cases_modal", "is_open"), - Input("new_case", "n_clicks"), - State("cases_modal", "is_open"), -) -def toggle_cases_modal(n1, is_open): - if n1: - return not is_open - return is_open - -@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"), - prevent_initial_call=True, -) -def add_new_case(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, - } - - salesforce_manager.add_case(query) - df = salesforce_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 deleted file mode 100644 index c35cf25a7..000000000 --- a/apps/dash-salesforce-crm/panels/leads.py +++ /dev/null @@ -1,99 +0,0 @@ -from dash import html, Input, Output, State, callback -import dash_bootstrap_components as dbc -import pandas as pd - -from constants import salesforce_manager -from utils.graphs import leads_choropleth_map, lead_source, converted_leads_count -from utils.components import leads_modal, leads_controls, leads_data_cards, leads_graphs - - -layout = html.Div([ - dbc.Row(leads_controls), - dbc.Row(leads_data_cards), - dbc.Row(leads_graphs), - leads_modal(), -]) - -@callback( - Output("left_leads_indicator", "children"), - Output("middle_leads_indicator", "children"), - Output("right_leads_indicator", "children"), - Output("lead_source", "figure"), - Output("leads_map", "figure"), - Output("converted_leads", "figure"), - Output("leads_table", "data"), - Output("leads_table", "columns"), - Input("leads_df", "data"), - Input("lead_source_dropdown", "value"), - Input("converted_leads_dropdown", "value"), -) -def update_graphs(df, status, period): - df = pd.read_json(df, orient="split") - - ## Data Cards - left_leads_indicator = len(df[df["Status"] == "Closed - Converted"].index) - middle_leads_indicator = len( df[ (df["Status"] == "Open - Not Contacted") | (df["Status"] == "Working - Contacted") ].index) - - lost_leads = len(df[df["Status"] == "Closed - Not Converted"].index) - right_leads_indicator = left_leads_indicator / (left_leads_indicator + lost_leads) * 100 - - ## Figures - fig_lead_source = lead_source(status, df) - fig_leads_choropleth_map = leads_choropleth_map(status, df) - fig_converted_leads_count = converted_leads_count(period, df) - - ## Table - 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"]] - table_data = df.to_dict('records') - table_cols = [{"name": i, "id": i} for i in df.columns] - - return left_leads_indicator, middle_leads_indicator, f"{right_leads_indicator:.2f}%", \ - fig_lead_source, fig_leads_choropleth_map, fig_converted_leads_count, \ - table_data, table_cols - -@callback( - Output("leads_modal", "is_open"), - Input("new_lead", "n_clicks"), - State("leads_modal", "is_open"), -) -def toggle_cases_modal(n1, is_open): - if n1: - return not is_open - return is_open - - -@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"), - prevent_initial_call=True, -) -def add_new_lead(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, - } - salesforce_manager.add_lead(query) - df = salesforce_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 deleted file mode 100644 index 4d3677600..000000000 --- a/apps/dash-salesforce-crm/panels/opportunities.py +++ /dev/null @@ -1,96 +0,0 @@ -from dash import html, dcc, Input, Output, State, callback -import dash_bootstrap_components as dbc -import pandas as pd - -from constants import salesforce_manager -from utils.helper_functions import millify, top_open_opportunities, top_lost_opportunities -from utils.components import opportunities_modal, opportunities_controls, opportunities_data_cards, opportunities_graphs -from utils.graphs import converted_opportunities, opportunities_heat_map_fig - -layout = html.Div([ - dbc.Row(opportunities_controls), - dbc.Row(opportunities_data_cards), - dbc.Row(opportunities_graphs), - opportunities_modal(), -]) - - -@callback( - Output("left_opportunities_indicator", "children"), - Output("middle_opportunities_indicator", "children"), - Output("right_opportunities_indicator", "children"), - Output("opportunities_heatmap", "figure"), - Output("converted_count", "figure"), - Output("top_open_opportunities", "data"), - Output("top_open_opportunities", "columns"), - Output("top_lost_opportunities", "data"), - Output("top_lost_opportunities", "columns"), - Input("opportunities_df", "data"), - Input("heatmap_dropdown", "value"), - Input("converted_opportunities_dropdown", "value"), - Input("source_dropdown", "value"), -) -def update_graphs(df, stage, period, source): - df = pd.read_json(df, orient="split") - - ## Data Cards - left_opportunities_indicator = millify(str(df[df["IsWon"] == 1]["Amount"].sum())) - middle_opportunities_indicator = millify(str(df[(df["IsClosed"] == 0)]["Amount"].sum())) - right_opportunities_indicator = millify(str(df[(df["IsWon"] == 0) & (df["IsClosed"] == 1)]["Amount"].sum())) - - ## Figures - fig_converted_opportunities = converted_opportunities(period, source, df) - fig_heatmap = opportunities_heat_map_fig(df, stage) - - ## Table - table_data_top_ten, table_cols_top_ten = top_open_opportunities(df) - table_data_top_lost, table_cols_top_lost = top_lost_opportunities(df) - - return left_opportunities_indicator, middle_opportunities_indicator, right_opportunities_indicator, \ - fig_heatmap, fig_converted_opportunities, \ - table_data_top_ten, table_cols_top_ten, table_data_top_lost, table_cols_top_lost - -@callback( - Output("opportunities_modal", "is_open"), - Input("new_opportunity", "n_clicks"), - State("opportunities_modal", "is_open"), -) -def toggle_opportunities_modal(n1, is_open): - if n1: - return not is_open - return is_open - - -@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"), - prevent_initial_call=True, -) -def add_new_opportunity( - 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, - } - salesforce_manager.add_opportunity(query) - df = salesforce_manager.get_opportunities() - return df.to_json(orient="split") - - return current_df \ No newline at end of file diff --git a/apps/dash-salesforce-crm/utils/components.py b/apps/dash-salesforce-crm/utils/components.py index fdcfdab19..d966e5bbd 100644 --- a/apps/dash-salesforce-crm/utils/components.py +++ b/apps/dash-salesforce-crm/utils/components.py @@ -1,7 +1,7 @@ from dash import html, dcc, dash_table +import dash_bootstrap_components as dbc from constants import states from datetime import date -import dash_bootstrap_components as dbc def Header(app): @@ -18,16 +18,16 @@ def Header(app): def dbc_indicator(text, id_value, width): return dbc.Col( dbc.Card([ - html.P(id=id_value, className="indicator_value"), - html.P(text, className="twelve columns indicator_text"), - ]), width = width + html.H1(id=id_value, className="card-title"), + html.P(text), + ], className="align-items-center"), width = width ) def dbc_card(header, child_id, width, table=None): if table is None: child = dcc.Graph(id=child_id, config=dict(displayModeBar=False)) else: - child = dash_table.DataTable(id=child_id) + child = dash_table.DataTable(id=child_id, style_table={'overflowX': 'auto'},) return dbc.Col( dbc.Card( dbc.CardBody([ @@ -49,8 +49,9 @@ def dbc_card(header, child_id, width, table=None): {"label": "By week", "value": "W-MON"}, {"label": "By month", "value": "M"}, ], - value="D", + value="M", clearable=False, + searchable=False, ) ], width=2), dbc.Col([ @@ -64,6 +65,7 @@ def dbc_card(header, child_id, width, table=None): ], value="all", clearable=False, + searchable=False, ) ], width=2), dbc.Col(width=6), @@ -105,6 +107,7 @@ def leads_modal(): id="new_lead_state", options=states, value="NY", + searchable=False, ), ], width=6), dbc.Col([ @@ -113,6 +116,7 @@ def leads_modal(): id="new_lead_status", options=["Open - Not Contacted", "Working - Contacted", "Closed - Converted", "Closed - Not Converted"], value="Open - Not Contacted", + searchable=False, ), ], width=6), dbc.Col([ @@ -121,6 +125,7 @@ def leads_modal(): id="new_lead_source", options=["Web", "Phone Inquiry", "Partner Referral", "Purchased List", "Other"], value="Web", + searchable=False, ), ], width=6), ])), @@ -143,8 +148,9 @@ def leads_modal(): {"label": "By week", "value": "W-MON"}, {"label": "By month", "value": "M"}, ], - value="D", + value="M", clearable=False, + searchable=False, ), ], width=2), dbc.Col([ @@ -158,6 +164,7 @@ def leads_modal(): ], value="all_p", clearable=False, + searchable=False, ), ], width=2), dbc.Col([ @@ -171,6 +178,7 @@ def leads_modal(): ], value="all", clearable=False, + searchable=False, ), ], width=2), dbc.Col(width=4), @@ -217,6 +225,7 @@ def cases_modal(salesforce_manager): for index, row in accounts.iterrows() ], clearable=False, + searchable=False, value=accounts.iloc[0].Id, ) ], width=6), @@ -232,6 +241,7 @@ def cases_modal(salesforce_manager): for index, row in contacts.iterrows() ], clearable=False, + searchable=False, value=contacts.iloc[0].Id, ) ], width=6), @@ -246,6 +256,7 @@ def cases_modal(salesforce_manager): ], value="Medium", clearable=False, + searchable=False, ), ], width=6), dbc.Col([ @@ -272,6 +283,7 @@ def cases_modal(salesforce_manager): {"label": "Other", "value": "Other"}, ], value="Electrical", + searchable=False, ), ], width=6), dbc.Col([ @@ -285,6 +297,7 @@ def cases_modal(salesforce_manager): ], value="Phone", clearable=False, + searchable=False, ), ], width=6), dbc.Col([ @@ -304,6 +317,7 @@ def cases_modal(salesforce_manager): {"label": "Closed", "value": "Closed"}, ], value="New", + searchable=False, ), ], width=6), dbc.Col([ @@ -339,6 +353,7 @@ def cases_modal(salesforce_manager): ], value="Installation", clearable=False, + searchable=False, ), ], width=6), dbc.Col([ @@ -389,8 +404,9 @@ def cases_modal(salesforce_manager): {"label": "By week", "value": "W-MON"}, {"label": "By month", "value": "M"}, ], - value="D", + value="M", clearable=False, + searchable=False, ), ], width=2), dbc.Col([ @@ -404,23 +420,25 @@ def cases_modal(salesforce_manager): ], value="all_s", clearable=False, + searchable=False, ), ], width=2), dbc.Col([ 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, - ), + 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, + searchable=False, + ), ], width=2), dbc.Col(width=4), dbc.Col([ @@ -503,6 +521,7 @@ def opportunities_modal(): ], clearable=False, value="Prospecting", + searchable=False, ) ], width=6), dbc.Col([ @@ -511,6 +530,7 @@ def opportunities_modal(): id="new_opportunity_source", options=["Web", "Phone Inquiry", "Partner Referral", "Purchased List", "Other"], value="Web", + searchable=False, ), ], width=6), dbc.Col([ @@ -532,6 +552,7 @@ def opportunities_modal(): id="new_opportunity_type", options=["Existing Customer - Replacement", "New Customer", "Existing Customer - Upgrade", "Existing Customer - Downgrade"], value="New Customer", + searchable=False, ), ], width=6), dbc.Col([ diff --git a/apps/dash-salesforce-crm/utils/figures.py b/apps/dash-salesforce-crm/utils/figures.py index 1cac3c277..d01ddc945 100644 --- a/apps/dash-salesforce-crm/utils/figures.py +++ b/apps/dash-salesforce-crm/utils/figures.py @@ -125,6 +125,8 @@ def converted_leads_count(period, df): def converted_opportunities(period, source, df): + print(df) + df["CreatedDate"] = pd.to_datetime(df["CreatedDate"], format="%Y-%m-%d") # source filtering @@ -132,7 +134,6 @@ def converted_opportunities(period, source, df): 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( diff --git a/apps/dash-salesforce-crm/utils/graphs.py b/apps/dash-salesforce-crm/utils/graphs.py deleted file mode 100644 index 62677ea8a..000000000 --- a/apps/dash-salesforce-crm/utils/graphs.py +++ /dev/null @@ -1,347 +0,0 @@ - -from plotly import graph_objs as go -import pandas as pd - - - - - -### Opportunity page graphs ### ### ### ### - - - - -def leads_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) - - -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} - - - - - -### Opportunity page graphs ### ### ### ### - - - - -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} - - -def opportunities_heat_map_fig(df, stage): - df = df[pd.notnull(df["Type"])] - x, y, z = [], 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"] - - 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) - - - - - -### Cases page graphs ### ### ### ### - - - - -def cases_pie_chart(df, column, priority, origin, h_orientation=None): - 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"]}, - ) - - if h_orientation: - layout["legend"]["orientation"] = "h" - - return {"data": [trace], "layout": layout} - - -def cases_by_period(df, period, priority): - 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, salesforce_manager): - accounts = salesforce_manager.get_accounts() - 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}