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> </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"> {{ schedule.custom_session_details }} - </span>
<span>{{ schedule.schedule_date }}</span>
<span style="font-size: 80%"> ({{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
}
})