diff --git a/models/Main.js b/models/Main.js index 59eda064..9c8c2778 100644 --- a/models/Main.js +++ b/models/Main.js @@ -1,16 +1,16 @@ .import "database.js" as DBCommon .import QtQuick.LocalStorage 2.7 as Sql +.import "accounts.js" as Account +.import "../models/dbinit.js" as DbInit /* Name: get_quadrant_current_week - * This function will return total of spent time for current week based on quadrants from timesheet entries - * 4 quadrants are as following - * 0 -> Urgent and Important - * 1 -> Import but not Urgent - * 2 -> Not Important but Urgent - * 3 -> Not Important and Not Urgent - */ - -.import "../models/dbinit.js" as DbInit + * This function will return total of spent time for current week based on quadrants from timesheet entries + * 4 quadrants are as following + * 0 -> Urgent and Important + * 1 -> Import but not Urgent + * 2 -> Not Important but Urgent + * 3 -> Not Important and Not Urgent + */ DbInit.initializeDatabase(); function get_quadrant_difference() { @@ -74,7 +74,7 @@ function get_quadrant_current_month() { * return format {: } */ -function get_projects_spent_hours(account, startDate, endDate) { +function get_projects_spent_hours(account, startDate, endDate, userId) { var project_details = []; try { @@ -121,6 +121,13 @@ function get_projects_spent_hours(account, startDate, endDate) { conditions.push("DATE(a.record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilter = Account.buildUserFilterSQL(userId, account, "a"); + if (userFilter.clause) { + conditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY p.account_id, p.id, p.name, parent.name, s.name ORDER BY total DESC"; @@ -148,7 +155,7 @@ function get_projects_spent_hours(account, startDate, endDate) { * return format {: } */ -function get_tasks_spent_hours(account, startDate, endDate) { +function get_tasks_spent_hours(account, startDate, endDate, userId) { var task_details = {}; try { @@ -186,6 +193,13 @@ function get_tasks_spent_hours(account, startDate, endDate) { conditions.push("DATE(a.record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterTask = Account.buildUserFilterSQL(userId, account, "a"); + if (userFilterTask.clause) { + conditions.push(userFilterTask.clause); + params = params.concat(userFilterTask.params); + } + } query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY a.task_id, t.name ORDER BY total DESC"; diff --git a/models/accounts.js b/models/accounts.js index 257bbc65..5e22304f 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -1,6 +1,6 @@ .import "database.js" as DBCommon -.import "logger.js" as Logger -.import QtQuick.LocalStorage 2.7 as Sql + .import "logger.js" as Logger + .import QtQuick.LocalStorage 2.7 as Sql /** * Retrieves the list of all user accounts from the local SQLite database. @@ -191,20 +191,20 @@ function getUsers(accountId) { try { var db = Sql.LocalStorage.openDatabaseSync( - DBCommon.NAME, - DBCommon.VERSION, - DBCommon.DISPLAY_NAME, - DBCommon.SIZE - ); + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); db.transaction(function (tx) { var result = tx.executeSql( - "SELECT u.id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, u.odoo_record_id, u.account_id, a.name as account_name " + - "FROM res_users_app u LEFT JOIN users a ON u.account_id = a.id " + - "WHERE u.account_id = ? AND (u.active IS NULL OR u.active = 1) AND u.name IS NOT NULL AND TRIM(u.name) != '' " + - "ORDER BY u.name COLLATE NOCASE ASC", - [accountId] - ); + "SELECT u.id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, u.odoo_record_id, u.account_id, a.name as account_name " + + "FROM res_users_app u LEFT JOIN users a ON u.account_id = a.id " + + "WHERE u.account_id = ? AND (u.active IS NULL OR u.active = 1) AND u.name IS NOT NULL AND TRIM(u.name) != '' " + + "ORDER BY u.name COLLATE NOCASE ASC", + [accountId] + ); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -238,9 +238,9 @@ function fetchParsedSyncLog(accountId) { db.transaction(function (tx) { var rs = tx.executeSql( - "SELECT timestamp, message FROM sync_report WHERE account_id = ? ORDER BY timestamp DESC", - [accountId] - ); + "SELECT timestamp, message FROM sync_report WHERE account_id = ? ORDER BY timestamp DESC", + [accountId] + ); for (var i = 0; i < rs.rows.length; i++) { var entry = rs.rows.item(i); @@ -284,18 +284,18 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap message: "", duplicateType: null }; - + try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - + db.transaction(function (tx) { - - + + const nameCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE LOWER(name) = LOWER(?)', [name] ); - + if (nameCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate account name found (case-insensitive): " + name); result.duplicateFound = true; @@ -303,13 +303,13 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap result.message = "An account with this name already exists."; return; } - - + + const connectionCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE link = ? AND database = ? AND username = ? COLLATE BINARY', [link, database, username] ); - + if (connectionCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate connection found for: " + link + "/" + database + "/" + username); result.duplicateFound = true; @@ -317,24 +317,24 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap result.message = "An account with this server connection already exists."; return; } - - + + const apiKeyToStore = (selectedConnectWithId === 1) ? apikey : ''; tx.executeSql( 'INSERT INTO users (name, link, database, username, connectwith_id, api_key) VALUES (?, ?, ?, ?, ?, ?)', [name, link, database, username, selectedConnectWithId, apiKeyToStore] ); - + DBCommon.log("New user account created successfully: " + name); result.message = "Account created successfully."; }); - + } catch (e) { DBCommon.logException("createAccount", e); result.duplicateFound = true; result.message = "Error creating account: " + e.message; } - + return result; } @@ -356,57 +356,57 @@ function updateAccount(accountId, name, link, database, username, selectedConnec message: "", duplicateType: null }; - + try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - + db.transaction(function (tx) { // Check for duplicate account name (excluding current account) const nameCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, accountId] ); - + if (nameCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate account name found (case-insensitive): " + name); result.duplicateType = "name"; result.message = "An account with this name already exists."; return; } - + // Check for duplicate connection (excluding current account) const connectionCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE link = ? AND database = ? AND username = ? COLLATE BINARY AND id != ?', [link, database, username, accountId] ); - + if (connectionCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate connection found for: " + link + "/" + database + "/" + username); result.duplicateType = "connection"; result.message = "An account with this server connection already exists."; return; } - + // Update the account const apiKeyToStore = (selectedConnectWithId === 1) ? apikey : ''; tx.executeSql( 'UPDATE users SET name = ?, link = ?, database = ?, username = ?, connectwith_id = ?, api_key = ? WHERE id = ?', [name, link, database, username, selectedConnectWithId, apiKeyToStore, accountId] ); - + DBCommon.log("User account updated successfully: " + name); result.success = true; result.message = "Account updated successfully."; }); - + } catch (e) { DBCommon.logException("updateAccount", e); result.message = "Error updating account: " + e.message; } - + return result; } - + /** * Deletes a user account and all related records from associated tables in the local SQLite database. * @@ -426,23 +426,23 @@ function deleteAccountAndRelatedData(userId) { db.transaction(function (tx) { const tables = [ - "sync_report", - "project_project_app", - "project_task_app", - "account_analytic_line_app", - "res_users_app", - "mail_activity_type_app", - "ir_model_app", - "mail_activity_app", - "ir_attachment_app", - "project_task_assignee_app", - "project_update_app", - "project_task_type_app", - "project_project_stage_app", - "attachment_download_app", - "form_drafts", - "notification" - ]; + "sync_report", + "project_project_app", + "project_task_app", + "account_analytic_line_app", + "res_users_app", + "mail_activity_type_app", + "ir_model_app", + "mail_activity_app", + "ir_attachment_app", + "project_task_assignee_app", + "project_update_app", + "project_task_type_app", + "project_project_stage_app", + "attachment_download_app", + "form_drafts", + "notification" + ]; var numericUserId = parseInt(userId, 10); for (let i = 0; i < tables.length; i++) { @@ -515,7 +515,7 @@ function getCurrentUserOdooId(accountId) { DBCommon.log(`Found username: ${username}, now checking res_users_app`); - const userResult = tx.executeSql("SELECT odoo_record_id FROM res_users_app WHERE login = ? AND account_id = ?", [username, accountId]); + const userResult = tx.executeSql("SELECT odoo_record_id FROM res_users_app WHERE (login = ? OR email = ? OR work_email = ?) AND account_id = ? LIMIT 1", [username, username, username, accountId]); if (userResult.rows.length > 0) { odooId = userResult.rows.item(0).odoo_record_id; @@ -648,6 +648,7 @@ function getAccountName(accountId) { * * @function getUserNameByOdooId * @param {number} odoo_record_id - The user ID from Odoo (remote system). + * @param {number} [accountId=-1] - The account ID to scope the lookup. * @returns {string} - The user's name if found; otherwise, an empty string. * * @description @@ -656,15 +657,24 @@ function getAccountName(accountId) { * If a match is found, extracts and returns the `name` field. * Logs any exceptions using `DBCommon.logException()` to ensure safe failure handling. */ -function getUserNameByOdooId(odoo_record_id) { +function getUserNameByOdooId(odoo_record_id, accountId) { var userName = ""; try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1"; - var result = tx.executeSql(query, [odoo_record_id, odoo_record_id]); + var numAccId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + var query = ""; + var params = []; + if (numAccId !== -1) { + query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1"; + params = [odoo_record_id, odoo_record_id, numAccId]; + } else { + query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1"; + params = [odoo_record_id, odoo_record_id]; + } + var result = tx.executeSql(query, params); if (result.rows.length > 0) { userName = result.rows.item(0).name; @@ -678,6 +688,952 @@ function getUserNameByOdooId(odoo_record_id) { return userName; } +/** + * Combines user objects that share the same email or login across different accounts. + * + * @param {Array} userList - The list of user objects. + * @param {Object} [tx] - Active SQLite transaction. + * @returns {Array} Combined user list with duplicate accounts merged. + */ +function combineUsersByEmail(userList, tx) { + if (!userList || userList.length <= 1) { + return userList || []; + } + + var groups = {}; + var groupOrder = []; + + for (var i = 0; i < userList.length; i++) { + var u = userList[i]; + var email = (u.email || u.work_email || "").trim().toLowerCase(); + var login = (u.login || "").trim().toLowerCase(); + + var key = ""; + if (email !== "") { + key = "email:" + email; + } else if (login !== "" && login.indexOf("@") !== -1) { + key = "email:" + login; + } else { + var accId = (u.accountId !== undefined && u.accountId !== null) ? u.accountId : + ((u.account_id !== undefined && u.account_id !== null) ? u.account_id : "0"); + key = "account:" + accId + ":id:" + u.id; + } + + if (!groups[key]) { + groups[key] = { + items: [u], + emails: email !== "" ? [email] : [], + logins: login !== "" ? [login] : [], + allUserIds: [u.id], + allAccountIds: (u.accountId !== undefined && u.accountId !== null) ? [u.accountId] : [] + }; + groupOrder.push(key); + } else { + var g = groups[key]; + g.items.push(u); + if (g.allUserIds.indexOf(u.id) === -1) { + g.allUserIds.push(u.id); + } + if (u.accountId !== undefined && u.accountId !== null && g.allAccountIds.indexOf(u.accountId) === -1) { + g.allAccountIds.push(u.accountId); + } + if (email !== "" && g.emails.indexOf(email) === -1) { + g.emails.push(email); + } + if (login !== "" && g.logins.indexOf(login) === -1) { + g.logins.push(login); + } + } + } + + var combined = []; + for (var j = 0; j < groupOrder.length; j++) { + var grp = groups[groupOrder[j]]; + var items = grp.items; + + // Pick best name: longest non-placeholder name + var bestName = items[0].name || ""; + var bestAvatar = items[0].avatar || ""; + for (var k = 0; k < items.length; k++) { + var candName = (items[k].name || "").trim(); + if (candName !== "" && !/^User #\d+$/i.test(candName)) { + if (bestName === "" || /^User #\d+$/i.test(bestName) || candName.length > bestName.length) { + bestName = candName; + } + } + if (!bestAvatar && items[k].avatar) { + bestAvatar = items[k].avatar; + } + } + + // Query res_users_app for any other account/user IDs for these emails + if (tx && grp.emails.length > 0) { + try { + var emPlaceholders = grp.emails.map(function () { return "?"; }).join(","); + var qRes = tx.executeSql( + "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE (LOWER(TRIM(email)) IN (" + emPlaceholders + ") OR LOWER(TRIM(work_email)) IN (" + emPlaceholders + "))", + grp.emails.concat(grp.emails) + ); + for (var m = 0; m < qRes.rows.length; m++) { + var rowUid = Number(qRes.rows.item(m).uid); + var rowAccId = Number(qRes.rows.item(m).account_id); + if (grp.allUserIds.indexOf(rowUid) === -1) { + grp.allUserIds.push(rowUid); + } + if (grp.allAccountIds.indexOf(rowAccId) === -1) { + grp.allAccountIds.push(rowAccId); + } + } + } catch (err) { + // Ignore query error, fall back to known items + } + } + + var accountNames = []; + if (tx && grp.allAccountIds && grp.allAccountIds.length > 0) { + try { + var aPl = grp.allAccountIds.map(function () { return "?"; }).join(","); + var aRes = tx.executeSql("SELECT id, name FROM users WHERE id IN (" + aPl + ")", grp.allAccountIds); + for (var an = 0; an < aRes.rows.length; an++) { + var aRow = aRes.rows.item(an); + if (aRow.name && accountNames.indexOf(aRow.name) === -1) { + accountNames.push(aRow.name); + } + } + } catch (aErr) { + // Ignore query error + } + } + + var primaryItem = items[0]; + combined.push({ + id: primaryItem.id, + name: bestName || primaryItem.name || ("User #" + primaryItem.id), + login: primaryItem.login || (grp.logins.length > 0 ? grp.logins[0] : ""), + email: primaryItem.email || (grp.emails.length > 0 ? grp.emails[0] : ""), + avatar: bestAvatar, + accountId: primaryItem.accountId, + account_id: primaryItem.accountId, + account_names: accountNames, + account_name: accountNames.join(", "), + allUserIds: grp.allUserIds, + allAccountIds: grp.allAccountIds + }); + } + + return combined; +} + +/** + * Resolves all { accountId, userId } pairs corresponding to a user filter. + * When accountId is -1 (All Accounts), users with the same email or login across + * different accounts are combined, and their corresponding user IDs in each account are returned. + * When accountId is specific (> -1), it resolves the user ID valid for that specific account. + * + * @param {number|string} userId - The selected user ID (-1 for all users). + * @param {number|string} [accountId=-1] - The active account ID (-1 for all accounts). + * @returns {Array} List of { accountId: number, userId: number } pairs. + */ +function getUserFilterAccountPairs(userId, accountId) { + var pairs = []; + if (userId === undefined || userId === null || userId === -1 || userId === "-1") { + return pairs; + } + + var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + + // Helper: query res_users_app for matching users in other accounts by email + function findMatchingUsersByEmail(tx, email, excludeAccountId) { + var matches = []; + if (!email || email.trim() === "") { + return matches; + } + var em = email.trim().toLowerCase(); + var sql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) "; + var params = [em, em]; + if (excludeAccountId !== undefined && excludeAccountId !== null && excludeAccountId >= 0) { + sql += "AND account_id != ? "; + params.push(excludeAccountId); + } + var res = tx.executeSql(sql, params); + for (var i = 0; i < res.rows.length; i++) { + matches.push({ + accountId: Number(res.rows.item(i).account_id), + userId: Number(res.rows.item(i).uid) + }); + } + return matches; + } + + // Helper: add pair if not already in list + var seenPairs = {}; + function addPair(accId, uId) { + var a = Number(accId); + var u = Number(uId); + if (isNaN(u) || u < 0) return; + var k = a + ":" + u; + if (!seenPairs[k]) { + seenPairs[k] = true; + pairs.push({ accountId: a, userId: u }); + } + } + + // Normalize single selection object as array + if (typeof userId === "object" && !Array.isArray(userId)) { + var objUid = (userId.user_id !== undefined) ? userId.user_id : (userId.id !== undefined ? userId.id : userId.userId); + if (objUid === -1 || objUid === "-1") { + return pairs; + } + userId = [userId]; + } + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + function resolveScalar(t, sId) { + if (isNaN(sId) || sId < 0) return; + + // If querying a specific account, strictly scope to that account + if (numAccountId !== -1) { + var sRes = t.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [numAccountId, sId, sId] + ); + if (sRes.rows.length > 0) { + addPair(numAccountId, Number(sRes.rows.item(0).uid)); + return; + } + + // Not directly in this account: check if sId exists in another account with an email + var otherRes = t.executeSql( + "SELECT email, work_email FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))", + [sId, sId] + ); + var matchedOther = false; + for (var o = 0; o < otherRes.rows.length; o++) { + var oEmail = (otherRes.rows.item(o).email || otherRes.rows.item(o).work_email || "").trim().toLowerCase(); + if (oEmail !== "") { + var targetMatch = t.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) LIMIT 1", + [numAccountId, oEmail, oEmail] + ); + if (targetMatch.rows.length > 0) { + addPair(numAccountId, Number(targetMatch.rows.item(0).uid)); + matchedOther = true; + break; + } + } + } + if (!matchedOther) { + addPair(numAccountId, sId); + } + return; + } + + // Querying All Accounts (-1): + // Find res_users_app records with this ID + var allRes = t.executeSql( + "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid, email, work_email " + + "FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))", + [sId, sId] + ); + + if (allRes.rows.length === 0) { + addPair(-1, sId); + return; + } + + if (allRes.rows.length === 1) { + var singleRow = allRes.rows.item(0); + var singleAcc = Number(singleRow.account_id); + var singleUid = Number(singleRow.uid); + addPair(singleAcc, singleUid); + + var singleEmail = (singleRow.email || singleRow.work_email || "").trim().toLowerCase(); + if (singleEmail !== "") { + var singleMatches = findMatchingUsersByEmail(t, singleEmail, singleAcc); + for (var sm = 0; sm < singleMatches.length; sm++) { + addPair(singleMatches[sm].accountId, singleMatches[sm].userId); + } + } + return; + } + + // Multiple accounts have a user with this ID number (e.g. ID 2 or 6 on different servers) + // Check if they all share the exact same non-empty email + var distinctEmails = []; + for (var r = 0; r < allRes.rows.length; r++) { + var rEmail = (allRes.rows.item(r).email || allRes.rows.item(r).work_email || "").trim().toLowerCase(); + if (rEmail !== "" && distinctEmails.indexOf(rEmail) === -1) { + distinctEmails.push(rEmail); + } + } + + if (distinctEmails.length === 1) { + var allShareEmail = true; + for (var rr = 0; rr < allRes.rows.length; rr++) { + var rrEmail = (allRes.rows.item(rr).email || allRes.rows.item(rr).work_email || "").trim().toLowerCase(); + if (rrEmail !== distinctEmails[0]) { + allShareEmail = false; + break; + } + } + + if (allShareEmail) { + for (var m = 0; m < allRes.rows.length; m++) { + addPair(Number(allRes.rows.item(m).account_id), Number(allRes.rows.item(m).uid)); + } + var emailMatches = findMatchingUsersByEmail(t, distinctEmails[0], -1); + for (var emIdx = 0; emIdx < emailMatches.length; emIdx++) { + addPair(emailMatches[emIdx].accountId, emailMatches[emIdx].userId); + } + return; + } + } + + // Different users sharing the same ID number on different servers! + // DO NOT mix them up! Scope to the default account (or first record). + var defAccId = -1; + var defRes = t.executeSql("SELECT id FROM users WHERE is_default = 1 LIMIT 1"); + if (defRes.rows.length > 0) { + defAccId = Number(defRes.rows.item(0).id); + } + + var chosenRow = null; + for (var c = 0; c < allRes.rows.length; c++) { + if (Number(allRes.rows.item(c).account_id) === defAccId) { + chosenRow = allRes.rows.item(c); + break; + } + } + if (!chosenRow) { + chosenRow = allRes.rows.item(0); + } + + var chosenAcc = Number(chosenRow.account_id); + var chosenUid = Number(chosenRow.uid); + addPair(chosenAcc, chosenUid); + + var chosenEmail = (chosenRow.email || chosenRow.work_email || "").trim().toLowerCase(); + if (chosenEmail !== "") { + var chosenMatches = findMatchingUsersByEmail(t, chosenEmail, chosenAcc); + for (var cm = 0; cm < chosenMatches.length; cm++) { + addPair(chosenMatches[cm].accountId, chosenMatches[cm].userId); + } + } + } + + // Handle Array of users/selections + if (Array.isArray(userId)) { + if (userId.length === 0) { + return; + } + + for (var a = 0; a < userId.length; a++) { + var item = userId[a]; + if (item === undefined || item === null || item === -1 || item === "-1") { + continue; + } + + if (typeof item === "object") { + var itemUid = (item.user_id !== undefined) ? item.user_id : (item.id !== undefined ? item.id : item.userId); + var itemAccId = (item.account_id !== undefined && item.account_id !== null) ? item.account_id : + ((item.accountId !== undefined && item.accountId !== null) ? item.accountId : -1); + + if (itemUid === undefined || itemUid === null || itemUid === -1 || itemUid === "-1") { + continue; + } + + // Case 1: Pre-combined user with allUserIds and allAccountIds + if (item.allUserIds && item.allAccountIds && item.allUserIds.length > 0) { + for (var uIdx = 0; uIdx < item.allUserIds.length; uIdx++) { + var uAcc = Number(item.allAccountIds[uIdx]); + var uVal = Number(item.allUserIds[uIdx]); + if (numAccountId === -1 || numAccountId === uAcc) { + addPair(uAcc, uVal); + } + } + continue; + } + + var nItemUid = Number(itemUid); + var nItemAccId = (itemAccId !== undefined && itemAccId !== null) ? Number(itemAccId) : -1; + + // Case 2: Object with known account_id + if (nItemAccId >= 0) { + if (numAccountId !== -1) { + if (numAccountId === nItemAccId) { + addPair(nItemAccId, nItemUid); + } else { + // Target account is different from item's account: + // Match in target account by email only + var uRes = tx.executeSql( + "SELECT email, work_email FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [nItemAccId, nItemUid, nItemUid] + ); + if (uRes.rows.length > 0) { + var emSingle = (uRes.rows.item(0).email || uRes.rows.item(0).work_email || "").trim().toLowerCase(); + if (emSingle !== "") { + var targetMatch = tx.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) LIMIT 1", + [numAccountId, emSingle, emSingle] + ); + if (targetMatch.rows.length > 0) { + addPair(numAccountId, Number(targetMatch.rows.item(0).uid)); + } + } + } + } + } else { + // All accounts: add this user, and match other accounts by email ONLY + addPair(nItemAccId, nItemUid); + var uResAll = tx.executeSql( + "SELECT email, work_email FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [nItemAccId, nItemUid, nItemUid] + ); + if (uResAll.rows.length > 0) { + var emAll = (uResAll.rows.item(0).email || uResAll.rows.item(0).work_email || "").trim().toLowerCase(); + if (emAll !== "") { + var otherMatches = findMatchingUsersByEmail(tx, emAll, nItemAccId); + for (var om = 0; om < otherMatches.length; om++) { + addPair(otherMatches[om].accountId, otherMatches[om].userId); + } + } + } + } + continue; + } + + // Case 3: Object without known account_id -> resolve as scalar ID + resolveScalar(tx, nItemUid); + } else { + // Scalar item in array + resolveScalar(tx, Number(item)); + } + } + return; + } + + // Direct scalar userId + var numScalarId = Number(userId); + if (!isNaN(numScalarId) && numScalarId >= 0) { + resolveScalar(tx, numScalarId); + } + }); + } catch (e) { + DBCommon.logException("getUserFilterAccountPairs", e); + } + + if (pairs.length === 0 && numAccountId !== -1) { + var fallbackUid = Number(userId); + if (!isNaN(fallbackUid) && fallbackUid >= 0) { + pairs.push({ accountId: numAccountId, userId: fallbackUid }); + } + } + + return pairs; +} + +/** + * Builds a SQL WHERE clause fragment and parameters array for filtering timesheets by user. + * Supports scalar user ID (-1 for all users), array of user IDs, or array of selection objects. + * + * @param {number|string|Array} userId - The user ID or array of IDs/selection objects. + * @param {number|string} [accountId=-1] - The active account ID (-1 for all accounts). + * @param {string} [tableAlias=""] - Optional table alias (e.g. "aal" or "a"). + * @returns {{ clause: string, params: Array }} + */ +function buildUserFilterSQL(userId, accountId, tableAlias) { + if (userId === undefined || userId === null || userId === -1 || userId === "-1") { + return { clause: "", params: [] }; + } + if (typeof userId === "object" && !Array.isArray(userId)) { + var singleObjUid = (userId.user_id !== undefined) ? userId.user_id : userId.userId; + if (singleObjUid === -1 || singleObjUid === "-1") { + return { clause: "", params: [] }; + } + userId = [userId]; + } + if (Array.isArray(userId)) { + if (userId.length === 0) { + return { clause: "", params: [] }; + } + if (userId.length === 1 && (userId[0] === -1 || userId[0] === "-1" || (typeof userId[0] === "object" && (userId[0].user_id === -1 || userId[0].userId === -1)))) { + return { clause: "", params: [] }; + } + } + + var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + var pairs = getUserFilterAccountPairs(userId, accountId); + var prefix = tableAlias ? (tableAlias + ".") : ""; + + if (pairs.length === 0) { + return { clause: "", params: [] }; + } + + if (numAccountId !== -1) { + var userIdsInAccount = []; + for (var p = 0; p < pairs.length; p++) { + if (userIdsInAccount.indexOf(pairs[p].userId) === -1) { + userIdsInAccount.push(pairs[p].userId); + } + } + if (userIdsInAccount.length === 0) { + return { clause: "", params: [] }; + } + if (userIdsInAccount.length === 1) { + return { clause: prefix + "user_id = ?", params: [userIdsInAccount[0]] }; + } + var placeholders = userIdsInAccount.map(function () { return "?"; }).join(", "); + return { + clause: prefix + "user_id IN (" + placeholders + ")", + params: userIdsInAccount + }; + } + + if (pairs.length === 1) { + if (pairs[0].accountId >= 0) { + return { + clause: "(" + prefix + "account_id = ? AND " + prefix + "user_id = ?)", + params: [pairs[0].accountId, pairs[0].userId] + }; + } else { + return { clause: prefix + "user_id = ?", params: [pairs[0].userId] }; + } + } + + var byAccount = {}; + var accList = []; + for (var i = 0; i < pairs.length; i++) { + var acct = pairs[i].accountId; + var u = pairs[i].userId; + if (!byAccount[acct]) { + byAccount[acct] = []; + accList.push(acct); + } + if (byAccount[acct].indexOf(u) === -1) { + byAccount[acct].push(u); + } + } + + var clauses = []; + var params = []; + for (var aIdx = 0; aIdx < accList.length; aIdx++) { + var aId = accList[aIdx]; + var uIds = byAccount[aId]; + if (aId >= 0) { + if (uIds.length === 1) { + clauses.push("(" + prefix + "account_id = ? AND " + prefix + "user_id = ?)"); + params.push(aId); + params.push(uIds[0]); + } else { + var inPlaceholders = uIds.map(function () { return "?"; }).join(", "); + clauses.push("(" + prefix + "account_id = ? AND " + prefix + "user_id IN (" + inPlaceholders + "))"); + params.push(aId); + params = params.concat(uIds); + } + } else { + if (uIds.length === 1) { + clauses.push(prefix + "user_id = ?"); + params.push(uIds[0]); + } else { + var inPl = uIds.map(function () { return "?"; }).join(", "); + clauses.push(prefix + "user_id IN (" + inPl + ")"); + params = params.concat(uIds); + } + } + } + + return { + clause: "(" + clauses.join(" OR ") + ")", + params: params + }; +} + +/** + * Retrieves active users available for filtering in the dashboard for a given account. + * When accountId is -1 (All Accounts), users with the same email or login across + * different accounts are combined into a single entry with combined metrics. + * + * @param {number} accountId - The account ID (-1 for all accounts). + * @returns {Array} List of users: [{ id, name, login, email, avatar, accountId, allUserIds }] + */ +function getDashboardFilterUsers(accountId) { + var users = []; + var seenIds = {}; + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + // Local Account (account_id = 0) + if (accountId === 0 || accountId === "0") { + users.push({ + id: 1, + name: "Local User", + login: "local_user", + email: "", + avatar: "", + accountId: 0, + allUserIds: [1] + }); + return; + } + + // 1. Find distinct user IDs that have timesheet records in the local database + var tsQuery = "SELECT DISTINCT account_id, user_id FROM account_analytic_line_app WHERE (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0 "; + var tsParams = []; + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + tsQuery += "AND account_id = ? "; + tsParams.push(accountId); + } + + var tsResult = tx.executeSql(tsQuery, tsParams); + var localUsers = []; + for (var i = 0; i < tsResult.rows.length; i++) { + var rowItem = tsResult.rows.item(i); + var uKey = rowItem.account_id + ":" + rowItem.user_id; + if (!seenIds[uKey]) { + seenIds[uKey] = true; + localUsers.push({ accountId: rowItem.account_id, userId: rowItem.user_id }); + } + } + + // 2. Ensure current logged-in user for this account is included ("including own name") + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + var loggedInUid = getCurrentUserOdooId(accountId); + var lKey = accountId + ":" + loggedInUid; + if (loggedInUid && loggedInUid > 0 && !seenIds[lKey]) { + seenIds[lKey] = true; + localUsers.push({ accountId: accountId, userId: loggedInUid }); + } + } else { + // For all accounts, include logged in user of every active account + var accRes = tx.executeSql("SELECT id FROM users WHERE id > 0"); + for (var a = 0; a < accRes.rows.length; a++) { + var instAccId = accRes.rows.item(a).id; + var instLoggedInUid = getCurrentUserOdooId(instAccId); + var instKey = instAccId + ":" + instLoggedInUid; + if (instLoggedInUid && instLoggedInUid > 0 && !seenIds[instKey]) { + seenIds[instKey] = true; + localUsers.push({ accountId: instAccId, userId: instLoggedInUid }); + } + } + } + + // 3. If user records were found from timesheets or logged-in user, enrich from res_users_app + if (localUsers.length > 0) { + for (var j = 0; j < localUsers.length; j++) { + var target = localUsers[j]; + var uQuery = "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS user_id, " + + "name, login, email, work_email, avatar_128, account_id " + + "FROM res_users_app " + + "WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) "; + var uParams = [target.userId, target.userId]; + if (target.accountId !== undefined && target.accountId !== null && target.accountId !== -1) { + uQuery += "AND account_id = ? "; + uParams.push(target.accountId); + } + uQuery += "LIMIT 1"; + + var uRes = tx.executeSql(uQuery, uParams); + if (uRes.rows.length > 0) { + var row = uRes.rows.item(0); + var accIdVal = row.account_id !== undefined ? row.account_id : target.accountId; + users.push({ + id: target.userId, + name: row.name || row.login || getUserNameByOdooId(target.userId, target.accountId) || ("User #" + target.userId), + login: row.login || "", + email: row.email || row.work_email || "", + avatar: row.avatar_128 || "", + accountId: accIdVal, + account_id: accIdVal, + allUserIds: [target.userId] + }); + } else { + users.push({ + id: target.userId, + name: getUserNameByOdooId(target.userId, target.accountId) || ("User #" + target.userId), + login: "", + email: "", + avatar: "", + accountId: target.accountId, + account_id: target.accountId, + allUserIds: [target.userId] + }); + } + } + } else { + // 4. Fallback: If no timesheet records exist yet, fall back to active users from res_users_app + var fallbackQuery = "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS user_id, " + + "name, login, email, work_email, avatar_128, account_id " + + "FROM res_users_app " + + "WHERE (active = 1 OR active IS NULL) "; + var fallbackParams = []; + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + fallbackQuery += "AND account_id = ? "; + fallbackParams.push(accountId); + } + fallbackQuery += "ORDER BY name COLLATE NOCASE ASC"; + + var fallbackRes = tx.executeSql(fallbackQuery, fallbackParams); + for (var k = 0; k < fallbackRes.rows.length; k++) { + var fRow = fallbackRes.rows.item(k); + var fUid = fRow.user_id; + var fbKey = fRow.account_id + ":" + fUid; + if (!seenIds[fbKey]) { + seenIds[fbKey] = true; + users.push({ + id: fUid, + name: fRow.name || fRow.login || ("User #" + fUid), + login: fRow.login || "", + email: fRow.email || fRow.work_email || "", + avatar: fRow.avatar_128 || "", + accountId: fRow.account_id, + account_id: fRow.account_id, + allUserIds: [fUid] + }); + } + } + } + + // 5. If all accounts (-1), combine users by email across accounts + if (accountId === -1 || accountId === "-1" || accountId === undefined || accountId === null) { + users = combineUsersByEmail(users, tx); + } + + // Sort alphabetically by name + users.sort(function (a, b) { + return (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }); + }); + }); + } catch (e) { + DBCommon.logException("getDashboardFilterUsers", e); + } + + return users; +} + +/** + * Resolves the default user to display on the dashboard for a given account. + * By default, the dashboard must display the currently logged-in user's personal metrics upon initial load, + * regardless of access level (standard employee, manager, or admin). + * Aggregated team data ("All Users") is only displayed when explicitly selected from the filter dropdown. + * + * @param {number} accountId - The account ID. + * @param {Array} [availableUsers] - Optional pre-fetched users list. + * @returns {Object|null} The default user object ({ id, name }), or null if none can be resolved. + */ +function getDashboardSingleUserDefault(accountId, availableUsers) { + if (!availableUsers) { + availableUsers = getDashboardFilterUsers(accountId); + } + + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId, 10) : -1; + + // Local Account (0) + if (parsedAccountId === 0) { + if (availableUsers && availableUsers.length > 0) { + return availableUsers[0]; + } + return { id: 1, name: "Local User" }; + } + + // Instance Account (> 0): Always default to currently logged-in user + if (parsedAccountId > 0) { + var loggedInUid = getCurrentUserOdooId(parsedAccountId); + if (loggedInUid && loggedInUid > 0) { + if (availableUsers && availableUsers.length > 0) { + for (var i = 0; i < availableUsers.length; i++) { + if (availableUsers[i].id === loggedInUid) { + return availableUsers[i]; + } + } + } + var userName = getUserNameByOdooId(loggedInUid, parsedAccountId); + return { + id: loggedInUid, + name: userName || ("User #" + loggedInUid) + }; + } + } + + // All Accounts (-1): Try to default to currently logged-in user of default account or any active account + if (parsedAccountId === -1) { + var targetUid = -1; + var sourceAccId = -1; + var defAccId = getDefaultRemoteAccountId(); + if (defAccId > 0) { + targetUid = getCurrentUserOdooId(defAccId); + sourceAccId = defAccId; + } + if (targetUid <= 0) { + var allAccs = getActiveAccounts(); + for (var a = 0; a < allAccs.length; a++) { + var aUid = getCurrentUserOdooId(allAccs[a].id); + if (aUid > 0) { + targetUid = aUid; + sourceAccId = allAccs[a].id; + break; + } + } + } + if (targetUid > 0 && availableUsers && availableUsers.length > 0) { + for (var m = 0; m < availableUsers.length; m++) { + var cand = availableUsers[m]; + if (cand.allUserIds && cand.allAccountIds) { + for (var u = 0; u < cand.allUserIds.length; u++) { + if (cand.allUserIds[u] === targetUid && (sourceAccId <= 0 || cand.allAccountIds[u] === sourceAccId)) { + return cand; + } + } + } else if (cand.id === targetUid) { + var cAcc = (cand.accountId !== undefined) ? cand.accountId : cand.account_id; + if (sourceAccId <= 0 || cAcc === undefined || cAcc === sourceAccId) { + return cand; + } + } + } + } + } + + // Fallback if exactly one user's data exists + if (availableUsers && availableUsers.length === 1) { + return availableUsers[0]; + } + + return null; +} + +/** + * Checks whether the specified account grants manager or admin access. + * + * An account grants manager or admin access if: + * 1. The account is an admin account (username or login is 'admin', or Odoo UID is 2). + * 2. The user is a Project Manager on at least one active project in the account. + * 3. The user has Timesheet/Team Manager access (timesheets belonging to other employees exist in the account). + * + * Standard access accounts (e.g. standard employees with no PM/admin rights, + * and local offline accounts) return false. + * For All Accounts (-1), returns true if any configured remote account grants manager/admin access. + * + * @param {number} accountId - The account ID (-1 for all accounts, 0 for local account). + * @param {Object} [transaction] - Optional SQLite transaction to reuse. + * @returns {boolean} True if manager or admin access is granted, false otherwise. + */ +function hasManagerOrAdminAccess(accountId, transaction) { + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId, 10) : -1; + if (parsedAccountId === 0) { + return false; + } + + if (transaction) { + return _checkManagerOrAdminAccessTx(parsedAccountId, transaction); + } + + var hasAccess = false; + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + hasAccess = _checkManagerOrAdminAccessTx(parsedAccountId, tx); + }); + } catch (e) { + DBCommon.logException("hasManagerOrAdminAccess", e); + } + return hasAccess; +} + +function _checkManagerOrAdminAccessTx(accountId, tx) { + if (accountId === 0) { + return false; + } + + // All Accounts (-1): Check if any active remote account grants manager/admin access + if (accountId === -1) { + var allAccs = tx.executeSql("SELECT id FROM users WHERE id > 0"); + for (var a = 0; a < allAccs.rows.length; a++) { + var instId = allAccs.rows.item(a).id; + if (_checkManagerOrAdminAccessTx(instId, tx)) { + return true; + } + } + return false; + } + + // 1. Check if username on the account is 'admin' + var accRes = tx.executeSql("SELECT username FROM users WHERE id = ?", [accountId]); + var username = ""; + if (accRes.rows.length > 0) { + username = accRes.rows.item(0).username || ""; + if (username.toLowerCase() === "admin") { + return true; + } + } + + // 2. Resolve current user's Odoo UID and check if user login is 'admin' or UID is 2 + var currentUid = null; + if (username) { + var uRes = tx.executeSql( + "SELECT odoo_record_id, login FROM res_users_app WHERE (login = ? OR email = ? OR work_email = ?) AND account_id = ? LIMIT 1", + [username, username, username, accountId] + ); + if (uRes.rows.length > 0) { + var uRow = uRes.rows.item(0); + currentUid = uRow.odoo_record_id; + var uLogin = uRow.login || ""; + if (uLogin.toLowerCase() === "admin") { + return true; + } + } + } + + if (!currentUid) { + currentUid = getCurrentUserOdooId(accountId); + } + + if (currentUid === 2) { + return true; + } + + // 3. Check if user is a Project Manager on any active project for this account + if (currentUid && currentUid > 0) { + var pmRes = tx.executeSql( + "SELECT COUNT(*) AS cnt FROM project_project_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND (user_id = ? OR user_id = CAST(? AS TEXT))", + [accountId, currentUid, currentUid] + ); + if (pmRes.rows.length > 0 && pmRes.rows.item(0).cnt > 0) { + return true; + } + } + + // 4. Check if user has Timesheet/Team Manager access (timesheets of other employees exist) + if (currentUid && currentUid > 0) { + var teamRes = tx.executeSql( + "SELECT COUNT(*) AS cnt FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0 AND user_id != ?", + [accountId, currentUid] + ); + if (teamRes.rows.length > 0 && teamRes.rows.item(0).cnt > 0) { + return true; + } + } else { + var tsRes = tx.executeSql( + "SELECT COUNT(DISTINCT user_id) AS cnt FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0", + [accountId] + ); + if (tsRes.rows.length > 0 && tsRes.rows.item(0).cnt > 1) { + return true; + } + } + + return false; +} + /** * Retrieves the Odoo model ID (`odoo_record_id`) from the local SQLite database * based on the given account ID and technical model name. @@ -707,7 +1663,7 @@ function getOdooModelId(accountId, technicalName) { if (rs.rows.length > 0) { odooRecordId = rs.rows.item(0).odoo_record_id; - // console.log("Found Odoo Model ID:", odooRecordId); + // console.log("Found Odoo Model ID:", odooRecordId); } else { Logger.warn("Accounts", "No matching ir.model found for:", technicalName) } @@ -722,7 +1678,7 @@ function getOdooModelId(accountId, technicalName) { function clearDefaultAccount() { try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - db.transaction(function(tx) { + db.transaction(function (tx) { tx.executeSql("UPDATE users SET is_default = 0"); }); } catch (e) { @@ -749,7 +1705,7 @@ function updateAccountSyncSettings(accountId, intervalMinutes, direction, enable ); }); DBCommon.log("Updated sync settings for account " + accountId + - ": interval=" + intervalMinutes + ", direction=" + direction + ", enabled=" + enabled); + ": interval=" + intervalMinutes + ", direction=" + direction + ", enabled=" + enabled); } catch (e) { DBCommon.logException("updateAccountSyncSettings", e); } diff --git a/models/global.js b/models/global.js index ceaff954..766c30ab 100644 --- a/models/global.js +++ b/models/global.js @@ -29,6 +29,23 @@ var dateRangeEndDate = "" // yyyy-MM-dd var dateRangePresetLabel = "This Month" var dateRangeInitialized = false +// Global Dashboard User Filter state +var dashboardUserId = -1; +var dashboardUserName = "All Users"; + +function setDashboardUserFilter(userId, userName) { + dashboardUserId = (userId !== undefined && userId !== null) ? userId : -1; + dashboardUserName = userName || "All Users"; +} + +function getDashboardUserFilter() { + return { + userId: dashboardUserId, + userName: dashboardUserName + }; +} + + // Functions to manage assignee filter state function setAssigneeFilter(enabled, assigneeIds) { assigneeFilterEnabled = enabled; diff --git a/models/project.js b/models/project.js index 7d33f945..4005be8b 100644 --- a/models/project.js +++ b/models/project.js @@ -1,8 +1,8 @@ .import "logger.js" as Logger .import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Account +.import "database.js" as DBCommon +.import "utils.js" as Utils +.import "accounts.js" as Account /** * Get local project ID from Odoo record ID @@ -1217,7 +1217,7 @@ function markProjectUpdateAsDeleted(updateId) { * @param {number|string} [accountId] - Optional account id to filter by. Use -1 for all accounts. * @returns {Array} - A list of objects with `project_id`, `name`, and `spentHours`. */ -function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) { +function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var resultList = []; @@ -1247,7 +1247,7 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) if (isAllAccounts) { Logger.debug("Project", " Aggregating spent hours for ALL accounts") - var sqlAll = "SELECT aal.project_id, aal.account_id, COALESCE(u.name, 'Unknown') AS account_name, " + + var sqlAll = "SELECT COALESCE(p.odoo_record_id, p.id) AS project_id, aal.account_id, COALESCE(u.name, 'Unknown') AS account_name, " + "COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + "FROM account_analytic_line_app aal " + "LEFT JOIN users u ON aal.account_id = u.id " + @@ -1272,8 +1272,15 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) sqlAll += "AND DATE(aal.record_date) <= DATE(?) "; paramsAll.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterAll = Account.buildUserFilterSQL(userId, -1, "aal"); + if (userFilterAll.clause) { + sqlAll += "AND " + userFilterAll.clause + " "; + paramsAll = paramsAll.concat(userFilterAll.params); + } + } - sqlAll += "GROUP BY aal.project_id, aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; + sqlAll += "GROUP BY aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlAll, paramsAll); @@ -1302,7 +1309,7 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) Logger.debug("Project", " Aggregating spent hours for single account:", acctNum) - var sqlSingle = "SELECT aal.project_id, COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + + var sqlSingle = "SELECT COALESCE(p.odoo_record_id, p.id) AS project_id, COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + "FROM account_analytic_line_app aal " + "JOIN project_project_app p ON (" + " (p.odoo_record_id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id) OR " + @@ -1324,8 +1331,15 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) sqlSingle += "AND DATE(aal.record_date) <= DATE(?) "; paramsSingle.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterSingle = Account.buildUserFilterSQL(userId, acctNum, "aal"); + if (userFilterSingle.clause) { + sqlSingle += "AND " + userFilterSingle.clause + " "; + paramsSingle = paramsSingle.concat(userFilterSingle.params); + } + } - sqlSingle += "GROUP BY aal.project_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; + sqlSingle += "GROUP BY p.account_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlSingle, paramsSingle); @@ -1356,9 +1370,10 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) * @param {number} accountId - Account ID, or -1 for all accounts. * @param {string} [startDate] - Optional start date (yyyy-MM-dd). * @param {string} [endDate] - Optional end date (yyyy-MM-dd). + * @param {number} [userId] - Optional user ID to filter timesheet hours by. * @returns {Array} Project summary rows for dashboard charts. */ -function getDashboardProjectTaskSummary(accountId, startDate, endDate) { +function getDashboardProjectTaskSummary(accountId, startDate, endDate, userId) { var resultList = []; try { @@ -1376,6 +1391,13 @@ function getDashboardProjectTaskSummary(accountId, startDate, endDate) { tsConditions.push("DATE(record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilter = Account.buildUserFilterSQL(userId, accountId, ""); + if (userFilter.clause) { + tsConditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } var accountWhere = ""; if (accountId !== -1 && accountId !== undefined && accountId !== null) { diff --git a/models/task.js b/models/task.js index 2915680f..7bf89a00 100644 --- a/models/task.js +++ b/models/task.js @@ -2,17 +2,17 @@ /* * MULTIPLE ASSIGNEES IMPLEMENTATION * ================================= - * - * This module now supports multiple assignees per task by storing them as comma-separated + * + * This module now supports multiple assignees per task by storing them as comma-separated * user IDs in the user_id field of the project_task_app table. - * + * * Key changes: * - Multiple assignee IDs are stored as comma-separated string in user_id field * - getTaskAssignees() parses the comma-separated IDs and returns assignee objects * - setTaskAssignees() updates task with multiple assignees * - saveOrUpdateTask() handles both single and multiple assignees * - migrateTaskAssignees() provides backward compatibility - * + * * Usage: * - For single assignee: store single ID in user_id field (backward compatible) * - For multiple assignees: store comma-separated IDs like "123,456,789" in user_id field @@ -24,6 +24,7 @@ .import "database.js" as DBCommon .import "utils.js" as Utils .import "draft_manager.js" as DraftManager + .import "accounts.js" as Accounts // Helper to handle -1/null function validId(value) { @@ -552,7 +553,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { result.hasChildren = true; result.childTasks = childTasks; - Logger.warn("Task", "Deletion blocked: Task has "+ childTasks.length + "child tasks") + Logger.warn("Task", "Deletion blocked: Task has " + childTasks.length + "child tasks") return; } @@ -566,7 +567,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { deletedTimesheetIds = markRelatedTimesheetsAsDeleted(tx, [taskId], timestamp); if (forceDelete && childTasks.length > 0) { - Logger.warn("Task", "Force delete enabled - deleted parent task with "+ childTasks.length + "children") + Logger.warn("Task", "Force delete enabled - deleted parent task with " + childTasks.length + "children") result.message = "Task '" + taskName + "' deleted (forced deletion with " + childTasks.length + " child tasks remaining)"; } else { result.message = "Task '" + taskName + "' successfully deleted"; @@ -575,7 +576,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { result.success = true; result.deletedTaskIds = [taskId]; - console.info("Task deleted successfully: "+ taskName); + console.info("Task deleted successfully: " + taskName); }); // Clean up any drafts for this deleted task and its related timesheets (outside transaction) @@ -596,7 +597,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { return result; } catch (e) { - Logger.error("Task", "Error marking task as deleted (ID "+ taskId + "): "+ e) + Logger.error("Task", "Error marking task as deleted (ID " + taskId + "): " + e) return { success: false, message: "Failed to delete task: " + e.message, @@ -1985,11 +1986,11 @@ function getFilteredTasksPaginated(filterType, searchQuery, accountId, limit, of var timeMap = {}; if (taskOdooIds.length > 0) { - var placeholders = taskOdooIds.map(function() { return "?"; }).join(","); + var placeholders = taskOdooIds.map(function () { return "?"; }).join(","); var timeQuery = "SELECT task_id, account_id, SUM(unit_amount) as total_hours " + - "FROM account_analytic_line_app " + - "WHERE task_id IN (" + placeholders + ") " + - "GROUP BY task_id, account_id"; + "FROM account_analytic_line_app " + + "WHERE task_id IN (" + placeholders + ") " + + "GROUP BY task_id, account_id"; var timeResult = tx.executeSql(timeQuery, taskOdooIds); for (var k = 0; k < timeResult.rows.length; k++) { var row = timeResult.rows.item(k); @@ -3092,7 +3093,7 @@ function setTaskPriority(taskId, priority, status) { * @param {number} accountId - The account ID * @returns {Array} A list of task objects with color and spentHours for the specified project. */ -function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) { +function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate, userId) { var taskList = []; try { @@ -3102,8 +3103,9 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) var params = [projectOdooRecordId, projectOdooRecordId, accountId]; var dateJoin = ""; var dateCondition = ""; - - if (startDate || endDate) { + var hasUserFilter = (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1"); + + if (startDate || endDate || hasUserFilter) { dateJoin = " INNER JOIN account_analytic_line_app al ON (al.task_id = t.odoo_record_id OR (t.account_id = 0 AND al.task_id = t.id)) AND al.account_id = t.account_id AND (al.status != 'deleted' OR al.status IS NULL) "; var dateFilters = []; if (startDate) { @@ -3114,6 +3116,13 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) dateFilters.push("DATE(al.record_date) <= DATE(?)"); params.push(endDate); } + if (hasUserFilter) { + var userFilterTask = Accounts.buildUserFilterSQL(userId, accountId, "al"); + if (userFilterTask.clause) { + dateFilters.push(userFilterTask.clause); + params = params.concat(userFilterTask.params); + } + } dateCondition = " AND " + dateFilters.join(" AND "); } @@ -3174,10 +3183,17 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) spentCondition += " AND DATE(record_date) <= DATE(?)"; spentParams.push(endDate); } + if (hasUserFilter) { + var userSpentFilter = Accounts.buildUserFilterSQL(userId, accountId, ""); + if (userSpentFilter.clause) { + spentCondition += " AND " + userSpentFilter.clause; + spentParams = spentParams.concat(userSpentFilter.params); + } + } var spentHoursQuery = ` - SELECT COALESCE(SUM(unit_amount), 0) as spent_hours - FROM account_analytic_line_app + SELECT COALESCE(SUM(unit_amount), 0) as spent_hours + FROM account_analytic_line_app WHERE task_id = ? AND account_id = ? AND (status != 'deleted' OR status IS NULL) ${spentCondition} `; @@ -3527,7 +3543,7 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { [accountId] ); - Logger.debug("Task", "getTaskStagesForProject: Found "+ result.rows.length + "PROJECT stages for account "+ accountId + "(before project filter)") + Logger.debug("Task", "getTaskStagesForProject: Found " + result.rows.length + "PROJECT stages for account " + accountId + "(before project filter)") // Filter stages to show only: // 1. Global stages (is_global = 1 or is_global = "1" - available to ALL projects) @@ -3566,7 +3582,7 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { if (isGlobalValue === 1 || isGlobalStr === "1") { // Global stage - available to all projects isAvailable = true; - Logger.debug("Task", "Stage '"+ row.name + "'is GLOBAL (is_global: "+ isGlobalValue + "["+ isGlobalType + "])") + Logger.debug("Task", "Stage '" + row.name + "'is GLOBAL (is_global: " + isGlobalValue + "[" + isGlobalType + "])") } else if (isGlobalStr.indexOf(",") !== -1) { // Project-specific stage - check if this project is in the comma-separated list var projectIds = isGlobalStr.split(","); @@ -3580,18 +3596,18 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { } if (isAvailable) { - Logger.debug("Task", "Stage '"+ row.name + "'is available for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "'contains project ID)") + Logger.debug("Task", "Stage '" + row.name + "'is available for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "'contains project ID)") } else { - Logger.debug("Task", "Stage '"+ row.name + "'NOT available for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "'does not contain project ID)") + Logger.debug("Task", "Stage '" + row.name + "'NOT available for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "'does not contain project ID)") } } else { // Could be a single project ID (no comma) - check if it matches if (isGlobalStr === String(projectOdooRecordId)) { isAvailable = true; - Logger.debug("Task", "Stage '"+ row.name + "'is available ONLY for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "')") + Logger.debug("Task", "Stage '" + row.name + "'is available ONLY for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "')") } else { // This stage is for a different single project - Logger.debug("Task", "Stage '"+ row.name + "'is for a DIFFERENT project (is_global: '"+ isGlobalValue + "', need: "+ projectOdooRecordId + ")") + Logger.debug("Task", "Stage '" + row.name + "'is for a DIFFERENT project (is_global: '" + isGlobalValue + "', need: " + projectOdooRecordId + ")") } } @@ -3608,10 +3624,10 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { } } - Logger.debug("Task", "getTaskStagesForProject: "+ stages.length + "stages available for project "+ projectOdooRecordId) + Logger.debug("Task", "getTaskStagesForProject: " + stages.length + "stages available for project " + projectOdooRecordId) if (stages.length === 0) { - Logger.warn("Task", "No stages available for project "+ projectOdooRecordId + "in account "+ accountId) + Logger.warn("Task", "No stages available for project " + projectOdooRecordId + "in account " + accountId) Logger.warn("Task", " This might indicate the project has no assigned stages in Odoo") } }); @@ -3979,7 +3995,7 @@ function getTasksByPersonalStagePaginated(personalStageOdooRecordId, assigneeIds foldedStageIds.push(foldResult.rows.item(f).odoo_record_id); } if (foldedStageIds.length > 0) { - var placeholders = foldedStageIds.map(function() { return "?"; }).join(","); + var placeholders = foldedStageIds.map(function () { return "?"; }).join(","); whereClauses.push("(t.state IS NULL OR t.state NOT IN (" + placeholders + "))"); for (var fi = 0; fi < foldedStageIds.length; fi++) { params.push(foldedStageIds[fi]); diff --git a/models/timesheet.js b/models/timesheet.js index 6a609e2e..2302dd11 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -1,9 +1,9 @@ .import "logger.js" as Logger .import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Accounts - .import "draft_manager.js" as DraftManager +.import "database.js" as DBCommon +.import "utils.js" as Utils +.import "accounts.js" as Accounts +.import "draft_manager.js" as DraftManager /** @@ -509,7 +509,7 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { * @param {string} status - The status filter: 'all', 'active', 'draft', etc. * @returns {Array} - A list of enriched timesheet entries for the task. */ -function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, endDate) { +function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var timesheetList = []; @@ -529,14 +529,19 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en // Build query based on status and accountId var baseQuery = "SELECT * FROM account_analytic_line_app WHERE task_id = ?"; var dateCondition = ""; - + var hasUserFilter = (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1"); + if (startDate) { dateCondition += " AND DATE(record_date) >= DATE(?)"; } if (endDate) { dateCondition += " AND DATE(record_date) <= DATE(?)"; } - + var userFilter = hasUserFilter ? Accounts.buildUserFilterSQL(userId, accountId, "") : { clause: "", params: [] }; + if (userFilter.clause) { + dateCondition += " AND " + userFilter.clause; + } + if (!status || status.toLowerCase() === "all") { if (accountId && accountId > 0) { query = baseQuery + " AND account_id = ? AND (status IS NULL OR status != 'deleted')" + dateCondition + " ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; @@ -554,9 +559,12 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en params = [taskOdooRecordId, status]; } } - + if (startDate) params.push(startDate); if (endDate) params.push(endDate); + if (userFilter.params && userFilter.params.length > 0) { + params = params.concat(userFilter.params); + } Logger.debug("Timesheet", "Executing getTimesheetsForTask query:", query, "with params:", params) var result = tx.executeSql(query, params); @@ -626,8 +634,12 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en } if (row.user_id !== undefined && row.user_id !== null) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.user_id, row.user_id]); - if (rs_user.rows.length > 0) userName = rs_user.rows.item(0).name; + var rs_user = tx.executeSql("SELECT name, login FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.user_id, row.user_id]); + if (rs_user.rows.length > 0) { + userName = rs_user.rows.item(0).name || rs_user.rows.item(0).login || ""; + } else if (row.account_id === 0) { + userName = "Local User"; + } } timesheetList.push({ diff --git a/qml/TSApp.qml b/qml/TSApp.qml index 02e8cabb..ac6e3e8a 100644 --- a/qml/TSApp.qml +++ b/qml/TSApp.qml @@ -33,6 +33,7 @@ MainView { objectName: "TS" applicationName: "ubtms" property bool init: true + property var rootApp: mainView property alias globalTimerWidget: globalWidgets.globalTimerWidget property alias modelDownloadTimerWidget: globalWidgets.modelDownloadTimerWidget property alias backend_bridge: globalWidgets.backend_bridge @@ -72,6 +73,7 @@ MainView { signal globalAccountChanged(int accountId, string accountName) signal accountDataRefreshRequested(int accountId) signal globalDateRangeChanged(int presetId, string startDate, string endDate, string presetLabel) + signal globalDashboardUserChanged(var userId, string userName) signal projectDataChanged() signal taskDataChanged() // Keep-alive heartbeat: Ensures Qt SceneGraph render thread remains active to safely process diff --git a/qml/components/pickers/FilterableHeaderContents.qml b/qml/components/pickers/FilterableHeaderContents.qml index dca0a569..9a97db1a 100644 --- a/qml/components/pickers/FilterableHeaderContents.qml +++ b/qml/components/pickers/FilterableHeaderContents.qml @@ -9,6 +9,7 @@ Item { property bool showDateFilter: false property string title: "" property bool showSubtitle: true + property string userLabel: "" property alias dateFilter: dateFilterItem signal dateRangeChanged() @@ -30,7 +31,7 @@ Item { } Label { - text: dateFilterItem.presetLabel + text: root.userLabel ? (dateFilterItem.presetLabel + " • " + root.userLabel) : dateFilterItem.presetLabel color: "white" fontSize: "small" opacity: 0.8 diff --git a/qml/components/selectors/AssigneeFilterMenu.qml b/qml/components/selectors/AssigneeFilterMenu.qml index faa6e908..3130ec12 100644 --- a/qml/components/selectors/AssigneeFilterMenu.qml +++ b/qml/components/selectors/AssigneeFilterMenu.qml @@ -47,14 +47,47 @@ Item { property bool expanded: false property var selectedAssigneeIds: [] property bool showAccountName: false + property bool showAllUsersOption: false property int maxMenuHeight: units.gu(50) + function isOnlyAllUsersSelected() { + if (!showAllUsersOption) return false; + if (selectedAssigneeIds.length === 0) return false; + for (var i = 0; i < selectedAssigneeIds.length; i++) { + var s = selectedAssigneeIds[i]; + var uid = (typeof s === 'object') ? s.user_id : s; + if (uid !== -1 && uid !== "-1") { + return false; + } + } + return true; + } + + function selectAllUsers() { + selectedAssigneeIds = [createSelection(-1, -1)]; + } + + function clearFilter() { + if (!showAllUsersOption) { + selectedAssigneeIds = []; + } else { + selectAllUsers(); + } + filterModel.update(); + recomputeDisplayAssigneeCache(); + expanded = false; + filterCleared(); + } + // Helper function to check if an assignee is selected (handles both old and new format) function isAssigneeSelected(userId, accountId) { + if (showAllUsersOption && userId === -1) { + return isOnlyAllUsersSelected(); + } for (var i = 0; i < selectedAssigneeIds.length; i++) { var selectedId = selectedAssigneeIds[i]; if (typeof selectedId === 'object') { - if (selectedId.user_id === userId && selectedId.account_id === accountId) { + if (selectedId.user_id === userId && (accountId === undefined || accountId === -1 || selectedId.account_id === -1 || selectedId.account_id === accountId)) { return true; } } else if (selectedId === userId) { @@ -86,6 +119,15 @@ Item { } function addSelectionIfMissing(selection) { + if (showAllUsersOption && selection.user_id !== -1) { + for (var k = selectedAssigneeIds.length - 1; k >= 0; k--) { + var cur = selectedAssigneeIds[k]; + var curUid = (typeof cur === 'object') ? cur.user_id : cur; + if (curUid === -1 || curUid === "-1") { + selectedAssigneeIds.splice(k, 1); + } + } + } if (!hasSelectedAssignee(selection)) { selectedAssigneeIds.push(createSelection(selection.user_id, selection.account_id)); } @@ -95,7 +137,7 @@ Item { for (var i = selectedAssigneeIds.length - 1; i >= 0; i--) { var existingId = selectedAssigneeIds[i]; if (typeof existingId === 'object') { - if (existingId.user_id === selection.user_id && existingId.account_id === selection.account_id) { + if (existingId.user_id === selection.user_id && (selection.account_id === undefined || selection.account_id === -1 || existingId.account_id === -1 || existingId.account_id === selection.account_id)) { selectedAssigneeIds.splice(i, 1); } } else if (existingId === selection.user_id) { @@ -166,7 +208,17 @@ Item { group.account_names.push(accountName); } - group.memberSelections.push(createSelection(assigneeId, accountId)); + if (assignee.memberSelections && assignee.memberSelections.length > 0) { + for (var ms = 0; ms < assignee.memberSelections.length; ms++) { + group.memberSelections.push(assignee.memberSelections[ms]); + } + } else if (assignee.allUserIds && assignee.allAccountIds && assignee.allUserIds.length > 1) { + for (var au = 0; au < assignee.allUserIds.length; au++) { + group.memberSelections.push(createSelection(assignee.allUserIds[au], assignee.allAccountIds[au] || accountId)); + } + } else { + group.memberSelections.push(createSelection(assigneeId, accountId)); + } } var visibleAssignees = []; @@ -199,7 +251,7 @@ Item { } visibleAssignees.sort(function (a, b) { - if (a.selected !== b.selected) { + if (!showAllUsersOption && a.selected !== b.selected) { return a.selected ? -1 : 1; } @@ -212,6 +264,26 @@ Item { return 0; }); + if (showAllUsersOption) { + var allUsersText = i18n.dtr("ubtms", "All Users"); + var isAllUsersSelected = isOnlyAllUsersSelected(); + var matchSearch = !normalizedSearch || allUsersText.toLowerCase().indexOf(normalizedSearch) >= 0; + if (matchSearch) { + visibleAssignees.unshift({ + assigneeId: -1, + name: allUsersText, + email: "", + account_name: "", + titleText: allUsersText, + showAccountChips: false, + accountNamesJson: "[]", + memberSelectionsJson: JSON.stringify([createSelection(-1, -1)]), + selected: isAllUsersSelected, + sectionLabel: isAllUsersSelected ? "selected" : "others" + }); + } + } + return visibleAssignees; } @@ -226,7 +298,7 @@ Item { var selectedCount = 0; for (var i = 0; i < displayAssignees.length; i++) { - if (displayAssignees[i].selected) { + if (displayAssignees[i].selected && displayAssignees[i].assigneeId !== -1) { selectedCount++; } } @@ -264,7 +336,7 @@ Item { Rectangle { id: menuContainer visible: expanded - width: units.gu(40) + width: Math.min(units.gu(40), parent.width - units.gu(2)) height: { // Calculate dynamic height: header + search + assignee list + buttons + margins var baseHeight = units.gu(22); // Header + search + buttons + margins @@ -337,7 +409,7 @@ Item { } Text { - text: i18n.dtr("ubtms", "Filter by Assignees") + text: showAllUsersOption ? i18n.dtr("ubtms", "Filter by Employee") : i18n.dtr("ubtms", "Filter by Assignees") font.bold: true font.pixelSize: units.gu(2.2) color: theme.palette.normal.backgroundText @@ -353,9 +425,10 @@ Item { color: theme.palette.normal.base } - // Search bar for long lists + // Search bar Row { - visible: getDisplayAssigneeCount() > 5 + id: searchRow + visible: true width: parent.width height: units.gu(4) spacing: units.gu(0.5) @@ -364,15 +437,15 @@ Item { id: searchField width: parent.width - clearSearchButton.width - parent.spacing height: parent.height - placeholderText: i18n.dtr("ubtms", "Search assignees...") + placeholderText: showAllUsersOption ? i18n.dtr("ubtms", "Search employees...") : i18n.dtr("ubtms", "Search assignees...") onAccepted: { filterModel.update(); // Handle enter key press } - // onTextChanged: { - // filterModel.update(); - // } + onTextChanged: { + filterModel.update(); + } } // Custom clear search button (needed because native clear doesn't trigger filter update) @@ -428,12 +501,20 @@ Item { } } + Text { + anchors.centerIn: parent + visible: filterModel.count === 0 + text: showAllUsersOption ? i18n.dtr("ubtms", "No employees found") : i18n.dtr("ubtms", "No assignees found") + font.pixelSize: units.gu(1.8) + color: theme.palette.normal.backgroundSecondaryText + } + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded width: units.gu(1) } - section.property: "sectionLabel" + section.property: showAllUsersOption ? "" : "sectionLabel" section.criteria: ViewSection.FullString section.delegate: Item { width: assigneeListView.width @@ -496,32 +577,54 @@ Item { } property bool hasHiddenChips: showsAccountChips && visibleChipCount < accountNames.length - width: parent.width + width: assigneeListView.width height: Math.max(units.gu(6), infoColumn.implicitHeight + contentMargin * 2) color: mouseArea.pressed ? theme.palette.selected.background : "transparent" radius: units.gu(0.5) + property int itemAssigneeId: (typeof model.assigneeId !== "undefined") ? model.assigneeId : assigneeId + property string itemMemberSelectionsJson: (typeof model.memberSelectionsJson !== "undefined") ? model.memberSelectionsJson : memberSelectionsJson + property string itemName: (typeof model.name !== "undefined") ? model.name : name + property string itemTitleText: (typeof model.titleText !== "undefined") ? model.titleText : (typeof titleText !== "undefined" ? titleText : itemName) + property string itemEmail: (typeof model.email !== "undefined" && model.email !== null) ? model.email : (typeof email !== "undefined" && email !== null ? email : "") + property bool itemSelected: (typeof model.selected !== "undefined") ? model.selected : selected + + function toggleSelection() { + if (showAllUsersOption && itemAssigneeId === -1) { + if (isOnlyAllUsersSelected()) { + selectedAssigneeIds = []; + } else { + selectAllUsers(); + } + selectedAssigneeIds = selectedAssigneeIds.slice(); + return; + } + + var memberSelections = []; + if (itemMemberSelectionsJson) { + memberSelections = JSON.parse(itemMemberSelectionsJson); + } + var shouldSelect = !hasAnySelectedAssignee(memberSelections); + + for (var i = 0; i < memberSelections.length; i++) { + if (shouldSelect) { + addSelectionIfMissing(memberSelections[i]); + } else { + removeSelectionIfPresent(memberSelections[i]); + } + } + + selectedAssigneeIds = selectedAssigneeIds.slice(); + } + MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true + cursorShape: Qt.PointingHandCursor onClicked: { - var memberSelections = []; - if (model.memberSelectionsJson) { - memberSelections = JSON.parse(model.memberSelectionsJson); - } - var shouldSelect = !hasAnySelectedAssignee(memberSelections); - - for (var i = 0; i < memberSelections.length; i++) { - if (shouldSelect) { - addSelectionIfMissing(memberSelections[i]); - } else { - removeSelectionIfPresent(memberSelections[i]); - } - } - - selectedAssigneeIds = selectedAssigneeIds.slice(); + delegateRoot.toggleSelection(); } } @@ -531,13 +634,13 @@ Item { anchors.leftMargin: delegateRoot.contentMargin anchors.top: parent.top anchors.topMargin: delegateRoot.contentMargin - checked: model.selected + checked: delegateRoot.itemSelected MouseArea { anchors.fill: parent - propagateComposedEvents: true + cursorShape: Qt.PointingHandCursor onClicked: { - mouseArea.clicked(mouse); + delegateRoot.toggleSelection(); mouse.accepted = true; } } @@ -545,13 +648,22 @@ Item { Icon { id: userIcon - name: "contact" + name: (delegateRoot.itemAssigneeId === -1) ? "contact-group" : "contact" width: units.gu(2.5) height: units.gu(2.5) anchors.left: checkbox.right anchors.leftMargin: delegateRoot.contentSpacing anchors.verticalCenter: checkbox.verticalCenter color: theme.palette.normal.backgroundText + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } Column { @@ -572,11 +684,20 @@ Item { Text { id: nameLabel width: Math.min(headerFlow.width, Math.max(implicitWidth, units.gu(8))) - text: model.titleText || model.name + text: delegateRoot.itemTitleText || delegateRoot.itemName font.pixelSize: units.gu(2) color: theme.palette.normal.backgroundText wrapMode: Text.NoWrap elide: Text.ElideRight + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } Repeater { @@ -601,6 +722,15 @@ Item { elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } } @@ -612,6 +742,7 @@ Item { border.color: theme.palette.selected.background border.width: 1 width: Math.max(units.gu(7), moreChipLabel.implicitWidth + units.gu(2.4)) + z: 2 Text { id: moreChipLabel @@ -623,6 +754,7 @@ Item { MouseArea { anchors.fill: parent + cursorShape: Qt.PointingHandCursor onClicked: { delegateRoot.expandedAccounts = !delegateRoot.expandedAccounts; mouse.accepted = true; @@ -632,13 +764,22 @@ Item { } Text { - visible: model.email !== "" - text: model.email + visible: delegateRoot.itemEmail !== "" + text: delegateRoot.itemEmail font.pixelSize: units.gu(1.5) color: theme.palette.normal.backgroundSecondaryText elide: Text.ElideRight width: infoColumn.width opacity: 0.7 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } } @@ -648,6 +789,7 @@ Item { color: theme.palette.highlighted.background opacity: mouseArea.containsMouse ? 0.1 : 0 radius: parent.radius + enabled: false Behavior on opacity { NumberAnimation { @@ -697,7 +839,7 @@ Item { // Clear Filter Button TSButton { text: i18n.dtr("ubtms", "Clear Filter") - enabled: selectedAssigneeIds.length > 0 + enabled: showAllUsersOption ? true : selectedAssigneeIds.length > 0 width: units.gu(15) height: units.gu(4) bgColor: enabled ? LomiriColors.orange : LomiriColors.ash @@ -706,10 +848,7 @@ Item { Component.onCompleted: {} onClicked: { - selectedAssigneeIds = []; - filterModel.update(); - expanded = false; - filterCleared(); + clearFilter(); } } } @@ -731,7 +870,15 @@ Item { Text { property int selectedCount: getSelectedDisplayAssigneeCount() - text: selectedCount + " assignee" + (selectedCount === 1 ? "" : "s") + " selected" + text: { + if (showAllUsersOption && isOnlyAllUsersSelected()) { + return i18n.dtr("ubtms", "All Users selected"); + } + if (showAllUsersOption) { + return selectedCount + " " + (selectedCount === 1 ? i18n.dtr("ubtms", "employee selected") : i18n.dtr("ubtms", "employees selected")); + } + return selectedCount + " " + (selectedCount === 1 ? i18n.dtr("ubtms", "assignee selected") : i18n.dtr("ubtms", "assignees selected")); + } font.pixelSize: units.gu(1.6) color: theme.palette.normal.backgroundText anchors.centerIn: parent @@ -743,8 +890,8 @@ Item { // Function to load assignees for the current account function loadAssignees(accountId) { - // This will be called from the parent component - // to populate the assigneeModel + // This will be called from the parent component + // to populate the assigneeModel } // Initialize the filter model when assigneeModel changes @@ -766,6 +913,12 @@ Item { } } + onExpandedChanged: { + if (!expanded && searchField) { + searchField.text = ""; + } + } + Component.onCompleted: { if (filterModel) { filterModel.update(); diff --git a/qml/components/visualization/EHower.qml b/qml/components/visualization/EHower.qml index c99a2947..7666e48f 100644 --- a/qml/components/visualization/EHower.qml +++ b/qml/components/visualization/EHower.qml @@ -30,6 +30,7 @@ import Qt.labs.settings 1.0 import "../../../models/constants.js" as AppConst import QtQuick.LocalStorage 2.7 as Sql import "../../../models/accounts.js" as Accounts +import "../../../models/global.js" as Global import ".." Item { @@ -37,15 +38,36 @@ Item { width: parent.width height: parent.height signal quadrantClicked(int quadrant) - property int selectedAccountId: accountPicker.selectedAccountId + property int selectedAccountId: (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property bool autoRefreshOnAccountChange: true property string quadrant1Hours: "0" property string quadrant2Hours: "0" property string quadrant3Hours: "0" property string quadrant4Hours: "0" - function refreshQuadrants(startDate, endDate) { - var result = getQuadrantHoursFromAllInstances(selectedAccountId, startDate, endDate); + function refreshQuadrants(startDate, endDate, userId, accountId) { + if (accountId !== undefined && accountId !== null) { + selectedAccountId = Number(accountId); + } else if (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) { + selectedAccountId = Number(accountPicker.selectedAccountId); + } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } else { + var userFilter = Global.getDashboardUserFilter(); + if (userFilter && userFilter.userId !== undefined && userFilter.userId !== null) { + selectedUserId = userFilter.userId; + } + } + var sDate = startDate; + var eDate = endDate; + if (sDate === undefined || eDate === undefined) { + var filterData = Global.getDateRangeFilter(); + sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + } + var result = getQuadrantHoursFromAllInstances(selectedAccountId, sDate, eDate, selectedUserId); quadrant1Hours = result[1] + "H"; quadrant2Hours = result[2] + "H"; quadrant3Hours = result[3] + "H"; @@ -53,7 +75,7 @@ Item { } // TODO: Move it to Utils - function getQuadrantHoursFromAllInstances(accountId, startDate, endDate) { + function getQuadrantHoursFromAllInstances(accountId, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync("myDatabase", "1.0", "My Database", 1000000); var quadrantHours = { 1: 0.0, @@ -68,9 +90,10 @@ Item { var conditions = ["(status IS NULL OR status != 'deleted')"]; var params = []; - if (accountId !== -1 && accountId !== undefined && accountId !== null) { + var targetAccountId = (accountId !== undefined && accountId !== null) ? accountId : selectedAccountId; + if (targetAccountId !== -1 && targetAccountId !== "-1" && targetAccountId !== undefined && targetAccountId !== null) { conditions.push("account_id = ?"); - params.push(accountId); + params.push(Number(targetAccountId)); } if (startDate) { conditions.push("DATE(record_date) >= DATE(?)"); @@ -80,6 +103,14 @@ Item { conditions.push("DATE(record_date) <= DATE(?)"); params.push(endDate); } + var uid = (userId !== undefined && userId !== null) ? userId : selectedUserId; + if (uid !== -1 && uid !== "-1") { + var userFilter = Accounts.buildUserFilterSQL(uid, targetAccountId, ""); + if (userFilter.clause) { + conditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } var query = "SELECT quadrant_id, SUM(unit_amount) as total FROM account_analytic_line_app WHERE " + conditions.join(" AND ") + " GROUP BY quadrant_id"; rs = tx.executeSql(query, params); @@ -454,18 +485,23 @@ Item { } } + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined" && accountPicker.selectedAccountId !== undefined) ? accountPicker.selectedAccountId : selectedAccountId; + refreshQuadrants(sDate, eDate, userId, accId); + } + } + Component.onCompleted: { - var result = getQuadrantHoursFromAllInstances(selectedAccountId); - quadrant1Hours = result[1] + "H"; - quadrant2Hours = result[2] + "H"; - quadrant3Hours = result[3] + "H"; - quadrant4Hours = result[4] + "H"; + refreshQuadrants(); } onVisibleChanged: { - var result = getQuadrantHoursFromAllInstances(selectedAccountId); - quadrant1Hours = result[1] + "H"; - quadrant2Hours = result[2] + "H"; - quadrant3Hours = result[3] + "H"; - quadrant4Hours = result[4] + "H"; + if (visible) { + refreshQuadrants(); + } } } diff --git a/qml/components/visualization/ProjectPieChart.qml b/qml/components/visualization/ProjectPieChart.qml index 3467edf6..b7e0fb17 100644 --- a/qml/components/visualization/ProjectPieChart.qml +++ b/qml/components/visualization/ProjectPieChart.qml @@ -28,6 +28,7 @@ import QtQuick.Controls 2.2 import "../../../models/utils.js" as Utils import "../../../models/project.js" as Project import "../../../models/accounts.js" as Account +import "../../../models/global.js" as Global import ".." Item { @@ -37,6 +38,7 @@ Item { // account used to fetch data; numeric -1 indicates "all accounts" property int selectedAccountId: accountPicker.selectedAccountId + property var selectedUserId: -1 property bool autoRefreshOnAccountChange: true // Custom styled title overlay @@ -260,11 +262,19 @@ Item { } // Fetch-and-load helper using the new project API signature - function refreshForAccount(accountId, startDate, endDate) { + function refreshForAccount(accountId, startDate, endDate, userId) { + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } else { + var userFilter = Global.getDashboardUserFilter(); + if (userFilter && userFilter.userId !== undefined) { + selectedUserId = userFilter.userId; + } + } - // call project API with account param and date range + // call project API with account param, date range and userId try { - var data = Project.getProjectSpentHoursList(true, accountId, startDate, endDate); + var data = Project.getProjectSpentHoursList(true, accountId, startDate, endDate, selectedUserId); // data should be an array; load chart load(data || []); } catch (e) { @@ -274,14 +284,35 @@ Item { } Component.onCompleted: { - refreshForAccount(accountPicker.selectedAccountId); + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + refreshForAccount(accountPicker.selectedAccountId, sDate, eDate, uid); } // Re-fetch when the account selector changes Connections { target: autoRefreshOnAccountChange ? accountPicker : null onAccepted: function (accountId, accountName) { - refreshForAccount(accountId); + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + refreshForAccount(accountId, sDate, eDate, uid); + } + } + + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; + refreshForAccount(accId, sDate, eDate, userId); } } } diff --git a/qml/features/dashboard/charts/Charts3.qml b/qml/features/dashboard/charts/Charts3.qml index 0a457d7e..31d7e00f 100644 --- a/qml/features/dashboard/charts/Charts3.qml +++ b/qml/features/dashboard/charts/Charts3.qml @@ -40,11 +40,12 @@ Item { property bool autoRefreshOnAccountChange: true property int selectedAccountId: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate, accountId) { + function reloadData(startDate, endDate, accountId, userId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; if (accountId !== undefined && accountId !== null) { @@ -52,10 +53,13 @@ Item { } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { selectedAccountId = accountPicker.selectedAccountId; } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } var t_proj = []; var maxVal = 0; - var data = Model.get_projects_spent_hours(root.selectedAccountId, filterStartDate, filterEndDate); + var data = Model.get_projects_spent_hours(root.selectedAccountId, filterStartDate, filterEndDate, root.selectedUserId); root.totalProjects = data.length; var limit = Math.min(root.displayLimit, data.length); @@ -114,7 +118,9 @@ Item { var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; - reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + reloadData(sDate, eDate, accId, uid); } Connections { @@ -132,15 +138,27 @@ Item { } Connections { - target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { - root.selectedAccountId = accountId; - root.displayLimit = 10; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + root.displayLimit = 10; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } } onAccountDataRefreshRequested: function (accountId) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + onGlobalDashboardUserChanged: function (userId, userName) { + root.selectedUserId = userId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId, userId); } } diff --git a/qml/features/dashboard/charts/Charts4.qml b/qml/features/dashboard/charts/Charts4.qml index c53ef0aa..dcd4cb2a 100644 --- a/qml/features/dashboard/charts/Charts4.qml +++ b/qml/features/dashboard/charts/Charts4.qml @@ -16,12 +16,13 @@ Item { property bool autoRefreshOnAccountChange: true property int selectedAccountId: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property var projectsModel: [] property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate, accountId) { + function reloadData(startDate, endDate, accountId, userId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; if (accountId !== undefined && accountId !== null) { @@ -29,11 +30,17 @@ Item { } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { selectedAccountId = accountPicker.selectedAccountId; } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } projectsModel = buildProjectsModel(filterStartDate, filterEndDate); + if (chartFlow && typeof chartFlow.refreshCurrentView === "function") { + chartFlow.refreshCurrentView(); + } } function buildProjectsModel(startDate, endDate) { - var rows = ProjectModel.getDashboardProjectTaskSummary(selectedAccountId, startDate, endDate); + var rows = ProjectModel.getDashboardProjectTaskSummary(selectedAccountId, startDate, endDate, selectedUserId); for (var i = 0; i < rows.length; i++) { var project = rows[i]; @@ -54,7 +61,7 @@ Item { } var projectRecordId = (project.accountId === 0 || !project.odooRecordId) ? project.localId : project.odooRecordId; - var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate); + var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate, selectedUserId); var mappedTasks = []; for (var i = 0; i < taskRows.length; i++) { @@ -87,14 +94,32 @@ Item { return mappedTasks; } - function loadLogsForTask(projectId, taskId) { + function loadLogsForTask(projectId, taskId, taskObj) { var project = findProject(projectId); + if (!project) { + return []; + } + var task = findTask(project, taskId); - if (!project || !task) { + if (!project.tasks || project.tasks.length === 0 || !task) { + loadTasksForProject(projectId); + task = findTask(project, taskId); + } + var targetTask = task || taskObj; + if (!targetTask) { return []; } - var timesheets = TimesheetModel.getTimesheetsForTask(task.odooRecordId, project.accountId, "all", filterStartDate, filterEndDate); + var targetTaskId = (project.accountId === 0 || !targetTask.odooRecordId) ? targetTask.localId : targetTask.odooRecordId; + if (!targetTaskId && taskId !== undefined && taskId !== null) { + var parts = String(taskId).split(":"); + targetTaskId = Number(parts[parts.length - 1]); + } + if (!targetTaskId) { + return []; + } + + var timesheets = TimesheetModel.getTimesheetsForTask(targetTaskId, project.accountId, "all", filterStartDate, filterEndDate, selectedUserId); var logs = []; for (var i = 0; i < timesheets.length; i++) { @@ -103,19 +128,24 @@ Item { id: entry.id, date: toIsoDate(entry.date), hours: parseHours(entry.spentHours), - note: entry.name || "" + note: entry.name || "", + user: entry.user || "" }); } - task.logs = logs; - task._logsLoaded = true; + if (task) { + task.logs = logs; + task._logsLoaded = true; + } return logs; } function findProject(projectId) { + if (!projectsModel) return null; for (var i = 0; i < projectsModel.length; i++) { - if (projectsModel[i].id === projectId) { - return projectsModel[i]; + var p = projectsModel[i]; + if (p.id === projectId || String(p.odooRecordId) === String(projectId) || String(p.localId) === String(projectId)) { + return p; } } return null; @@ -127,8 +157,9 @@ Item { } for (var i = 0; i < project.tasks.length; i++) { - if (project.tasks[i].id === taskId) { - return project.tasks[i]; + var t = project.tasks[i]; + if (t.id === taskId || String(t.localId) === String(taskId) || (t.odooRecordId && String(t.odooRecordId) === String(taskId))) { + return t; } } return null; @@ -197,7 +228,9 @@ Item { var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; - reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + reloadData(sDate, eDate, accId, uid); } Connections { @@ -213,14 +246,26 @@ Item { } Connections { - target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } } onAccountDataRefreshRequested: function (accountId) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + onGlobalDashboardUserChanged: function (userId, userName) { + root.selectedUserId = userId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId, userId); } } } diff --git a/qml/features/dashboard/charts/TaskTimeChart.qml b/qml/features/dashboard/charts/TaskTimeChart.qml index d13f0ee7..d1955f83 100644 --- a/qml/features/dashboard/charts/TaskTimeChart.qml +++ b/qml/features/dashboard/charts/TaskTimeChart.qml @@ -47,6 +47,18 @@ Item { return "#E95420"; } + readonly property real selectedTaskSpentHours: { + if (!selectedTask) return 0; + if (selectedTaskLogs && selectedTaskLogs.length > 0) { + var sum = 0; + for (var i = 0; i < selectedTaskLogs.length; i++) { + sum += Number(selectedTaskLogs[i].hours || 0); + } + return sum; + } + return Number(selectedTask.totalHours || 0); + } + function openProject(projectData) { if (!projectData) return; selectedProject = projectData; @@ -62,7 +74,7 @@ Item { if (!selectedProject || !taskData) return; selectedTask = taskData; selectedTask.projectTotalHours = Number(selectedProject.totalHours || 0); - selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, taskData.id) || []) : []; + selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, taskData.id, taskData) || []) : []; selectedTask.logs = selectedTaskLogs; currentView = "task"; taskOpened(selectedProject.id, taskData.id); @@ -79,6 +91,80 @@ Item { backNavigated(); } + function refreshCurrentView() { + if (currentView === "project" && selectedProject) { + var updatedProject = null; + if (projectsModel) { + for (var i = 0; i < projectsModel.length; i++) { + if (projectsModel[i].id === selectedProject.id) { + updatedProject = projectsModel[i]; + break; + } + } + } + if (updatedProject) { + selectedProject = updatedProject; + selectedProjectTasks = projectTasksProvider ? ChartUtils.prepareTasks(projectTasksProvider(selectedProject.id) || []) : []; + } else { + goBack(); + } + } else if (currentView === "task" && selectedProject && selectedTask) { + var updatedProj = null; + if (projectsModel) { + for (var j = 0; j < projectsModel.length; j++) { + if (projectsModel[j].id === selectedProject.id) { + updatedProj = projectsModel[j]; + break; + } + } + } + if (updatedProj) { + selectedProject = updatedProj; + selectedProjectTasks = projectTasksProvider ? ChartUtils.prepareTasks(projectTasksProvider(selectedProject.id) || []) : []; + + var updatedTask = null; + for (var k = 0; k < selectedProjectTasks.length; k++) { + if (selectedProjectTasks[k].id === selectedTask.id || + String(selectedProjectTasks[k].localId) === String(selectedTask.localId || selectedTask.id) || + (selectedTask.odooRecordId && String(selectedProjectTasks[k].odooRecordId) === String(selectedTask.odooRecordId))) { + updatedTask = selectedProjectTasks[k]; + break; + } + } + + if (updatedTask) { + selectedTask = updatedTask; + } else { + var resetTask = Object.assign({}, selectedTask); + resetTask.totalHours = 0; + selectedTask = resetTask; + } + selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, selectedTask.id, selectedTask) || []) : []; + if (selectedTask) { + var updatedSelectedTask = Object.assign({}, selectedTask); + updatedSelectedTask.projectTotalHours = Number(selectedProject.totalHours || 0); + updatedSelectedTask.logs = selectedTaskLogs; + if (selectedTaskLogs && selectedTaskLogs.length > 0) { + var totalLogHours = 0; + for (var l = 0; l < selectedTaskLogs.length; l++) { + totalLogHours += Number(selectedTaskLogs[l].hours || 0); + } + updatedSelectedTask.totalHours = totalLogHours; + } else if (!updatedTask) { + updatedSelectedTask.totalHours = 0; + } + selectedTask = updatedSelectedTask; + } + } else { + goBack(); + } + } + } + + onProjectsModelChanged: { + refreshCurrentView(); + } + function ensureVisibleInAncestorFlickable(item) { if (!item) return; @@ -553,7 +639,7 @@ Item { } Label { - text: ChartUtils.formatHours(root.selectedTask ? root.selectedTask.totalHours : 0) + text: ChartUtils.formatHours(root.selectedTaskSpentHours) color: root.summaryAccentTextColor font.bold: true font.pixelSize: units.dp(20) @@ -579,7 +665,7 @@ Item { } Label { - text: ChartUtils.percentLabel(root.selectedTask ? root.selectedTask.totalHours : 0, root.selectedProject ? root.selectedProject.totalHours : 0) + text: ChartUtils.percentLabel(root.selectedTaskSpentHours, root.selectedProject ? root.selectedProject.totalHours : 0) color: root.summaryPrimaryTextColor font.bold: true font.pixelSize: units.dp(20) @@ -706,12 +792,37 @@ Item { } } - Label { + Column { Layout.fillWidth: true - text: modelData.note || i18n.dtr("ubtms", "No note") - color: Theme.palette.normal.backgroundText - font.pixelSize: units.dp(13) - wrapMode: Text.WordWrap + spacing: units.gu(0.4) + + RowLayout { + spacing: units.gu(0.6) + visible: !!modelData.user && modelData.user !== "" + + Icon { + name: "contact" + width: units.gu(1.4) + height: units.gu(1.4) + color: root.activeAccent + } + + Label { + text: modelData.user || "" + color: Theme.palette.normal.baseText + font.pixelSize: units.dp(12) + font.bold: true + elide: Text.ElideRight + } + } + + Label { + width: parent.width + text: modelData.note || i18n.dtr("ubtms", "No note") + color: Theme.palette.normal.backgroundText + font.pixelSize: units.dp(13) + wrapMode: Text.WordWrap + } } Label { diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index f567f7dc..c40b2539 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -45,6 +45,68 @@ Page { property string loadingMessage: i18n.dtr("ubtms", "Loading dashboard...") property int refreshStage: -1 property int lastRefreshAccountId: -999999 + property var selectedUserId: -1 + property string selectedUserName: i18n.dtr("ubtms", "All Users") + property var availableUsers: [] + property bool isUserFilterVisible: false + property int lastFilterAccountId: -999999 + + function initUserFilterForCurrentAccount(targetAccountId) { + var accId = (targetAccountId !== undefined && targetAccountId !== null) + ? targetAccountId + : ((typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) + ? accountPicker.selectedAccountId + : -1); + lastFilterAccountId = accId; + isUserFilterVisible = Account.hasManagerOrAdminAccess(accId); + if (!isUserFilterVisible) { + assigneeFilterMenu.expanded = false; + } + availableUsers = Account.getDashboardFilterUsers(accId); + + var menuAssignees = []; + for (var i = 0; i < availableUsers.length; i++) { + var u = availableUsers[i]; + var acctId = (u.accountId !== undefined && u.accountId !== null) ? u.accountId : ((u.account_id !== undefined) ? u.account_id : accId); + menuAssignees.push({ + id: u.id, + odoo_record_id: u.id, + name: u.name, + email: u.email || "", + account_name: u.account_name || (u.account_names ? u.account_names.join(", ") : ""), + account_id: acctId, + allUserIds: u.allUserIds || [u.id], + allAccountIds: u.allAccountIds || [acctId] + }); + } + assigneeFilterMenu.showAccountName = (accId === -1); + assigneeFilterMenu.assigneeModel = menuAssignees; + + var defaultUser = Account.getDashboardSingleUserDefault(accId, availableUsers); + if (defaultUser) { + selectedUserId = defaultUser.id; + selectedUserName = defaultUser.name; + var defSelections = []; + if (defaultUser.allUserIds && defaultUser.allAccountIds) { + for (var d = 0; d < defaultUser.allUserIds.length; d++) { + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.allUserIds[d], defaultUser.allAccountIds[d] || accId)); + } + } else { + var defAcctId = (defaultUser.accountId !== undefined) ? defaultUser.accountId : accId; + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.id, defAcctId)); + } + assigneeFilterMenu.selectedAssigneeIds = defSelections; + } else { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + } // Timer for deferred loading - gives UI time to render loading indicator Timer { @@ -59,6 +121,13 @@ Page { // Update navigation tracking when Dashboard becomes visible Global.setLastVisitedPage("Dashboard"); + var currentAccId = (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) + ? accountPicker.selectedAccountId + : -1; + if (lastFilterAccountId !== currentAccId || availableUsers.length === 0) { + initUserFilterForCurrentAccount(currentAccId); + } + refreshData(); } } @@ -85,6 +154,7 @@ Page { contents: FilterableHeaderContents { id: headerContents title: i18n.dtr("ubtms", "Dashboard") + userLabel: selectedUserName onDateRangeChanged: refreshData() Component.onCompleted: { @@ -107,7 +177,7 @@ Page { } ] - trailingActionBar.numberOfSlots: 5 + trailingActionBar.numberOfSlots: 6 trailingActionBar.actions: [ Action { id: infoAction @@ -142,6 +212,15 @@ Page { openNewTimesheetPage(false); } }, + Action { + id: userFilterAction + iconName: (Array.isArray(selectedUserId) && selectedUserId.length > 1) ? "contact-group" : "contact" + text: selectedUserName ? selectedUserName : i18n.dtr("ubtms", "All Users") + visible: !headerContents.showDateFilter && isUserFilterVisible + onTriggered: { + assigneeFilterMenu.expanded = !assigneeFilterMenu.expanded; + } + }, Action { id: filterAction iconName: "filters" @@ -183,15 +262,14 @@ Page { } } - function refreshData() { + function refreshData(force) { Logger.debug("Dashboard", "Refreshing Dashboard data...") - var targetAccountId = accountPicker.selectedAccountId; - if (isLoading && refreshStage >= 0 && lastRefreshAccountId === targetAccountId) { + var targetAccountId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + if (!force && isLoading && lastRefreshAccountId === targetAccountId) { return; } lastRefreshAccountId = targetAccountId; - refreshStage = 0; loadingTimer.stop(); loadingMessage = targetAccountId === -1 ? i18n.dtr("ubtms", "Preparing all-account dashboard...") @@ -212,46 +290,38 @@ Page { function _doRefreshData() { try { - var sDate = typeof headerContents.dateFilter !== "undefined" ? headerContents.dateFilter.startDate : ""; - var eDate = typeof headerContents.dateFilter !== "undefined" ? headerContents.dateFilter.endDate : ""; - - switch (refreshStage) { - case 0: - Logger.debug("Dashboard", "Dashboard refresh stage 0: priority matrix") - if (typeof ehoverMatrix !== "undefined" && ehoverMatrix.refreshQuadrants) { - ehoverMatrix.refreshQuadrants(sDate, eDate); - } - loadingMessage = i18n.dtr("ubtms", "Loading project chart..."); - refreshStage = 1; - loadingTimer.interval = 0; - loadingTimer.start(); - return; - case 1: - Logger.debug("Dashboard", "Dashboard refresh stage 1: project chart") - if (typeof projectchart !== "undefined") { - projectchart.refreshForAccount(accountPicker.selectedAccountId, sDate, eDate); - } - loadingMessage = i18n.dtr("ubtms", "Loading additional charts..."); - refreshStage = 2; - loadingTimer.start(); - return; - case 2: - Logger.debug("Dashboard", "Dashboard refresh stage 2: additional charts") - var activeAccId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; - if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { - mobileProjectChartLoader.item.selectedAccountId = activeAccId; - mobileProjectChartLoader.item.reloadData(sDate, eDate, activeAccId); - } - if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { - mobileTaskChartLoader.item.selectedAccountId = activeAccId; - mobileTaskChartLoader.item.reloadData(sDate, eDate, activeAccId); - } - break; - default: - break; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var activeAccId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); + + Logger.debug("Dashboard", "Executing Dashboard refresh for user: " + JSON.stringify(uid) + ", account: " + activeAccId); + + // 1. Priority Matrix (Eisenhower) + if (typeof ehoverMatrix !== "undefined" && ehoverMatrix.refreshQuadrants) { + ehoverMatrix.refreshQuadrants(sDate, eDate, uid, activeAccId); + } + + // 2. Overview Pie Chart + if (typeof projectchart !== "undefined" && typeof projectchart.refreshForAccount === "function") { + projectchart.refreshForAccount(activeAccId, sDate, eDate, uid); + } + + // 3. Mobile tab loaders (Projects & Tasks charts) + if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { + mobileProjectChartLoader.item.selectedAccountId = activeAccId; + mobileProjectChartLoader.item.selectedUserId = uid; + mobileProjectChartLoader.item.reloadData(sDate, eDate, activeAccId, uid); + } + + if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { + mobileTaskChartLoader.item.selectedAccountId = activeAccId; + mobileTaskChartLoader.item.selectedUserId = uid; + mobileTaskChartLoader.item.reloadData(sDate, eDate, activeAccId, uid); } } catch(e) { - Logger.error("Dashboard", "_doRefreshData ERROR: ", e) + Logger.error("Dashboard", "_doRefreshData ERROR: ", e); } finishRefreshData(); } @@ -336,10 +406,6 @@ Page { height: width autoRefreshOnAccountChange: false anchors.centerIn: parent - quadrant1Hours: "120.2" - quadrant2Hours: "65.5" - quadrant3Hours: "55.0" - quadrant4Hours: "178.1" onQuadrantClicked: {} } } @@ -425,6 +491,30 @@ Page { onCurrentIndexChanged: { if (mobileChartTabBar.currentIndex !== currentIndex) mobileChartTabBar.currentIndex = currentIndex; + + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); + + if (currentIndex === 0) { + if (typeof projectchart !== "undefined" && typeof projectchart.refreshForAccount === "function") { + projectchart.refreshForAccount(accId, sDate, eDate, uid); + } + } else if (currentIndex === 1) { + if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { + mobileProjectChartLoader.item.selectedAccountId = accId; + mobileProjectChartLoader.item.selectedUserId = uid; + mobileProjectChartLoader.item.reloadData(sDate, eDate, accId, uid); + } + } else if (currentIndex === 2) { + if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { + mobileTaskChartLoader.item.selectedAccountId = accId; + mobileTaskChartLoader.item.selectedUserId = uid; + mobileTaskChartLoader.item.reloadData(sDate, eDate, accId, uid); + } + } } Item { @@ -447,7 +537,13 @@ Page { if (item) { item.autoRefreshOnAccountChange = false; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); item.selectedAccountId = accId; + item.selectedUserId = uid; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -463,7 +559,13 @@ Page { if (item) { item.autoRefreshOnAccountChange = false; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); item.selectedAccountId = accId; + item.selectedUserId = uid; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -523,22 +625,138 @@ Page { if (accountPicker.selectedAccountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountPicker.selectedAccountName + "]"; } + initUserFilterForCurrentAccount(accountPicker.selectedAccountId); refreshData(); } onAccepted: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; + initUserFilterForCurrentAccount(accountId); refreshData(); } } Connections { - target: typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; - refreshData(); + initUserFilterForCurrentAccount(accountId); + refreshData(true); } onAccountDataRefreshRequested: function (accountId) { - refreshData(); + initUserFilterForCurrentAccount(accountId); + refreshData(true); + } + onGlobalDashboardUserChanged: function (userId, userName) { + selectedUserId = userId; + selectedUserName = userName; + if (userId === -1 || userId === "-1") { + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } else if (Array.isArray(userId)) { + assigneeFilterMenu.selectedAssigneeIds = userId.slice(); + } else { + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(userId, accId)]; + } + refreshData(true); + } + } + + Connections { + target: typeof mainView !== "undefined" ? mainView : null + onGlobalDateRangeChanged: function (presetId, startDate, endDate, presetLabel) { + refreshData(true); + } + } + + function handleAssigneeFilterApplied(assigneeIds) { + var rawIds = assigneeIds || []; + var isAllUsers = false; + for (var k = 0; k < rawIds.length; k++) { + var itm = rawIds[k]; + var uidVal = (typeof itm === "object") ? itm.user_id : itm; + if (uidVal === -1 || uidVal === "-1") { + isAllUsers = true; + break; + } + } + + if (isAllUsers) { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + Global.setDashboardUserFilter(-1, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(-1, selectedUserName); + } + refreshData(true); + return; + } + + // Match selected assignees against availableUsers + var selectedUsersList = []; + for (var i = 0; i < availableUsers.length; i++) { + var u = availableUsers[i]; + var isSel = false; + for (var j = 0; j < rawIds.length; j++) { + var s = rawIds[j]; + var sUid = (typeof s === "object") ? s.user_id : s; + if (sUid === u.id || (u.allUserIds && u.allUserIds.indexOf(sUid) !== -1)) { + isSel = true; + break; + } + } + if (isSel) { + selectedUsersList.push(u); + } + } + + if (selectedUsersList.length === 0) { + handleAssigneeFilterCleared(); + return; + } + + if (selectedUsersList.length === 1) { + selectedUserId = selectedUsersList[0].id; + selectedUserName = selectedUsersList[0].name; + } else { + selectedUserId = rawIds.slice(); + selectedUserName = selectedUsersList.length + " " + i18n.dtr("ubtms", "Employees"); + } + + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + refreshData(true); + } + + function handleAssigneeFilterCleared() { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + refreshData(true); + } + + AssigneeFilterMenu { + id: assigneeFilterMenu + anchors.fill: parent + z: 1000 + showAllUsersOption: true + + onFilterApplied: function (assigneeIds) { + handleAssigneeFilterApplied(assigneeIds); + } + + onFilterCleared: function () { + handleAssigneeFilterCleared(); } } @@ -604,6 +822,7 @@ Page { Component.onCompleted: { Logger.debug("Dashboard", "Dashboard status is: " + mainPage.status) + initUserFilterForCurrentAccount(); // Load notifications on startup notificationBell.loadNotifications(); } diff --git a/qml/features/dashboard/pages/Dashboard2.qml b/qml/features/dashboard/pages/Dashboard2.qml index 2e501b8a..3cc6ef3f 100644 --- a/qml/features/dashboard/pages/Dashboard2.qml +++ b/qml/features/dashboard/pages/Dashboard2.qml @@ -102,13 +102,17 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; if (load3.item && typeof load3.item.reloadData === "function") { load3.item.selectedAccountId = accountId; - load3.item.reloadData(sDate, eDate, accountId); + load3.item.selectedUserId = uid; + load3.item.reloadData(sDate, eDate, accountId, uid); } if (load4.item && typeof load4.item.reloadData === "function") { load4.item.selectedAccountId = accountId; - load4.item.reloadData(sDate, eDate, accountId); + load4.item.selectedUserId = uid; + load4.item.reloadData(sDate, eDate, accountId, uid); } } @@ -121,13 +125,16 @@ Page { } Connections { - target: typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { refreshData(true); } onAccountDataRefreshRequested: function (accountId) { refreshData(true); } + onGlobalDashboardUserChanged: function (userId, userName) { + refreshData(true); + } } Connections { @@ -185,7 +192,10 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - item.reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + item.selectedUserId = uid; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -209,7 +219,10 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - item.reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + item.selectedUserId = uid; + item.reloadData(sDate, eDate, accId, uid); } } } diff --git a/qml/features/dashboard/pages/Dashboard3.qml b/qml/features/dashboard/pages/Dashboard3.qml index f443ffcf..6421d8a8 100644 --- a/qml/features/dashboard/pages/Dashboard3.qml +++ b/qml/features/dashboard/pages/Dashboard3.qml @@ -133,7 +133,9 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - var quadrant_data = Model.get_tasks_spent_hours(accountId, sDate, eDate); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + var quadrant_data = Model.get_tasks_spent_hours(accountId, sDate, eDate, uid); var count = 0; var timeval; var timecat = []; @@ -179,6 +181,13 @@ Page { } } + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + reloadData(); + } + } + Rectangle { id: hoverInfo width: Math.min(hoverText.implicitWidth + units.gu(3), parent.width - units.gu(4)) diff --git a/qml/features/dashboard/pages/TaskDetailPage.qml b/qml/features/dashboard/pages/TaskDetailPage.qml index 56e6fdbd..2a09666b 100644 --- a/qml/features/dashboard/pages/TaskDetailPage.qml +++ b/qml/features/dashboard/pages/TaskDetailPage.qml @@ -11,6 +11,18 @@ Page { property var taskData: ({}) property string projectName: "" + readonly property real taskSpentHours: { + if (!taskData) return 0; + if (taskData.logs && taskData.logs.length > 0) { + var sum = 0; + for (var i = 0; i < taskData.logs.length; i++) { + sum += Number(taskData.logs[i].hours || 0); + } + return sum; + } + return Number(taskData.totalHours || 0); + } + header: PageHeader { id: pageHeader title: taskData ? taskData.name : "" @@ -40,12 +52,12 @@ Page { model: [ { label: i18n.dtr("ubtms", "Time spent"), - value: ChartUtils.formatHours(taskData ? taskData.totalHours : 0) + value: ChartUtils.formatHours(root.taskSpentHours) }, { label: i18n.dtr("ubtms", "% of project"), value: taskData && taskData.projectTotalHours ? - ChartUtils.percentLabel(taskData.totalHours, taskData.projectTotalHours) : + ChartUtils.percentLabel(root.taskSpentHours, taskData.projectTotalHours) : i18n.dtr("ubtms", "0.0%") } ] @@ -137,10 +149,11 @@ Page { delegate: UbuntuShape { width: parent.width - height: units.gu(7) + implicitHeight: Math.max(units.gu(7), logDetailRowLayout.implicitHeight + units.gu(2)) color: Theme.palette.normal.base RowLayout { + id: logDetailRowLayout anchors.fill: parent anchors.leftMargin: units.gu(1.5) anchors.rightMargin: units.gu(1.5) @@ -154,12 +167,37 @@ Page { elide: Text.ElideRight } - Label { + Column { Layout.fillWidth: true - text: modelData.note || i18n.dtr("ubtms", "No note") - color: Theme.palette.normal.backgroundText - font.pixelSize: units.dp(12) - elide: Text.ElideRight + spacing: units.gu(0.3) + + RowLayout { + spacing: units.gu(0.5) + visible: !!modelData.user && modelData.user !== "" + + Icon { + name: "contact" + width: units.gu(1.3) + height: units.gu(1.3) + color: Theme.palette.normal.backgroundText + } + + Label { + text: modelData.user || "" + color: Theme.palette.normal.baseText + font.pixelSize: units.dp(11) + font.bold: true + elide: Text.ElideRight + } + } + + Label { + width: parent.width + text: modelData.note || i18n.dtr("ubtms", "No note") + color: Theme.palette.normal.backgroundText + font.pixelSize: units.dp(12) + wrapMode: Text.WordWrap + } } Label { diff --git a/tests/qml/README.md b/tests/qml/README.md index 9c014819..bcac468f 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -24,13 +24,11 @@ tests/ │ ├── README.md # This guide │ ├── models/ # JavaScript core and model test suites │ │ ├── tst_constants.qml # Test suite for constants and quadrant color configuration -│ │ ├── tst_database.qml # Test suite for database constants, row conversions, and local seeders -│ │ ├── tst_dbinit.qml # Test suite for database table initialization and auto-sync settings -│ │ ├── tst_draft_manager.qml # Test suite for form draft saving, loading, diffing, and cleanup -│ │ ├── tst_global.qml # Test suite for global assignee filtering and date range persistence +│ │ ├── tst_dashboard_user_filter.qml # Test suite for dashboard user filter state, models, and queries │ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities +│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu selector component │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_infobar.qml # Test suite for InfoBar feedback component diff --git a/tests/qml/models/tst_dashboard_user_filter.qml b/tests/qml/models/tst_dashboard_user_filter.qml new file mode 100644 index 00000000..1d65e6ff --- /dev/null +++ b/tests/qml/models/tst_dashboard_user_filter.qml @@ -0,0 +1,843 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DBInit +import "../../../models/global.js" as Global +import "../../../models/accounts.js" as Account +import "../../../models/project.js" as Project +import "../../../models/task.js" as Task +import "../../../models/timesheet.js" as Timesheet +import "../../../models/Main.js" as Main +import "../../../qml/components/visualization" + +Item { + width: 200 + height: 200 + + EHower { + id: testEHower + visible: false + autoRefreshOnAccountChange: false + } + + TestCase { + name: "DashboardUserFilterTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DBInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + Global.setDashboardUserFilter(-1, "All Users"); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM users"); + tx.executeSql("DELETE FROM res_users_app"); + tx.executeSql("DELETE FROM project_project_app"); + tx.executeSql("DELETE FROM project_task_app"); + tx.executeSql("DELETE FROM account_analytic_line_app"); + }); + } + + // --- 1. Global User Filter State --- + function test_globalDashboardUserFilterState() { + Global.setDashboardUserFilter(-1, "All Users"); + var current = Global.getDashboardUserFilter(); + compare(current.userId, -1); + compare(current.userName, "All Users"); + + Global.setDashboardUserFilter(101, "Alice Smith"); + current = Global.getDashboardUserFilter(); + compare(current.userId, 101); + compare(current.userName, "Alice Smith"); + + // Reset with null/undefined values + Global.setDashboardUserFilter(null, ""); + current = Global.getDashboardUserFilter(); + compare(current.userId, -1); + compare(current.userName, "All Users"); + } + + // --- 2. Account.getDashboardFilterUsers --- + function test_getDashboardFilterUsers_empty() { + var users = Account.getDashboardFilterUsers(1); + compare(users.length, 0); + } + + function test_getDashboardFilterUsers_populated() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1 active users + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 10, 'Charlie', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 20, 'Alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 30, 'Bob', 1)"); + // Account 1 inactive user (should be ignored) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 40, 'Inactive User', 0)"); + // Account 2 user (should be ignored) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (2, 50, 'Account2 User', 1)"); + }); + + var users = Account.getDashboardFilterUsers(1); + compare(users.length, 3); + // Should be sorted alphabetically: Alice, Bob, Charlie + compare(users[0].name, "Alice"); + compare(users[0].id, 20); + compare(users[1].name, "Bob"); + compare(users[1].id, 30); + compare(users[2].name, "Charlie"); + compare(users[2].id, 10); + } + + // --- 3. Account.getDashboardSingleUserDefault --- + function test_getDashboardSingleUserDefault_singleUser() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 15, 'Single User', 1)"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 1); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + verify(defaultUser !== null); + compare(defaultUser.id, 15); + compare(defaultUser.name, "Single User"); + } + + function test_getDashboardSingleUserDefault_standardEmployeeSingleTimesheets() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Multiple users exist in directory + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 10, 'Employee Alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 20, 'Manager Bob', 1)"); + + // But only Alice's timesheets are synced locally (standard employee permission) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 8.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 7.5, '2026-10-02', 'synced')"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 1); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + verify(defaultUser !== null); + compare(defaultUser.id, 10); + compare(defaultUser.name, "Employee Alice"); + } + + function test_getDashboardSingleUserDefault_managerMultipleTimesheets() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1 with Bob logged in as manager + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acme Corp', 'https://odoo.acme.com', 'acme_db', 'bob')"); + + // Multiple users exist + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'Alice', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 20, 'Bob', 'bob', 1)"); + + // Both users have timesheets + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 8.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 20, 6.0, '2026-10-01', 'synced')"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 2); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + // Manager viewing team: should default to logged-in user's personal metrics (Bob, 20), not All Users + verify(defaultUser !== null); + compare(defaultUser.id, 20); + compare(defaultUser.name, "Bob"); + } + + function test_getDashboardSingleUserDefault_unresolvedLoggedInUserMultipleUsers() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // No entry in users table for account 1 + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'Alice', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 20, 'Bob', 'bob', 1)"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 2); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + // If logged-in user cannot be resolved and multiple users exist, returns null (fallback to All Users) + compare(defaultUser, null); + } + + function test_getDashboardSingleUserDefault_localAccount() { + var defaultUser = Account.getDashboardSingleUserDefault(0); + verify(defaultUser !== null); + compare(defaultUser.id, 1); + compare(defaultUser.name, "Local User"); + + var users = Account.getDashboardFilterUsers(0); + compare(users.length, 1); + compare(users[0].name, "Local User"); + } + + // --- 4. Filtering in Model Queries --- + function test_projectSpentHoursList_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 1, 200, 'Project Beta')"); + + // Project Alpha: Alice 5 hrs, Bob 3 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 10, 5.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 20, 3.0, '2026-10-01', 'synced')"); + + // Project Beta: Alice 2 hrs, Bob 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 200, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 200, 20, 7.0, '2026-10-01', 'synced')"); + }); + + // All Users (userId = -1) + var allData = Project.getProjectSpentHoursList(true, 1, "", "", -1); + compare(allData.length, 2); + var alphaAll = allData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaAll = allData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaAll.spentHours, 8.0); + compare(betaAll.spentHours, 9.0); + + // Filter by Alice (userId = 10) + var aliceData = Project.getProjectSpentHoursList(true, 1, "", "", 10); + compare(aliceData.length, 2); + var alphaAlice = aliceData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaAlice = aliceData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaAlice.spentHours, 5.0); + compare(betaAlice.spentHours, 2.0); + + // Filter by Bob (userId = 20) + var bobData = Project.getProjectSpentHoursList(true, 1, "", "", 20); + compare(bobData.length, 2); + var alphaBob = bobData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaBob = bobData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaBob.spentHours, 3.0); + compare(betaBob.spentHours, 7.0); + } + + function test_mainSpentHours_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 10, 4.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 20, 6.0, '2026-10-01', 'synced')"); + }); + + // Projects spent hours via Main.get_projects_spent_hours + var allProjects = Main.get_projects_spent_hours(1, "", "", -1); + compare(allProjects.length, 1); + compare(allProjects[0].total, 10.0); + + var aliceProjects = Main.get_projects_spent_hours(1, "", "", 10); + compare(aliceProjects.length, 1); + compare(aliceProjects[0].total, 4.0); + + // Pie chart spent hours via Project.getProjectSpentHoursList + var allPieProjects = Project.getProjectSpentHoursList(true, 1, "", "", -1); + compare(allPieProjects.length, 1); + compare(allPieProjects[0].spentHours, 10.0); + + var alicePieProjects = Project.getProjectSpentHoursList(true, 1, "", "", 10); + compare(alicePieProjects.length, 1); + compare(alicePieProjects[0].spentHours, 4.0); + + var bobPieProjects = Project.getProjectSpentHoursList(true, 1, "", "", 20); + compare(bobPieProjects.length, 1); + compare(bobPieProjects[0].spentHours, 6.0); + + // Tasks spent hours via Main.get_tasks_spent_hours + var allTasks = Main.get_tasks_spent_hours(1, "", "", -1); + compare(allTasks["Task 1"], 10.0); + + var aliceTasks = Main.get_tasks_spent_hours(1, "", "", 10); + compare(aliceTasks["Task 1"], 4.0); + + var bobTasks = Main.get_tasks_spent_hours(1, "", "", 20); + compare(bobTasks["Task 1"], 6.0); + } + + function test_timesheetsForTask_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, name, record_date, status) VALUES (1, 100, 501, 10, 2.5, 'Alice Work', '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, name, record_date, status) VALUES (1, 100, 501, 20, 3.5, 'Bob Work', '2026-10-01', 'synced')"); + }); + + // All timesheets + var allTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", -1); + compare(allTs.length, 2); + + // Alice only + var aliceTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", 10); + compare(aliceTs.length, 1); + compare(aliceTs[0].name, "Alice Work"); + + // Bob only + var bobTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", 20); + compare(bobTs.length, 1); + compare(bobTs[0].name, "Bob Work"); + } + + // --- 5. Eisenhower Priority Matrix User Filter --- + function test_eisenhowerQuadrants_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Alice (10): Q1 = 3 hrs, Q3 = 1 hr + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 1, 10, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 3, 10, 1.0, '2026-10-01', 'synced')"); + // Bob (20): Q1 = 5 hrs, Q2 = 4 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 1, 20, 5.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 2, 20, 4.0, '2026-10-01', 'synced')"); + // Charlie (30): Q2 = 2 hrs, Q4 = 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 2, 30, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 4, 30, 7.0, '2026-10-01', 'synced')"); + }); + + // 1. All users (-1) + var allTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", -1); + compare(allTotals[1], "8"); // 3 + 5 + compare(allTotals[2], "6"); // 4 + 2 + compare(allTotals[3], "1"); // 1 + compare(allTotals[4], "7"); // 7 + + // 2. Single user: Alice (10) + var aliceTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", 10); + compare(aliceTotals[1], "3"); + compare(aliceTotals[2], "0"); + compare(aliceTotals[3], "1"); + compare(aliceTotals[4], "0"); + + // 3. Single user: Bob (20) + var bobTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", 20); + compare(bobTotals[1], "5"); + compare(bobTotals[2], "4"); + compare(bobTotals[3], "0"); + compare(bobTotals[4], "0"); + + // 4. Multi-user selection array: Alice (10) and Bob (20) + var aliceBobTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [10, 20]); + compare(aliceBobTotals[1], "8"); // 3 + 5 + compare(aliceBobTotals[2], "4"); // 4 + compare(aliceBobTotals[3], "1"); // 1 + compare(aliceBobTotals[4], "0"); // 0 + + // 5. Multi-user selection array: Bob (20) and Charlie (30) + var bobCharlieTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [20, 30]); + compare(bobCharlieTotals[1], "5"); // 5 + compare(bobCharlieTotals[2], "6"); // 4 + 2 + compare(bobCharlieTotals[3], "0"); // 0 + compare(bobCharlieTotals[4], "7"); // 7 + + // 6. Selection objects array: [{ user_id: 10, account_id: 1 }, { user_id: 30, account_id: 1 }] + var objTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [ + { user_id: 10, account_id: 1 }, + { user_id: 30, account_id: 1 } + ]); + compare(objTotals[1], "3"); // 3 + compare(objTotals[2], "2"); // 2 + compare(objTotals[3], "1"); // 1 + compare(objTotals[4], "7"); // 7 + + // 7. Verify refreshQuadrants updates component properties + testEHower.refreshQuadrants("", "", [10, 20], 1); + compare(testEHower.quadrant1Hours, "8H"); + compare(testEHower.quadrant2Hours, "4H"); + compare(testEHower.quadrant3Hours, "1H"); + compare(testEHower.quadrant4Hours, "0H"); + } + + // --- 6. Tasks Tab Summary & Project Tasks User Filter --- + function test_dashboardProjectTaskSummary_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 20, 6.0, '2026-10-01', 'synced')"); + }); + + // Summary: all users (-1) + var allSummary = Project.getDashboardProjectTaskSummary(1, "", "", -1); + compare(allSummary.length, 1); + compare(allSummary[0].totalHours, 8.0); + + // Summary: Alice (10) + var aliceSummary = Project.getDashboardProjectTaskSummary(1, "", "", 10); + compare(aliceSummary.length, 1); + compare(aliceSummary[0].totalHours, 2.0); + + // Summary: Bob (20) + var bobSummary = Project.getDashboardProjectTaskSummary(1, "", "", 20); + compare(bobSummary.length, 1); + compare(bobSummary[0].totalHours, 6.0); + + // Tasks for project: Alice (10) + var aliceTasks = Task.getTasksForProject(100, 1, "", "", 10); + compare(aliceTasks.length, 1); + + // Tasks for project: non-existent user (99) + var nonExistentTasks = Task.getTasksForProject(100, 1, "", "", 99); + compare(nonExistentTasks.length, 0); + } + + // --- 7. Combining users by email across accounts --- + function test_combineUsersByEmail_acrossAccounts() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Account 1', 'https://a1.com', 'db1', 'u1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Account 2', 'https://a2.com', 'db2', 'u2', 0)"); + + // Account 1: User 10 named 'Parvathy', email 'p.nair@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 'pnair', 1)"); + // Account 2: User 25 named 'Parvathy Nair', email 'p.nair@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 'pnair2', 1)"); + // Account 2: User 30 named 'Other User', email 'other@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 30, 'Other User', 'other@cit-services.eu', 'other', 1)"); + + // Timesheets so they appear as active dashboard users + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 2.5, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (2, 25, 4.0, '2026-10-02', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (2, 30, 1.5, '2026-10-03', 'synced')"); + }); + + // Single account 1: only Parvathy + var acc1Users = Account.getDashboardFilterUsers(1); + compare(acc1Users.length, 1); + compare(acc1Users[0].id, 10); + compare(acc1Users[0].name, "Parvathy"); + + // Single account 2: Parvathy Nair and Other User + var acc2Users = Account.getDashboardFilterUsers(2); + compare(acc2Users.length, 2); + + // All accounts (-1): Should combine users by email + var allUsers = Account.getDashboardFilterUsers(-1); + compare(allUsers.length, 2); + + // Find combined Parvathy Nair entry + var pEntry = null; + for (var i = 0; i < allUsers.length; i++) { + if (allUsers[i].email === "p.nair@cit-services.eu") { + pEntry = allUsers[i]; + break; + } + } + verify(pEntry !== null); + // Should choose the longer, more complete name 'Parvathy Nair' + compare(pEntry.name, "Parvathy Nair"); + // allUserIds should contain both IDs (10 and 25) + verify(pEntry.allUserIds.indexOf(10) !== -1); + verify(pEntry.allUserIds.indexOf(25) !== -1); + } + + // --- 8. Cross-Account buildUserFilterSQL & Data Aggregation across widgets --- + function test_crossAccountCombinedUserDataAggregation() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Instance 1', 'https://a1.com', 'db1', 'u1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Instance 2', 'https://a2.com', 'db2', 'u2', 0)"); + + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 30, 'Bob Ross', 'bob@example.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project In Instance 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Project In Instance 2')"); + + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task A1', 101)"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (2, 2, 601, 'Task A2', 202)"); + + // Timesheets: + // Instance 1, user 10 (Parvathy): 3.0 hrs in Project 101 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (1, 101, 501, 10, 3.0, '2026-10-01', 'synced', 1)"); + // Instance 2, user 25 (Parvathy Nair): 5.0 hrs in Project 202 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (2, 202, 601, 25, 5.0, '2026-10-02', 'synced', 2)"); + // Instance 2, user 30 (Bob): 4.0 hrs in Project 202 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (2, 202, 601, 30, 4.0, '2026-10-02', 'synced', 1)"); + }); + + // 1. Overview Pie Chart: getProjectSpentHoursList for All Accounts (-1) with user 10 + var pieProjects = Project.getProjectSpentHoursList(true, -1, "", "", 10); + compare(pieProjects.length, 2); + var totalSpent = 0; + for (var p = 0; p < pieProjects.length; p++) { + totalSpent += pieProjects[p].spentHours; + } + compare(totalSpent, 8.0); // 3.0 + 5.0 + + // 2. Projects Bar Chart: Main.get_projects_spent_hours for All Accounts (-1) with user 10 + var barProjects = Main.get_projects_spent_hours(-1, "", "", 10); + compare(barProjects.length, 2); + var barTotal = 0; + for (var b = 0; b < barProjects.length; b++) { + barTotal += barProjects[b].total; + } + compare(barTotal, 8.0); // 3.0 + 5.0 + + // 3. Tasks Tab Project Summary: Project.getDashboardProjectTaskSummary for All Accounts (-1) with user 10 + var summary = Project.getDashboardProjectTaskSummary(-1, "", "", 10); + var summaryHours = 0; + for (var s = 0; s < summary.length; s++) { + summaryHours += summary[s].totalHours; + } + compare(summaryHours, 8.0); // 3.0 + 5.0 + + // 4. Tasks Tab Drilldown: Task.getTasksForProject for Project 202 (Account 2) passing user 10 + // Even though user ID 10 is from Account 1, it should map to user 25 in Account 2 + var tasksForP2 = Task.getTasksForProject(202, 2, "", "", 10); + compare(tasksForP2.length, 1); + compare(tasksForP2[0].spent_hours, 5.0); + + // 5. Timesheet logs for task 601 (Account 2) passing user 10 + var logs = Timesheet.getTimesheetsForTask(601, 2, "all", "", "", 10); + compare(logs.length, 1); + compare(logs[0].spentHours, "5:00"); + } + + // --- 11. Multi-assignee Filter Tests --- + function test_multiUserFilterSQL_emptyOrAllUsers() { + var fEmpty = Account.buildUserFilterSQL([], 1, "a"); + compare(fEmpty.clause, ""); + compare(fEmpty.params.length, 0); + + var fAllArr = Account.buildUserFilterSQL([-1], 1, "a"); + compare(fAllArr.clause, ""); + compare(fAllArr.params.length, 0); + + var fAllObj = Account.buildUserFilterSQL([{ user_id: -1, account_id: -1 }], 1, "a"); + compare(fAllObj.clause, ""); + compare(fAllObj.params.length, 0); + } + + function test_multiUserFilterSQL_singleAccountMultiSelect() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acct 1', 'https://a1.com', 'db1', 'admin1')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Alice', 'alice@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 20, 'Bob', 'bob@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 30, 'Charlie', 'charlie@test.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project 1')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 101)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 20, 3.5, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 30, 5.0, '2026-10-01', 'synced')"); + }); + + // Filtering by Alice and Bob [10, 20] + var filterSQL = Account.buildUserFilterSQL([10, 20], 1, "a"); + compare(filterSQL.clause, "a.user_id IN (?, ?)"); + compare(filterSQL.params.length, 2); + + // Verify Project.getProjectSpentHoursList filters for both Alice and Bob (2.0 + 3.5 = 5.5 hrs) + var pieProjects = Project.getProjectSpentHoursList(true, 1, "", "", [10, 20]); + compare(pieProjects.length, 1); + compare(pieProjects[0].spentHours, 5.5); + + // Verify Main.get_projects_spent_hours + var barProjects = Main.get_projects_spent_hours(1, "", "", [10, 20]); + compare(barProjects.length, 1); + compare(barProjects[0].total, 5.5); + + // Selection objects [{ user_id: 10, account_id: 1 }, { user_id: 20, account_id: 1 }] + var pieObj = Project.getProjectSpentHoursList(true, 1, "", "", [{ user_id: 10, account_id: 1 }, { user_id: 20, account_id: 1 }]); + compare(pieObj.length, 1); + compare(pieObj[0].spentHours, 5.5); + } + + function test_multiUserFilterSQL_allAccountsMultiSelect() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Instance 1', 'https://a1.com', 'db1', 'admin1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Instance 2', 'https://a2.com', 'db2', 'admin2', 0)"); + + // Parvathy in both accounts (uid 10 in acc 1, uid 25 in acc 2) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 1)"); + // Bob only in account 2 (uid 30) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 30, 'Bob Ross', 'bob@example.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Project 2')"); + + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 101)"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (2, 2, 601, 'Task 2', 202)"); + + // Timesheets: + // Parvathy: 3.0 in Project 1 (acc 1), 5.0 in Project 2 (acc 2) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 10, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 601, 25, 5.0, '2026-10-02', 'synced')"); + // Bob: 4.0 in Project 2 (acc 2) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 601, 30, 4.0, '2026-10-02', 'synced')"); + }); + + // Multi-selecting Parvathy (10) and Bob (30) in All Accounts (-1) + var multiFilter = Account.buildUserFilterSQL([10, 30], -1, "aal"); + verify(multiFilter.clause.length > 0); + + // Total spent across all accounts for Parvathy + Bob: 3.0 + 5.0 + 4.0 = 12.0 hrs + var pieProjects = Project.getProjectSpentHoursList(true, -1, "", "", [10, 30]); + var totalSpent = 0; + for (var p = 0; p < pieProjects.length; p++) { + totalSpent += pieProjects[p].spentHours; + } + compare(totalSpent, 12.0); + + // Bar chart: Main.get_projects_spent_hours + var barProjects = Main.get_projects_spent_hours(-1, "", "", [10, 30]); + var barTotal = 0; + for (var b = 0; b < barProjects.length; b++) { + barTotal += barProjects[b].total; + } + compare(barTotal, 12.0); + + // Project task summary: Project.getDashboardProjectTaskSummary + var summary = Project.getDashboardProjectTaskSummary(-1, "", "", [10, 30]); + var summaryHours = 0; + for (var s = 0; s < summary.length; s++) { + summaryHours += summary[s].totalHours; + } + compare(summaryHours, 12.0); + } + + // --- 10. Dynamic Visibility: hasManagerOrAdminAccess --- + function test_hasManagerOrAdminAccess_localAccount() { + // Local Account (0) is single-user offline only - standard access + compare(Account.hasManagerOrAdminAccess(0), false); + } + + function test_hasManagerOrAdminAccess_standardEmployee() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1: Standard Employee + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acct 1', 'https://a1.com', 'db1', 'employee@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 100, 'John Doe', 'employee@test.com', 'employee@test.com', 1)"); + // Project managed by someone else (user_id = 999) + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 1, 101, 'Project 1', 999)"); + // Timesheets only for self (user_id = 100) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 100, 4.0, '2026-10-01', 'synced')"); + }); + + // Standard access account: hide icon + compare(Account.hasManagerOrAdminAccess(1), false); + } + + function test_hasManagerOrAdminAccess_adminUsername() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (2, 'Admin Acct', 'https://a2.com', 'db2', 'admin')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 2, 'Administrator', 'admin@test.com', 'admin', 1)"); + }); + + // Admin account: show icon + compare(Account.hasManagerOrAdminAccess(2), true); + } + + function test_hasManagerOrAdminAccess_adminLogin() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Username in users table is user's email, but res_users_app login is 'admin' + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (3, 'Admin Acct 2', 'https://a3.com', 'db3', 'admin@corp.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (3, 200, 'Corp Admin', 'admin@corp.com', 'admin', 1)"); + }); + + compare(Account.hasManagerOrAdminAccess(3), true); + } + + function test_hasManagerOrAdminAccess_adminUid2() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Odoo default Administrator UID is 2 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (4, 'Acct 4', 'https://a4.com', 'db4', 'odoo_super')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (4, 2, 'Super User', 'super@test.com', 'odoo_super', 1)"); + }); + + compare(Account.hasManagerOrAdminAccess(4), true); + } + + function test_hasManagerOrAdminAccess_projectManager() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 5: User is Project Manager of Project 1 (user_id = 50 matches odoo_record_id = 50) + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (5, 'PM Acct', 'https://a5.com', 'db5', 'pm@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (5, 50, 'PM User', 'pm@test.com', 'pm@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 5, 201, 'Managed Project', 50)"); + // Only own timesheet + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (5, 201, 1, 50, 2.0, '2026-10-01', 'synced')"); + }); + + // Project Manager account: show icon + compare(Account.hasManagerOrAdminAccess(5), true); + } + + function test_hasManagerOrAdminAccess_teamTimesheetManager() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 6: Lead with team timesheets + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (6, 'Team Acct', 'https://a6.com', 'db6', 'lead@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (6, 60, 'Team Lead', 'lead@test.com', 'lead@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (6, 70, 'Subordinate', 'sub@test.com', 'sub@test.com', 1)"); + // Project not managed by lead + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 6, 301, 'Team Project', 999)"); + // Timesheets for multiple employees (lead 60 + subordinate 70) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (6, 301, 1, 60, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (6, 301, 1, 70, 5.0, '2026-10-01', 'synced')"); + }); + + // Team manager with multiple employees' timesheets: show icon + compare(Account.hasManagerOrAdminAccess(6), true); + } + + function test_hasManagerOrAdminAccess_allAccounts() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Standard Account 1 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Standard 1', 'https://a1.com', 'db1', 'u1@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'User 1', 'u1@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 1, 101, 'Proj 1', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 10, 2.0, '2026-10-01', 'synced')"); + + // Standard Account 2 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (2, 'Standard 2', 'https://a2.com', 'db2', 'u2@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (2, 20, 'User 2', 'u2@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (2, 2, 201, 'Proj 2', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 201, 1, 20, 3.0, '2026-10-01', 'synced')"); + }); + + // When all accounts only grant standard access: hide icon + compare(Account.hasManagerOrAdminAccess(-1), false); + + // Now add an Admin account (Account 3) + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (3, 'Admin Acct', 'https://a3.com', 'db3', 'admin')"); + }); + + // When at least one instance account grants manager/admin access: show icon + compare(Account.hasManagerOrAdminAccess(-1), true); + } + + function test_accountSwitching_togglesManagerAccess() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account A (ID 10): Standard Employee + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (10, 'Instance A', 'https://a.com', 'dbA', 'user@company.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (10, 101, 'User A', 'user@company.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 10, 501, 'Proj A', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (10, 501, 1, 101, 3.0, '2026-10-01', 'synced')"); + + // Account B (ID 20): Project Manager + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (20, 'Instance B', 'https://b.com', 'dbB', 'user@company.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (20, 202, 'User B', 'user@company.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (2, 20, 601, 'Proj B', 202)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (20, 601, 1, 202, 5.0, '2026-10-01', 'synced')"); + }); + + // 1. Initial Account A (Standard Access): Icon hidden + var activeAccountId = 10; + var isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 2. User toggles to Account B (Project Manager Access): Icon visible + activeAccountId = 20; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, true); + + // 3. User toggles back to Account A (Standard Access): Icon hidden + activeAccountId = 10; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 4. User toggles to Local Account (0): Icon hidden + activeAccountId = 0; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 5. User toggles to All Accounts (-1): Icon visible because Account B is PM + activeAccountId = -1; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, true); + } + + // --- 11. Cross-Server User ID Isolation --- + function test_usersSharingSameIdAcrossServers_notMixedUp() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Two distinct servers / accounts + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Server 1', 'https://s1.com', 'db1', 'admin1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Server 2', 'https://s2.com', 'db2', 'admin2', 0)"); + + // Both servers have users with ID 2 and ID 6, but DIFFERENT people and emails! + // Server 1: User 2 is Alice (alice@server1.com), User 6 is Alan (alan@server1.com) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 2, 'Alice S1', 'alice@server1.com', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 6, 'Alan S1', 'alan@server1.com', 'alan', 1)"); + + // Server 2: User 2 is Bob (bob@server2.com), User 6 is Brian (brian@server2.com) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 2, 'Bob S2', 'bob@server2.com', 'bob', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 6, 'Brian S2', 'brian@server2.com', 'brian', 1)"); + + // Projects and timesheets + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Proj 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Proj 2')"); + + // Alice (Server 1, user 2): 3 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 2, 3.0, '2026-10-01', 'synced')"); + // Bob (Server 2, user 2): 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 2, 2, 7.0, '2026-10-01', 'synced')"); + }); + + // 1. In combineUsersByEmail across all accounts: + // Alice (S1) and Bob (S2) should be separate users (not combined into one)! + var allUsers = Account.getDashboardFilterUsers(-1); + var aliceEntry = null; + var bobEntry = null; + for (var u = 0; u < allUsers.length; u++) { + if (allUsers[u].email === "alice@server1.com") aliceEntry = allUsers[u]; + if (allUsers[u].email === "bob@server2.com") bobEntry = allUsers[u]; + } + verify(aliceEntry !== null); + verify(bobEntry !== null); + compare(aliceEntry.name, "Alice S1"); + compare(bobEntry.name, "Bob S2"); + compare(aliceEntry.allAccountIds.length, 1); + compare(bobEntry.allAccountIds.length, 1); + + // 2. Filter for Alice using her selection object { user_id: 2, account_id: 1 } across All Accounts (-1): + // Spent hours must ONLY be 3.0 (from Server 1), NOT 10.0 (mixed with Bob)! + var aliceHours = Project.getProjectSpentHoursList(true, -1, "", "", [{ user_id: 2, account_id: 1 }]); + var aliceTotal = 0; + for (var a = 0; a < aliceHours.length; a++) { + aliceTotal += aliceHours[a].spentHours; + } + compare(aliceTotal, 3.0); + + // 3. Filter for Bob using selection { user_id: 2, account_id: 2 } across All Accounts (-1): + // Spent hours must ONLY be 7.0 (from Server 2), NOT 10.0 (mixed with Alice)! + var bobHours = Project.getProjectSpentHoursList(true, -1, "", "", [{ user_id: 2, account_id: 2 }]); + var bobTotal = 0; + for (var b = 0; b < bobHours.length; b++) { + bobTotal += bobHours[b].spentHours; + } + compare(bobTotal, 7.0); + + // 4. getUserNameByOdooId with accountId scopes correctly: + compare(Account.getUserNameByOdooId(2, 1), "Alice S1"); + compare(Account.getUserNameByOdooId(2, 2), "Bob S2"); + compare(Account.getUserNameByOdooId(6, 1), "Alan S1"); + compare(Account.getUserNameByOdooId(6, 2), "Brian S2"); + } + } +} diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml new file mode 100644 index 00000000..946b3484 --- /dev/null +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -0,0 +1,347 @@ +/* + * MIT License + * + * Copyright (c) 2025 CIT-Services + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + AssigneeFilterMenu { + id: testMenu + } + + SignalSpy { + id: filterAppliedSpy + target: testMenu + signalName: "filterApplied" + } + + SignalSpy { + id: filterClearedSpy + target: testMenu + signalName: "filterCleared" + } + + TestCase { + name: "AssigneeFilterMenuTests" + when: windowShown + + function init() { + testMenu.expanded = false; + testMenu.showAllUsersOption = false; + testMenu.showAccountName = false; + testMenu.assigneeModel = []; + testMenu.selectedAssigneeIds = []; + filterAppliedSpy.clear(); + filterClearedSpy.clear(); + } + + function test_defaultProperties() { + compare(testMenu.showAllUsersOption, false); + compare(testMenu.expanded, false); + compare(testMenu.showAccountName, false); + compare(testMenu.selectedAssigneeIds.length, 0); + } + + function test_showAllUsersOption_initialState() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + verify(!testMenu.isOnlyAllUsersSelected()); + + testMenu.selectAllUsers(); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + verify(testMenu.isOnlyAllUsersSelected()); + } + + function test_buildDisplayAssignees_pinsAllUsersAtTop() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 2, name: "Bob", account_id: 1, email: "bob@example.com" }, + { id: 1, name: "Alice", account_id: 1, email: "alice@example.com" } + ]; + + var list = testMenu.buildDisplayAssignees(""); + compare(list.length, 3); + // First item is "All Users" + compare(list[0].assigneeId, -1); + compare(list[0].name, i18n.dtr("ubtms", "All Users")); + // Subsequent items sorted alphabetically + compare(list[1].name, "Alice"); + compare(list[2].name, "Bob"); + } + + function test_searchIntegration_filterByName() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 1, name: "Alice Smith", account_id: 1, email: "alice@example.com" }, + { id: 2, name: "Bob Jones", account_id: 1, email: "bob@example.com" } + ]; + + // Search by name "alice" + var resultsAlice = testMenu.buildDisplayAssignees("alice"); + compare(resultsAlice.length, 1); + compare(resultsAlice[0].name, "Alice Smith"); + + // Search by name "bob" + var resultsBob = testMenu.buildDisplayAssignees("bob"); + compare(resultsBob.length, 1); + compare(resultsBob[0].name, "Bob Jones"); + + // Empty query returns all (All Users + 2 employees) + var resultsAll = testMenu.buildDisplayAssignees(""); + compare(resultsAll.length, 3); + } + + function test_selectionExclusivity_specificUserClearsAllUsers() { + testMenu.showAllUsersOption = true; + testMenu.selectAllUsers(); + verify(testMenu.isOnlyAllUsersSelected()); + + // Select a specific user + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + verify(!testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, 10); + verify(testMenu.isAssigneeSelected(10, 1)); + verify(!testMenu.isAssigneeSelected(-1, -1)); + } + + function test_multiSelection_multipleUsers() { + testMenu.showAllUsersOption = true; + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + testMenu.addSelectionIfMissing(testMenu.createSelection(20, 1)); + + compare(testMenu.selectedAssigneeIds.length, 2); + verify(testMenu.isAssigneeSelected(10, 1)); + verify(testMenu.isAssigneeSelected(20, 1)); + verify(!testMenu.isOnlyAllUsersSelected()); + } + + function test_removeSelection_leavesEmptyWhenLastUserRemoved() { + testMenu.showAllUsersOption = true; + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 1); + + // Removing the last selected user leaves selection empty (no auto-check of All Users) + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 0); + verify(!testMenu.isOnlyAllUsersSelected()); + verify(!testMenu.isAssigneeSelected(-1, -1)); + } + + function test_removeSelection_allAccountsMode() { + testMenu.showAllUsersOption = true; + // Case 1: Existing selection has account_id: -1 (All Accounts mode), target has account_id: 1 + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, -1)]; + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 0); + + // Case 2: Existing selection has account_id: 1, target has account_id: -1 + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + compare(testMenu.selectedAssigneeIds.length, 1); + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, -1)); + compare(testMenu.selectedAssigneeIds.length, 0); + } + + function test_noUserSelected_doesNotSelectAllUsers() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + verify(!testMenu.isOnlyAllUsersSelected()); + verify(!testMenu.isAssigneeSelected(-1, -1)); + compare(testMenu.selectedAssigneeIds.length, 0); + } + + function test_filterAppliedSignal() { + var selections = [testMenu.createSelection(10, 1), testMenu.createSelection(20, 1)]; + testMenu.filterApplied(selections); + compare(filterAppliedSpy.count, 1); + compare(filterAppliedSpy.signalArguments[0][0].length, 2); + } + + function test_filterClearedSignal() { + testMenu.filterCleared(); + compare(filterClearedSpy.count, 1); + } + + function test_clearFilter_withoutShowAllUsersOption() { + testMenu.showAllUsersOption = false; + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + compare(testMenu.selectedAssigneeIds.length, 0); + compare(testMenu.expanded, false); + } + + function test_clearFilter_withShowAllUsersOption() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + verify(testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + compare(testMenu.expanded, false); + } + + function test_clearFilter_withShowAllUsersOption_emptySelection() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + verify(testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + compare(testMenu.expanded, false); + } + + function test_delegateLayoutAndClick() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 10, name: "Alice", account_id: 1, email: "alice@example.com" }, + { id: 20, name: "Bob", account_id: 1, email: "bob@example.com" } + ]; + testMenu.expanded = true; + wait(300); + + var listView = null; + function findListView(item) { + if (!item) return; + if (item.toString().indexOf("QQuickListView") !== -1) { + listView = item; + return; + } + for (var i = 0; i < (item.children ? item.children.length : 0); i++) { + findListView(item.children[i]); + if (listView) return; + } + } + findListView(testMenu); + verify(listView !== null); + + var ci = listView.contentItem; + + + function findItemWithText(rootItem, targetText) { + if (!rootItem || !rootItem.children) return null; + for (var i = 0; i < rootItem.children.length; i++) { + var ch = rootItem.children[i]; + if (ch.text && ch.text.indexOf(targetText) !== -1) return ch; + var res = findItemWithText(ch, targetText); + if (res) return res; + } + return null; + } + + // 1. Click on employee name label selects the user + var aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found"); + testMenu.selectedAssigneeIds = []; + mouseClick(aliceLabel, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + + // 2. Query fresh aliceLabel and click again to toggle selection off + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found after re-render"); + mouseClick(aliceLabel, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 0); + + // 3. Query fresh bobEmail and click to select Bob + var bobEmail = findItemWithText(listView.contentItem, "bob@example.com"); + verify(bobEmail !== null, "Bob email should be found"); + mouseClick(bobEmail, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(20, 1)); + + // 4. Query fresh allUsersLabel and click to select All Users + var allUsersLabel = findItemWithText(listView.contentItem, "All Users"); + verify(allUsersLabel !== null, "All Users label should be found"); + mouseClick(allUsersLabel, 5, 5); + wait(100); + verify(testMenu.isOnlyAllUsersSelected()); + + // 5. Query fresh Alice delegate and click directly on its checkbox + testMenu.selectedAssigneeIds = []; + wait(50); + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found"); + var delegateItem = aliceLabel.parent; + while (delegateItem && delegateItem.itemAssigneeId === undefined) { + delegateItem = delegateItem.parent; + } + verify(delegateItem !== null, "Delegate root should be found"); + + var aliceCheckbox = null; + for (var c = 0; c < delegateItem.children.length; c++) { + var ch = delegateItem.children[c]; + if (ch && (ch.hasOwnProperty("checked") || ch.checked !== undefined)) { + aliceCheckbox = ch; + break; + } + } + verify(aliceCheckbox !== null, "Alice checkbox should be found"); + + // Direct click on checkbox selects Alice + mouseClick(aliceCheckbox, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + + // Direct click on checkbox deselects Alice (re-query after re-render) + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found after re-render"); + delegateItem = aliceLabel.parent; + while (delegateItem && delegateItem.itemAssigneeId === undefined) { + delegateItem = delegateItem.parent; + } + verify(delegateItem !== null, "Delegate root should be found after re-render"); + aliceCheckbox = null; + for (var c2 = 0; c2 < delegateItem.children.length; c2++) { + var ch2 = delegateItem.children[c2]; + if (ch2 && (ch2.hasOwnProperty("checked") || ch2.checked !== undefined)) { + aliceCheckbox = ch2; + break; + } + } + verify(aliceCheckbox !== null, "Alice checkbox should be found after re-render"); + mouseClick(aliceCheckbox, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 0); + } + } +}