class ReportsViewController extends TimeCardsViewController { /** * An object containing column information for each report type. */ get columnInformation() { return { periods: { aggregationLabel: "Total", fields: { period_ids: { hidden: true }, id_projects: { displayName: "Project", getArrayCellValue: (keys, row, index) => keys.map(key => key ? (TimeCards.dataManager.getEntity("project", key)?.name ?? "Unknown") : "", this).filter(name => name).join("\r\n"), //TODO: Localize this. }, id_users: { displayName: "Employee", getArrayCellValue: (keys, row, index) => keys.map(key => key ? (TimeCards.dataManager.getEntity("user", key)?.full_name ?? "Unknown") : "", this).filter(full_name => full_name).join("\r\n") //TODO: Localize this. }, id_invoices: { displayName: "Invoiced?", singleType: "boolean", textAlign: "center", getArrayCellValue: (keys, row, index) => { let invoicedCount = 0; keys.forEach(key => invoicedCount += key != 0 ? 1 : 0); return keys.length > 1 ? invoicedCount + " / " + keys.length : (keys.length > 0 ? (keys[0] != 0 ? "TRUE" : "FALSE") : ""); } }, work_dates: { displayName: "Date", type: "date" }, work_weeks: { displayName: "Week" }, work_months: { displayName: "Month" }, work_years: { displayName: "Year" }, work: { displayName: "Work" }, notes: { displayName: "Notes" }, target_work_time: { hidden: true }, target_work_time_mode: { hidden: true }, target_work_time_per_user: { displayName: "Target Work Hours", type: "number", aggregation: "sum", round: 4, getArrayCellValue: (keys, row, index) => { if (keys.length == 0 || !keys[0]) { return ""; } const targetWorkTime = this.getTargetWorkTimeForFirstUser(keys); if (typeof targetWorkTime === "string") { return targetWorkTime; } //Store in static scope in order to retrieve it in difference column. ReportsViewController.currentRowTargetWorkTime = targetWorkTime; return ReportsViewController.currentRowTargetWorkTime; } }, total_time: { displayName: "Work Hours", type: "number", aggregation: "sum", round: 4 }, work_time_difference: { displayName: "Difference", type: "difference", aggregation: "sum", getArrayCellValue: (keys, row, index) => { if (keys.length == 0 || !keys[0]) { return ""; } const targetWorkTime = this.getTargetWorkTimeForFirstUser(keys); if (typeof targetWorkTime === "string") { return targetWorkTime; } return parseFloat((row[13] - ReportsViewController.currentRowTargetWorkTime).toFixed(4)); } }, tariff: { displayName: "Tariff", type: "currency" }, total_value: { displayName: "Total Value", type: "currency", aggregation: "sum" } } } }; } constructor(element) { super(element); this.reportCells = {}; this.reportCache = {}; TimeCards.dataManager.addDataListener("report", null, this.onReportUpdate.bind(this)); this.deleteReportDialog = new Dialog("Are you sure you want to delete this report?", this.onDeleteDialogReturned.bind(this), Dialog.BUTTONS_DESTRUCTIVE); //TODO: Localize this. this.loadReportRequest = new Request("/App/TimeCards/LoadReport", "POST", this.onReportLoaded.bind(this), this.onReportLoadError.bind(this)); } viewDidLoad() { this.parameterDialog = UIKit.getViewControllerById("report-parameters-view-controller"); this.reportsTableController.addEventListener("cellselected", this.onCellSelected.bind(this)); this.newReportCell = new OptionsTableViewCell(); this.newReportCell.titleLabel.innerText = "New Report"; //Add "all" and "ask" filter options. this.filterUserController.addOption("all", "(All)"); //TODO: Localize this. this.filterUserController.addOption("ask", "(Ask on load)"); //TODO: Localize this. this.filterProjectController.addOption("all", "(All)"); //TODO: Localize this. this.filterProjectController.addOption("ask", "(Ask on load)"); //TODO: Localize this. //Hide report view initially. this.onCellSelected({}); //Update table size regularly. setInterval(this.updateSize.bind(this), 250); } onReportUpdate(reportId, report, sortedBefore) { //Obtain reference to context menu if not yet done. if (!this.optionsContextMenu) { this.optionsContextMenu = UIKit.getPopoverById("report-options"); this.optionsContextMenu.setViewController(this); this.newReportCell.setData({ contextMenu: this.optionsContextMenu }); } if (!report) { //Handle the deleted report. if (!(reportId in this.reportCells)) { return; } var reportCell = this.reportCells[reportId]; this.reportsTableController.removeCell(reportCell); delete this.reportCells[reportId]; } else if (!(reportId in this.reportCells)) { //Handle the added report. var reportCell = new ReportTableViewCell(); reportCell.setData({ title: report.name, subtitle: report.description.replaceAll("\r\n", " ").replaceAll("\n", " "), contextMenu: this.optionsContextMenu, report }); this.reportsTableController.addCell(reportCell, 0, sortedBefore ? this.reportCells[sortedBefore] : null); this.reportCells[reportId] = reportCell; if (this.waitingForReportCreation) { this.waitingForReportCreation = false; this.reportsTableController.selectCell(reportCell); } } else { //Handle the changed report. var reportCell = this.reportCells[reportId]; reportCell.setData({ title: report.name, subtitle: report.description.replaceAll("\r\n", " ").replaceAll("\n", " "), report }); //Move the cell to the appropriate section. this.reportsTableController.addCell(reportCell, 0, sortedBefore ? this.reportCells[sortedBefore] : null); } } onCellSelected(event) { if (!event.cell) { this.reportView.style.display = "none"; this.selectedReport = null; } else { //Hide the new report cell if another cell was selected. if (this.isNewReport && event.cell != this.newReportCell) { this.onDeleteButtonPressed(event); } this.setReportData(event.cell.report); this.reportView.style.display = ""; } } onFieldChanged(event) { event.stopPropagation(); //Save report information when a setting has changed. this.save(); } onFormSubmitted(event) { event.preventDefault(); event.stopPropagation(); //Save report information when form is submitted. this.save(); } onReportLoaded(responseData) { //Add empty rows if necessary. this.addEmptyRows(responseData); //Display loaded data. this.setTableData(responseData); //Cache loaded data. this.reportCache[this.loadingReportId] = responseData; } onReportLoadError(event) { } addEmptyRows(data) { let mode; let columnIndex = -1; for (const group of this.groups) { const columnKey = group + "s"; if (!group.startsWith("work_") || !(columnKey in this.columnInformation.periods.fields)) { continue; } mode = columnKey; columnIndex = Object.keys(this.columnInformation.periods.fields).indexOf(columnKey); } //Do nothing if there is no grouping for a column to add empty rows for. if (columnIndex == -1) { return; } //Iterate over all rows and insert empty rows when there is a gap. let followingRow = data.length > 0 ? data[data.length - 1] : null; for (let i = data.length - 2; i >= 1; i--) { let row = data[i]; const value = row[columnIndex]; const expectedPreviousValue = this.getPreviousDateValue(followingRow[columnIndex], mode); //Remember column indexes and content of following row to copy. const keys = Object.keys(this.columnInformation.periods.fields); const keysToCopy = ["id_users", "target_work_time_per_user", "work_time_difference"]; const columnIndexesToCopy = []; const followingRowValuesToCopy = []; for (const key of keysToCopy) { const keyIndex = keys.indexOf(key); columnIndexesToCopy.push(keyIndex); followingRowValuesToCopy.push(followingRow[keyIndex]); } followingRow = row; if (!expectedPreviousValue) { continue; } //Skip row entirely if value is greater than expected previous value. //IMPORTANT: This is necessary to prevent an infinite loop. if (value > expectedPreviousValue) { continue; } //Skip row if value matches the expected previous value of the following row. if (value == expectedPreviousValue) { continue; } //Add empty row here if value did not match. //Use new empty row as current row. row = []; for (let j = 0; j < Object.keys(this.columnInformation.periods.fields).length; j++) { //Copy user ID from following row. const copyIndex = columnIndexesToCopy.indexOf(j); row.push(j == columnIndex ? expectedPreviousValue : (copyIndex != -1 ? followingRowValuesToCopy[copyIndex] : "")); } data.splice(i + 1, 0, row); //Process newly inserted row again. i++; followingRow = data[i]; } } getPreviousDateValue(value, mode) { if (!value) { return null; } if (mode == "work_dates") { const date = new Date(value); date.setDate(date.getDate() - 1); return date.toISOString().substring(0, 10); } else if (mode == "work_weeks") { const parts = value.replaceAll(" ", "").split("W"); if (parts.length != 2) { return null; } let year = parseInt(parts[0]); let week = parseInt(parts[1]); week--; if (week < 1) { week += 53; year--; } return year + "W" + ("" + week).padStart(2, "0"); } else if (mode == "work_months") { const parts = value.split("-"); if (parts.length != 2) { return null; } let year = parseInt(parts[0]); let month = parseInt(parts[1]); month--; if (month < 1) { month += 12; year--; } return year + "-" + ("" + month).padStart(2, "0"); } else if (mode == "work_years") { return parseInt(value) - 1; } return null; } setTableData(data) { //Set column information for table. this.updateColumnInformation(); //Populate table. this.dataTableController.setData(data); //Enable or disable download button. this.downloadButton.disabled = !data || data.length == 0; } /** * Used in target work time related columns. * @param {*} userIds */ getTargetWorkTimeForFirstUser(userIds) { if (userIds.length != 1) { return "Not available (must group by employee)"; //TODO: Localize this. } const user = TimeCards.dataManager.getEntity("user", userIds[0]); if (!user) { return "Not available (user not found)"; //TODO: Localize this. } const targetWorkTimeMode = (user.target_work_time_mode == "day" ? "date" : user.target_work_time_mode); if (!this.groups.includes("work_" + targetWorkTimeMode)) { return "Not available (must summarize by " + targetWorkTimeMode + ")"; //TODO: Localize this. } return user.target_work_time; } /** * Sets the table's size according to available space. * This is necessary to restrict the table to available space and make it scrollable. */ updateSize() { this.dataTable.style.height = (this.reportView.scrollHeight - this.reportSettings.scrollHeight) + "px"; this.dataTable.style.width = this.reportView.scrollWidth + "px"; } save(filters = null) { //Do not attempt to save if form input is not valid. if (!this.editForm.reportValidity()) { return; } //Build groups array even in case there is no write access. //This is necessary in order to build groups in parameter mode. this.buildGroups(); //Do not attempt to save if permission is missing. if (!this.hasWriteAccess) { return; } //Get filters if none are provided. if (!filters) { filters = this.getFilters(); } const config = { report_key: "periods", filters: filters ?? {}, groups: this.groups ?? [], date_mode: this.configDateMode, custom_from_date: this.filterFromDate.value, custom_to_date: this.filterToDate.value, hidden_columns: [...this.columnChooser.querySelectorAll("input")].map(input => !input.checked ? input.getAttribute("column-key") : null).filter(columnKey => columnKey) }; const report = { name: this.reportNameField.value, description: this.reportDescriptionField.value, config: JSON.stringify(config) }; if (this.reportOwnerSelect.value) { report.id_user = this.reportOwnerSelect.value; } if (this.reportVisibilitySelectController.value) { report.visibility = parseInt(this.reportVisibilitySelectController.value); } if (this.isNewReport) { if (!this.waitingForReportCreation) { TimeCards.dataManager.store("report", report); this.waitingForReportCreation = true; } } else { TimeCards.dataManager.store("report", this.selectedReport.report_id, report); } } trimPercents(string) { if (string.startsWith("%")) { string = string.substring(1); } if (string.endsWith("%")) { string = string.substring(0, string.length - 1); } return string; } setReportData(report) { this.selectedReport = report; this.reportNameField.value = report.name; this.reportDescriptionField.value = report.description; this.reportOwnerSelect.value = report.id_user; this.reportVisibilitySelectController.value = report.visibility; // WARNING: // Any change to the config structure needs to be reflected in AccountsApi for default reports on account creation. const config = JSON.parse(report.config ?? "{}"); this.configDateMode = config.date_mode ?? "last_month"; this.filterDate.value = this.configDateMode; this.filterFromDate.value = config.custom_from_date ?? new Date().toISOString().substring(0, 10); this.filterToDate.value = config.custom_to_date ?? new Date().toISOString().substring(0, 10); this.filterProject.value = config.filters?.id_project ?? "all"; this.filterUser.value = config.filters?.id_user ?? "all"; this.filterInvoiced.value = !config.filters?.id_invoice ? "all" : (config.filters?.id_invoice == "ask" ? "ask" : ((config.filters?.id_invoice?.cond ?? "=") == "=" ? "not_invoiced_only" : "invoiced_only")); this.filterWork.value = this.trimPercents(config.filters?.card_title?.value ?? ""); this.filterNotes.value = this.trimPercents(config.filters?.notes?.value ?? ""); const groupCheckboxes = this.element.querySelectorAll(".group-checkbox"); for (const checkbox of groupCheckboxes) { checkbox.checked = config.groups?.includes(checkbox.value) ?? false; } const summarizeRadios = this.element.querySelectorAll(".summarize-radio"); for (const radio of summarizeRadios) { //Always check ("none") option first and then check the actual group. //This way, the "none" option is checked when no other option is selected. radio.checked = radio.value === "" || (config.groups?.includes(radio.value) ?? false); } //Show selectable columns. this.columnChooser.innerHTML = ""; const columnInformation = this.columnInformation.periods.fields; for (const columnKey in columnInformation) { if (!Object.hasOwn(columnInformation, columnKey)) continue; const column = columnInformation[columnKey]; //Skip always-hidden columns. if (column.hidden) { continue; } const columnItem = document.createElement("label"); this.columnChooser.appendChild(columnItem); const columnCheckbox = document.createElement("input"); columnCheckbox.type = "checkbox"; columnCheckbox.setAttribute("column-key", columnKey); columnCheckbox.addEventListener("change", this.onColumnSelected.bind(this)); columnCheckbox.checked = !config.hidden_columns || !config.hidden_columns.includes(columnKey); columnItem.appendChild(columnCheckbox); const columnLabel = document.createElement("span"); columnLabel.innerText = column.displayName; columnItem.appendChild(columnLabel); } //Determine whether the session user has write access or not. this.hasWriteAccess = Authentication.isResourceAvailable("report", "w") && (!this.selectedReport || this.selectedReport.id_user == Authentication.currentUser.user_id || Authentication.isResourceAvailable("report_all", "w")) //Update inputs depending on write access. const inputs = this.settingsBox.querySelectorAll("input, select, textarea"); inputs.forEach(input => input.disabled = !this.hasWriteAccess); //Store original diabled value for owner dropdown. if (this.ownerSelectInitialState !== true && this.ownerSelectInitialState !== false) { this.ownerSelectInitialState = this.reportOwnerSelect.disabled; } //Disable the fields if the session user has only read access to them. this.reportNameField.disabled = !this.hasWriteAccess; this.reportDescriptionField.disabled = !this.hasWriteAccess; this.reportOwnerSelect.disabled = this.ownerSelectInitialState || !this.hasWriteAccess; this.reportVisibilitySelect.querySelector("select").disabled = !this.hasWriteAccess; this.setTableData(this.reportCache[this.selectedReport.report_id] ?? []); this.updateDateMode(); this.updateSize(); } updateColumnInformation() { const columnInformation = this.columnInformation.periods; //Add hidden flag to columns not selected. const columnCheckboxes = this.columnChooser.querySelectorAll("input"); for (const checkbox of columnCheckboxes) { const columnKey = checkbox.getAttribute("column-key"); if (checkbox.checked || !(columnKey in columnInformation.fields)) { continue; } columnInformation.fields[columnKey].hidden = true; } this.dataTableController.setColumnInformation(columnInformation); } getFilters() { if (this.filterDate.value == "last_year") { let now = new Date(); //Use local year but set it to UTC full year. //This is because we do not want the time zone to affect the ISO string output. //FYI: toISOString() always outputs the date in UTC. now.setUTCFullYear(now.getFullYear() - 1, 0, 1); now.setUTCHours(0, 0, 0, 0); this.filterFromDate.value = now.toISOString().substring(0, 10); now.setUTCFullYear(now.getFullYear() + 1, 0, 1); now.setUTCHours(0, 0, 0, -1); this.filterToDate.value = now.toISOString().substring(0, 10); } else if (this.filterDate.value == "this_year") { let now = new Date(); now.setUTCFullYear(now.getFullYear(), 0, 1); now.setUTCHours(0, 0, 0, 0); this.filterFromDate.value = now.toISOString().substring(0, 10); now.setUTCFullYear(now.getUTCFullYear() + 1, 0, 1); now.setUTCHours(0, 0, 0, -1); this.filterToDate.value = now.toISOString().substring(0, 10); } else if (this.filterDate.value == "last_month") { let now = new Date(); now.setUTCMonth(now.getMonth() - 1, 1); now.setUTCHours(0, 0, 0, 0); this.filterFromDate.value = now.toISOString().substring(0, 10); now.setUTCMonth(now.getUTCMonth() + 1, 1); now.setUTCHours(0, 0, 0, -1); this.filterToDate.value = now.toISOString().substring(0, 10); } else if (this.filterDate.value == "this_month") { let now = new Date(); now.setUTCMonth(now.getMonth(), 1); now.setUTCHours(0, 0, 0, 0); this.filterFromDate.value = now.toISOString().substring(0, 10); now.setUTCMonth(now.getUTCMonth() + 1, 1); now.setUTCHours(0, 0, 0, -1); this.filterToDate.value = now.toISOString().substring(0, 10); } else if (this.filterDate.value == "last_week") { let now = new Date(); //Get current weekday and convert from SUN–SAT to MON-SUN range. let currentWeekday = now.getDay(); currentWeekday--; if (currentWeekday < 0) { currentWeekday += 7; } now.setUTCDate(now.getDate() - currentWeekday - 7); now.setUTCHours(0, 0, 0, 0); this.filterFromDate.value = now.toISOString().substring(0, 10); now.setUTCDate(now.getUTCDate() + 7, 1); now.setUTCHours(0, 0, 0, -1); this.filterToDate.value = now.toISOString().substring(0, 10); } else if (this.filterDate.value == "this_week") { let now = new Date(); //Get current weekday and convert from SUN–SAT to MON-SUN range. let currentWeekday = now.getDay(); currentWeekday--; if (currentWeekday < 0) { currentWeekday += 7; } now.setUTCDate(now.getDate() - currentWeekday); now.setUTCHours(0, 0, 0, 0); this.filterFromDate.value = now.toISOString().substring(0, 10); now.setUTCDate(now.getUTCDate() + 7, 1); now.setUTCHours(0, 0, 0, -1); this.filterToDate.value = now.toISOString().substring(0, 10); } //Increase to date by one day because we are using a < condition and not <= on the server side. const toDateObject = new Date(this.filterToDate.value); toDateObject.setUTCDate(toDateObject.getUTCDate() + 1); const fromDate = this.filterDate.value != "all" ? this.filterFromDate.value + " 00:00:00" : null; const toDate = this.filterDate.value != "all" ? toDateObject.toISOString().substring(0, 10) + " 00:00:00" : null; const filters = {}; if (this.filterUser.value != "all") { filters.id_user = this.filterUser.value != "ask" ? parseInt(this.filterUser.value) : "ask"; } if (this.filterProject.value != "all") { filters.id_project = this.filterProject.value != "ask" ? parseInt(this.filterProject.value) : "ask"; } if (fromDate) { filters.start_time = { cond: ">=", value: fromDate }; } if (toDate) { filters.end_time = { cond: "<", value: toDate }; } if (this.filterInvoiced.value == "invoiced_only") { filters.id_invoice = { cond: "!=", value: null } } else if (this.filterInvoiced.value == "not_invoiced_only") { filters.id_invoice = { cond: "=", value: null } } else if (this.filterInvoiced.value == "ask") { filters.id_invoice = "ask"; } if (this.filterWork.value != "") { filters.card_title = { cond: "LIKE", value: "%" + this.filterWork.value + "%" } } if (this.filterNotes.value != "") { filters.notes = { cond: "LIKE", value: "%" + this.filterNotes.value + "%" } } return filters; } buildGroups() { this.groups = []; const groupCheckboxes = this.element.querySelectorAll(".group-checkbox"); for (const checkbox of groupCheckboxes) { if (!checkbox.checked) { continue; } this.groups.push(checkbox.value); } const summarizeRadio = this.element.querySelector(".summarize-radio:checked"); if (summarizeRadio.value !== "") { this.groups.push(summarizeRadio.value); } } /** * Loads the selected report with the given filters. * @param {object} filters Filters to save in cofig. * @param {object|null} parameterFilters Filters to actually apply when loading. */ loadWithFilters(filters, parameterFilters = null) { //Save config. this.save(filters); this.loadingReportId = this.selectedReport.report_id; this.loadReportRequest.send({ report_key: "periods", filters: parameterFilters ?? filters, groups: this.groups }); } loadWithParameters(filters) { //Pass config filters to save and actual filters to load the report. this.loadWithFilters(filters, this.getFilters()); } onDuplicateButtonPressed(event) { //Do nothing in case of new report cell. if (this.isNewReport) { return; } //Create copy of report. const duplicate = structuredClone(this.optionsContextMenu.cell.report); delete duplicate.report_id; delete duplicate.created_at; delete duplicate.updated_at; TimeCards.dataManager.store("report", duplicate); } onDeleteButtonPressed(event) { if (this.isNewReport) { this.isNewReport = false; this.reportView.classList.remove("new-report"); this.reportsTableController.removeCell(this.newReportCell); } else { this.reportToDelete = this.optionsContextMenu.cell.report; this.deleteReportDialog.show(); } } onDeleteDialogReturned(buttonId) { if (buttonId == "delete") { TimeCards.dataManager.delete("report", this.reportToDelete?.report_id); } } onCreateButtonPressed(event) { this.isNewReport = true; this.reportView.classList.add("new-report"); this.newReportCell.report = { name: "New report", //TODO: Localize this. description: "", id_user: Authentication.currentUser.user_id, config: "{}", visibility: 0 }; this.reportsTableController.addCell(this.newReportCell); this.reportsTableController.selectCell(this.newReportCell); this.reportNameField.focus(); } onSettingsButtonPressed(event) { this.settingsBox.style.height = this.settingsWrapper.scrollHeight + "px"; this.settingsBox.classList.toggle("visible"); } onConfigFieldChanged(event) { this.updateDateMode(); //Do nothing further if we are in parameter mode. if (this.parameterMode) { return; } //Save on filter change. this.save(); } updateDateMode() { //Show or hide custom date box. this.customDateBox.style.display = this.filterDate.value == "custom" ? "" : "none"; } onLoadButtonPressed(event) { const filters = this.getFilters(); //Store date mode because it is saved in config and not in filters. this.configDateMode = this.filterDate.value; //If any filter is set to "ask", show parameter dialog. if (this.filterUser.value == "ask" || this.filterProject.value == "ask" || this.filterDate.value == "ask" || this.filterInvoiced.value == "ask") { this.parameterMode = true; this.parameterDialog.setReport(this.selectedReport, this.filterContainer, (loadReport) => { this.parameterMode = false; if (!loadReport) { return; } //Pass config filters so that they can be saved. this.loadWithParameters(filters); }, () => this.updateDateMode()); this.presentModalViewController(this.parameterDialog); this.updateDateMode(); return; } //If no filters are set to "ask", load immediately. this.loadWithFilters(filters); } onColumnSelected(event) { this.updateColumnInformation(); this.save(); } onDownloadButtonPressed(event) { const csvString = this.dataTableController.toCsvString(); const blob = new Blob([csvString], { type: "text/csv;charset=utf-8" }); const link = document.createElement("a"); const url = URL.createObjectURL(blob); link.href = url; const fileName = this.selectedReport.name + ".csv"; link.download = fileName; document.body.appendChild(link); link.click(); setTimeout(function() { document.body.removeChild(link); URL.revokeObjectURL(url); }, 0); } } UIKit.registerViewControllerType(ReportsViewController);