Client Scripts & Pages
Client Scripts bring your app to life in the browser. They handle user
interactions, dynamically modify the UI, and talk to the server. Frappe gives you
two mechanisms: the Client Script DocType (created via Desk, stored in the
database) and Form Scripts (.js files in your app, version-controlled). If
you’re coming from Express, this is the equivalent of the client-side JavaScript
you’d ship alongside a server-rendered admin — except Frappe wires it to the form
lifecycle for you.
Client Script DocType vs. Form Scripts
Section titled “Client Script DocType vs. Form Scripts”| Feature | Client Script (Desk) | Form Script (.js file) |
|---|---|---|
| Created via | Desk UI | Code editor |
| Stored in | Database | File system |
| Version controlled | Fixtures only | Natively with Git |
| Best for | Quick site-specific tweaks | Distributable apps |
| Applies to | Specific DocType | Specific DocType |
For the ScoopJoy franchise app we’ll use Form Scripts (the .js files) since we’re
building a distributable app. The patterns are identical — the only difference is
where the code lives.
Form Script event reference
Section titled “Form Script event reference”Every DocType can have a client-side controller at
{app}/{module}/doctype/{doctype}/{doctype}.js. You register handlers against
form events with frappe.ui.form.on(...) — using a field name as a key fires the
handler whenever that field changes.
frappe.ui.form.on("Franchise Outlet", { // Runs once when the form object is created (before data loads) setup(frm) { // Set up queries, filters, formatters },
// Runs when form data is loaded from server onload(frm) { // Modify loaded data, set initial state },
// Runs every time the form is displayed (after load, save, amend, etc.) refresh(frm) { // Add custom buttons, update indicators, toggle visibility },
// Runs before saving -- return false to cancel save validate(frm) { // Client-side validation },
// Runs before the save request is sent to server before_save(frm) { // Last-chance modifications },
// Runs after save completes successfully after_save(frm) { // Post-save actions like showing notifications },
// Runs when a specific field changes (fieldname as the key) status(frm) { // Triggered when the 'status' field changes },
city(frm) { // Triggered when the 'city' field changes },});Practical example 1: Sales Invoice client script
Section titled “Practical example 1: Sales Invoice client script”When a franchise outlet is selected on a Sales Invoice, auto-filter the warehouse
and cost center to match that outlet, and fetch the outlet’s details into the
related fields when franchise_outlet changes.
frappe.ui.form.on("Sales Invoice", { setup(frm) { // Filter warehouse to show only the outlet's warehouse frm.set_query("set_warehouse", function () { if (frm.doc.franchise_outlet) { return { filters: { name: frm.doc.custom_outlet_warehouse || undefined, }, }; } });
// Filter cost center to match outlet frm.set_query("cost_center", function () { if (frm.doc.franchise_outlet) { return { filters: { name: frm.doc.custom_outlet_cost_center || undefined, }, }; } }); },
refresh(frm) { // Add franchise indicator if (frm.doc.franchise_outlet) { frm.dashboard.add_indicator( __("Franchise: {0}", [frm.doc.franchise_outlet]), "blue" ); } },
franchise_outlet(frm) { // Triggered when franchise_outlet field changes if (!frm.doc.franchise_outlet) { frm.set_value("franchise_code", ""); return; }
// Fetch outlet details and set related fields frappe.call({ method: "frappe.client.get", args: { doctype: "Franchise Outlet", name: frm.doc.franchise_outlet, }, callback(r) { if (r.message) { const outlet = r.message; frm.set_value("franchise_code", outlet.franchise_code); frm.set_value("cost_center", outlet.cost_center); frm.set_value("set_warehouse", outlet.warehouse);
frappe.show_alert({ message: __("Outlet details loaded: {0}", [outlet.outlet_name]), indicator: "green", }); } }, }); },});Register this script in hooks.py so it loads on the Sales Invoice form:
doctype_js = { "Sales Invoice": "public/js/sales_invoice.js",}After adding the hook, rebuild assets:
bench build --app scoopjoyfrappe.call() and frappe.xcall()
Section titled “frappe.call() and frappe.xcall()”frappe.call() is the primary way to call server methods from the client. It takes
a callback and supports a freeze overlay while the request is in flight:
// Callback stylefrappe.call({ method: "scoopjoy.api.get_franchise_metrics", args: { outlet: frm.doc.franchise_outlet, period: "Monthly", }, freeze: true, freeze_message: __("Fetching metrics..."), callback(r) { if (r.message) { console.log(r.message); } }, error(r) { frappe.msgprint(__("Failed to fetch metrics")); },});frappe.xcall() is the promise-based alternative — cleaner with async/await, the
way you’d reach for fetch over a callback in modern Node:
async function load_metrics(outlet) { try { const metrics = await frappe.xcall( "scoopjoy.api.get_franchise_metrics", { outlet: outlet, period: "Monthly" } ); console.log(metrics); return metrics; } catch (e) { frappe.msgprint(__("Failed to fetch metrics")); }}UI manipulation methods
Section titled “UI manipulation methods”Custom buttons
Section titled “Custom buttons”frm.add_custom_button(label, fn, group) adds a button to the form toolbar; pass a
group name to nest several buttons under one dropdown.
refresh(frm) { // Simple custom button frm.add_custom_button(__("View Sales Report"), () => { frappe.set_route("query-report", "Franchise Sales", { franchise_outlet: frm.doc.name, }); });
// Grouped buttons (appear in a dropdown) frm.add_custom_button(__("Generate Report"), () => { generate_compliance_report(frm); }, __("Actions"));
frm.add_custom_button(__("Send Notification"), () => { send_owner_notification(frm); }, __("Actions"));
frm.add_custom_button(__("Sync Stock"), () => { sync_outlet_stock(frm); }, __("Actions"));
// Primary action button (highlighted blue) frm.add_custom_button(__("Create Agreement"), () => { frappe.new_doc("Franchise Agreement", { franchise_outlet: frm.doc.name, }); }, __("Create"));
// Make the "Create" group primary frm.page.set_inner_btn_group_as_primary(__("Create"));}Dynamic field manipulation
Section titled “Dynamic field manipulation”Toggle visibility, requiredness, and field properties from the refresh handler
based on the document’s state:
refresh(frm) { // Show/hide fields based on conditions frm.toggle_display("bank_details_section", frm.doc.status === "Active"); frm.toggle_display( ["opening_time", "closing_time"], !frm.doc.is_24_hours );
// Make fields required/optional dynamically frm.toggle_reqd("expiry_date", frm.doc.status === "Active");
// Set field properties dynamically frm.set_df_property("royalty_percentage", "read_only", frm.doc.docstatus === 1); frm.set_df_property("status", "options", [ "Active", "Inactive", "Under Renovation", "Closed" ]);
// Form indicators if (frm.doc.status === "Closed") { frm.page.set_indicator(__("Closed"), "red"); } else if (frm.doc.status === "Active") { frm.page.set_indicator(__("Active"), "green"); }}Filtering link fields (frm.set_query)
Section titled “Filtering link fields (frm.set_query)”frm.set_query restricts what a Link field can resolve to. Use a static filters
object for simple cases, or point query at a whitelisted server method for
anything dynamic:
setup(frm) { // Static filter frm.set_query("warehouse", () => { return { filters: { company: frm.doc.company, is_group: 0, }, }; });
// Dynamic filter with server-side query frm.set_query("franchise_outlet", () => { return { query: "scoopjoy.api.get_active_outlets", filters: { territory: frm.doc.territory, }, }; });}The server-side query function — note the @frappe.validate_and_sanitize_search_inputs
decorator, which guards against injection through the search parameters:
import frappe
@frappe.whitelist()@frappe.validate_and_sanitize_search_inputsdef get_active_outlets(doctype, txt, searchfield, start, page_len, filters): territory = filters.get("territory")
conditions = "AND fo.city = %(territory)s" if territory else ""
return frappe.db.sql( f""" SELECT fo.name, fo.outlet_name, fo.city FROM `tabFranchise Outlet` fo WHERE fo.status = 'Active' AND (fo.name LIKE %(txt)s OR fo.outlet_name LIKE %(txt)s) {conditions} ORDER BY fo.outlet_name LIMIT %(page_len)s OFFSET %(start)s """, { "txt": f"%{txt}%", "territory": territory, "start": start, "page_len": page_len, }, )Child table manipulation
Section titled “Child table manipulation”Child tables are plain arrays on frm.doc. Add rows with frm.add_child, mutate
the array directly to remove rows, then call frm.refresh_field so the grid
re-renders — and frm.dirty() to mark the form modified.
// Add a row to a child tablefunction add_default_terms(frm) { const default_terms = [ { term_title: "Quality Standards", term_description: "Maintain quality as per brand guidelines", is_mandatory: 1 }, { term_title: "Operating Hours", term_description: "Maintain specified operating hours", is_mandatory: 1 }, { term_title: "Branding", term_description: "Use approved branding materials only", is_mandatory: 1 }, ];
default_terms.forEach((term) => { let row = frm.add_child("agreement_terms", term); });
frm.refresh_field("agreement_terms");}
// Remove rows matching a conditionfunction remove_inactive_items(frm) { // Filter and keep only active items frm.doc.menu_items = frm.doc.menu_items.filter( (row) => row.item !== "DISCONTINUED-ITEM" ); frm.refresh_field("menu_items"); frm.dirty(); // Mark form as modified}frappe.ui.Dialog: custom dialogs
Section titled “frappe.ui.Dialog: custom dialogs”Dialogs are modal popups with form fields. They’re powerful for gathering user
input without navigating away from the current form. A dialog’s fields array uses
the same fieldtype objects as a DocType, so a field like
{ fieldtype: "Select", fieldname: "report_period", ... } renders exactly as it
would on a form.
Practical example 2: performance dashboard dialog
Section titled “Practical example 2: performance dashboard dialog”This opens a dialog from the Franchise Outlet form, renders metric cards from server data, and re-fetches when the period select changes.
frappe.ui.form.on("Franchise Outlet", { refresh(frm) { if (!frm.is_new()) { frm.add_custom_button(__("View Performance"), () => { show_performance_dashboard(frm); }); } },});
async function show_performance_dashboard(frm) { const metrics = await frappe.xcall( "scoopjoy.api.get_franchise_metrics", { outlet: frm.doc.name, period: "Monthly" } );
const d = new frappe.ui.Dialog({ title: __("Performance Dashboard: {0}", [frm.doc.outlet_name]), size: "extra-large", fields: [ { fieldtype: "HTML", fieldname: "metrics_html", }, { fieldtype: "Section Break", label: __("Quick Actions"), }, { fieldtype: "Select", fieldname: "report_period", label: __("Period"), options: ["Monthly", "Quarterly", "Yearly"], default: "Monthly", onchange() { refresh_dashboard(d, frm.doc.name); }, }, ], });
// Render the metrics HTML const html = ` <div class="row"> <div class="col-sm-4"> <div class="stat-card" style="padding: 15px; background: var(--subtle-accent); border-radius: 8px; margin-bottom: 10px;"> <div style="font-size: 12px; color: var(--text-muted);">${__("Total Sales")}</div> <div style="font-size: 24px; font-weight: 600;">${format_currency(metrics.total_sales)}</div> </div> </div> <div class="col-sm-4"> <div class="stat-card" style="padding: 15px; background: var(--subtle-accent); border-radius: 8px; margin-bottom: 10px;"> <div style="font-size: 12px; color: var(--text-muted);">${__("Invoices")}</div> <div style="font-size: 24px; font-weight: 600;">${metrics.invoice_count}</div> </div> </div> <div class="col-sm-4"> <div class="stat-card" style="padding: 15px; background: var(--subtle-accent); border-radius: 8px; margin-bottom: 10px;"> <div style="font-size: 12px; color: var(--text-muted);">${__("Avg Invoice")}</div> <div style="font-size: 24px; font-weight: 600;">${format_currency(metrics.average_invoice_value)}</div> </div> </div> </div> `;
d.fields_dict.metrics_html.$wrapper.html(html); d.show();}
async function refresh_dashboard(dialog, outlet) { const period = dialog.get_value("report_period"); const metrics = await frappe.xcall( "scoopjoy.api.get_franchise_metrics", { outlet: outlet, period: period } );
const html = ` <div class="row"> <div class="col-sm-4"> <div class="stat-card" style="padding: 15px; background: var(--subtle-accent); border-radius: 8px; margin-bottom: 10px;"> <div style="font-size: 12px; color: var(--text-muted);">${__("Total Sales")}</div> <div style="font-size: 24px; font-weight: 600;">${format_currency(metrics.total_sales)}</div> </div> </div> <div class="col-sm-4"> <div class="stat-card" style="padding: 15px; background: var(--subtle-accent); border-radius: 8px; margin-bottom: 10px;"> <div style="font-size: 12px; color: var(--text-muted);">${__("Invoices")}</div> <div style="font-size: 24px; font-weight: 600;">${metrics.invoice_count}</div> </div> </div> <div class="col-sm-4"> <div class="stat-card" style="padding: 15px; background: var(--subtle-accent); border-radius: 8px; margin-bottom: 10px;"> <div style="font-size: 12px; color: var(--text-muted);">${__("Avg Invoice")}</div> <div style="font-size: 24px; font-weight: 600;">${format_currency(metrics.average_invoice_value)}</div> </div> </div> </div> `;
dialog.fields_dict.metrics_html.$wrapper.html(html);}Practical example 3: multi-step wizard dialog
Section titled “Practical example 3: multi-step wizard dialog”A franchise onboarding wizard that guides users through creating an outlet, an
agreement, and the initial setup. The trick is a single dialog with all fields for
all steps declared up front; you toggle each step’s Section Break hidden property
to move between steps, and swap the primary action on the final step.
function show_onboarding_wizard() { let current_step = 1; let outlet_data = {}; let agreement_data = {};
const d = new frappe.ui.Dialog({ title: __("Franchise Onboarding Wizard - Step 1 of 3"), size: "large", fields: [ // Step 1: Outlet Details { fieldtype: "Section Break", fieldname: "step_1", label: __("Outlet Details"), }, { fieldtype: "Data", fieldname: "outlet_name", label: __("Outlet Name"), reqd: 1, }, { fieldtype: "Column Break", }, { fieldtype: "Data", fieldname: "city", label: __("City"), reqd: 1, }, { fieldtype: "Section Break", }, { fieldtype: "Link", fieldname: "owner_customer", label: __("Franchise Owner (Customer)"), options: "Customer", reqd: 1, }, { fieldtype: "Column Break", }, { fieldtype: "Data", fieldname: "phone", label: __("Phone"), options: "Phone", },
// Step 2: Agreement Terms (initially hidden) { fieldtype: "Section Break", fieldname: "step_2", label: __("Agreement Terms"), hidden: 1, }, { fieldtype: "Date", fieldname: "agreement_date", label: __("Agreement Start Date"), default: frappe.datetime.get_today(), reqd: 1, }, { fieldtype: "Column Break", }, { fieldtype: "Date", fieldname: "expiry_date", label: __("Agreement End Date"), reqd: 1, }, { fieldtype: "Section Break", }, { fieldtype: "Currency", fieldname: "franchise_fee", label: __("Initial Franchise Fee"), reqd: 1, }, { fieldtype: "Column Break", }, { fieldtype: "Percent", fieldname: "royalty_rate", label: __("Royalty Rate (%)"), default: 5, },
// Step 3: Confirmation (initially hidden) { fieldtype: "Section Break", fieldname: "step_3", label: __("Review & Confirm"), hidden: 1, }, { fieldtype: "HTML", fieldname: "confirmation_html", }, ], primary_action_label: __("Next"), primary_action(values) { if (current_step === 1) { // Validate step 1 and move to step 2 if (!values.outlet_name || !values.city || !values.owner_customer) { frappe.msgprint(__("Please fill all required fields")); return; } outlet_data = values; move_to_step(d, 2); current_step = 2; } else if (current_step === 2) { // Validate step 2 and move to step 3 if (!values.agreement_date || !values.expiry_date || !values.franchise_fee) { frappe.msgprint(__("Please fill all required fields")); return; } agreement_data = values; show_confirmation(d, outlet_data, agreement_data); move_to_step(d, 3); d.set_primary_action(__("Create Franchise"), () => { create_franchise(d, outlet_data, agreement_data); }); current_step = 3; } }, secondary_action_label: __("Back"), secondary_action() { if (current_step === 2) { move_to_step(d, 1); current_step = 1; } else if (current_step === 3) { move_to_step(d, 2); d.set_primary_action_label(__("Next")); current_step = 2; } }, });
d.show();}
function move_to_step(dialog, step) { dialog.set_df_property("step_1", "hidden", step !== 1); dialog.set_df_property("step_2", "hidden", step !== 2); dialog.set_df_property("step_3", "hidden", step !== 3); dialog.set_title(__("Franchise Onboarding Wizard - Step {0} of 3", [step]));}
function show_confirmation(dialog, outlet, agreement) { const html = ` <div style="padding: 10px;"> <h5>${__("Outlet")}</h5> <p><strong>${outlet.outlet_name}</strong> in ${outlet.city}</p> <p>Owner: ${outlet.owner_customer}</p> <hr> <h5>${__("Agreement")}</h5> <p>Period: ${agreement.agreement_date} to ${agreement.expiry_date}</p> <p>Franchise Fee: ${format_currency(agreement.franchise_fee)}</p> <p>Royalty Rate: ${agreement.royalty_rate}%</p> </div> `; dialog.fields_dict.confirmation_html.$wrapper.html(html);}
async function create_franchise(dialog, outlet_data, agreement_data) { try { const result = await frappe.xcall( "scoopjoy.api.create_franchise_onboarding", { outlet_data: outlet_data, agreement_data: agreement_data, } );
dialog.hide(); frappe.show_alert({ message: __("Franchise created successfully!"), indicator: "green", }); frappe.set_route("Form", "Franchise Outlet", result.outlet_name); } catch (e) { frappe.msgprint(__("Error creating franchise. Please try again.")); }}The server-side handler creates the outlet and agreement together. Note that whitelisted methods receive child-object arguments as JSON strings, so it parses them defensively before building the documents:
@frappe.whitelist()def create_franchise_onboarding(outlet_data, agreement_data): """Create a franchise outlet and agreement in one transaction.""" import json if isinstance(outlet_data, str): outlet_data = json.loads(outlet_data) if isinstance(agreement_data, str): agreement_data = json.loads(agreement_data)
# Create outlet outlet = frappe.get_doc({ "doctype": "Franchise Outlet", "outlet_name": outlet_data["outlet_name"], "city": outlet_data["city"], "owner_name": outlet_data["owner_customer"], "phone": outlet_data.get("phone"), "status": "Active", }) outlet.insert()
# Create agreement agreement = frappe.get_doc({ "doctype": "Franchise Agreement", "franchise_outlet": outlet.name, "agreement_date": agreement_data["agreement_date"], "expiry_date": agreement_data["expiry_date"], "franchise_fee": agreement_data["franchise_fee"], "royalty_rate": agreement_data.get("royalty_rate", 5), "agreement_terms": [ { "term_title": "Quality Standards", "term_description": "Maintain food quality per brand guidelines", "is_mandatory": 1, }, { "term_title": "Operating Hours", "term_description": "Maintain agreed operating hours", "is_mandatory": 1, }, ], }) agreement.insert() agreement.submit()
return {"outlet_name": outlet.name, "agreement_name": agreement.name}Practical example 4: custom Desk page — Franchise Command Center
Section titled “Practical example 4: custom Desk page — Franchise Command Center”Custom Desk Pages are standalone pages within the Frappe Desk with custom HTML, JS, and server-side data. They’re perfect for dashboards — think of them as a route in your admin where you own the whole page body rather than a generated form.
-
Create the Page DocType. Go to Home > Page > New and set:
- Page Name:
franchise-command-center - Title: Franchise Command Center
- Module: ScoopJoy
- Standard: Yes (if in developer mode, this writes files to your app)
This creates the page files in your app directory:
Directoryscoopjoy/
Directoryscoopjoy/
Directorypage/
Directoryfranchise_command_center/
- franchise_command_center.js
- franchise_command_center.json
- franchise_command_center.html
- franchise_command_center.css
- Page Name:
-
Write the page HTML template — the static shell the JS will populate.
franchise_command_center.html <div class="franchise-command-center"><div class="page-header"><h3>Franchise Command Center</h3><p class="text-muted">Real-time overview of all franchise outlets</p></div><div class="row summary-cards" style="margin-bottom: 20px;"><div class="col-sm-3"><div class="card" style="padding: 20px; border-radius: 8px;"><div class="text-muted" style="font-size: 12px;">Total Outlets</div><div class="total-outlets" style="font-size: 28px; font-weight: 600;">-</div></div></div><div class="col-sm-3"><div class="card" style="padding: 20px; border-radius: 8px;"><div class="text-muted" style="font-size: 12px;">Active Outlets</div><div class="active-outlets" style="font-size: 28px; font-weight: 600; color: var(--green-500);">-</div></div></div><div class="col-sm-3"><div class="card" style="padding: 20px; border-radius: 8px;"><div class="text-muted" style="font-size: 12px;">Monthly Revenue</div><div class="monthly-revenue" style="font-size: 28px; font-weight: 600;">-</div></div></div><div class="col-sm-3"><div class="card" style="padding: 20px; border-radius: 8px;"><div class="text-muted" style="font-size: 12px;">Royalty Due</div><div class="royalty-due" style="font-size: 28px; font-weight: 600; color: var(--orange-500);">-</div></div></div></div><div class="row"><div class="col-sm-8"><div class="outlet-list-container"><h5>Outlet Performance</h5><div class="outlet-list"></div></div></div><div class="col-sm-4"><div class="alerts-container"><h5>Alerts</h5><div class="alerts-list"></div></div></div></div></div> -
Write the page JavaScript. Frappe calls
on_page_loadonce andon_page_showon every navigation back to the page. Build the toolbar withmake_app_page, add filter fields, then fetch and render the data.franchise_command_center.js frappe.pages["franchise-command-center"].on_page_load = function (wrapper) {const page = frappe.ui.make_app_page({parent: wrapper,title: "Franchise Command Center",single_column: true,});page.set_secondary_action("Refresh", () => load_dashboard(page), "refresh");// Add filter controlspage.add_field({fieldname: "period",label: __("Period"),fieldtype: "Select",options: ["This Month", "Last Month", "This Quarter", "This Year"],default: "This Month",change() {load_dashboard(page);},});page.add_field({fieldname: "status_filter",label: __("Status"),fieldtype: "Select",options: ["All", "Active", "Inactive", "Under Renovation", "Closed"],default: "All",change() {load_dashboard(page);},});// Store page reference and load$(wrapper).find(".layout-main-section").empty().append(frappe.render_template("franchise_command_center"));wrapper.page_instance = page;load_dashboard(page);};frappe.pages["franchise-command-center"].on_page_show = function (wrapper) {load_dashboard(wrapper.page_instance);};async function load_dashboard(page) {const period = page.fields_dict.period?.get_value() || "This Month";const status = page.fields_dict.status_filter?.get_value() || "All";try {const data = await frappe.xcall("scoopjoy.api.get_command_center_data",{ period: period, status_filter: status });render_summary_cards(data.summary);render_outlet_list(data.outlets);render_alerts(data.alerts);} catch (e) {frappe.msgprint(__("Failed to load dashboard data"));}}function render_summary_cards(summary) {$(".total-outlets").text(summary.total_outlets);$(".active-outlets").text(summary.active_outlets);$(".monthly-revenue").text(format_currency(summary.monthly_revenue));$(".royalty-due").text(format_currency(summary.royalty_due));}function render_outlet_list(outlets) {const container = $(".outlet-list");container.empty();if (!outlets.length) {container.html('<p class="text-muted">No outlets found</p>');return;}const table = $(`<table class="table table-hover" style="font-size: 13px;"><thead><tr><th>${__("Outlet")}</th><th>${__("City")}</th><th class="text-right">${__("Sales")}</th><th class="text-right">${__("Invoices")}</th><th>${__("Status")}</th></tr></thead><tbody></tbody></table>`);outlets.forEach((outlet) => {const status_color = {Active: "green",Inactive: "grey","Under Renovation": "orange",Closed: "red",}[outlet.status] || "grey";table.find("tbody").append(`<tr style="cursor: pointer;" onclick="frappe.set_route('Form', 'Franchise Outlet', '${outlet.name}')"><td><strong>${outlet.outlet_name}</strong></td><td>${outlet.city || ""}</td><td class="text-right">${format_currency(outlet.total_sales || 0)}</td><td class="text-right">${outlet.invoice_count || 0}</td><td><span class="indicator-pill ${status_color}">${outlet.status}</span></td></tr>`);});container.append(table);}function render_alerts(alerts) {const container = $(".alerts-list");container.empty();if (!alerts.length) {container.html('<p class="text-muted">No alerts</p>');return;}alerts.forEach((alert) => {const color = {danger: "red",warning: "orange",info: "blue",}[alert.level] || "grey";container.append(`<div class="alert-item" style="padding: 10px; margin-bottom: 8px; border-left: 3px solid var(--${color}-500); background: var(--subtle-fg); border-radius: 4px;"><div style="font-size: 12px; font-weight: 600;">${alert.title}</div><div style="font-size: 11px; color: var(--text-muted);">${alert.message}</div></div>`);});} -
Write the server-side API that backs the page. One whitelisted entry point fans out to helpers for the summary cards, per-outlet performance, and alerts.
scoopjoy/scoopjoy/api.py import frappefrom frappe.utils import today, add_months, getdate, get_first_day, get_last_day@frappe.whitelist()def get_command_center_data(period="This Month", status_filter="All"):"""Return all data for the Franchise Command Center page."""date_range = get_date_range(period)return {"summary": get_summary(date_range, status_filter),"outlets": get_outlet_performance(date_range, status_filter),"alerts": get_alerts(),}def get_date_range(period):"""Convert period label to start/end dates."""today_date = getdate(today())if period == "This Month":return get_first_day(today_date), get_last_day(today_date)elif period == "Last Month":last_month = add_months(today_date, -1)return get_first_day(last_month), get_last_day(last_month)elif period == "This Quarter":quarter_start_month = ((today_date.month - 1) // 3) * 3 + 1start = today_date.replace(month=quarter_start_month, day=1)return start, today_dateelse: # This Yearreturn today_date.replace(month=1, day=1), today_datedef get_summary(date_range, status_filter):"""Get summary cards data."""outlet_filters = {}if status_filter != "All":outlet_filters["status"] = status_filtertotal_outlets = frappe.db.count("Franchise Outlet", filters=outlet_filters)active_outlets = frappe.db.count("Franchise Outlet", filters={"status": "Active"})# Monthly revenue from submitted invoicesmonthly_revenue = frappe.db.get_value("Sales Invoice",filters={"docstatus": 1,"franchise_outlet": ["is", "set"],"posting_date": ["between", date_range],},fieldname="sum(net_total)",) or 0# Calculate royalty dueroyalty_due = frappe.db.sql("""SELECT SUM(si.net_total * fo.royalty_percentage / 100) as total_royaltyFROM `tabSales Invoice` siJOIN `tabFranchise Outlet` fo ON si.franchise_outlet = fo.nameWHERE si.docstatus = 1AND si.posting_date BETWEEN %s AND %sAND si.franchise_outlet IS NOT NULL""",date_range,as_dict=True,)[0].total_royalty or 0return {"total_outlets": total_outlets,"active_outlets": active_outlets,"monthly_revenue": monthly_revenue,"royalty_due": royalty_due,}def get_outlet_performance(date_range, status_filter):"""Get per-outlet sales performance."""filters = {}if status_filter != "All":filters["status"] = status_filteroutlets = frappe.get_all("Franchise Outlet",filters=filters,fields=["name", "outlet_name", "city", "status"],order_by="outlet_name",)for outlet in outlets:sales_data = frappe.db.get_value("Sales Invoice",filters={"franchise_outlet": outlet.name,"docstatus": 1,"posting_date": ["between", date_range],},fieldname=["sum(net_total) as total_sales", "count(name) as invoice_count"],as_dict=True,)outlet.total_sales = sales_data.total_sales or 0 if sales_data else 0outlet.invoice_count = sales_data.invoice_count or 0 if sales_data else 0return outletsdef get_alerts():"""Get active alerts for the command center."""alerts = []# Check for expiring agreements (next 30 days)expiring = frappe.get_all("Franchise Agreement",filters={"docstatus": 1,"expiry_date": ["between", [today(), frappe.utils.add_days(today(), 30)]],},fields=["franchise_outlet", "expiry_date"],)for agreement in expiring:alerts.append({"level": "warning","title": f"Agreement Expiring: {agreement.franchise_outlet}","message": f"Expires on {agreement.expiry_date}",})# Check for inactive outlets with no recent salesinactive_outlets = frappe.db.sql("""SELECT fo.name, fo.outlet_nameFROM `tabFranchise Outlet` foWHERE fo.status = 'Active'AND fo.name NOT IN (SELECT DISTINCT franchise_outletFROM `tabSales Invoice`WHERE docstatus = 1AND posting_date >= %sAND franchise_outlet IS NOT NULL)""",add_months(today(), -1),as_dict=True,)for outlet in inactive_outlets:alerts.append({"level": "danger","title": f"No Sales: {outlet.outlet_name}","message": "No invoices in the last 30 days",})return alerts@frappe.whitelist()def get_franchise_metrics(outlet, period="Monthly"):"""Return franchise performance metrics for a single outlet."""today_date = getdate(today())if period == "Monthly":start_date = add_months(today_date, -1)elif period == "Quarterly":start_date = add_months(today_date, -3)else:start_date = today_date.replace(month=1, day=1)filters = {"franchise_outlet": outlet,"docstatus": 1,"posting_date": [">=", start_date],}total_sales = frappe.db.get_value("Sales Invoice", filters=filters, fieldname="sum(grand_total)") or 0invoice_count = frappe.db.count("Sales Invoice", filters=filters)avg_invoice = total_sales / invoice_count if invoice_count else 0return {"outlet": outlet,"period": period,"total_sales": total_sales,"invoice_count": invoice_count,"average_invoice_value": avg_invoice,} -
Register the page route. The page is accessible at
/app/franchise-command-centerafter running:Terminal window bench --site mysite.localhost migratebench build --app scoopjoyThen add it to a Workspace for easy access: go to Workspace > ScoopJoy (create one if it doesn’t exist), and add a shortcut linking to the page.
frappe.ui.form.MultiSelectDialog
Section titled “frappe.ui.form.MultiSelectDialog”For cases where users need to select multiple records from a list — for example,
picking several outlets to run a bulk report — reach for MultiSelectDialog. The
setters object pre-fills filter fields (here, defaulting status to Active).
function select_outlets_for_report(frm) { const d = new frappe.ui.form.MultiSelectDialog({ doctype: "Franchise Outlet", target: frm, setters: { city: null, status: "Active", }, add_filters_group: 1, columns: ["name", "outlet_name", "city", "status"], primary_action_label: __("Generate Report"), action(selections) { if (selections.length === 0) { frappe.msgprint(__("Please select at least one outlet")); return; } frappe.call({ method: "scoopjoy.api.generate_bulk_report", args: { outlets: selections }, freeze: true, freeze_message: __("Generating reports..."), callback(r) { frappe.msgprint(__("Reports generated for {0} outlets", [selections.length])); d.dialog.hide(); }, }); }, });}The complete hooks.py
Section titled “The complete hooks.py”Here’s the full hooks.py bringing together everything from the custom-app
chapters — creating the app,
custom DocTypes and fields,
server scripts and scheduled jobs, and the
client scripts in this chapter:
app_name = "scoopjoy"app_title = "ScoopJoy"app_publisher = "Your Company Name"app_description = "Franchise outlet management, royalty tracking, and compliance"app_email = "dev@yourcompany.com"app_license = "MIT"
required_apps = ["frappe/erpnext"]
# Includes in <head>app_include_js = "/assets/scoopjoy/js/scoopjoy.bundle.js"
# DocType JS (loaded on specific forms)doctype_js = { "Sales Invoice": "public/js/sales_invoice.js", "Sales Order": "public/js/sales_order.js",}
# Document Eventsdoc_events = { "Sales Invoice": { "on_submit": "scoopjoy.events.sales_invoice.on_submit", "on_cancel": "scoopjoy.events.sales_invoice.on_cancel", },}
# Scheduled Tasksscheduler_events = { "hourly": [ "scoopjoy.tasks.sync.sync_outlet_stock_levels", ], "daily": [ "scoopjoy.tasks.compliance.check_expiring_agreements", ], "daily_long": [ "scoopjoy.tasks.analytics.rebuild_franchise_dashboards", ], "weekly": [ "scoopjoy.tasks.reports.send_weekly_franchise_digest", ], "monthly": [ "scoopjoy.tasks.billing.generate_royalty_invoices", ], "cron": { "0 */6 * * *": [ "scoopjoy.tasks.sync.sync_central_inventory", ], },}
# Fixturesfixtures = [ { "dt": "Custom Field", "filters": [["module", "=", "ScoopJoy"]], }, { "dt": "Property Setter", "filters": [["module", "=", "ScoopJoy"]], },]
# Installation hooksafter_install = "scoopjoy.install.after_install"after_uninstall = "scoopjoy.install.after_uninstall"Summary: what we built
Section titled “Summary: what we built”Across the custom-app chapters, we’ve built a complete ScoopJoy franchise management app:
- Creating a custom app — scaffolded the app,
understood the directory structure, configured
hooks.py, installed and verified the app. - Custom DocTypes & fields — created three DocTypes (Franchise Outlet, Franchise Agreement with the Agreement Term child table, and Franchise Menu Item for Table MultiSelect), added custom fields to Sales Invoice via fixtures, and used Property Setters to modify existing fields.
- Server scripts & scheduled jobs — wrote
controller validation logic, created Server Scripts for royalty calculation,
hooked into Sales Invoice events to update franchise dashboards, set up scheduled
jobs for stock sync and compliance checks, and used
frappe.enqueuefor bulk report generation. - Client scripts & pages (this chapter) — built client scripts for Sales Invoice franchise filtering, created a performance dashboard dialog, implemented a multi-step onboarding wizard, and built a full custom Desk Page (Franchise Command Center) with real-time data.
The patterns demonstrated here — hooks for integration, controllers for business logic, background jobs for heavy processing, and client scripts for UX — are the building blocks of every serious Frappe custom app.