Skip to content

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.

FeatureClient Script (Desk)Form Script (.js file)
Created viaDesk UICode editor
Stored inDatabaseFile system
Version controlledFixtures onlyNatively with Git
Best forQuick site-specific tweaksDistributable apps
Applies toSpecific DocTypeSpecific 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.

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.

scoopjoy/scoopjoy/doctype/franchise_outlet/franchise_outlet.js
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.

scoopjoy/scoopjoy/public/js/sales_invoice.js
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:

scoopjoy/hooks.py
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js",
}

After adding the hook, rebuild assets:

Terminal window
bench build --app scoopjoy

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 style
frappe.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"));
}
}

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"));
}

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");
}
}

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:

scoopjoy/api.py
import frappe
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def 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 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 table
function 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 condition
function 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
}

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.

scoopjoy/scoopjoy/doctype/franchise_outlet/franchise_outlet.js
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.

scoopjoy/scoopjoy/public/js/onboarding_wizard.js
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:

scoopjoy/api.py
@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.

  1. 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
  2. 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>
  3. Write the page JavaScript. Frappe calls on_page_load once and on_page_show on every navigation back to the page. Build the toolbar with make_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 controls
    page.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>
    `);
    });
    }
  4. 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 frappe
    from 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 + 1
    start = today_date.replace(month=quarter_start_month, day=1)
    return start, today_date
    else: # This Year
    return today_date.replace(month=1, day=1), today_date
    def get_summary(date_range, status_filter):
    """Get summary cards data."""
    outlet_filters = {}
    if status_filter != "All":
    outlet_filters["status"] = status_filter
    total_outlets = frappe.db.count("Franchise Outlet", filters=outlet_filters)
    active_outlets = frappe.db.count("Franchise Outlet", filters={"status": "Active"})
    # Monthly revenue from submitted invoices
    monthly_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 due
    royalty_due = frappe.db.sql(
    """
    SELECT SUM(si.net_total * fo.royalty_percentage / 100) as total_royalty
    FROM `tabSales Invoice` si
    JOIN `tabFranchise Outlet` fo ON si.franchise_outlet = fo.name
    WHERE si.docstatus = 1
    AND si.posting_date BETWEEN %s AND %s
    AND si.franchise_outlet IS NOT NULL
    """,
    date_range,
    as_dict=True,
    )[0].total_royalty or 0
    return {
    "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_filter
    outlets = 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 0
    outlet.invoice_count = sales_data.invoice_count or 0 if sales_data else 0
    return outlets
    def 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 sales
    inactive_outlets = frappe.db.sql(
    """
    SELECT fo.name, fo.outlet_name
    FROM `tabFranchise Outlet` fo
    WHERE fo.status = 'Active'
    AND fo.name NOT IN (
    SELECT DISTINCT franchise_outlet
    FROM `tabSales Invoice`
    WHERE docstatus = 1
    AND posting_date >= %s
    AND 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 0
    invoice_count = frappe.db.count("Sales Invoice", filters=filters)
    avg_invoice = total_sales / invoice_count if invoice_count else 0
    return {
    "outlet": outlet,
    "period": period,
    "total_sales": total_sales,
    "invoice_count": invoice_count,
    "average_invoice_value": avg_invoice,
    }
  5. Register the page route. The page is accessible at /app/franchise-command-center after running:

    Terminal window
    bench --site mysite.localhost migrate
    bench build --app scoopjoy

    Then 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.

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();
},
});
},
});
}

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:

scoopjoy/hooks.py
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 Events
doc_events = {
"Sales Invoice": {
"on_submit": "scoopjoy.events.sales_invoice.on_submit",
"on_cancel": "scoopjoy.events.sales_invoice.on_cancel",
},
}
# Scheduled Tasks
scheduler_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",
],
},
}
# Fixtures
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "ScoopJoy"]],
},
{
"dt": "Property Setter",
"filters": [["module", "=", "ScoopJoy"]],
},
]
# Installation hooks
after_install = "scoopjoy.install.after_install"
after_uninstall = "scoopjoy.install.after_uninstall"

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.enqueue for 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.