Beyond CRUD: How do you build complex, data-heavy "Business Screens" in Frappe/ERPNext?

Hi everyone,

We all know Frappe is brilliant and lightning-fast for standard CRUD, master-detail forms, and reports. However, I’m hitting a wall when it comes to “heavy” business screens—the kind that require multiple panels, inter-dependent data tables, and custom workflows that don’t fit the standard DocType layout.

I’m curious about how the experts here handle this:

  1. Custom Pages vs. Frappe UI: Do you stick to standard “Pages” with jQuery/Vanilla JS, or have you fully moved to Frappe UI (Vue 3/Tailwind) for these complex interfaces?

  2. HTMX in the Desk: Has anyone successfully integrated HTMX within a custom Page in the Desk? It seems like a great way to keep logic in Python while having a reactive UI, but I’d love to hear about real-world stability.

  3. External Frameworks: Is it viable to build a standalone SPA (React/Vue) and simply link it/iframe it into the Desk, or does the authentication and API overhead make it more trouble than it’s worth?

My goal is to create high-density dashboards that feel like a “cockpit” for the user, far beyond a simple list or form view.

How are you building your “Pro” screens? Any repos or boilerplate recommendations would be greatly appreciated!

4 Likes

Got same question will be awesome to be able to build a diff frontend with erpnext api rest I found myself very hard to customize the current frontend, back is awesome but front still need some work

I usually prefer building a SPA using React (or Vue) for such a scenario. These modern frameworks offer easy state management and rendering.

Mint is a good example of this (disclaimer: I built it) - it’s a specialized tool for bank reconciliation and is quite data heavy with interdependent state/logic.

4 Likes

I do a lot of this in my installation. I have two basic approaches:

  1. Server-side, using the standard FrappeUI/Vue build chain. Hussain’s doppio extension is super convenient for setting up boilerplate.

  2. Client-side, using a singleton doctype with an HTML docfield and Vue at runtime.

I don’t use Pages for anything. They don’t hook into any of the client-side scripting tools I use and feel less convenient overall than singleton doctypes.

1 Like

any possibility to share one real world example?

Definitely. This is an Attendance tool I created. It draws on a number of different doctypes to record class sessions and attendance states. The code likely needs a serious refactor, but that’s kind of the point. We use approach for fast and sloppy tool development.

const template = `
<div class="appHeader container-fluid">
    <div class="row">
        <div class="academic_term col-sm"></div>
        <div class="course col-sm"></div>
    </div>
    <div class="row">
        <div class="col-sm" style="display: flex; gap: 5px; flex-wrap: wrap">
            <button class="btn btn-sm btn-secondary" v-for="term in term_list.slice(0,3)" @click="term_field_ref.set_value(term.name)">{{ term.name }}</button>
        </div>
        <div class="col-sm" style="display: flex; gap: 5px; flex-wrap: wrap">
            <button class="btn btn-sm btn-secondary" v-for="course in course_list" @click="course_field_ref.set_value(course.course)">{{ course.course.match(/[A-Z]+/g).join("") }}</button>
        </div>
    </div>
</div>
<hr>
<div class="pb-2 text-lg container-fluid">
    <div v-if="course_filter && term_filter">
        <div v-if="data_available === true">
            <div v-for="group in selected_groups" class="groupContent">
                <div class="sectionHeader">
                    <h4>{{ group.name }}</h4>
                    <button @click="createDate(null, group, course_filter)" class="btn btn-primary btn-sm primary-action">+New</button>
                </div>
                <div class="tableWrapper">
                    <table >
                        <thead>
                            <tr>
                                <th class="firstCol" colspan=2>&nbsp;</th>
                                <th><span class="vertical-text" style="font-weight: normal; font-style: italic;">Scheduled Hours</span></th>
                                <th v-for="student in group.students">
                                    <span class="vertical-text">
                                        {{ student.student_name }}
                                    </span>
                                </th>
                            </tr>
                        </thead>
                        <template v-for="session_type in session_types">
                            <tbody>
                                <tr><td rowspan=100% class="vertical-text" style="text-align: center; font-weight: bold;">{{session_type || "No Session Type" }}</td></tr>
                                <tr v-for="schedule in schedule_list.filter(s => s.student_group === group.name && s.custom_session_type === session_type)" @click="createDate(schedule, group, course_filter)">
                                    <td class="firstCol">
                                        <span v-if="schedule.custom_session_details">&nbsp;{{ schedule.custom_session_details }} - </span>
                                        <span>{{ schedule.schedule_date }}</span>
                                        <span style="font-size: 80%">&nbsp;({{schedule.from_time.substring(0,5)}}–{{schedule.to_time.substring(0,5)}})</span>
                                    </td>
                                    <td>{{ timeDiff(schedule.from_time, schedule.to_time) }}</td>
                                    <td v-for="student in group.students">
                                        <div :set="attd = attendance_list.find(a => a.student === student.student && a.course_schedule === schedule.name)">
                                            <span v-if="attd?.status === 'Present' && !attd?.custom_late" style="color: green">✔</span>
                                            <span v-if="attd?.status === 'Present' && attd?.custom_late" style="color: orange">✔︎</span>
                                            <span v-if="attd?.status === 'Absent'" style="color: red">︎❌</span>
                                        </div>
                                    </td>
                                </tr>
                                <tr v-if="schedule_list.filter(s => s.student_group === group.name).length === 0">
                                    <td colspan="100%">No times scheduled.</td>
                                </tr>
                            </tbody>
                            <tfoot>
                                <tr>
                                    <td class="firstCol" colspan=2>{{ session_type }} Totals</td>
                                    <td>
                                        {{ total_hours_by_type[session_type] }}
                                    </td>
                                    <td v-for="student in group.students">
                                        <span>{{  student_hours_by_type[session_type][student.student] }}</span>
                                    </td>
                                </tr>
                            </tfoot>
                        </template>
                    </table>
                </div>
            </div>
            <div v-if="!selected_groups.length">No Student Groups match the selected filters.</div>
        </div>
        <div v-else>Loading...</div>
    </div>
    <div v-else>Please set Academic Term and Course filters</div>
</div>
`
const styleSheet = `
.sectionHeader {
    display: flex;
    align-items: end;
    justify-content: space-between;
}
.groupContent {
    position: relative;
}
.tableWrapper {
    overflow: auto;
}
.firstCol {
    position: sticky;
    left: 0;
    background-color: inherit;
    border: 1px solid lightgray;
    max-width: inherit !important;
    width: auto;
    white-space: nowrap;
    overflow: hidden;
    z-index: 1;
}

table {
    overflow: scroll;
    border-collapse: separate;
    border-spacing: 0;
    width: 100%
}
th {
    vertical-align: bottom;
}
td, th {
    padding: 10px;
    border: 1px solid lightgray;
    text-align: center;
    background-color: inherit;
    max-width: 70px;
    width: 40px
}
thead tr {
    background-color: #ffffff;
}
tbody tr:nth-child(odd) {
    background-color: #fffffff;
}
tbody tr:nth-child(even) {
    background-color: #f8f8f8;
}
tbody tr:hover {
    cursor: pointer;
}
tbody td:first-child {
    text-align: start;
}
tfoot td {
    font-weight: bold;
    background-color: #eee
}
tfoot td:first-child {
    text-align: end;
    font-weight: bold;
    background-color: #eee
}
.vertical-text {
    writing-mode: vertical-rl; 
    inline-size: fit-content; 
    transform: rotate(180deg); 
    line-height: 1; 
    white-space: nowrap;
}
button {
    margin-bottom: var(--margin-md);
}

select:has(option[value="Present"]:checked) {
    background-color: #abe2a8 !important;
}
select:has(option[value="Late"]:checked) {
    background-color: #fceeb6 !important;
}
select:has(option[value="Absent"]:checked) {
    background-color: #edad9e !important;
}
.control-label {
    white-space: nowrap;
}
.btn-default[data-fieldname="present"] {
    background-color: green !important;
    color: white !important;
    position: absolute;
    bottom: -50px;
}
.btn-default[data-fieldname="delete"] {
    background-color: red !important;
    color: white !important;
    position: absolute;
}
`

frappe.realtime.on('doctype:course-schedule', (data) => {
    console.log("realtime course-schedule", data)
})
frappe.realtime.on('docupdate', (data) => {
    console.log("realitme docupdate", data)
})

function timeDiff(a,b) {
    // gets difference in hours of two times in string format "HH:MM"
    a_int = Number(a.split(':')[0]) + (Number(a.split(':')[1]) / 60)
    b_int = Number(b.split(':')[0]) + (Number(b.split(':')[1]) / 60)
    return (Math.round((b_int - a_int)*100)/100)
}
function countHours(group_name, student_id, schedule_list, attendance_list, session_type) {
    let filtered_list = schedule_list.filter(s => s.student_group === group_name)
    // console.log("session type:", session_type)
    if (session_type !== null)
        filtered_list = filtered_list.filter(s => s.custom_session_type === session_type)
    
    // console.log(filtered_list)
    return filtered_list.reduce((acc, cur) => { 
            const base_hours =  timeDiff(cur.from_time, cur.to_time)
            if (!student_id) 
                return acc + base_hours
            
            const attendance = attendance_list.find(a => a.student === student_id && a.student_group === group_name && a.course_schedule === cur.name)

            const attended = attendance?.status === "Present" ? 1 : 0
            const late = attendance?.custom_late ? 0.5 : 1
            return acc + base_hours * attended * late
        }, 0)
}

frappe.ui.form.on('Attendance Register', {
	refresh(frm) {
	    if (typeof Vue === "undefined") return
		const { createApp, ref, computed, watch } = Vue
		const app = createApp({
            setup() {
                // base state variables
                const data_available = ref(false)
                const term_filter = ref('')
                const course_filter = ref('')
                const term_field_ref = ref()
                const course_field_ref = ref()
                
                // base data sets
                const course_list = ref([])
                const term_list = ref([])
                const group_list = ref([])
                const student_list = ref([])
                const schedule_list = ref([])
                const attendance_list = ref([])
                
                // selected_groups indexes Student Groups that match filters with list of active members
                const selected_groups = computed(() => {
                    const programs = course_list.value
                        .filter(c => c.course === course_filter.value)
                        .map(c => c.parent)
                    const groups = group_list.value
                        .filter(g => g.academic_term === term_filter.value && programs.includes(g.program))
                        .map(g => { return { name: g.name } })
                    groups.forEach(g => {
                        g['students'] = student_list.value.filter(s => s.parent === g.name)
                    })
                    return groups
                })
                const session_types = computed(() => {
                    return [...new Set(schedule_list.value.map(s => s.custom_session_type))]
                        .sort((a, b) => {
                            if (a?.match(/^Faculty/)) a = "!" + a
                            if (b?.match(/^Faculty/)) b = "!" + b
                            if (a === b) return 0;
                            if (a === null) return -1;
                            if (b === null) return 1;
                            return a.localeCompare(b);
                        });
                })
                const total_hours = computed(() => {
                    // todo: this hardcodes selected group 0 right now, but should work if multiple are selected
                    return countHours(selected_groups.value[0].name, null, schedule_list.value, attendance_list.value)
                })
                const student_hours = computed(() => {
                    //  student_hours[student.student] ??
                    const totals = {}
                    selected_groups.value[0].students.forEach(s => {
                        totals[s.student] = countHours(selected_groups.value[0].name, s.student, schedule_list.value, attendance_list.value)
                    })
                    // countHours(group.name, student.student, schedule_list, attendance_list)
                    return totals
                })
                const total_hours_by_type = computed(() => {
                    const totals = {}
                    session_types.value.forEach(session_type => {
                        // console.log(session_type)
                        totals[session_type] = countHours(selected_groups.value[0].name, null, schedule_list.value, attendance_list.value, session_type)
                    })
                    // console.log("totals", totals)
                    return totals
                })
                const student_hours_by_type = computed(() => {
                    const totals = {}
                    session_types.value.forEach(session_type => {
                        const students = {}
                        selected_groups.value[0].students.forEach(s => {
                            students[s.student] = countHours(selected_groups.value[0].name, s.student, schedule_list.value, attendance_list.value, session_type)
                        })
                        totals[session_type] = students
                    })
                    return totals
                })
                
                // static data fetchers
                async function get_courses() {
                    return frappe.call('frappe.client.get_list', {
                        doctype: 'Program Course',
                        parent: 'Program',
                        fields: ['parent', 'course', 'idx'],
                        order_by: 'parent asc, idx asc',
                        limit_page_length: 9999
                    }).then(r => course_list.value = r.message)
                }
                async function get_terms() {
                    return frappe.call('frappe.client.get_list', {
                        doctype: 'Academic Term',
                        fields: ['name', 'term_start_date', 'custom_inactive'],
                        order_by: 'term_start_date desc',
                        filters: {custom_inactive: 0},
                        limit_page_length: 9999
                    }).then(r => term_list.value = r.message)
                }
                async function get_groups() {
                    return frappe.call('frappe.client.get_list', {
                        doctype: 'Student Group',
                        fields: ['name', 'academic_term', 'program'],
                        limit_page_length: 9999
                    }).then(r => group_list.value = r.message)
                }
                async function get_students() {
                    // TODO: evaluate whether this should be fetched only after filters are set
                    return frappe.call('frappe.client.get_list', {
                        doctype: 'Student Group Student',
                        parent: 'Student Group',
                        fields: ['parent', 'student', 'student_name'],
                        filters: {active: 1},
                        limit_page_length: 9999,
                        order_by: "student_name asc"
                    }).then(r => student_list.value = r.message)
                }
                Promise.all([get_courses(), get_groups(), get_students(), get_terms()]).then(r => {
                    data_available.value=true
                })
                
                // filter-dependent data fetchers
                watch([term_filter, course_filter, data_available], (([term, course, data_available]) => {
                    if (term && course && data_available) {
                        // console.log("getting schedules", term, course, data_available)
                        get_schedule()
                    }
                }))
                async function get_schedule() {
                    const groups = selected_groups.value.map(g => g.name)
                    const course = course_filter.value
                    frappe.call('frappe.client.get_list', {
                        doctype: 'Course Schedule',
                        fields: ['*'],
                        filters: {
                            course: course,
                            student_group: ['in', groups]
                        },
                        limit_page_length: 9999,
                        order_by: 'schedule_date asc'
                    }).then(r => {
                        // console.log(course)
                        schedule_list.value = r.message
                        get_attendance()
                    })
                }
                watch([schedule_list], ([new_schedule_list]) => {
                    // console.log("getting attendance")
                    get_attendance()
                })
                async function get_attendance() {
                    const schedules = schedule_list.value.map(a => a.name)
                    frappe.call('frappe.client.get_list', {
                        doctype: 'Student Attendance',
                        fields: ['*'],
                        filters: {
                            course_schedule: ['in', schedules]
                        },
                        limit_page_length: 9999,
                    }).then(r => {
                        attendance_list.value = r.message
                    })
                }
                
                // data manipulation methods
                async function deleteSession(session) {
                    data_available.value=false;
                    const attns = attendance_list.value.filter(a => a.course_schedule === session.name)
                    const deletePromises = []
                    attns.forEach(a => {
                        const job = frappe.db.delete_doc("Student Attendance", a.name)
                        deletePromises.push(job)
                    })
                    Promise.all(deletePromises).then(r => {
                        frappe.db.delete_doc("Course Schedule", session.name).then(r => {
                            get_schedule()
                            data_available.value=true
                        })
                    })
                }
                async function markAttendance(schedule, students, form_values) {
                    const status_map = {Present: "Present", Late: "Present", Absent: "Absent"}
                    const submitPromises = [];
                    students.forEach(s => {
                        if (form_values[s.student]) {
                            const match = attendance_list.value.find(a => a.course_schedule === schedule && a.student === s.student)
                            if (match) {
                                let job
                                if (form_values[s.student] === '-remove-') {
                                    job = frappe.db.delete_doc("Student Attendance", match.name)
                                }
                                else {
                                    job = frappe.db.set_value("Student Attendance", match.name, {
                                        status: status_map[form_values[s.student]],
                                        custom_late: form_values[s.student] === "Late" ? 1 : 0,
                                    })
                                }
                                submitPromises.push(job)
                            }
                            else {
                                const job = frappe.db.insert({
                                    doctype: "Student Attendance",
                                    student: s.student,
                                    status: status_map[form_values[s.student]],
                                    custom_late: form_values[s.student] === "Late" ? 1 : 0,
                                    course_schedule: schedule,
                                })
                                submitPromises.push(job)
                            }
                        }
                    })
                    Promise.all(submitPromises).then(r => {
                        get_schedule()
                    })
                }

                // createDate modal
                async function createDate(session, group, course) {
                    // define fields for each student
                    const student_columns = 4
                    let student_fields = []
                    group.students.forEach((s,i) => {
                        student_fields.push({fieldtype: i % student_columns ? 'Column Break' : 'Section Break', hide_border: i ? 1 : 0})
                        student_fields.push({
                            label: s.student_name, 
                            fieldname: s.student,
                            fieldtype: 'Select',
                            options:`\nPresent\nLate\nAbsent\n-remove-`
                        })
                    })
                    // add breaks for last line to keep fields appropriate size
                    for (let i = (student_columns - group.students.length % student_columns); i > 0; i--) {
                        if (i === student_columns) break;
                        student_fields.push({fieldtype: 'Column Break'})
                    }
                    let d = new frappe.ui.Dialog({
                        title: group.name + " - " + course,
                        fields: [
                            {
                                label: 'Session Type',
                                fieldname: 'custom_session_type',
                                fieldtype: 'Select',
                                options: '\nFaculty-led Seminar\nFaculty-led Workshop\nDirected Research Exercise\nSupervised Field Research',
                                default: 'Faculty-led Seminar'
                            },
                            { fieldtype: 'Column Break' },
                            {
                                label: 'Session Details',
                                fieldname: 'custom_session_details',
                                fieldtype: 'Data'
                            },
                            { fieldtype: 'Section Break', hide_border: 1 },
                            {
                                label: 'Date',
                                fieldname: 'schedule_date',
                                fieldtype: 'Date',
                            },
                            { fieldtype: 'Column Break'},
                            {
                                label: 'Start Time',
                                fieldname: 'from_time',
                                fieldtype: 'Time',
                                change(e) {
                                    if (!d.fields_dict["to_time"].value) {
                                        const later_time = String(
                                            (Number(d.fields_dict["from_time"].value.substring(0,2)) + 2) 
                                            + d.fields_dict["from_time"].value.substring(2)
                                        ).padStart(8, '0')
                                        d.fields_dict["to_time"].set_value(later_time)
                                    }
                                }
                            },
                            { fieldtype: 'Column Break' },
                            {
                                label: 'End Time',
                                fieldname: 'to_time',
                                fieldtype: 'Time'
                            },
                            { fieldtype: 'Column Break' },
                            {
                                label: 'Mark All Present',
                                fieldname: 'present',
                                fieldtype: 'Button',
                                click: () => {
                                    group.students.forEach(s => {
                                        if (d.fields_dict[s.student].value === null)
                                            d.fields_dict[s.student].set_value("Present")
                                    })
                                },
                            },
                            ...student_fields,
                        ],
                        size: 'large', // small, large, extra-large 
                        primary_action_label: 'Submit',
                        primary_action(values) {
                            if (!values?.schedule_date || !values?.from_time || !values?.to_time) {
                                frappe.throw("Please check that Date, Start Time, and End Time are all set.")
                                return;
                            }
                            
                            // create a new session
                            const formVals = {
                                schedule_date: values.schedule_date,
                                from_time: values.from_time,
                                to_time: values.to_time,
                                custom_session_type: values.custom_session_type,
                                custom_session_details: values.custom_session_details ?? ''
                            }

                            if (session === null) {
                                frappe.db.insert({
                                    doctype: "Course Schedule",
                                    student_group: group.name,
                                    course: course,
                                    ...formVals
                                }).then(doc => {
                                    // console.log(doc)
                                    markAttendance(doc.name, group.students, values)
                                    d.hide()
                                })
                            }
                            else {
                                frappe.db.set_value("Course Schedule", session.name, {
                                    ...formVals
                                }).then(message => {
                                    // console.log(formVals)
                                    markAttendance(session.name, group.students, values)
                                    d.hide()
                                })
                            }
                        },
                        secondary_action_label: 'Delete',
                        secondary_action(values) { 
                            if (session) {
                                deleteSession(session)
                                d.hide()
                            }
                            else {
                                d.hide()
                            }    
                        },
                    }).show()
                    if (session) {
                        d.fields_dict["custom_session_type"].set_value(session.custom_session_type)
                        d.fields_dict["custom_session_details"].set_value(session.custom_session_details)
                        d.fields_dict["schedule_date"].set_value(session.schedule_date)
                        d.fields_dict["from_time"].set_value(session.from_time.padStart(8, '0'))
                        d.fields_dict["to_time"].set_value(session.to_time.padStart(8, '0'))
                        group.students.forEach(s => {
                            const match = attendance_list.value.find(a => a.course_schedule === session.name && a.student === s.student)
                            let attn_status
                            if (match?.status === "Present")
                                attn_status = match?.custom_late ? "Late" : "Present"
                            else if (match?.status === "Absent")
                                attn_status = "Absent"
                            d.fields_dict[s.student].set_value(attn_status)
                        })
                    }
                    else {
                        d.fields_dict["schedule_date"].set_value(frappe.datetime.get_today())
                    }
                    
                }
                
                return { term_filter, course_filter, selected_groups, session_types,
                    data_available, schedule_list, attendance_list, createDate, 
                    timeDiff, countHours, total_hours, student_hours, total_hours_by_type, student_hours_by_type,
                    course_list, term_list, term_field_ref, course_field_ref }
            },
            template: template,
            mounted() {
                // console.log('mounted')
            }
        }).mount('#pageApp')
    
        const queryString = window.location.search;
        const urlParams = new URLSearchParams(queryString);
        // console.log(urlParams.get('term'), urlParams.get('course'))
        
        // set up filter fields
        const term_field = frappe.ui.form.make_control({
            parent: document.querySelector('#pageApp .academic_term'),
            df: {
                label: 'Academic Term',
                fieldtype: 'Link',
                fieldname: 'academic_term',
                options: 'Academic Term',
                default: urlParams.get('term'),
                change() { 
                    app.term_filter = this.value;
                    var queryParams = new URLSearchParams(window.location.search);
                    queryParams.set("term", this.value);
                    history.replaceState(null, null, "?"+queryParams.toString());
                },
            },
            render_input: true,
            columns: 1
        })
        const course_field = frappe.ui.form.make_control({
            parent: document.querySelector('#pageApp .course'),
            df: {
                label: 'Course Unit',
                fieldtype: 'Link',
                fieldname: 'course',
                options: 'Course',
                default: urlParams.get('course'),
                change() {
                    app.course_filter = this.value;
                    var queryParams = new URLSearchParams(window.location.search);
                    queryParams.set("course", this.value);
                    history.replaceState(null, null, "?"+queryParams.toString());
                },
            },
            render_input: true,
            columns: 1
        })
        
        term_field.set_value(urlParams.get('term'))
        course_field.set_value(urlParams.get('course'))
        
        if (!urlParams.get('term')) {
            frappe.db.get_single_value('Education Settings', 'current_academic_term')
                .then(term => term_field.set_value(term))
        }   
        

        // apply css to head
        var style = document.createElement('style');
        style.type = 'text/css';
        style.innerHTML = styleSheet;
        document.getElementsByTagName('head')[0].appendChild(style);
        
        window.app = app
        app.course_field_ref = course_field
        app.term_field_ref = term_field
	}
})
1 Like