diff --git a/manifest.json.in b/manifest.json.in index fa357907..dc8448d6 100644 --- a/manifest.json.in +++ b/manifest.json.in @@ -18,7 +18,7 @@ "push-helper": "ubtms-push-helper.json" } }, - "version": "1.3.2", + "version": "1.3.3", "maintainer": "CIT Services ", "framework" : "@CLICK_FRAMEWORK@" } diff --git a/models/Main.js b/models/Main.js index 1bb749d5..59eda064 100644 --- a/models/Main.js +++ b/models/Main.js @@ -87,12 +87,27 @@ function get_projects_spent_hours(account, startDate, endDate) { db.transaction(function (tx) { var params = []; - var conditions = []; + var conditions = [ + "(a.status IS NULL OR a.status != 'deleted')", + "a.unit_amount > 0", + "(a.project_id IS NOT NULL OR a.sub_project_id IS NOT NULL)" + ]; var query = - "SELECT p.name AS entity_name, SUM(a.unit_amount) AS total, s.name AS stage_name " + + "SELECT p.name AS entity_name, parent.name AS parent_name, SUM(a.unit_amount) AS total, s.name AS stage_name " + "FROM account_analytic_line_app a " + - "LEFT JOIN project_project_app p ON p.odoo_record_id = a.project_id AND p.account_id = a.account_id " + - "LEFT JOIN project_project_stage_app s ON s.odoo_record_id = p.stage AND s.account_id = p.account_id "; + "JOIN project_project_app p ON (" + + " (p.odoo_record_id = COALESCE(NULLIF(a.sub_project_id, 0), a.project_id) OR " + + " (a.account_id = 0 AND p.id = COALESCE(NULLIF(a.sub_project_id, 0), a.project_id))) " + + " AND p.account_id = a.account_id " + + ") " + + "LEFT JOIN project_project_app parent ON (" + + " (parent.odoo_record_id = p.parent_id OR (a.account_id = 0 AND parent.id = p.parent_id)) " + + " AND parent.account_id = p.account_id " + + ") " + + "LEFT JOIN project_project_stage_app s ON (" + + " (s.odoo_record_id = p.stage OR (a.account_id = 0 AND s.id = p.stage)) " + + " AND s.account_id = p.account_id " + + ") "; if (account !== -1 && account !== undefined && account !== null) { conditions.push("a.account_id = ?"); @@ -107,16 +122,13 @@ function get_projects_spent_hours(account, startDate, endDate) { params.push(endDate); } - if (conditions.length > 0) { - query += "WHERE " + conditions.join(" AND ") + " "; - } - - query += "GROUP BY a.project_id, a.account_id, p.name, s.name ORDER BY total DESC"; + query += "WHERE " + conditions.join(" AND ") + " "; + query += "GROUP BY p.account_id, p.id, p.name, parent.name, s.name ORDER BY total DESC"; var result = tx.executeSql(query, params); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); - var name = row.entity_name || "Unknown Project"; + var name = row.parent_name ? (row.parent_name + " / " + row.entity_name) : (row.entity_name || "Unknown Project"); project_details.push({ name: name, total: row.total, @@ -149,11 +161,18 @@ function get_tasks_spent_hours(account, startDate, endDate) { db.transaction(function (tx) { var params = []; - var conditions = []; + var conditions = [ + "(a.status IS NULL OR a.status != 'deleted')", + "a.unit_amount > 0", + "a.task_id IS NOT NULL" + ]; var query = "SELECT t.name AS entity_name, SUM(a.unit_amount) AS total " + "FROM account_analytic_line_app a " + - "LEFT JOIN project_task_app t ON t.id = a.task_id "; + "JOIN project_task_app t ON (" + + " (t.odoo_record_id = a.task_id OR (a.account_id = 0 AND t.id = a.task_id)) " + + " AND t.account_id = a.account_id " + + ") "; if (account !== -1 && account !== undefined && account !== null) { conditions.push("a.account_id = ?"); @@ -168,10 +187,7 @@ function get_tasks_spent_hours(account, startDate, endDate) { params.push(endDate); } - if (conditions.length > 0) { - query += "WHERE " + conditions.join(" AND ") + " "; - } - + query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY a.task_id, t.name ORDER BY total DESC"; var result = tx.executeSql(query, params); diff --git a/models/accounts.js b/models/accounts.js index cf1d4983..257bbc65 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -20,7 +20,11 @@ function getAccountsList() { for (var i = 0; i < accounts.rows.length; i++) { var row = accounts.rows.item(i); - accountsList.push(DBCommon.rowToObject(row)); + var obj = DBCommon.rowToObject(row); + if (obj.id === 0 || obj.name === "Local Account") { + obj.name = "Local"; + } + accountsList.push(obj); } }); @@ -123,6 +127,11 @@ function getDefaultAccountId() { var res = tx.executeSql("SELECT id FROM users WHERE is_default = 1 LIMIT 1"); if (res.rows.length > 0) { defaultId = res.rows.item(0).id; + } else { + var fallback = tx.executeSql("SELECT id FROM users ORDER BY id ASC LIMIT 1"); + if (fallback.rows.length > 0) { + defaultId = fallback.rows.item(0).id; + } } }); @@ -134,6 +143,37 @@ function getDefaultAccountId() { } +/** + * Retrieves the ID of the default remote account (id > 0), or the first remote account. + * + * @returns {number} The remote account ID, or -1 if no remote account exists. + */ +function getDefaultRemoteAccountId() { + var remoteId = -1; + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + var res = tx.executeSql("SELECT id FROM users WHERE is_default = 1 AND id > 0 LIMIT 1"); + if (res.rows.length > 0) { + remoteId = res.rows.item(0).id; + } else { + var fallback = tx.executeSql("SELECT id FROM users WHERE id > 0 ORDER BY id ASC LIMIT 1"); + if (fallback.rows.length > 0) { + remoteId = fallback.rows.item(0).id; + } + } + }); + + } catch (e) { + DBCommon.logException(e); + } + + return remoteId; +} + + /** * Retrieves a list of Odoo users associated with the given account ID. * @@ -159,7 +199,10 @@ function getUsers(accountId) { 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 = ?", + "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] ); @@ -373,6 +416,10 @@ function updateAccount(accountId, name, link, database, username, selectedConnec * @param {number} userId - The `id` of the user to delete. */ function deleteAccountAndRelatedData(userId) { + if (userId === 0 || userId === "0") { + console.warn("Cannot delete Local Account"); + return; + } try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); @@ -385,23 +432,55 @@ function deleteAccountAndRelatedData(userId) { "account_analytic_line_app", "res_users_app", "mail_activity_type_app", - "mail_activity_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++) { const table = tables[i]; - DBCommon.log("Deleting data from account " + userId) - tx.executeSql(`DELETE FROM ${table} WHERE account_id = ?`, [userId]); + DBCommon.log("Deleting data from account " + numericUserId); + try { + tx.executeSql(`DELETE FROM ${table} WHERE account_id = ?`, [numericUserId]); + } catch (tableErr) { + DBCommon.log("Could not delete from table " + table + ": " + tableErr); + } } - DBCommon.log(`Deleting user from users table where id = ${userId}`); - tx.executeSql("DELETE FROM users WHERE id = ?", [userId]); + DBCommon.log(`Deleting user from users table where id = ${numericUserId}`); + tx.executeSql("DELETE FROM users WHERE id = ?", [numericUserId]); - DBCommon.log(`Account and related data deleted for account_id: ${userId}`); + // Ensure a valid default account exists + var defaultCheck = tx.executeSql("SELECT id FROM users WHERE is_default = 1 LIMIT 1"); + if (defaultCheck.rows.length === 0) { + var firstAccount = tx.executeSql("SELECT id FROM users ORDER BY id ASC LIMIT 1"); + if (firstAccount.rows.length > 0) { + tx.executeSql("UPDATE users SET is_default = 1 WHERE id = ?", [firstAccount.rows.item(0).id]); + } + } + + // Post-deletion sweep across all tables to purge any unlinked records + for (let j = 0; j < tables.length; j++) { + try { + tx.executeSql(`DELETE FROM ${tables[j]} WHERE account_id NOT IN (SELECT id FROM users)`); + } catch (sweepErr) { + // Ignore sweep errors for optional tables + } + } + + DBCommon.log(`Account and related data deleted for account_id: ${numericUserId}`); }); } catch (e) { - DBCommon.logException(e); + DBCommon.logException("Accounts", e); } } @@ -414,7 +493,8 @@ function deleteAccountAndRelatedData(userId) { * @returns {number|null} The `odoo_record_id` of the matched user, or `null` if not found. */ function getCurrentUserOdooId(accountId) { - if (accountId === 0) { + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId) : -1; + if (parsedAccountId === 0) { return 1; // Local account } let odooId = null; @@ -536,6 +616,10 @@ function getAccountName(accountId) { return ""; } + if (Number(accountId) === 0) { + return "Local"; + } + try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var name = ""; @@ -547,6 +631,10 @@ function getAccountName(accountId) { } }); + if (name === "Local Account") { + return "Local"; + } + return name; } catch (e) { Logger.error("Accounts", "getAccountName failed:", e) @@ -575,8 +663,8 @@ function getUserNameByOdooId(odoo_record_id) { 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 = ? LIMIT 1"; - var result = tx.executeSql(query, [odoo_record_id]); + 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]); if (result.rows.length > 0) { userName = result.rows.item(0).name; diff --git a/models/activity.js b/models/activity.js index 16a8ca05..448b4fdd 100644 --- a/models/activity.js +++ b/models/activity.js @@ -311,19 +311,20 @@ function resolveProjectLinkage(tx, link_id, account_id) { try { let rs_project = tx.executeSql( - `SELECT odoo_record_id, parent_id FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1`, - [link_id, account_id] + `SELECT id, odoo_record_id, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1`, + [link_id, link_id, account_id] ); if (rs_project.rows.length > 0) { let row = rs_project.rows.item(0); + let effectiveId = (account_id === 0 || !row.odoo_record_id) ? row.id : row.odoo_record_id; let parent_id = sanitizeId(row.parent_id); if (parent_id !== -1 && parent_id !== 0) { // Has a valid parent (not -1 for invalid, not 0 for no parent) result.project_id = parent_id; - result.sub_project_id = row.odoo_record_id; + result.sub_project_id = effectiveId; } else { - result.project_id = row.odoo_record_id; + result.project_id = effectiveId; result.sub_project_id = -1; } } else { @@ -352,8 +353,8 @@ function resolveActivityLinkage(tx, link_id, account_id) { // Step 1: Determine if link_id is subtask or task let rs_task = tx.executeSql( - `SELECT odoo_record_id, parent_id, project_id FROM project_task_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1`, - [link_id, account_id] + `SELECT id, odoo_record_id, parent_id, project_id FROM project_task_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1`, + [link_id, link_id, account_id] ); let resolved_task_id = -1; @@ -362,67 +363,30 @@ function resolveActivityLinkage(tx, link_id, account_id) { if (rs_task.rows.length > 0) { let row_task = rs_task.rows.item(0); - console.log("resolveActivityLinkage: Found task row:", JSON.stringify({ - odoo_record_id: row_task.odoo_record_id, - parent_id: row_task.parent_id, - project_id: row_task.project_id - })); - + let effectiveTaskId = (account_id === 0 || !row_task.odoo_record_id) ? row_task.id : row_task.odoo_record_id; let parent_id = sanitizeId(row_task.parent_id); - Logger.debug("Activity", "resolveActivityLinkage: Sanitized parent_id:", parent_id, "from raw value:", row_task.parent_id, "type:", typeof row_task.parent_id) if (parent_id !== -1 && parent_id !== 0) { // Has a valid parent (not -1 for invalid, not 0 for no parent) // It is a subtask resolved_task_id = parent_id; - resolved_sub_task_id = row_task.odoo_record_id; - Logger.debug("Activity", "resolveActivityLinkage: Identified as SUBTASK. Parent task_id:", resolved_task_id, "Sub task_id:", resolved_sub_task_id) + resolved_sub_task_id = effectiveTaskId; // For subtask, we need to get project_id from the parent task let rs_parent_task = tx.executeSql( - `SELECT project_id, odoo_record_id FROM project_task_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1`, - [resolved_task_id, account_id] + `SELECT id, project_id, odoo_record_id FROM project_task_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1`, + [resolved_task_id, resolved_task_id, account_id] ); if (rs_parent_task.rows.length > 0) { task_project_id = sanitizeId(rs_parent_task.rows.item(0).project_id); - Logger.debug("Activity", "resolveActivityLinkage: Found parent task with odoo_record_id:", rs_parent_task.rows.item(0).odoo_record_id, "project_id:", task_project_id) } else { - Logger.warn("Activity", "resolveActivityLinkage: Parent task not found for resolved_task_id:", resolved_task_id) - - // Fallback: try to find parent task by local database id (in case parent_id references local id instead of odoo_record_id) - let rs_parent_by_id = tx.executeSql( - `SELECT project_id, odoo_record_id FROM project_task_app WHERE id = ? AND account_id = ? LIMIT 1`, - [resolved_task_id, account_id] - ); - - if (rs_parent_by_id.rows.length > 0) { - task_project_id = sanitizeId(rs_parent_by_id.rows.item(0).project_id); - Logger.debug("Activity", "resolveActivityLinkage: Found parent task by local id:", resolved_task_id, "odoo_record_id:", rs_parent_by_id.rows.item(0).odoo_record_id, "project_id:", task_project_id) - // Update resolved_task_id to use the correct odoo_record_id - resolved_task_id = rs_parent_by_id.rows.item(0).odoo_record_id; - Logger.debug("Activity", "resolveActivityLinkage: Updated resolved_task_id to odoo_record_id:", resolved_task_id) - } else { - // FINAL FALLBACK: Maybe parent_id is negative (local record), try searching for negative values - let rs_parent_negative = tx.executeSql( - `SELECT project_id, odoo_record_id FROM project_task_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1`, - [resolved_task_id, account_id] - ); - - if (rs_parent_negative.rows.length > 0) { - task_project_id = sanitizeId(rs_parent_negative.rows.item(0).project_id); - Logger.debug("Activity", "resolveActivityLinkage: Found parent task with negative odoo_record_id:", resolved_task_id, "project_id:", task_project_id) - } else { - task_project_id = sanitizeId(row_task.project_id); // Final fallback to subtask's project_id - Logger.debug("Activity", "resolveActivityLinkage: Using final fallback project_id from subtask:", task_project_id) - } - } + task_project_id = sanitizeId(row_task.project_id); } } else { // It is a parent task - resolved_task_id = row_task.odoo_record_id; + resolved_task_id = effectiveTaskId; resolved_sub_task_id = -1; task_project_id = sanitizeId(row_task.project_id); - Logger.debug("Activity", "resolveActivityLinkage: Identified as PARENT TASK. Task_id:", resolved_task_id, "Project_id:", task_project_id) } } else { Logger.warn("Activity", "Link_id is not a valid task in project_task_app:", link_id, "account_id:", account_id) @@ -430,27 +394,23 @@ function resolveActivityLinkage(tx, link_id, account_id) { } // Step 2: Determine if project_id is subproject or top-level - if (task_project_id !== -1) { // Changed from > 0 to !== -1 to allow negative values + if (task_project_id !== -1) { let rs_project = tx.executeSql( - `SELECT parent_id FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1`, - [task_project_id, account_id] + `SELECT id, odoo_record_id, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1`, + [task_project_id, task_project_id, account_id] ); if (rs_project.rows.length > 0) { let parent_project_id = sanitizeId(rs_project.rows.item(0).parent_id); - Logger.debug("Activity", "resolveActivityLinkage: Project parent_id:", parent_project_id) if (parent_project_id !== -1 && parent_project_id !== 0) { // Has a valid parent result.project_id = parent_project_id; // parent project result.sub_project_id = task_project_id; // subproject - Logger.debug("Activity", "resolveActivityLinkage: Project is SUBPROJECT. Parent:", parent_project_id, "Sub:", task_project_id) } else { result.project_id = task_project_id; // top-level project result.sub_project_id = -1; - Logger.debug("Activity", "resolveActivityLinkage: Project is TOP-LEVEL:", task_project_id) } } else { - Logger.warn("Activity", "Project lookup failed for task_project_id:", task_project_id) result.project_id = task_project_id; result.sub_project_id = -1; } @@ -496,32 +456,51 @@ function sanitizeId(value) { //enrichment over /** - * Retrieves the name of an activity type from the local SQLite database - * based on the provided Odoo record ID. + * Retrieves the name of an activity type from the local SQLite database. * - * @function getActivityTypeName - * @param {number} odooRecordId - The ID of the activity type as stored in Odoo. - * @returns {string} - Returns the name of the activity type if found, otherwise an empty string. + * Local Account activity types use the local SQLite id, while + * Odoo account activity types use the odoo_record_id. * - * @description - * Opens a local SQLite database transaction and queries the `mail_activity_type_app` table - * for a record matching the given `odooRecordId`. - * Extracts the `name` field from the result and returns it. - * Logs any exception encountered during the operation via `DBCommon.logException()`. + * @function getActivityTypeName + * @param {number} activityTypeId - The activity type ID. + * @param {number} accountId - The account ID. + * @returns {string} - Returns the activity type name if found, otherwise an empty string. */ -function getActivityTypeName(odooRecordId) { +function getActivityTypeName(activityTypeId, accountId) { + if (!activityTypeId || activityTypeId <= 0) { + return ""; + } + var typeName = ""; try { - var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + var db = Sql.LocalStorage.openDatabaseSync( + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); db.transaction(function (tx) { - var query = ` - SELECT name FROM mail_activity_type_app - WHERE odoo_record_id = ? - LIMIT 1 - `; - var rs = tx.executeSql(query, [odooRecordId]); + var query = ""; + var params = []; + + if (accountId === 0) { + query = "SELECT name FROM mail_activity_type_app " + + "WHERE account_id = 0 AND id = ? LIMIT 1"; + params = [activityTypeId]; + } else if (accountId !== undefined && accountId !== null && accountId > 0) { + query = "SELECT name FROM mail_activity_type_app " + + "WHERE account_id = ? AND odoo_record_id = ? LIMIT 1"; + params = [accountId, activityTypeId]; + } else { + query = "SELECT name FROM mail_activity_type_app " + + "WHERE (odoo_record_id = ? AND odoo_record_id > 0) " + + "OR id = ? LIMIT 1"; + params = [activityTypeId, activityTypeId]; + } + + var rs = tx.executeSql(query, params); if (rs.rows.length > 0) { typeName = rs.rows.item(0).name; @@ -534,7 +513,6 @@ function getActivityTypeName(odooRecordId) { return typeName; } - /** * Marks a specific activity record as "done" in the local SQLite database * by updating its `state` and `status` fields. @@ -661,7 +639,9 @@ function getActivityTypesForAccount(account_id) { var query = ` SELECT * FROM mail_activity_type_app - WHERE account_id = ? AND (status IS NULL OR status != 'deleted')`; + WHERE account_id = ? AND (status IS NULL OR status != 'deleted') + AND name IS NOT NULL AND TRIM(name) != '' + ORDER BY name COLLATE NOCASE ASC`; var rs = tx.executeSql(query, [account_id]); @@ -944,22 +924,16 @@ function getActivitiesForProject(projectOdooRecordId, accountId) { projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; } - // Step 2: Fetch activities linked to the specific project - // This includes activities linked directly to the project and activities linked to tasks within the project + // Step 2: Fetch activities linked directly to the specific project var rs = tx.executeSql(` SELECT DISTINCT a.* FROM mail_activity_app a WHERE a.account_id = ? AND LOWER(TRIM(COALESCE(a.state, ''))) != 'done' - AND ( - (a.resModel = 'project.project' AND a.link_id = ?) - OR - (a.resModel = 'project.task' AND a.link_id IN ( - SELECT odoo_record_id FROM project_task_app - WHERE project_id = ? AND account_id = ? - )) - ) + AND (a.status IS NULL OR a.status != 'deleted') + AND a.resModel = 'project.project' + AND a.link_id = ? ORDER BY a.due_date ASC - `, [accountId, projectOdooRecordId, projectOdooRecordId, accountId]); + `, [accountId, projectOdooRecordId]); for (var i = 0; i < rs.rows.length; i++) { var row = rs.rows.item(i); @@ -1033,22 +1007,17 @@ function getActivitiesForProjectPaginated(projectOdooRecordId, accountId, limit, projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; } - // Step 2: Fetch activities linked to the specific project with LIMIT/OFFSET + // Step 2: Fetch activities linked directly to the specific project with LIMIT/OFFSET var rs = tx.executeSql(` SELECT DISTINCT a.* FROM mail_activity_app a WHERE a.account_id = ? AND LOWER(TRIM(COALESCE(a.state, ''))) != 'done' - AND ( - (a.resModel = 'project.project' AND a.link_id = ?) - OR - (a.resModel = 'project.task' AND a.link_id IN ( - SELECT odoo_record_id FROM project_task_app - WHERE project_id = ? AND account_id = ? - )) - ) + AND (a.status IS NULL OR a.status != 'deleted') + AND a.resModel = 'project.project' + AND a.link_id = ? ORDER BY a.due_date ASC LIMIT ? OFFSET ? - `, [accountId, projectOdooRecordId, projectOdooRecordId, accountId, limit, offset]); + `, [accountId, projectOdooRecordId, limit, offset]); for (var i = 0; i < rs.rows.length; i++) { var row = rs.rows.item(i); @@ -1117,24 +1086,18 @@ function getActivitiesForTask(taskOdooRecordId, accountId) { // Get the task's project_id for color inheritance var taskProjectId = null; var taskRs = tx.executeSql( - "SELECT project_id FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", - [taskOdooRecordId] + "SELECT project_id FROM project_task_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [taskOdooRecordId, taskOdooRecordId] ); if (taskRs.rows.length > 0) { taskProjectId = taskRs.rows.item(0).project_id; } - var query = ` - SELECT * FROM mail_activity_app - WHERE resModel = 'project.task' - AND link_id = ? - AND LOWER(TRIM(COALESCE(state, ''))) != 'done' - AND (status IS NULL OR status != 'deleted') - ORDER BY due_date ASC`; - var params = [taskOdooRecordId]; + var query = ""; + var params = []; // If accountId is provided, filter by it - if (accountId && accountId > 0) { + if (accountId !== undefined && accountId !== null && accountId >= 0) { query = ` SELECT * FROM mail_activity_app WHERE resModel = 'project.task' @@ -1144,6 +1107,15 @@ function getActivitiesForTask(taskOdooRecordId, accountId) { AND (status IS NULL OR status != 'deleted') ORDER BY due_date ASC`; params = [taskOdooRecordId, accountId]; + } else { + query = ` + SELECT * FROM mail_activity_app + WHERE resModel = 'project.task' + AND link_id = ? + AND LOWER(TRIM(COALESCE(state, ''))) != 'done' + AND (status IS NULL OR status != 'deleted') + ORDER BY due_date ASC`; + params = [taskOdooRecordId]; } var rs = tx.executeSql(query, params); @@ -1200,8 +1172,8 @@ function getActivitiesForTaskPaginated(taskOdooRecordId, accountId, limit, offse // Get the task's project_id for color inheritance var taskProjectId = null; var taskRs = tx.executeSql( - "SELECT project_id FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", - [taskOdooRecordId] + "SELECT project_id FROM project_task_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [taskOdooRecordId, taskOdooRecordId] ); if (taskRs.rows.length > 0) { taskProjectId = taskRs.rows.item(0).project_id; @@ -1211,7 +1183,7 @@ function getActivitiesForTaskPaginated(taskOdooRecordId, accountId, limit, offse var params = []; // If accountId is provided, filter by it - if (accountId && accountId > 0) { + if (accountId !== undefined && accountId !== null && accountId >= 0) { query = ` SELECT * FROM mail_activity_app WHERE resModel = 'project.task' @@ -1848,7 +1820,7 @@ function passesActivitySearchFilter(activity, searchQuery) { } - var activityTypeName = getActivityTypeName(activity.activity_type_id); + var activityTypeName = getActivityTypeName(activity.activity_type_id, activity.account_id); if (activityTypeName && activityTypeName.toLowerCase().indexOf(query) >= 0) { return true; } @@ -1961,11 +1933,12 @@ function getAllActivityAssignees(accountId) { SELECT u.id, u.odoo_record_id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, 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.odoo_record_id IN (${placeholders}) + WHERE u.account_id = ? AND (u.odoo_record_id IN (${placeholders}) OR u.id IN (${placeholders})) + AND u.name IS NOT NULL AND TRIM(u.name) != '' ORDER BY u.name COLLATE NOCASE ASC `; - var queryParams = [acctId].concat(userIds); + var queryParams = [acctId].concat(userIds).concat(userIds); var userResult = tx.executeSql(userQuery, queryParams); for (var k = 0; k < userResult.rows.length; k++) { @@ -2098,4 +2071,4 @@ function getAllDoneActivitiesPaginated(limit, offset) { } return activityList; -} \ No newline at end of file +} diff --git a/models/constants.js b/models/constants.js index 0a0d4ab2..bddbff49 100644 --- a/models/constants.js +++ b/models/constants.js @@ -2,7 +2,7 @@ .pragma library -var version="1.3.2" +var version="1.3.3" //fonts var FontSizes = { @@ -15,11 +15,11 @@ var Colors = { StickyNote: "#F5F5F5", Border: "#B0BEC5", Shadow: "#55000000", - Button: "#0cc0df", + Button: "#E95420", ButtonText: "white", - ButtonHover: "#ef8037", - Orange :"#F25C27", - ButtonDisabled :"grey", + ButtonHover: "#d3481b", + Orange: "#E95420", + ButtonDisabled: "grey", Quadrants: { Q1: "#E53935", // Urgent & Important diff --git a/models/database.js b/models/database.js index 9383e53a..2c0d2696 100644 --- a/models/database.js +++ b/models/database.js @@ -116,8 +116,8 @@ function ensureDefaultLocalAccountExists() { db.transaction(function (tx) { // Step 1: Ensure Local Account Exists const result = tx.executeSql( - "SELECT id FROM users WHERE id = 0 OR name = ?", - ["Local Account"] + "SELECT id FROM users WHERE id = 0 OR name = ? OR name = ?", + ["Local Account", "Local"] ); if (result.rows.length === 0) { @@ -125,7 +125,7 @@ function ensureDefaultLocalAccountExists() { "INSERT INTO users (id, name, link, last_modified, database, connectwith_id, api_key, username, is_default) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ 0, - "Local Account", + "Local", "local://", new Date().toISOString(), "local", @@ -135,6 +135,10 @@ function ensureDefaultLocalAccountExists() { 1 ] ); + } else { + tx.executeSql( + "UPDATE users SET name = 'Local' WHERE id = 0 AND name = 'Local Account'" + ); } // Step 2: Ensure Local User Exists in res_users_app @@ -164,11 +168,131 @@ function ensureDefaultLocalAccountExists() { } }); + ensureDefaultLocalProjectStages(); + ensureDefaultLocalTaskStages(); } catch (e) { logException(e); } } +/** + * Ensures default activity types exist for the Local Account. + * + * Local accounts do not sync activity types from Odoo, so provide + * a predefined set of activity types during database initialization. + */ +function ensureDefaultLocalActivityTypes() { + const defaultTypes = ["To Do", "Call", "Email", "Meeting"]; + + try { + const db = Sql.LocalStorage.openDatabaseSync(NAME, VERSION, DISPLAY_NAME, SIZE); + + db.transaction(function (tx) { + defaultTypes.forEach(function (typeName, index) { + const result = tx.executeSql( + "SELECT id FROM mail_activity_type_app WHERE account_id = ? AND name = ? AND (status IS NULL OR status != 'deleted')", + [0, typeName] + ); + + if (result.rows.length === 0) { + tx.executeSql( + "INSERT INTO mail_activity_type_app (account_id, name, status, odoo_record_id) VALUES (?, ?, ?, ?)", + [0, typeName, "", -(index + 1)] + ); + } + }); + }); + } catch (e) { + logException("ensureDefaultLocalActivityTypes", e); + } +} + +/** + * Ensures default project stages exist for the Local Account. + * + * Local accounts do not sync project stages from Odoo, so provide + * a predefined set of project stages during database initialization. + */ +function ensureDefaultLocalProjectStages() { + const defaultStages = [ + { name: "Planning", sequence: 10, fold: 0, odoo_record_id: -1 }, + { name: "In Progress", sequence: 20, fold: 0, odoo_record_id: -2 }, + { name: "Completed", sequence: 30, fold: 1, odoo_record_id: -3 }, + { name: "Cancelled", sequence: 40, fold: 1, odoo_record_id: -4 } + ]; + + try { + const db = Sql.LocalStorage.openDatabaseSync(NAME, VERSION, DISPLAY_NAME, SIZE); + const timestamp = new Date().toISOString(); + + db.transaction(function (tx) { + defaultStages.forEach(function (stage) { + const result = tx.executeSql( + "SELECT id FROM project_project_stage_app WHERE account_id = ? AND odoo_record_id = ?", + [0, stage.odoo_record_id] + ); + + if (result.rows.length === 0) { + tx.executeSql( + "INSERT INTO project_project_stage_app (account_id, odoo_record_id, name, sequence, fold, active, create_date, write_date, status) VALUES (?, ?, ?, ?, ?, 1, ?, ?, '')", + [0, stage.odoo_record_id, stage.name, stage.sequence, stage.fold, timestamp, timestamp] + ); + } + }); + + // Safe migration: Set initial stage (-1, Planning) for local projects without stage + tx.executeSql( + "UPDATE project_project_app SET stage = -1 WHERE account_id = 0 AND (stage IS NULL OR stage = 0)" + ); + }); + } catch (e) { + logException("ensureDefaultLocalProjectStages", e); + } +} + +/** + * Ensures default task stages exist for the Local Account. + * + * Local accounts do not sync task types/stages from Odoo, so provide + * a predefined set of global task stages during database initialization. + */ +function ensureDefaultLocalTaskStages() { + const defaultStages = [ + { name: "New", sequence: 1, fold: 0, odoo_record_id: -1 }, + { name: "In Progress", sequence: 2, fold: 0, odoo_record_id: -2 }, + { name: "Done", sequence: 3, fold: 1, odoo_record_id: -3 }, + { name: "Cancelled", sequence: 4, fold: 1, odoo_record_id: -4 } + ]; + + try { + const db = Sql.LocalStorage.openDatabaseSync(NAME, VERSION, DISPLAY_NAME, SIZE); + const timestamp = new Date().toISOString(); + + db.transaction(function (tx) { + defaultStages.forEach(function (stage) { + const result = tx.executeSql( + "SELECT id FROM project_task_type_app WHERE account_id = ? AND odoo_record_id = ?", + [0, stage.odoo_record_id] + ); + + if (result.rows.length === 0) { + tx.executeSql( + "INSERT INTO project_task_type_app (account_id, odoo_record_id, name, sequence, fold, is_global, active, create_date, write_date, status) VALUES (?, ?, ?, ?, ?, 1, 1, ?, ?, '')", + [0, stage.odoo_record_id, stage.name, stage.sequence, stage.fold, timestamp, timestamp] + ); + } + }); + + // Safe migration: Set initial stage (-1, New) for local tasks without state + tx.executeSql( + "UPDATE project_task_app SET state = -1 WHERE account_id = 0 AND (state IS NULL OR state = 0 OR state = '' OR state = '0')" + ); + }); + } catch (e) { + logException("ensureDefaultLocalTaskStages", e); + } +} + /** * Creates a table if it doesn't exist, and ensures all expected columns are present. * diff --git a/models/dbinit.js b/models/dbinit.js index 2191655e..0d72fe82 100644 --- a/models/dbinit.js +++ b/models/dbinit.js @@ -231,6 +231,8 @@ function initializeDatabase() { )', ['id INTEGER', 'account_id INTEGER', 'name TEXT', 'status TEXT DEFAULT ""', 'odoo_record_id INTEGER'] ); + // Ensure default activity types for Local Account + DBCommon.ensureDefaultLocalActivityTypes(); DBCommon.createOrUpdateTable("ir_model_app", @@ -430,6 +432,8 @@ function initializeDatabase() { 'has_draft INTEGER DEFAULT 0' ] ); + // Ensure default task stages for Local Account + DBCommon.ensureDefaultLocalTaskStages(); DBCommon.createOrUpdateTable("project_project_stage_app", 'CREATE TABLE IF NOT EXISTS project_project_stage_app (\ @@ -468,6 +472,8 @@ function initializeDatabase() { 'has_draft INTEGER DEFAULT 0' ] ); + // Ensure default project stages for Local Account + DBCommon.ensureDefaultLocalProjectStages(); DBCommon.createOrUpdateTable("dl_cache_app", 'CREATE TABLE IF NOT EXISTS dl_cache_app (\ @@ -641,10 +647,15 @@ function initializeDatabase() { } + Logger.debug("Dbinit", "Database initialization complete") +} + +function performPostStartupMaintenance() { + Logger.debug("Dbinit", "Performing post-startup database maintenance...") purgeCache(); syncDraftFlags(); - - Logger.debug("Dbinit", "Database initialization complete") + cleanupOrphanAccountData(); + Logger.debug("Dbinit", "Post-startup database maintenance complete") } /** @@ -796,3 +807,46 @@ function syncDraftFlags() { } } } + +function cleanupOrphanAccountData() { + try { + var db = Sql.LocalStorage.openDatabaseSync( + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); + db.transaction(function (tx) { + var 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" + ]; + + for (var i = 0; i < tables.length; i++) { + try { + tx.executeSql("DELETE FROM " + tables[i] + " WHERE account_id NOT IN (SELECT id FROM users)"); + } catch (tableErr) { + Logger.debug("Dbinit", "Could not purge orphan data from " + tables[i] + ": " + tableErr); + } + } + }); + Logger.debug("Dbinit", "Orphan account data cleanup complete"); + } catch (e) { + Logger.error("Dbinit", "cleanupOrphanAccountData failed:", e); + } +} + diff --git a/models/project.js b/models/project.js index af424737..7d33f945 100644 --- a/models/project.js +++ b/models/project.js @@ -18,8 +18,8 @@ function getLocalIdFromOdooId(odooRecordId, accountId) { db.transaction(function (tx) { var result = tx.executeSql( - 'SELECT id FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1', - [odooRecordId, accountId] + 'SELECT id FROM project_project_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1', + [odooRecordId, odooRecordId, accountId] ); if (result.rows.length > 0) { @@ -228,7 +228,7 @@ function getAllProjectUpdates(accountId) { Logger.debug("Project", "Fetching project updates for account:", accountId) } else { - query = "SELECT * FROM project_update_app WHERE status != 'deleted' ORDER BY date DESC"; + query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND account_id IN (SELECT id FROM users) ORDER BY date DESC"; result = tx.executeSql(query); Logger.debug("Project", "Fetching all project updates (no account filter)") } @@ -253,8 +253,8 @@ function getProjectUpdatesByProject(projectOdooRecordId, accountId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND project_id = ? AND account_id = ? ORDER BY date DESC"; - var result = tx.executeSql(query, [projectOdooRecordId, accountId]); + var query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND (project_id = ? OR project_id IN (SELECT odoo_record_id FROM project_project_app WHERE (id = ? OR odoo_record_id = ?) AND account_id = ?)) AND account_id = ? ORDER BY date DESC"; + var result = tx.executeSql(query, [projectOdooRecordId, projectOdooRecordId, projectOdooRecordId, accountId, accountId]); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -329,10 +329,12 @@ function getProjectUpdateByOdooId(odoo_record_id, accountId) { return update || {}; } -function getProjectStageName(odooRecordId) { - var stageName = null; - +function getProjectStageName(odooRecordId, accountId) { + var stageName = ""; try { + if (!odooRecordId || odooRecordId === 0) { + return ""; + } var db = Sql.LocalStorage.openDatabaseSync( DBCommon.NAME, DBCommon.VERSION, @@ -341,14 +343,21 @@ function getProjectStageName(odooRecordId) { ); db.transaction(function (tx) { - var query = ` - SELECT name - FROM project_project_stage_app - WHERE odoo_record_id = ? - LIMIT 1 - `; + var query = ""; + var params = []; - var result = tx.executeSql(query, [odooRecordId]); + if (odooRecordId < 0) { + query = "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? AND account_id = 0 LIMIT 1"; + params = [odooRecordId]; + } else if (accountId !== undefined && accountId !== null && accountId >= 0) { + query = "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1"; + params = [odooRecordId, accountId]; + } else { + query = "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? LIMIT 1"; + params = [odooRecordId]; + } + + var result = tx.executeSql(query, params); if (result.rows.length > 0) { stageName = result.rows.item(0).name; @@ -478,18 +487,27 @@ function updateProjectStage(projectId, stageOdooRecordId, accountId) { // Verify the stage exists var stageCheck = tx.executeSql( - 'SELECT id FROM project_project_stage_app WHERE odoo_record_id = ?', - [stageOdooRecordId] + 'SELECT id FROM project_project_stage_app WHERE odoo_record_id = ? AND account_id = ?', + [stageOdooRecordId, accountId] ); + if (stageCheck.rows.length === 0) { + // Fallback check if stage exists globally + stageCheck = tx.executeSql( + 'SELECT id FROM project_project_stage_app WHERE odoo_record_id = ?', + [stageOdooRecordId] + ); + } + if (stageCheck.rows.length === 0) { throw "Stage not found"; } // Update the project's stage + var statusVal = (accountId === 0) ? "saved" : "updated"; tx.executeSql( 'UPDATE project_project_app SET stage = ?, last_modified = ?, status = ? WHERE id = ?', - [stageOdooRecordId, timestamp, "updated", projectId] + [stageOdooRecordId, timestamp, statusVal, projectId] ); }); @@ -522,7 +540,7 @@ function getAttachmentsForProject(odooRecordId, accountId) { db.transaction(function (tx) { var query = ` - SELECT name, mimetype, account_id, odoo_record_id + SELECT name, mimetype, account_id, odoo_record_id, url, file_path, local_url, file_size FROM ir_attachment_app WHERE res_model = 'project.project' AND res_id = ? @@ -541,9 +559,9 @@ function getAttachmentsForProject(odooRecordId, accountId) { mimetype: row.mimetype, account_id: row.account_id, odoo_record_id: row.odoo_record_id, - url: "", // placeholder for file path if added later - size: 0, // unknown at this stage - created: "" // optional, to be filled if available later + url: row.local_url || row.url || row.file_path || "", + size: row.file_size || 0, + created: "" }); } }); @@ -694,7 +712,7 @@ function getAllProjectsPaginated(limit, offset) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var query = "SELECT * FROM project_project_app ORDER BY name COLLATE NOCASE ASC LIMIT ? OFFSET ?"; + var query = "SELECT * FROM project_project_app WHERE account_id IN (SELECT id FROM users) ORDER BY name COLLATE NOCASE ASC LIMIT ? OFFSET ?"; var result = tx.executeSql(query, [limit, offset]); for (var i = 0; i < result.rows.length; i++) { @@ -738,23 +756,33 @@ function getProjectsFilteredPaginated(options) { if (options.accountId !== undefined && options.accountId >= 0) { whereClauses.push("account_id = ?"); params.push(options.accountId); + } else { + whereClauses.push("account_id IN (SELECT id FROM users)"); } // Stage filter if (options.stageId !== undefined && options.stageId !== null) { - if (options.stageId === -2 && options.openStageIds && options.openStageIds.length > 0) { - // "Open" filter — match any of the open stage IDs - var placeholders = options.openStageIds.map(function () { return "?"; }).join(","); - whereClauses.push("stage IN (" + placeholders + ")"); - for (var s = 0; s < options.openStageIds.length; s++) { - params.push(options.openStageIds[s]); + if (!options.isStage && options.stageId === -2) { + // "Open" filter + if (options.openStageIds && options.openStageIds.length > 0) { + var placeholders = options.openStageIds.map(function () { return "?"; }).join(","); + if (options.accountId === -1 || options.accountId === 0 || options.accountId === undefined) { + // Match open stages or projects without a stage (default/unassigned) + whereClauses.push("(stage IN (" + placeholders + ") OR stage = 0 OR stage IS NULL)"); + } else { + // Specific remote account + whereClauses.push("stage IN (" + placeholders + ")"); + } + for (var s = 0; s < options.openStageIds.length; s++) { + params.push(options.openStageIds[s]); + } } - } else if (options.stageId >= 0) { + } else if (options.isStage || options.stageId >= 0 || (options.stageId < 0 && options.stageId !== -1 && options.stageId !== -2)) { // Specific stage whereClauses.push("stage = ?"); params.push(options.stageId); } - // stageId === -1 means "All" → no stage filter needed + // !options.isStage && stageId === -1 means "All" → no stage filter needed } // Search filter @@ -807,8 +835,8 @@ function getProjectUpdatesByProjectPaginated(projectOdooRecordId, accountId, lim var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND project_id = ? AND account_id = ? ORDER BY date DESC LIMIT ? OFFSET ?"; - var result = tx.executeSql(query, [projectOdooRecordId, accountId, limit, offset]); + var query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND (project_id = ? OR project_id IN (SELECT odoo_record_id FROM project_project_app WHERE (id = ? OR odoo_record_id = ?) AND account_id = ?)) AND account_id = ? ORDER BY date DESC LIMIT ? OFFSET ?"; + var result = tx.executeSql(query, [projectOdooRecordId, projectOdooRecordId, projectOdooRecordId, accountId, accountId, limit, offset]); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -845,7 +873,7 @@ function getAllProjectUpdatesPaginated(accountId, limit, offset) { query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND account_id = ? ORDER BY date DESC LIMIT ? OFFSET ?"; result = tx.executeSql(query, [accountId, limit, offset]); } else { - query = "SELECT * FROM project_update_app WHERE status != 'deleted' ORDER BY date DESC LIMIT ? OFFSET ?"; + query = "SELECT * FROM project_update_app WHERE status != 'deleted' AND account_id IN (SELECT id FROM users) ORDER BY date DESC LIMIT ? OFFSET ?"; result = tx.executeSql(query, [limit, offset]); } @@ -891,6 +919,8 @@ function getProjectUpdatesFilteredPaginated(options) { if (options.accountId !== undefined && options.accountId !== null && options.accountId >= 0) { whereClauses.push("u.account_id = ?"); params.push(options.accountId); + } else { + whereClauses.push("u.account_id IN (SELECT id FROM users)"); } // Project filter (when viewing updates for a specific project) @@ -1024,6 +1054,11 @@ function createUpdateProject(project_data, recordid) { db.transaction(function (tx) { try { + var defaultStage = (project_data.account_id === 0) ? -1 : 0; + var stageValue = (project_data.stage !== undefined && project_data.stage !== null && project_data.stage !== 0) + ? project_data.stage + : defaultStage; + if (recordid === 0) { tx.executeSql('INSERT INTO project_project_app \ (account_id, name, parent_id, planned_start_date, planned_end_date, \ @@ -1031,7 +1066,7 @@ function createUpdateProject(project_data, recordid) { Values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [project_data.account_id, project_data.name, project_data.parent_id, project_data.planned_start_date, project_data.planned_end_date, Utils.convertDurationToFloat(project_data.allocated_hours), - project_data.favorites, project_data.description, timestamp, project_data.color, project_data.stage || 0, project_data.status, project_data.user_id || null]); + project_data.favorites, project_data.description, timestamp, project_data.color, stageValue, project_data.status, project_data.user_id || null]); // Get the ID of the newly inserted project var result = tx.executeSql("SELECT last_insert_rowid() as id"); @@ -1045,7 +1080,7 @@ function createUpdateProject(project_data, recordid) { where id = ?', [project_data.account_id, project_data.name, project_data.parent_id, project_data.planned_start_date, project_data.planned_end_date, Utils.convertDurationToFloat(project_data.allocated_hours), - project_data.favorites, project_data.description, timestamp, project_data.color, project_data.stage || 0, project_data.status, project_data.user_id || null, recordid]); + project_data.favorites, project_data.description, timestamp, project_data.color, stageValue, project_data.status, project_data.user_id || null, recordid]); } messageObj['is_success'] = true; messageObj['message'] = 'Project saved Successfully!'; @@ -1213,11 +1248,20 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) 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, " + - "COALESCE(p.name, 'Unknown') AS project_name, SUM(aal.unit_amount) AS total_spent " + + "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 " + - "LEFT JOIN project_project_app p ON p.odoo_record_id = aal.project_id AND p.account_id = aal.account_id " + - "WHERE " + (is_work_state ? "aal.account_id != 0 " : "aal.account_id = 0 "); + "JOIN project_project_app p ON (" + + " (p.odoo_record_id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id) OR " + + " (aal.account_id = 0 AND p.id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id))) " + + " AND p.account_id = aal.account_id " + + ") " + + "LEFT JOIN project_project_app parent ON (" + + " (parent.odoo_record_id = p.parent_id OR (aal.account_id = 0 AND parent.id = p.parent_id)) " + + " AND parent.account_id = p.account_id " + + ") " + + "WHERE (aal.status IS NULL OR aal.status != 'deleted') AND aal.unit_amount > 0 AND (aal.project_id IS NOT NULL OR aal.sub_project_id IS NOT NULL) " + + "AND " + (is_work_state ? "aal.account_id != 0 " : "aal.account_id = 0 "); var paramsAll = []; if (startDate) { @@ -1229,21 +1273,22 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) paramsAll.push(endDate); } - sqlAll += "GROUP BY aal.project_id, aal.account_id, u.name, p.name ORDER BY total_spent DESC"; + sqlAll += "GROUP BY aal.project_id, aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlAll, paramsAll); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); - var projectName = row.project_name || "Unknown"; + var baseProjectName = row.project_name || "Unknown"; + var fullProjectName = row.parent_name ? (row.parent_name + " / " + baseProjectName) : baseProjectName; var accountName = row.account_name || "Unknown"; resultList.push({ project_id: row.project_id, - name: projectName + " (" + accountName + ")", + name: fullProjectName + " (" + accountName + ")", spentHours: parseFloat((parseFloat(row.total_spent || 0)).toFixed(1)), account_id: row.account_id, account_name: accountName, - original_project_name: projectName + original_project_name: fullProjectName }); } @@ -1257,10 +1302,18 @@ 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, SUM(aal.unit_amount) AS total_spent " + + 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 " + "FROM account_analytic_line_app aal " + - "LEFT JOIN project_project_app p ON p.odoo_record_id = aal.project_id AND p.account_id = aal.account_id " + - "WHERE aal.account_id = ? "; + "JOIN project_project_app p ON (" + + " (p.odoo_record_id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id) OR " + + " (aal.account_id = 0 AND p.id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id))) " + + " AND p.account_id = aal.account_id " + + ") " + + "LEFT JOIN project_project_app parent ON (" + + " (parent.odoo_record_id = p.parent_id OR (aal.account_id = 0 AND parent.id = p.parent_id)) " + + " AND parent.account_id = p.account_id " + + ") " + + "WHERE aal.account_id = ? AND (aal.status IS NULL OR aal.status != 'deleted') AND aal.unit_amount > 0 AND (aal.project_id IS NOT NULL OR aal.sub_project_id IS NOT NULL) "; var paramsSingle = [acctNum]; if (startDate) { @@ -1272,20 +1325,21 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) paramsSingle.push(endDate); } - sqlSingle += "GROUP BY aal.project_id, p.name ORDER BY total_spent DESC"; + sqlSingle += "GROUP BY aal.project_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlSingle, paramsSingle); for (var j = 0; j < result.rows.length; j++) { var r = result.rows.item(j); - var projectNameSingle = r.project_name || "Unknown"; + var baseProjectNameSingle = r.project_name || "Unknown"; + var fullProjectNameSingle = r.parent_name ? (r.parent_name + " / " + baseProjectNameSingle) : baseProjectNameSingle; resultList.push({ project_id: r.project_id, - name: projectNameSingle, + name: fullProjectNameSingle, spentHours: parseFloat((parseFloat(r.total_spent || 0)).toFixed(1)), account_id: acctNum, account_name: undefined, - original_project_name: projectNameSingle + original_project_name: fullProjectNameSingle }); } } @@ -1332,18 +1386,19 @@ function getDashboardProjectTaskSummary(accountId, startDate, endDate) { var query = "SELECT " + "p.account_id, p.odoo_record_id, p.id AS local_id, p.name, p.color_pallet, s.name AS stage_name, " + - "COUNT(t.id) AS task_count, COALESCE(SUM(ts.total_hours), 0) AS total_hours " + + "COUNT(DISTINCT t.id) AS task_count, COALESCE(SUM(ts.total_hours), 0) AS total_hours " + "FROM project_project_app p " + - "LEFT JOIN project_project_stage_app s ON s.odoo_record_id = p.stage AND s.account_id = p.account_id " + + "LEFT JOIN project_project_stage_app s ON (s.odoo_record_id = p.stage OR (p.account_id = 0 AND s.id = p.stage)) AND s.account_id = p.account_id " + "LEFT JOIN project_task_app t ON t.account_id = p.account_id " + - "AND t.project_id = p.odoo_record_id " + + "AND ((p.odoo_record_id IS NOT NULL AND (t.project_id = p.odoo_record_id OR t.sub_project_id = p.odoo_record_id)) " + + " OR (p.account_id = 0 AND (t.project_id = p.id OR t.sub_project_id = p.id))) " + "AND (t.status IS NULL OR t.status != 'deleted') " + "LEFT JOIN ( " + "SELECT account_id, task_id, SUM(unit_amount) AS total_hours " + "FROM account_analytic_line_app " + "WHERE " + tsConditions.join(" AND ") + " " + "GROUP BY account_id, task_id " + - ") ts ON ts.account_id = t.account_id AND ts.task_id = t.odoo_record_id " + + ") ts ON ts.account_id = t.account_id AND (ts.task_id = t.odoo_record_id OR (t.account_id = 0 AND ts.task_id = t.id)) " + accountWhere + "GROUP BY p.account_id, p.odoo_record_id, p.id, p.name, p.color_pallet, s.name " + "ORDER BY total_hours DESC, p.name COLLATE NOCASE ASC"; @@ -1353,7 +1408,7 @@ function getDashboardProjectTaskSummary(accountId, startDate, endDate) { for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); resultList.push({ - id: String(row.account_id) + ":" + String(row.odoo_record_id), + id: String(row.account_id) + ":" + String(row.odoo_record_id || row.local_id), accountId: row.account_id, odooRecordId: row.odoo_record_id, localId: row.local_id, @@ -1389,10 +1444,18 @@ function getProjectName(projectId, accountId) { var projectName = "Unknown Project"; db.transaction(function (tx) { - var result = tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? AND account_id = ?", - [projectId, accountId] - ); + var result; + if (accountId === 0) { + result = tx.executeSql( + "SELECT name FROM project_project_app WHERE (id = ? OR odoo_record_id = ?) AND account_id = 0", + [projectId, projectId] + ); + } else { + result = tx.executeSql( + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) AND account_id = ?", + [projectId, projectId, accountId] + ); + } if (result.rows.length > 0) { projectName = result.rows.item(0).name; } @@ -1441,3 +1504,38 @@ function toggleProjectFavorite(projectId, isFavorite, status) { return { success: false, message: "Failed to update project favorite status: " + e.message }; } } + +/** + * Gets a map of project_id -> task count for active tasks. + * + * @param {number} accountId - Optional account ID filter. + * @returns {Object} Map of project ID to task count. + */ +function getProjectTaskCountMap(accountId) { + var countMap = {}; + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + var query = "SELECT project_id, sub_project_id, COUNT(*) AS cnt FROM project_task_app WHERE (status IS NULL OR status != 'deleted')"; + var params = []; + if (accountId !== undefined && accountId >= 0) { + query += " AND account_id = ?"; + params.push(accountId); + } + query += " GROUP BY project_id, sub_project_id"; + var rs = tx.executeSql(query, params); + for (var i = 0; i < rs.rows.length; i++) { + var row = rs.rows.item(i); + if (row.project_id) { + countMap[row.project_id] = (countMap[row.project_id] || 0) + row.cnt; + } + if (row.sub_project_id && row.sub_project_id !== row.project_id) { + countMap[row.sub_project_id] = (countMap[row.sub_project_id] || 0) + row.cnt; + } + } + }); + } catch (e) { + Logger.error("Project", "getProjectTaskCountMap failed:", e); + } + return countMap; +} diff --git a/models/task.js b/models/task.js index c0f70e23..2915680f 100644 --- a/models/task.js +++ b/models/task.js @@ -92,8 +92,8 @@ function getLocalIdFromOdooId(odooRecordId, accountId) { db.transaction(function (tx) { var result = tx.executeSql( - 'SELECT id FROM project_task_app WHERE odoo_record_id = ? AND account_id = ? AND (status IS NULL OR status != \"deleted\") LIMIT 1', - [odooRecordId, accountId] + 'SELECT id FROM project_task_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? AND (status IS NULL OR status != "deleted") LIMIT 1', + [odooRecordId, odooRecordId, accountId] ); if (result.rows.length > 0) { @@ -136,6 +136,11 @@ function saveOrUpdateTask(data) { userIdValue = formatAssigneeIds(data.multipleAssignees); } + var defaultTaskStage = (data.accountId === 0) ? -1 : null; + var taskStageVal = (data.stageOdooRecordId !== undefined && data.stageOdooRecordId !== null && data.stageOdooRecordId !== 0) + ? data.stageOdooRecordId + : defaultTaskStage; + db.transaction(function (tx) { if (data.record_id) { // UPDATE @@ -147,7 +152,7 @@ function saveOrUpdateTask(data) { resolvedParentId, data.plannedHours, data.priority, data.description, userIdValue, data.subProjectId, data.startDate, data.endDate, data.deadline, - data.stageOdooRecordId || null, + taskStageVal, data.personalStageOdooRecordId || null, timestamp, data.status, data.record_id ] @@ -162,7 +167,7 @@ function saveOrUpdateTask(data) { resolvedParentId, data.startDate, data.endDate, data.deadline, data.priority, data.plannedHours, data.description, userIdValue, - data.subProjectId, data.stageOdooRecordId || null, + data.subProjectId, taskStageVal, data.personalStageOdooRecordId || null, timestamp, data.status ] @@ -185,11 +190,11 @@ function saveOrUpdateTask(data) { function getTaskStageName(odooRecordId, accountId) { - var stageName = "Undefined"; + var stageName = ""; try { - if (odooRecordId === -1) { - return "Undefined"; // special case + if (!odooRecordId || odooRecordId === 0) { + return ""; } var db = Sql.LocalStorage.openDatabaseSync( @@ -200,14 +205,21 @@ function getTaskStageName(odooRecordId, accountId) { ); db.transaction(function (tx) { - var query = ` - SELECT name - FROM project_task_type_app - WHERE odoo_record_id = ? AND account_id = ? - LIMIT 1 - `; + var query = ""; + var params = []; - var result = tx.executeSql(query, [odooRecordId, accountId]); + if (odooRecordId < 0) { + query = "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? AND account_id = 0 LIMIT 1"; + params = [odooRecordId]; + } else if (accountId !== undefined && accountId !== null && accountId >= 0) { + query = "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1"; + params = [odooRecordId, accountId]; + } else { + query = "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? LIMIT 1"; + params = [odooRecordId]; + } + + var result = tx.executeSql(query, params); if (result.rows.length > 0) { stageName = result.rows.item(0).name; @@ -217,17 +229,18 @@ function getTaskStageName(odooRecordId, accountId) { Logger.error("Task", "getTaskStageName failed:", e) } - return stageName; + return stageName || ""; } /** * Check if a task's stage has fold == 1 * @param {number} stageId - The odoo_record_id of the task stage + * @param {number} [accountId] - Optional account ID * @returns {boolean} True if the stage has fold == 1 */ -function isTaskStageFolded(stageId) { +function isTaskStageFolded(stageId, accountId) { try { - if (!stageId || stageId === -1) { + if (!stageId || (stageId === -1 && accountId !== 0)) { return false; } @@ -241,14 +254,12 @@ function isTaskStageFolded(stageId) { var isFolded = false; db.transaction(function (tx) { - var query = ` - SELECT fold - FROM project_task_type_app - WHERE odoo_record_id = ? - LIMIT 1 - `; + var query = (accountId !== undefined && accountId !== null) + ? "SELECT fold FROM project_task_type_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1" + : "SELECT fold FROM project_task_type_app WHERE odoo_record_id = ? LIMIT 1"; + var params = (accountId !== undefined && accountId !== null) ? [stageId, accountId] : [stageId]; - var result = tx.executeSql(query, [stageId]); + var result = tx.executeSql(query, params); if (result.rows.length > 0) { isFolded = result.rows.item(0).fold === 1; @@ -283,7 +294,7 @@ function getAttachmentsForTask(odooRecordId, accountId) { db.transaction(function (tx) { var query = ` - SELECT name, mimetype, account_id, odoo_record_id + SELECT name, mimetype, account_id, odoo_record_id, url, file_path, local_url, file_size FROM ir_attachment_app WHERE res_model = 'project.task' AND res_id = ? @@ -302,9 +313,9 @@ function getAttachmentsForTask(odooRecordId, accountId) { mimetype: row.mimetype, account_id: row.account_id, odoo_record_id: row.odoo_record_id, - url: "", // no file path stored locally - size: 0, // placeholder - created: "" // optional field + url: row.local_url || row.url || row.file_path || "", + size: row.file_size || 0, + created: "" }); } }); @@ -315,6 +326,54 @@ function getAttachmentsForTask(odooRecordId, accountId) { return attachmentList; } +function updateAttachmentResourceId(resModel, oldResId, newResId, accountId) { + if (oldResId === newResId) { + return true; + } + try { + var db = Sql.LocalStorage.openDatabaseSync( + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); + db.transaction(function (tx) { + tx.executeSql( + "UPDATE ir_attachment_app SET res_id = ? WHERE res_model = ? AND res_id = ? AND account_id = ?", + [newResId, resModel, oldResId, accountId] + ); + }); + return true; + } catch (e) { + DBCommon.logException("updateAttachmentResourceId", e); + return false; + } +} + +function cleanupTemporaryAttachments(resModel, tempResId, accountId) { + if (!tempResId || tempResId >= 0) { + return true; + } + try { + var db = Sql.LocalStorage.openDatabaseSync( + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); + db.transaction(function (tx) { + tx.executeSql( + "DELETE FROM ir_attachment_app WHERE res_model = ? AND res_id = ? AND account_id = ?", + [resModel, tempResId, accountId] + ); + }); + return true; + } catch (e) { + DBCommon.logException("cleanupTemporaryAttachments", e); + return false; + } +} + /** @@ -347,13 +406,13 @@ function getTaskAssignees(taskId, accountId) { // Get user details for each ID var userQuery = ` - SELECT odoo_record_id as user_id, name + 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 FROM res_users_app - WHERE account_id = ? AND odoo_record_id IN (${placeholders}) + WHERE account_id = ? AND (odoo_record_id IN (${placeholders}) OR (account_id = 0 AND id IN (${placeholders}))) ORDER BY name COLLATE NOCASE ASC `; - var queryParams = [accountId].concat(userIds); + var queryParams = [accountId].concat(userIds).concat(userIds); var userResult = tx.executeSql(userQuery, queryParams); for (var i = 0; i < userResult.rows.length; i++) { @@ -467,7 +526,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { db.transaction(function (tx) { // First, get the task details var taskResult = tx.executeSql( - "SELECT id, name, project_id, odoo_record_id FROM project_task_app WHERE id = ? AND (status IS NULL OR status != 'deleted')", + "SELECT id, name, project_id, account_id, odoo_record_id FROM project_task_app WHERE id = ? AND (status IS NULL OR status != 'deleted')", [taskId] ); @@ -481,8 +540,8 @@ function markTaskAsDeleted(taskId, forceDelete = false) { Logger.debug("Task", "Attempting to delete task: '" + taskName + "' (Local ID: " + taskId + ", Odoo ID: " + taskOdooRecordId + ")") - // Check for child tasks using both possible parent reference methods - var childTasks = getChildTasks(tx, taskId, taskOdooRecordId); + // Check for child tasks using both possible parent reference methods, scoped to account + var childTasks = getChildTasks(tx, taskId, taskOdooRecordId, taskRow.account_id); if (childTasks.length > 0 && !forceDelete) { // Prevent deletion - task has children @@ -607,21 +666,25 @@ function markMultipleTasksAsDeleted(taskIds, forceDelete = false) { * @param {Object} tx - Database transaction object * @param {number} parentLocalId - The local 'id' of the parent task * @param {number} parentOdooRecordId - The 'odoo_record_id' of the parent task + * @param {number} accountId - Optional account ID filter * @returns {Array} Array of child task objects */ -function getChildTasks(tx, parentLocalId, parentOdooRecordId) { +function getChildTasks(tx, parentLocalId, parentOdooRecordId, accountId) { var childTasks = []; - var seenIds = new Set(); // To prevent duplicates + var seenIds = new Set(); try { - // Method 1: Check if parent_id references local 'id' field - var childResult1 = tx.executeSql( - "SELECT id, name, odoo_record_id FROM project_task_app WHERE parent_id = ? AND (status IS NULL OR status != 'deleted')", - [parentLocalId] - ); + var query = "SELECT id, name, odoo_record_id FROM project_task_app WHERE (status IS NULL OR status != 'deleted') AND (parent_id = ? OR (parent_id = ? AND ? > 0))"; + var params = [parentLocalId, parentOdooRecordId || 0, parentOdooRecordId || 0]; - for (var i = 0; i < childResult1.rows.length; i++) { - var row = childResult1.rows.item(i); + if (accountId !== undefined && accountId !== null && accountId >= 0) { + query += " AND account_id = ?"; + params.push(accountId); + } + + var result = tx.executeSql(query, params); + for (var i = 0; i < result.rows.length; i++) { + var row = result.rows.item(i); if (!seenIds.has(row.id)) { childTasks.push({ id: row.id, @@ -631,29 +694,8 @@ function getChildTasks(tx, parentLocalId, parentOdooRecordId) { seenIds.add(row.id); } } - - // Method 2: Check if parent_id references 'odoo_record_id' field - if (parentOdooRecordId && parentOdooRecordId > 0) { - var childResult2 = tx.executeSql( - "SELECT id, name, odoo_record_id FROM project_task_app WHERE parent_id = ? AND (status IS NULL OR status != 'deleted')", - [parentOdooRecordId] - ); - - for (var j = 0; j < childResult2.rows.length; j++) { - var row2 = childResult2.rows.item(j); - if (!seenIds.has(row2.id)) { - childTasks.push({ - id: row2.id, - name: row2.name, - odoo_record_id: row2.odoo_record_id - }); - seenIds.add(row2.id); - } - } - } - } catch (e) { - Logger.error("Task", "Error getting child tasks:", e) + Logger.error("Task", "Error getting child tasks:", e); } return childTasks; @@ -671,13 +713,13 @@ function checkTaskHasChildren(taskId) { db.transaction(function (tx) { var taskResult = tx.executeSql( - "SELECT id, name, odoo_record_id FROM project_task_app WHERE id = ?", + "SELECT id, name, odoo_record_id, account_id FROM project_task_app WHERE id = ?", [taskId] ); if (taskResult.rows.length > 0) { var taskRow = taskResult.rows.item(0); - var childTasks = getChildTasks(tx, taskRow.id, taskRow.odoo_record_id); + var childTasks = getChildTasks(tx, taskRow.id, taskRow.odoo_record_id, taskRow.account_id); result.hasChildren = childTasks.length > 0; result.childCount = childTasks.length; @@ -688,7 +730,7 @@ function checkTaskHasChildren(taskId) { return result; } catch (e) { - Logger.error("Task", "Error checking task children:", e) + Logger.error("Task", "Error checking task children:", e); return { hasChildren: false, childCount: 0, childTasks: [], error: e.message }; } } @@ -930,8 +972,8 @@ function getTaskDetails(task_id) { // Look up project_project_app to check if this project has a parent_id (indicating it is a subproject) // Include account_id check to ensure project is from the same account var rs_project = tx.executeSql( - 'SELECT parent_id FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1', - [project_id, row.account_id] + 'SELECT parent_id FROM project_project_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1', + [project_id, project_id, row.account_id] ); if (rs_project.rows.length > 0) { @@ -1485,6 +1527,115 @@ function getTasksByParentIdPaginated(parentId, accountId, limit, offset, dateFil return taskList; } +/** + * Retrieves direct subtasks for a given parent task, strictly scoped to account and optional project. + * + * @param {number} parentId - The parent task ID (local ID or odoo_record_id). + * @param {number} accountId - Optional account ID filter. + * @param {number} projectOdooRecordId - Optional project ID filter. + * @returns {Array} List of subtask objects. + */ +function getSubtasksForParent(parentId, accountId, projectOdooRecordId) { + var subtaskList = []; + if (!parentId || parentId <= 0) { + return subtaskList; + } + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + var accId = accountId; + if (accId === undefined || accId === null || accId < 0) { + var detectAccRes = tx.executeSql( + "SELECT account_id FROM project_task_app WHERE (id = ? OR parent_id = ? OR odoo_record_id = ?) AND (status IS NULL OR status != 'deleted') AND account_id IS NOT NULL AND account_id >= 0 ORDER BY CASE WHEN id = ? THEN 0 WHEN parent_id = ? THEN 1 ELSE 2 END LIMIT 1", + [parentId, parentId, parentId, parentId, parentId] + ); + if (detectAccRes.rows.length > 0) { + accId = detectAccRes.rows.item(0).account_id; + } + } + + // Look up parent task row to find local ID, odoo_record_id, and account_id + var parentQuery = "SELECT id, odoo_record_id, account_id FROM project_task_app WHERE (odoo_record_id = ? OR id = ?)"; + var parentParams = [parentId, parentId]; + if (accId !== undefined && accId !== null && accId >= 0) { + parentQuery += " AND account_id = ?"; + parentParams.push(accId); + } + parentQuery += " ORDER BY CASE WHEN odoo_record_id = ? THEN 0 ELSE 1 END LIMIT 1"; + parentParams.push(parentId); + + var parentRes = tx.executeSql(parentQuery, parentParams); + var localPid = parentId; + var odooPid = 0; + + if (parentRes.rows.length > 0) { + var pRow = parentRes.rows.item(0); + localPid = pRow.id; + odooPid = pRow.odoo_record_id || 0; + if (accId === undefined || accId === null || accId < 0) { + accId = pRow.account_id; + } + } + + var query = "SELECT * FROM project_task_app WHERE (status IS NULL OR status != 'deleted') AND (parent_id = ? OR (parent_id = ? AND ? > 0))"; + var params = [localPid, odooPid, odooPid]; + + if (accId !== undefined && accId !== null && accId >= 0) { + query += " AND account_id = ?"; + params.push(accId); + } + + if (projectOdooRecordId !== undefined && projectOdooRecordId !== null && projectOdooRecordId > 0) { + query += " AND (project_id = ? OR sub_project_id = ?)"; + params.push(projectOdooRecordId, projectOdooRecordId); + } + + query += " ORDER BY end_date ASC"; + + // Build projectColorMap for project color resolution + var projectColorMap = {}; + var projectQuery = "SELECT odoo_record_id, color_pallet FROM project_project_app"; + var projectResult = tx.executeSql(projectQuery); + for (var j = 0; j < projectResult.rows.length; j++) { + var projectRow = projectResult.rows.item(j); + projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + } + + var result = tx.executeSql(query, params); + for (var i = 0; i < result.rows.length; i++) { + var task = DBCommon.rowToObject(result.rows.item(i)); + + // Inherit color from sub_project, project, or walk up hierarchy + var inheritedColor = 0; + if (task.sub_project_id) { + inheritedColor = resolveProjectColor(task.sub_project_id, projectColorMap, tx); + } + if (!inheritedColor && task.project_id) { + inheritedColor = resolveProjectColor(task.project_id, projectColorMap, tx); + } + task.color_pallet = inheritedColor; + + // Calculate total hours spent from timesheet entries + var effectiveTaskId = (task.account_id === 0 || !task.odoo_record_id) ? task.id : task.odoo_record_id; + var timeQuery = "SELECT SUM(unit_amount) as total_hours FROM account_analytic_line_app WHERE (status IS NULL OR status != 'deleted') AND (task_id = ? OR sub_task_id = ?) AND account_id = ?"; + var timeParams = [effectiveTaskId, effectiveTaskId, task.account_id]; + var timeResult = tx.executeSql(timeQuery, timeParams); + if (timeResult.rows.length > 0 && timeResult.rows.item(0).total_hours !== null) { + task.spent_hours = timeResult.rows.item(0).total_hours; + } else { + task.spent_hours = 0; + } + + subtaskList.push(task); + } + }); + } catch (e) { + Logger.error("Task", "getSubtasksForParent failed:", e); + } + return subtaskList; +} + /** * Retrieves all non-deleted tasks from the `project_task_app` table, * and adds inherited color and total hours spent from timesheet entries. @@ -1726,107 +1877,146 @@ function getFilteredTasksPaginated(filterType, searchQuery, accountId, limit, of var filteredTasks = []; var currentDate = new Date(); - var batchSize = limit * 3; // Fetch 3x more raw items to account for filtering - var dbOffset = 0; - var skipped = 0; var hasMore = true; - var maxIterations = 10; // Safety limit to prevent infinite loops - var iteration = 0; + var dbOffset = 0; + var isAllFilter = (!filterType || filterType === "all"); try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - while (filteredTasks.length < limit && hasMore && iteration < maxIterations) { - iteration++; - var rawTasks = []; - - db.transaction(function (tx) { - var query = "SELECT * FROM project_task_app WHERE (status IS NULL OR status != 'deleted')"; - var params = []; + db.transaction(function (tx) { + // Fast-path: When filter is "all", let SQLite handle LIMIT & OFFSET directly in 1 query + if (isAllFilter) { + var fastQuery = "SELECT * FROM project_task_app WHERE (status IS NULL OR status != 'deleted')"; + var fastParams = []; if (accountId !== undefined && accountId >= 0) { - query += " AND account_id = ?"; - params.push(accountId); + fastQuery += " AND account_id = ?"; + fastParams.push(accountId); } - query += " ORDER BY end_date ASC LIMIT ? OFFSET ?"; - params.push(batchSize, dbOffset); + if (searchQuery && searchQuery.trim() !== "") { + var sParam = "%" + searchQuery.trim() + "%"; + fastQuery += " AND (name LIKE ? OR description LIKE ?)"; + fastParams.push(sParam, sParam); + } - var result = tx.executeSql(query, params); - for (var i = 0; i < result.rows.length; i++) { - rawTasks.push(DBCommon.rowToObject(result.rows.item(i))); + fastQuery += " ORDER BY end_date ASC LIMIT ? OFFSET ?"; + fastParams.push(limit, offset); + + var fastResult = tx.executeSql(fastQuery, fastParams); + for (var i = 0; i < fastResult.rows.length; i++) { + filteredTasks.push(DBCommon.rowToObject(fastResult.rows.item(i))); } - }); - // If we got fewer items than batch size, no more data in DB - if (rawTasks.length < batchSize) { - hasMore = false; - } + hasMore = (filteredTasks.length >= limit); + dbOffset = offset + filteredTasks.length; - // Apply JS-based filtering - for (var i = 0; i < rawTasks.length; i++) { - var task = rawTasks[i]; - var passesFilter = true; + } else { + // Filtered path (today, overdue, this_week, etc.) + var batchSize = Math.max(limit * 4, 150); // Larger batches = fewer iterations + var skipped = 0; + var iteration = 0; + var maxIterations = 50; - // Apply date filter using existing logic - if (filterType && filterType !== "all" && !passesDateFilter(task, filterType, currentDate)) { - passesFilter = false; - } + while (filteredTasks.length < limit && hasMore && iteration < maxIterations) { + iteration++; + var rawTasks = []; - // Apply search filter - if (passesFilter && searchQuery && !passesSearchFilter(task, searchQuery)) { - passesFilter = false; - } + var query = "SELECT * FROM project_task_app WHERE (status IS NULL OR status != 'deleted')"; + var params = []; - if (passesFilter) { - if (skipped < offset) { - // Skip items until we reach the offset - skipped++; - } else if (filteredTasks.length < limit) { - // Add to results - filteredTasks.push(task); - } else { - // We have enough items - break; + if (accountId !== undefined && accountId >= 0) { + query += " AND account_id = ?"; + params.push(accountId); } - } - } - dbOffset += rawTasks.length; - } + if (searchQuery && searchQuery.trim() !== "") { + var sParam = "%" + searchQuery.trim() + "%"; + query += " AND (name LIKE ? OR description LIKE ?)"; + params.push(sParam, sParam); + } - // Add project colors and spent hours to filtered tasks - db.transaction(function (tx) { - var projectColorMap = {}; - var projectQuery = "SELECT odoo_record_id, color_pallet FROM project_project_app"; - var projectResult = tx.executeSql(projectQuery); - for (var j = 0; j < projectResult.rows.length; j++) { - projectColorMap[projectResult.rows.item(j).odoo_record_id] = projectResult.rows.item(j).color_pallet; + query += " ORDER BY end_date ASC LIMIT ? OFFSET ?"; + params.push(batchSize, dbOffset); + + var result = tx.executeSql(query, params); + for (var i = 0; i < result.rows.length; i++) { + rawTasks.push(DBCommon.rowToObject(result.rows.item(i))); + } + + if (rawTasks.length < batchSize) { + hasMore = false; + } + + for (var j = 0; j < rawTasks.length; j++) { + var task = rawTasks[j]; + if (passesDateFilter(task, filterType, currentDate)) { + if (skipped < offset) { + skipped++; + } else if (filteredTasks.length < limit) { + filteredTasks.push(task); + } else { + break; + } + } + } + + dbOffset += rawTasks.length; + } } - for (var i = 0; i < filteredTasks.length; i++) { - var task = filteredTasks[i]; + // Batched enrichment (Colors + Spent Hours) + if (filteredTasks.length > 0) { + // 1. Project Colors Map + var projectColorMap = {}; + var projectResult = tx.executeSql("SELECT odoo_record_id, color_pallet FROM project_project_app"); + for (var p = 0; p < projectResult.rows.length; p++) { + projectColorMap[projectResult.rows.item(p).odoo_record_id] = projectResult.rows.item(p).color_pallet; + } - // Inherit color - var inheritedColor = 0; - if (task.sub_project_id) { - inheritedColor = resolveProjectColor(task.sub_project_id, projectColorMap, tx); + // 2. Batch Spent Hours (Replaces individual N+1 queries with 1 single GROUP BY query) + var taskOdooIds = []; + for (var t = 0; t < filteredTasks.length; t++) { + if (filteredTasks[t].odoo_record_id) { + taskOdooIds.push(filteredTasks[t].odoo_record_id); + } } - if (!inheritedColor && task.project_id) { - inheritedColor = resolveProjectColor(task.project_id, projectColorMap, tx); + + var timeMap = {}; + if (taskOdooIds.length > 0) { + 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"; + var timeResult = tx.executeSql(timeQuery, taskOdooIds); + for (var k = 0; k < timeResult.rows.length; k++) { + var row = timeResult.rows.item(k); + timeMap[row.account_id + "_" + row.task_id] = row.total_hours; + } } - task.color_pallet = inheritedColor; - // Calculate spent hours - var timeQuery = "SELECT SUM(unit_amount) as total_hours FROM account_analytic_line_app WHERE task_id = ? AND account_id = ?"; - var timeResult = tx.executeSql(timeQuery, [task.odoo_record_id, task.account_id]); - task.spent_hours = (timeResult.rows.length > 0 && timeResult.rows.item(0).total_hours !== null) - ? timeResult.rows.item(0).total_hours : 0; + // Apply colors and spent hours to tasks + for (var i = 0; i < filteredTasks.length; i++) { + var task = filteredTasks[i]; + var inheritedColor = 0; + if (task.sub_project_id) { + inheritedColor = resolveProjectColor(task.sub_project_id, projectColorMap, tx); + } + if (!inheritedColor && task.project_id) { + inheritedColor = resolveProjectColor(task.project_id, projectColorMap, tx); + } + task.color_pallet = inheritedColor; + + var timeKey = task.account_id + "_" + task.odoo_record_id; + task.spent_hours = (timeMap[timeKey] !== undefined && timeMap[timeKey] !== null) ? timeMap[timeKey] : 0; + } } }); } catch (e) { - Logger.error("Task", "getFilteredTasksPaginated failed:", e) + Logger.error("Task", "getFilteredTasksPaginated failed:", e); } return { @@ -2123,8 +2313,8 @@ function getTasksByAssigneesPaginated(assigneeIds, accountId, filterType, search // Project filter if (projectOdooRecordId !== undefined && projectOdooRecordId > 0) { - whereClauses.push("t.project_id = ?"); - params.push(projectOdooRecordId); + whereClauses.push("(t.project_id = ? OR t.sub_project_id = ?)"); + params.push(projectOdooRecordId, projectOdooRecordId); } // Assignee filter using LIKE for comma-separated user_id field @@ -2270,8 +2460,8 @@ function getTasksByAssigneesPaginated(assigneeIds, accountId, filterType, search } if (projectOdooRecordId !== undefined && projectOdooRecordId > 0) { - whereClauses.push("t.project_id = ?"); - params.push(projectOdooRecordId); + whereClauses.push("(t.project_id = ? OR t.sub_project_id = ?)"); + params.push(projectOdooRecordId, projectOdooRecordId); } // Assignee filter @@ -2909,12 +3099,12 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var params = [projectOdooRecordId, accountId]; + var params = [projectOdooRecordId, projectOdooRecordId, accountId]; var dateJoin = ""; var dateCondition = ""; if (startDate || endDate) { - dateJoin = " INNER JOIN account_analytic_line_app al ON al.task_id = t.odoo_record_id AND al.account_id = t.account_id AND (al.status != 'deleted' OR al.status IS NULL) "; + 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) { dateFilters.push("DATE(al.record_date) >= DATE(?)"); @@ -2950,7 +3140,7 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) t.has_draft FROM project_task_app t ${dateJoin} - WHERE t.project_id = ? + WHERE (t.project_id = ? OR t.sub_project_id = ?) AND t.account_id = ? AND (t.status != 'deleted' OR t.status IS NULL) ${dateCondition} @@ -2960,19 +3150,21 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) var result = tx.executeSql(query, params); // Build a map of project colors for efficient lookup - var projectColorQuery = "SELECT odoo_record_id, color_pallet FROM project_project_app WHERE account_id = ?"; + var projectColorQuery = "SELECT id, odoo_record_id, color_pallet FROM project_project_app WHERE account_id = ?"; var projectColorResult = tx.executeSql(projectColorQuery, [accountId]); var projectMap = {}; for (var j = 0; j < projectColorResult.rows.length; j++) { var projectRow = projectColorResult.rows.item(j); - projectMap[projectRow.odoo_record_id] = projectRow.color_pallet; + if (projectRow.odoo_record_id) projectMap[projectRow.odoo_record_id] = projectRow.color_pallet; + if (projectRow.id) projectMap[projectRow.id] = projectRow.color_pallet; } for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); // Calculate spent hours for this task - var spentParams = [row.odoo_record_id, accountId]; + var effectiveTaskId = (accountId === 0 || !row.odoo_record_id) ? row.id : row.odoo_record_id; + var spentParams = [effectiveTaskId, accountId]; var spentCondition = ""; if (startDate) { spentCondition += " AND DATE(record_date) >= DATE(?)"; @@ -3057,8 +3249,8 @@ function getTasksForProjectPaginated(projectOdooRecordId, accountId, limit, offs if (!needsJSFilter) { // Simple case: no date/search filter, pure SQL pagination db.transaction(function (tx) { - var query = "SELECT * FROM project_task_app WHERE project_id = ? AND account_id = ? AND (status != 'deleted' OR status IS NULL) ORDER BY last_modified DESC LIMIT ? OFFSET ?"; - var result = tx.executeSql(query, [projectOdooRecordId, accountId, limit + 1, offset]); + var query = "SELECT * FROM project_task_app WHERE (project_id = ? OR sub_project_id = ?) AND account_id = ? AND (status != 'deleted' OR status IS NULL) ORDER BY last_modified DESC LIMIT ? OFFSET ?"; + var result = tx.executeSql(query, [projectOdooRecordId, projectOdooRecordId, accountId, limit + 1, offset]); hasMore = result.rows.length > limit; var count = Math.min(result.rows.length, limit); @@ -3119,8 +3311,8 @@ function getTasksForProjectPaginated(projectOdooRecordId, accountId, limit, offs var rawTasks = []; db.transaction(function (tx) { - var query = "SELECT * FROM project_task_app WHERE project_id = ? AND account_id = ? AND (status != 'deleted' OR status IS NULL) ORDER BY last_modified DESC LIMIT ? OFFSET ?"; - var result = tx.executeSql(query, [projectOdooRecordId, accountId, batchSize, dbOffset]); + var query = "SELECT * FROM project_task_app WHERE (project_id = ? OR sub_project_id = ?) AND account_id = ? AND (status != 'deleted' OR status IS NULL) ORDER BY last_modified DESC LIMIT ? OFFSET ?"; + var result = tx.executeSql(query, [projectOdooRecordId, projectOdooRecordId, accountId, batchSize, dbOffset]); for (var i = 0; i < result.rows.length; i++) { rawTasks.push(DBCommon.rowToObject(result.rows.item(i))); } @@ -3273,19 +3465,20 @@ function getAllTaskAssignees(accountId) { SELECT u.id, u.odoo_record_id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, 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.odoo_record_id IN (${placeholders}) + WHERE u.account_id = ? AND (u.odoo_record_id IN (${placeholders}) OR (u.account_id = 0 AND u.id IN (${placeholders}))) ORDER BY u.name COLLATE NOCASE ASC `; - var queryParams = [acctId].concat(userIds); + var queryParams = [acctId].concat(userIds).concat(userIds); var userResult = tx.executeSql(userQuery, queryParams); for (var k = 0; k < userResult.rows.length; k++) { var userRow = userResult.rows.item(k); - Logger.debug("Task", "Loading assignee:", userRow.name, "Account:", userRow.account_name, "ID:", userRow.odoo_record_id) + var effectiveOdooId = (userRow.account_id === 0 || !userRow.odoo_record_id || userRow.odoo_record_id <= 0) ? userRow.id : userRow.odoo_record_id; + Logger.debug("Task", "Loading assignee:", userRow.name, "Account:", userRow.account_name, "ID:", effectiveOdooId) assignees.push({ id: userRow.id, - odoo_record_id: userRow.odoo_record_id, + odoo_record_id: effectiveOdooId, name: userRow.name, email: userRow.email || "", account_id: userRow.account_id, @@ -3458,14 +3651,23 @@ function updateTaskStage(taskId, stageOdooRecordId, accountId) { [stageOdooRecordId, accountId] ); + if (stageCheck.rows.length === 0) { + // Fallback check if stage exists globally + stageCheck = tx.executeSql( + 'SELECT id FROM project_task_type_app WHERE odoo_record_id = ?', + [stageOdooRecordId] + ); + } + if (stageCheck.rows.length === 0) { throw "Stage not found or does not belong to this account"; } // Update the task's stage + var statusVal = (accountId === 0) ? "saved" : "updated"; tx.executeSql( 'UPDATE project_task_app SET state = ?, last_modified = ?, status = ? WHERE id = ?', - [stageOdooRecordId, timestamp, "updated", taskId] + [stageOdooRecordId, timestamp, statusVal, taskId] ); }); diff --git a/models/timesheet.js b/models/timesheet.js index 5cf303c5..6a609e2e 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -20,12 +20,17 @@ function fetchTimesheetsByStatus(status, accountId) { try { db.transaction(function (tx) { - // Build map of odoo_record_id -> color_pallet + // Build map of odoo_record_id and local id -> color_pallet var projectColorMap = {}; - var projectResult = tx.executeSql("SELECT odoo_record_id, color_pallet FROM project_project_app"); + var projectResult = tx.executeSql("SELECT id, odoo_record_id, color_pallet FROM project_project_app"); for (var j = 0; j < projectResult.rows.length; j++) { var projectRow = projectResult.rows.item(j); - projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + if (projectRow.odoo_record_id) { + projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + } + if (projectRow.id) { + projectColorMap[projectRow.id] = projectRow.color_pallet; + } } var query = ""; @@ -35,6 +40,9 @@ function fetchTimesheetsByStatus(status, accountId) { if (!status || status.toLowerCase() === "all") { query = "SELECT * FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; params = [accountId]; + } else if (status === "draft") { + query = "SELECT * FROM account_analytic_line_app WHERE account_id = ? AND (status = 'draft' OR status = 'saved') ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; + params = [accountId]; } else { query = "SELECT * FROM account_analytic_line_app WHERE account_id = ? AND status = ? ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; params = [accountId, status]; @@ -61,8 +69,8 @@ function fetchTimesheetsByStatus(status, accountId) { if (row.project_id) { var rs_project = tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [row.project_id] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.project_id, row.project_id] ); if (rs_project.rows.length > 0) { @@ -70,8 +78,8 @@ function fetchTimesheetsByStatus(status, accountId) { if (project_row.parent_id && project_row.parent_id > 0) { // Subproject case var rs_parent = tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [project_row.parent_id] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [project_row.parent_id, project_row.parent_id] ); if (rs_parent.rows.length > 0) { projectName = rs_parent.rows.item(0).name + " / " + project_row.name; @@ -92,8 +100,8 @@ function fetchTimesheetsByStatus(status, accountId) { var taskName = "Unknown Task"; if (row.task_id) { var rs_task = tx.executeSql( - "SELECT name FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", - [row.task_id] + "SELECT name FROM project_task_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.task_id, row.task_id] ); if (rs_task.rows.length > 0) { taskName = rs_task.rows.item(0).name; @@ -102,13 +110,13 @@ function fetchTimesheetsByStatus(status, accountId) { // Resolve instance and user names var instanceName = "", userName = ""; - if (row.account_id) { + if (row.account_id !== undefined && row.account_id !== null) { var rs_instance = tx.executeSql("SELECT name FROM users WHERE id = ? LIMIT 1", [row.account_id]); if (rs_instance.rows.length > 0) instanceName = rs_instance.rows.item(0).name; } - if (row.user_id) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE odoo_record_id = ? LIMIT 1", [row.user_id]); + 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; } @@ -143,10 +151,15 @@ function fetchTimesheetsForAllAccounts(status) { try { db.transaction(function (tx) { var projectColorMap = {}; - var projectResult = tx.executeSql("SELECT odoo_record_id, color_pallet FROM project_project_app"); + var projectResult = tx.executeSql("SELECT id, odoo_record_id, color_pallet FROM project_project_app"); for (var j = 0; j < projectResult.rows.length; j++) { var projectRow = projectResult.rows.item(j); - projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + if (projectRow.odoo_record_id) { + projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + } + if (projectRow.id) { + projectColorMap[projectRow.id] = projectRow.color_pallet; + } } var query = ""; var params = []; @@ -154,6 +167,9 @@ function fetchTimesheetsForAllAccounts(status) { if (!status || status.toLowerCase() === "all") { query = "SELECT * FROM account_analytic_line_app WHERE status IS NULL OR status != 'deleted' ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; params = []; + } else if (status === "draft") { + query = "SELECT * FROM account_analytic_line_app WHERE (status = 'draft' OR status = 'saved') ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; + params = []; } else { query = "SELECT * FROM account_analytic_line_app WHERE status = ? ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; params = [status]; @@ -180,16 +196,16 @@ function fetchTimesheetsForAllAccounts(status) { if (row.project_id) { var rs_project = tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [row.project_id] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.project_id, row.project_id] ); if (rs_project.rows.length > 0) { var project_row = rs_project.rows.item(0); if (project_row.parent_id && project_row.parent_id > 0) { var rs_parent = tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [project_row.parent_id] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [project_row.parent_id, project_row.parent_id] ); if (rs_parent.rows.length > 0) { projectName = rs_parent.rows.item(0).name + " / " + project_row.name; @@ -208,8 +224,8 @@ function fetchTimesheetsForAllAccounts(status) { var taskName = "Unknown Task"; if (row.task_id) { var rs_task = tx.executeSql( - "SELECT name FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", - [row.task_id] + "SELECT name FROM project_task_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.task_id, row.task_id] ); if (rs_task.rows.length > 0) { taskName = rs_task.rows.item(0).name; @@ -218,13 +234,13 @@ function fetchTimesheetsForAllAccounts(status) { // Resolve instance and user names var instanceName = "", userName = ""; - if (row.account_id) { + if (row.account_id !== undefined && row.account_id !== null) { var rs_instance = tx.executeSql("SELECT name FROM users WHERE id = ? LIMIT 1", [row.account_id]); if (rs_instance.rows.length > 0) instanceName = rs_instance.rows.item(0).name; } - if (row.user_id) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE odoo_record_id = ? LIMIT 1", [row.user_id]); + 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; } @@ -271,10 +287,15 @@ function fetchTimesheetsByStatusPaginated(status, accountId, limit, offset) { try { db.transaction(function (tx) { var projectColorMap = {}; - var projectResult = tx.executeSql("SELECT odoo_record_id, color_pallet FROM project_project_app"); + var projectResult = tx.executeSql("SELECT id, odoo_record_id, color_pallet FROM project_project_app"); for (var j = 0; j < projectResult.rows.length; j++) { var projectRow = projectResult.rows.item(j); - projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + if (projectRow.odoo_record_id) { + projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + } + if (projectRow.id) { + projectColorMap[projectRow.id] = projectRow.color_pallet; + } } var query = ""; @@ -283,6 +304,9 @@ function fetchTimesheetsByStatusPaginated(status, accountId, limit, offset) { if (!status || status.toLowerCase() === "all") { query = "SELECT * FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') ORDER BY COALESCE(last_modified, record_date) DESC, id DESC LIMIT ? OFFSET ?"; params = [accountId, limit, offset]; + } else if (status === "draft") { + query = "SELECT * FROM account_analytic_line_app WHERE account_id = ? AND (status = 'draft' OR status = 'saved') ORDER BY COALESCE(last_modified, record_date) DESC, id DESC LIMIT ? OFFSET ?"; + params = [accountId, limit, offset]; } else { query = "SELECT * FROM account_analytic_line_app WHERE account_id = ? AND status = ? ORDER BY COALESCE(last_modified, record_date) DESC, id DESC LIMIT ? OFFSET ?"; params = [accountId, status, limit, offset]; @@ -297,27 +321,27 @@ function fetchTimesheetsByStatusPaginated(status, accountId, limit, offset) { var projectName = "Unknown Project"; var inheritedColor = 0; if (row.project_id) { - var projectAccountId = row.account_id || null; - var rs_project = projectAccountId ? + var projectAccountId = (row.account_id !== undefined && row.account_id !== null) ? row.account_id : null; + var rs_project = (projectAccountId !== null) ? tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1", - [row.project_id, projectAccountId] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) AND account_id = ? LIMIT 1", + [row.project_id, row.project_id, projectAccountId] ) : tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [row.project_id] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.project_id, row.project_id] ); if (rs_project.rows.length > 0) { var project_row = rs_project.rows.item(0); if (project_row.parent_id && project_row.parent_id > 0) { - var rs_parent = projectAccountId ? + var rs_parent = (projectAccountId !== null) ? tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1", - [project_row.parent_id, projectAccountId] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) AND account_id = ? LIMIT 1", + [project_row.parent_id, project_row.parent_id, projectAccountId] ) : tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [project_row.parent_id] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [project_row.parent_id, project_row.parent_id] ); projectName = rs_parent.rows.length > 0 ? rs_parent.rows.item(0).name + " / " + project_row.name : project_row.name; inheritedColor = projectColorMap[row.project_id] || projectColorMap[project_row.parent_id] || 0; @@ -328,25 +352,26 @@ function fetchTimesheetsByStatusPaginated(status, accountId, limit, offset) { } } - var taskName = "Unknown Task"; + var taskName = ""; if (row.task_id) { - var rs_task = tx.executeSql("SELECT name FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", [row.task_id]); + var rs_task = tx.executeSql("SELECT name FROM project_task_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.task_id, row.task_id]); if (rs_task.rows.length > 0) taskName = rs_task.rows.item(0).name; } var instanceName = "", userName = ""; - if (row.account_id) { + if (row.account_id !== undefined && row.account_id !== null) { var rs_instance = tx.executeSql("SELECT name FROM users WHERE id = ? LIMIT 1", [row.account_id]); if (rs_instance.rows.length > 0) instanceName = rs_instance.rows.item(0).name; } - if (row.user_id) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE odoo_record_id = ? LIMIT 1", [row.user_id]); + 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; } timesheetList.push({ id: row.id, instance: instanceName, + account_id: (row.account_id !== undefined && row.account_id !== null) ? row.account_id : 0, name: row.name || '', spentHours: Utils.convertDecimalHoursToHHMM(row.unit_amount), project: projectName, @@ -386,10 +411,15 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { try { db.transaction(function (tx) { var projectColorMap = {}; - var projectResult = tx.executeSql("SELECT odoo_record_id, color_pallet FROM project_project_app"); + var projectResult = tx.executeSql("SELECT id, odoo_record_id, color_pallet FROM project_project_app"); for (var j = 0; j < projectResult.rows.length; j++) { var projectRow = projectResult.rows.item(j); - projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + if (projectRow.odoo_record_id) { + projectColorMap[projectRow.odoo_record_id] = projectRow.color_pallet; + } + if (projectRow.id) { + projectColorMap[projectRow.id] = projectRow.color_pallet; + } } var query = ""; @@ -398,6 +428,9 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { if (!status || status.toLowerCase() === "all") { query = "SELECT * FROM account_analytic_line_app WHERE status IS NULL OR status != 'deleted' ORDER BY COALESCE(last_modified, record_date) DESC, id DESC LIMIT ? OFFSET ?"; params = [limit, offset]; + } else if (status === "draft") { + query = "SELECT * FROM account_analytic_line_app WHERE (status = 'draft' OR status = 'saved') ORDER BY COALESCE(last_modified, record_date) DESC, id DESC LIMIT ? OFFSET ?"; + params = [limit, offset]; } else { query = "SELECT * FROM account_analytic_line_app WHERE status = ? ORDER BY COALESCE(last_modified, record_date) DESC, id DESC LIMIT ? OFFSET ?"; params = [status, limit, offset]; @@ -412,11 +445,11 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { var projectName = "Unknown Project"; var inheritedColor = 0; if (row.project_id) { - var rs_project = tx.executeSql("SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", [row.project_id]); + var rs_project = tx.executeSql("SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.project_id, row.project_id]); if (rs_project.rows.length > 0) { var project_row = rs_project.rows.item(0); if (project_row.parent_id && project_row.parent_id > 0) { - var rs_parent = tx.executeSql("SELECT name FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", [project_row.parent_id]); + var rs_parent = tx.executeSql("SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [project_row.parent_id, project_row.parent_id]); projectName = rs_parent.rows.length > 0 ? rs_parent.rows.item(0).name + " / " + project_row.name : project_row.name; inheritedColor = projectColorMap[row.project_id] || projectColorMap[project_row.parent_id] || 0; } else { @@ -426,25 +459,26 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { } } - var taskName = "Unknown Task"; + var taskName = ""; if (row.task_id) { - var rs_task = tx.executeSql("SELECT name FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", [row.task_id]); + var rs_task = tx.executeSql("SELECT name FROM project_task_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.task_id, row.task_id]); if (rs_task.rows.length > 0) taskName = rs_task.rows.item(0).name; } var instanceName = "", userName = ""; - if (row.account_id) { + if (row.account_id !== undefined && row.account_id !== null) { var rs_instance = tx.executeSql("SELECT name FROM users WHERE id = ? LIMIT 1", [row.account_id]); if (rs_instance.rows.length > 0) instanceName = rs_instance.rows.item(0).name; } - if (row.user_id) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE odoo_record_id = ? LIMIT 1", [row.user_id]); + 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; } timesheetList.push({ id: row.id, instance: instanceName, + account_id: (row.account_id !== undefined && row.account_id !== null) ? row.account_id : 0, name: row.name || '', spentHours: Utils.convertDecimalHoursToHHMM(row.unit_amount), project: projectName, @@ -545,8 +579,8 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en if (row.project_id) { var rs_project = tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [row.project_id] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.project_id, row.project_id] ); if (rs_project.rows.length > 0) { @@ -554,8 +588,8 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en if (project_row.parent_id && project_row.parent_id > 0) { // Subproject case var rs_parent = tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [project_row.parent_id] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [project_row.parent_id, project_row.parent_id] ); if (rs_parent.rows.length > 0) { projectName = rs_parent.rows.item(0).name + " / " + project_row.name; @@ -576,8 +610,8 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en var taskName = "Unknown Task"; if (row.task_id) { var rs_task = tx.executeSql( - "SELECT name FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", - [row.task_id] + "SELECT name FROM project_task_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.task_id, row.task_id] ); if (rs_task.rows.length > 0) { taskName = rs_task.rows.item(0).name; @@ -586,13 +620,13 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en // Resolve instance and user names var instanceName = "", userName = ""; - if (row.account_id) { + if (row.account_id !== undefined && row.account_id !== null) { var rs_instance = tx.executeSql("SELECT name FROM users WHERE id = ? LIMIT 1", [row.account_id]); if (rs_instance.rows.length > 0) instanceName = rs_instance.rows.item(0).name; } - if (row.user_id) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE odoo_record_id = ? LIMIT 1", [row.user_id]); + 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; } @@ -688,28 +722,28 @@ function getTimesheetsForTaskPaginated(taskOdooRecordId, accountId, status, limi var inheritedColor = 0; if (row.project_id) { - var accountId = row.account_id || null; - var rs_project = accountId ? + var accountId = (row.account_id !== undefined && row.account_id !== null) ? row.account_id : null; + var rs_project = (accountId !== null) ? tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1", - [row.project_id, accountId] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) AND account_id = ? LIMIT 1", + [row.project_id, row.project_id, accountId] ) : tx.executeSql( - "SELECT name, parent_id FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [row.project_id] + "SELECT name, parent_id FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.project_id, row.project_id] ); if (rs_project.rows.length > 0) { var project_row = rs_project.rows.item(0); if (project_row.parent_id && project_row.parent_id > 0) { - var rs_parent = accountId ? + var rs_parent = (accountId !== null) ? tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1", - [project_row.parent_id, accountId] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) AND account_id = ? LIMIT 1", + [project_row.parent_id, project_row.parent_id, accountId] ) : tx.executeSql( - "SELECT name FROM project_project_app WHERE odoo_record_id = ? LIMIT 1", - [project_row.parent_id] + "SELECT name FROM project_project_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [project_row.parent_id, project_row.parent_id] ); if (rs_parent.rows.length > 0) { projectName = rs_parent.rows.item(0).name + " / " + project_row.name; @@ -731,8 +765,8 @@ function getTimesheetsForTaskPaginated(taskOdooRecordId, accountId, status, limi var taskName = "Unknown Task"; if (row.task_id) { var rs_task = tx.executeSql( - "SELECT name FROM project_task_app WHERE odoo_record_id = ? LIMIT 1", - [row.task_id] + "SELECT name FROM project_task_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", + [row.task_id, row.task_id] ); if (rs_task.rows.length > 0) { taskName = rs_task.rows.item(0).name; @@ -741,13 +775,13 @@ function getTimesheetsForTaskPaginated(taskOdooRecordId, accountId, status, limi // Resolve instance and user names var instanceName = "", userName = ""; - if (row.account_id) { + if (row.account_id !== undefined && row.account_id !== null) { var rs_instance = tx.executeSql("SELECT name FROM users WHERE id = ? LIMIT 1", [row.account_id]); if (rs_instance.rows.length > 0) instanceName = rs_instance.rows.item(0).name; } - if (row.user_id) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE odoo_record_id = ? LIMIT 1", [row.user_id]); + 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; } @@ -1020,6 +1054,8 @@ function getTimeSheetDetails(record_id, accountId) { timesheet_detail = { 'id': row.id, 'instance_id': row.account_id, + 'account_id': row.account_id, + 'status': row.status || 'draft', 'project_id': row.project_id, 'sub_project_id': row.sub_project_id, 'task_id': row.task_id, @@ -1073,7 +1109,7 @@ function getTimeSheetDetailsByOdooId(odoo_record_id, accountId) { var query = 'SELECT * FROM account_analytic_line_app WHERE odoo_record_id = ?'; var params = [odoo_record_id]; - if (accountId !== undefined && accountId !== null && accountId > 0) { + if (accountId !== undefined && accountId !== null && accountId >= 0) { query += ' AND account_id = ?'; params.push(accountId); } @@ -1087,6 +1123,8 @@ function getTimeSheetDetailsByOdooId(odoo_record_id, accountId) { timesheet_detail = { 'id': row.id, 'instance_id': row.account_id, + 'account_id': row.account_id, + 'status': row.status || 'draft', 'project_id': row.project_id, 'sub_project_id': row.sub_project_id, 'task_id': row.task_id, @@ -1157,7 +1195,7 @@ function saveTimesheet(data) { has_draft = 0 WHERE id = ?`, [ - data.instance_id || null, + (data.instance_id !== undefined && data.instance_id !== null) ? data.instance_id : ((data.account_id !== undefined && data.account_id !== null) ? data.account_id : null), data.record_date || Utils.getToday(), data.project || null, data.task || null, @@ -1169,7 +1207,7 @@ function saveTimesheet(data) { timestamp, data.status || "draft", data.timer_type || "manual", - (data.user_id !== undefined && data.user_id !== null) ? data.user_id : null, + (data.user_id !== undefined && data.user_id !== null && data.user_id !== "") ? data.user_id : ((data.instance_id === 0 || data.account_id === 0) ? 1 : null), data.id ]); @@ -1188,15 +1226,22 @@ function createTimesheet(instance_id, userid) { var timestamp = Utils.getFormattedTimestampUTC(); var result = { success: false, error: "", id: null }; - // Validate required parameters - if (!instance_id || instance_id <= 0) { + var acctId = (instance_id !== undefined && instance_id !== null) ? parseInt(instance_id) : -1; + if (isNaN(acctId) || acctId < 0) { result.error = "Invalid instance_id provided"; return result; } - if (!userid || userid <= 0) { - result.error = "Invalid user_id provided"; - return result; + var uid = (userid !== undefined && userid !== null) ? parseInt(userid) : 0; + if (acctId === 0) { + if (isNaN(uid) || uid <= 0) { + uid = 1; + } + } else { + if (isNaN(uid) || uid <= 0) { + result.error = "Invalid user_id provided"; + return result; + } } try { @@ -1206,7 +1251,7 @@ function createTimesheet(instance_id, userid) { sub_task_id, quadrant_id, unit_amount, last_modified, status, timer_type, user_id, has_draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`, [ - instance_id, // account_id + acctId, // account_id Utils.getToday(), // record_date, fallback to today null, // project_id null, // task_id @@ -1218,7 +1263,7 @@ function createTimesheet(instance_id, userid) { timestamp, // last_modified "draft", // status "manual", // timer_type - default to manual - userid // user_id + uid // user_id ]); // Retrieve the last inserted ID @@ -1258,7 +1303,7 @@ function createTimesheet(instance_id, userid) { var task = null; db.readTransaction(function (tx) { - var rs = tx.executeSql("SELECT * FROM project_task_app WHERE odoo_record_id = ?", [taskRecordId]); + var rs = tx.executeSql("SELECT * FROM project_task_app WHERE (odoo_record_id = ? OR id = ?)", [taskRecordId, taskRecordId]); if (rs.rows.length > 0) { task = rs.rows.item(0); } @@ -1269,7 +1314,7 @@ function createTimesheet(instance_id, userid) { return result; } - if (!task.project_id || !task.account_id) { + if (!task.project_id || task.account_id === undefined || task.account_id === null || task.account_id < 0) { result.error = "Task missing required project/account linkage."; return result; } @@ -1277,6 +1322,9 @@ function createTimesheet(instance_id, userid) { // Always use the current logged-in user for timesheet creation // Even if task has an assigned user, the timesheet should belong to who is creating it var userId = Accounts.getCurrentUserOdooId(task.account_id); + if (task.account_id === 0 && (!userId || userId <= 0)) { + userId = 1; + } if (!userId || userId <= 0) { result.error = "Unable to determine current user for account " + task.account_id; return result; @@ -1295,13 +1343,14 @@ function createTimesheet(instance_id, userid) { // Now update the created empty timesheet with project, task, description, etc. var today = Utils.getToday(); // ensure "yyyy-MM-dd" + var effectiveTaskId = (task.account_id === 0 || !task.odoo_record_id) ? task.id : task.odoo_record_id; var timesheet_data = { id: timesheetId, instance_id: task.account_id, record_date: today, project: task.project_id, - task: task.odoo_record_id || null, + task: effectiveTaskId, subprojectId: task.sub_project_id || null, subTask: null, description: "Timesheet (" + today + ") " + (task.name || ""), @@ -1338,7 +1387,7 @@ function createTimesheetFromProject(projectRecordId) { try { var project = null; db.readTransaction(function (tx) { - var rs = tx.executeSql("SELECT * FROM project_project_app WHERE odoo_record_id = ?", [projectRecordId]); + var rs = tx.executeSql("SELECT * FROM project_project_app WHERE (odoo_record_id = ? OR id = ?)", [projectRecordId, projectRecordId]); if (rs.rows.length > 0) { project = rs.rows.item(0); Logger.debug("Timesheet", "Project data:", JSON.stringify(project)) @@ -1352,7 +1401,7 @@ function createTimesheetFromProject(projectRecordId) { return result; } - if (!project.account_id || project.account_id <= 0) { + if (project.account_id === undefined || project.account_id === null || project.account_id < 0) { result.error = "Project missing required account_id. Current value: " + project.account_id; return result; } @@ -1360,6 +1409,9 @@ function createTimesheetFromProject(projectRecordId) { // Always use the current logged-in user for timesheet creation // Projects don't have assigned users, so use whoever is creating the timesheet var userId = Accounts.getCurrentUserOdooId(project.account_id); + if (project.account_id === 0 && (!userId || userId <= 0)) { + userId = 1; + } if (!userId || userId <= 0) { result.error = "Unable to determine current user for account " + project.account_id; return result; @@ -1375,13 +1427,14 @@ function createTimesheetFromProject(projectRecordId) { var timesheetId = tsResult.id; var today = Utils.getToday(); + var effectiveProjectId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; // Update timesheet with project data var timesheet_data = { id: timesheetId, instance_id: project.account_id, record_date: today, - project: project.odoo_record_id, + project: effectiveProjectId, task: null, // No specific task for project-level timesheet subprojectId: null, subTask: null, @@ -1415,8 +1468,8 @@ function doesProjectIdMatchSheetInActive(projectId, sheetId) { try { db.transaction(function (tx) { var rs = tx.executeSql( - "SELECT id FROM account_analytic_line_app WHERE id = ? AND status = ? AND project_id = ? LIMIT 1", - [sheetId, "active", projectId] + "SELECT id FROM account_analytic_line_app WHERE id = ? AND status = ? AND (project_id = ? OR sub_project_id = ?) LIMIT 1", + [sheetId, "active", projectId, projectId] ); if (rs.rows.length > 0) { matches = true; @@ -1502,15 +1555,72 @@ function markTimesheetAsActiveById(timesheetId) { } /** - * Marks a timesheet as ready to be synced to Odoo by setting its status to "updated". - * The timesheet must have required project/task information to be marked as ready. + * Retrieves the account_id for a given timesheet record. * - * @param {number} timesheetId - The ID of the timesheet to be marked as ready for sync + * @param {number} timesheetId - The ID of the timesheet + * @returns {number} - The account ID or -1 if not found + */ +function getTimesheetAccountId(timesheetId) { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + var accountId = -1; + try { + db.readTransaction(function (tx) { + var rs = tx.executeSql("SELECT account_id FROM account_analytic_line_app WHERE id = ? LIMIT 1", [timesheetId]); + if (rs.rows.length > 0) { + var raw = rs.rows.item(0).account_id; + accountId = (raw !== undefined && raw !== null) ? parseInt(raw) : 0; + } + }); + } catch (e) { + Logger.debug("Timesheet", "getTimesheetAccountId failed:", e); + } + return accountId; +} + +/** + * Marks a timesheet as saved in the local SQLite database by setting its status to 'saved'. + * Used for local account timesheets that do not require Odoo sync. + * + * @param {number} timesheetId - The ID of the timesheet + * @returns {Object} - Result with success and error + */ +function markTimesheetAsSavedById(timesheetId) { + var result = { success: false, error: "", id: timesheetId }; + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + var timestamp = Utils.getFormattedTimestampUTC(); + + try { + db.transaction(function (tx) { + tx.executeSql( + "UPDATE account_analytic_line_app SET last_modified = ?, status = ? WHERE id = ?", + [timestamp, "saved", timesheetId] + ); + }); + Logger.debug("Timesheet", "Timesheet " + timesheetId + " marked as saved successfully."); + result.success = true; + } catch (e) { + Logger.error("Timesheet", "markTimesheetAsSavedById failed:", e); + result.error = e.message; + } + return result; +} + +/** + * Marks a timesheet as ready to be synced to Odoo by setting its status to "updated", + * or as "saved" if it belongs to a local account. + * + * @param {number} timesheetId - The ID of the timesheet * @returns {Object} - An object with `success` (boolean) and `error` (string) indicating the result */ function markTimesheetAsReadyById(timesheetId) { var result = { success: false, error: "", id: null }; + // For local accounts, mark directly as 'saved' without Odoo sync validation + var accountId = getTimesheetAccountId(timesheetId); + if (accountId === 0) { + return markTimesheetAsSavedById(timesheetId); + } + if (!isTimesheetReadyToRecord(timesheetId)) { result.success = false; result.error = "Timesheet not ready - both project and task must be selected"; @@ -1541,14 +1651,7 @@ function markTimesheetAsReadyById(timesheetId) { } /** - * Marks a timesheet as draft by setting its status to "draft". - * This is typically used when stopping a timer to reset the timesheet status. - * - * @param {number} timesheetId - The ID of the timesheet to be marked as draft - * @returns {Object} - An object with `success` (boolean) and `error` (string) indicating the result - */ -/** - * Checks if a timesheet is finalized (has "updated" status). + * Checks if a timesheet is finalized (has "updated" or "saved" status). * * @param {number} timesheetId - The ID of the timesheet to check * @returns {boolean} - True if the timesheet is finalized, false otherwise @@ -1562,7 +1665,7 @@ function isTimesheetFinalized(timesheetId) { var result = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = ?", [timesheetId]); if (result.rows.length > 0) { var status = result.rows.item(0).status; - isFinalized = (status === "updated"); + isFinalized = (status === "updated" || status === "saved"); Logger.debug("Timesheet", "Timesheet", timesheetId, "status:", status, "finalized:", isFinalized) } }); diff --git a/models/utils.js b/models/utils.js index 76e8a69c..be651e75 100644 --- a/models/utils.js +++ b/models/utils.js @@ -161,8 +161,8 @@ function getUserInfoByOdooId(accountId, odooUserId) { db.transaction(function (tx) { var rs = tx.executeSql( - 'SELECT name, avatar_128, odoo_record_id, login, job_title FROM res_users_app WHERE account_id = ? AND odoo_record_id = ?', - [accountId, odooUserId] + 'SELECT name, avatar_128, odoo_record_id, login, job_title FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?))', + [accountId, odooUserId, odooUserId] ); if (rs.rows.length > 0) { var row = rs.rows.item(0); @@ -204,8 +204,8 @@ function getTaskAssignerName(accountId, taskId) { var createUid = taskRs.rows.item(0).create_uid; // Now look up the user name var userRs = tx.executeSql( - 'SELECT name FROM res_users_app WHERE account_id = ? AND odoo_record_id = ?', - [accountId, createUid] + 'SELECT name FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?))', + [accountId, createUid, createUid] ); if (userRs.rows.length > 0) { assignerName = userRs.rows.item(0).name; @@ -241,8 +241,8 @@ function getActivityAssignerInfo(accountId, activityId) { var createUid = activityRs.rows.item(0).create_uid; // Now look up the user info var userRs = tx.executeSql( - 'SELECT name, avatar_128 FROM res_users_app WHERE account_id = ? AND odoo_record_id = ?', - [accountId, createUid] + 'SELECT name, avatar_128 FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?))', + [accountId, createUid, createUid] ); if (userRs.rows.length > 0) { var row = userRs.rows.item(0); @@ -280,14 +280,37 @@ function fetch_subtasks(instance_id, parent_task_id) { } function validateAndCleanOdooURL(url) { - // Strip trailing slash - if (url.endsWith("/")) { + if (!url || typeof url !== "string") { + return { + isValid: false, + cleanedUrl: "" + }; + } + + url = url.trim(); + if (url.length === 0 || url === "http://" || url === "https://") { + return { + isValid: false, + cleanedUrl: "" + }; + } + + // Add https:// if protocol is missing + if (!url.startsWith("http://") && !url.startsWith("https://")) { + if (!url.includes("://")) { + url = "https://" + url; + } + } + + // Strip trailing slashes beyond the protocol + while (url.endsWith("/") && url !== "http://" && url !== "https://") { url = url.slice(0, -1); } const pattern = new RegExp( '^(https?:\\/\\/)?' + - '(([a-zA-Z0-9\\-\\.]+)\\.([a-zA-Z]{2,4})|' + + '(([a-zA-Z0-9\\-\\.]+)\\.([a-zA-Z]{2,63})|' + + 'localhost|' + '(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})|' + '\\[([a-fA-F0-9:\\.]+)\\])' + '(\\:\\d+)?(\\/[-a-zA-Z0-9@:%_\\+.~#?&//=]*)?$', diff --git a/po/ja.po b/po/ja.po new file mode 100644 index 00000000..03103f50 --- /dev/null +++ b/po/ja.po @@ -0,0 +1,2151 @@ +msgid "" +msgstr "" +"Project-Id-Version: ubtms\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-08 08:23+0000\n" +"PO-Revision-Date: 2026-08-17 13:30+0530\n" +"Last-Translator: Aniket Palsodkar\n" +"Language-Team: Japanese\n" +"Language: ja\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../qml/app/AppDrawer.qml:49 ../qml/app/navigation/MenuPage.qml:39 +#: ../qml/app/pages/AboutPage.qml:63 +#: ../qml/features/activities/pages/Activity_Page.qml:55 +#: ../qml/features/dashboard/pages/Dashboard.qml:91 +#: ../qml/features/projects/pages/Project_Page.qml:56 +#: ../qml/features/settings/pages/Settings_Page.qml:50 +#: ../qml/features/tasks/pages/MyTasksPage.qml:62 +#: ../qml/features/tasks/pages/Task_Page.qml:57 +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:80 +#: ../qml/features/updates/pages/Updates_Page.qml:70 +msgid "Menu" +msgstr "メニュー" + +#: ../qml/app/GlobalWidgets.qml:59 +msgid "Switch account" +msgstr "アカウントを切り替える" + +#: ../qml/app/navigation/MenuPage.qml:55 +msgid "Switch Accounts" +msgstr "アカウントを切り替える" + +#: ../qml/app/navigation/MenuPage.qml:62 +msgid "Dark Mode" +msgstr "ダークモード" + +#: ../qml/app/navigation/MenuPage.qml:62 +msgid "Light Mode" +msgstr "ライトモード" + +#: ../qml/app/pages/AboutPage.qml:35 ../qml/app/pages/AboutPage.qml:52 +msgid "About" +msgstr "について" + +#: ../qml/components/cards/ActivityDetailsCard.qml:74 +msgid "OVERDUE" +msgstr "期限を過ぎました" + +#: ../qml/components/cards/ActivityDetailsCard.qml:80 +msgid "TODAY" +msgstr "今日" + +#: ../qml/components/cards/ActivityDetailsCard.qml:86 +msgid "DONE" +msgstr "終わり" + +#: ../qml/components/cards/ActivityDetailsCard.qml:92 +msgid "PLANNED" +msgstr "計画済み" + +#: ../qml/components/cards/ActivityDetailsCard.qml:190 +#: ../qml/components/cards/UpdatesDetailsCard.qml:52 +#: ../qml/features/activities/pages/Activities.qml:101 +#: ../qml/features/projects/pages/Projects.qml:136 +#: ../qml/features/settings/pages/Account_Page.qml:77 +#: ../qml/features/tasks/pages/Tasks.qml:79 +#: ../qml/features/updates/pages/Updates.qml:131 +msgid "Edit" +msgstr "編集" + +#: ../qml/components/cards/ActivityDetailsCard.qml:201 +msgid "Mark Done" +msgstr "完了マークを付ける" + +#: ../qml/components/cards/ActivityDetailsCard.qml:206 +msgid "Follow-up" +msgstr "フォローアップ" + +#: ../qml/components/cards/ActivityDetailsCard.qml:283 +msgid "No Summary" +msgstr "概要なし" + +#: ../qml/components/cards/ActivityDetailsCard.qml:294 +msgid "No Notes" +msgstr "メモなし" + +#: ../qml/components/cards/ActivityDetailsCard.qml:306 +msgid "Assigned to: " +msgstr "担当者:" + +#: ../qml/components/cards/ActivityDetailsCard.qml:306 +#: ../qml/features/dashboard/charts/Charts4.qml:63 +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:609 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:94 +msgid "Unassigned" +msgstr "未割り当て" + +#: ../qml/components/cards/ActivityDetailsCard.qml:328 +#: ../qml/components/cards/ProjectDetailsCard.qml:341 +#: ../qml/components/cards/UpdatesDetailsCard.qml:167 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:622 +#: ../qml/features/timesheets/components/TimeSheetDetailsCard.qml:292 +msgid "DRAFT" +msgstr "下書き" + +#: ../qml/components/cards/ActivityDetailsCard.qml:351 +msgid "Type ID: " +msgstr "タイプID:" + +#: ../qml/components/cards/ActivityDetailsCard.qml:392 +msgid "Reschedule Activity Date" +msgstr "アクティビティの日付を再スケジュールする" + +#: ../qml/components/cards/ProjectDetailsCard.qml:136 +msgid "Stop Timer" +msgstr "タイマーの停止" + +#: ../qml/components/cards/ProjectDetailsCard.qml:369 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:630 +msgid "Planned (H): " +msgstr "計画(H):" + +#: ../qml/components/cards/ProjectDetailsCard.qml:377 +#: ../qml/components/cards/ProjectDetailsCard.qml:385 +#: ../qml/components/selectors/ProjectStageSelector.qml:108 +#: ../qml/features/projects/pages/Projects.qml:658 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:637 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:644 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:37 +#: ../qml/features/tasks/components/TaskScheduleFields.qml:140 +#: ../qml/features/tasks/components/TaskStagesDisplayGrid.qml:41 +#: ../qml/features/tasks/components/TaskStagesDisplayGrid.qml:99 +msgid "Not set" +msgstr "未設定" + +#: ../qml/components/cards/ProjectDetailsCard.qml:377 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:637 +msgid "Start Date: " +msgstr "開始日:" + +#: ../qml/components/cards/ProjectDetailsCard.qml:385 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:644 +msgid "End Date: " +msgstr "終了日:" + +#: ../qml/components/cards/TasksForDayWidget.qml:36 +msgid "Plan for Today" +msgstr "今日の計画" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:83 +msgid "Untitled Update" +msgstr "無題の更新" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:102 +#: ../qml/components/dialogs/CreateUpdateDialog.qml:81 +#: ../qml/components/selectors/StatusSelector.qml:26 +#: ../qml/components/workflow/CreateUpdatePage.qml:269 +#: ../qml/features/updates/pages/Updates.qml:464 +#: ../qml/features/updates/pages/Updates_Page.qml:270 +msgid "On Track" +msgstr "順調に進んでいます" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:103 +#: ../qml/components/dialogs/CreateUpdateDialog.qml:82 +#: ../qml/components/selectors/StatusSelector.qml:38 +#: ../qml/components/workflow/CreateUpdatePage.qml:270 +#: ../qml/features/updates/pages/Updates.qml:465 +#: ../qml/features/updates/pages/Updates_Page.qml:271 +msgid "At Risk" +msgstr "危険にさらされています" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:104 +#: ../qml/components/dialogs/CreateUpdateDialog.qml:83 +#: ../qml/components/selectors/StatusSelector.qml:50 +#: ../qml/components/workflow/CreateUpdatePage.qml:271 +#: ../qml/features/updates/pages/Updates.qml:466 +#: ../qml/features/updates/pages/Updates_Page.qml:272 +msgid "Off Track" +msgstr "オフトラック" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:105 +#: ../qml/components/dialogs/CreateUpdateDialog.qml:84 +#: ../qml/components/workflow/CreateUpdatePage.qml:272 +#: ../qml/features/updates/pages/Updates.qml:467 +#: ../qml/features/updates/pages/Updates_Page.qml:273 +msgid "On Hold" +msgstr "保留中" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:122 +msgid "By: " +msgstr "による:" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:122 +msgid "No Date" +msgstr "日付なし" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:122 +msgid "Unknown User" +msgstr "不明なユーザー" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:130 +msgid "Details" +msgstr "詳細" + +#: ../qml/components/cards/UpdatesDetailsCard.qml:147 +#: ../qml/features/updates/pages/Updates.qml:83 +msgid "Unknown Project" +msgstr "未知のプロジェクト" + +#: ../qml/components/dialogs/AccountSelectorDialog.qml:21 +#: ../qml/components/selectors/ProjectSelector.qml:62 +#: ../qml/components/selectors/ProjectSelector.qml:99 +msgid "Select Account" +msgstr "アカウントの選択" + +#: ../qml/components/dialogs/AccountSelectorDialog.qml:72 +#: ../qml/components/navigation/AccountDrawer.qml:366 +#: ../qml/components/navigation/AccountDrawer.qml:416 +#: ../qml/components/navigation/AccountDrawer.qml:417 +msgid "All Accounts" +msgstr "すべてのアカウント" + +#: ../qml/components/dialogs/AccountSelectorDialog.qml:102 +msgid "Choose an account to use" +msgstr "使用するアカウントを選択してください" + +#: ../qml/components/dialogs/AccountSelectorDialog.qml:195 +msgid "No accounts available" +msgstr "利用可能なアカウントがありません" + +#: ../qml/components/dialogs/AccountSelectorDialog.qml:231 +#: ../qml/components/dialogs/CreateUpdateDialog.qml:138 +#: ../qml/components/dialogs/SaveDiscardDialog.qml:95 +#: ../qml/components/dialogs/TimePickerPopup.qml:103 +#: ../qml/components/pickers/CustomDatePicker.qml:132 +#: ../qml/components/pickers/CustomDatePicker.qml:160 +#: ../qml/components/richtext/ColorPickerDialog.qml:65 +#: ../qml/components/richtext/FontSizeDialog.qml:72 +#: ../qml/components/richtext/LinkDialog.qml:54 +#: ../qml/components/selectors/OptionSelectorPopover.qml:56 +#: ../qml/components/selectors/ProjectSelector.qml:137 +#: ../qml/components/selectors/ProjectSelector.qml:184 +#: ../qml/components/selectors/ProjectStageSelector.qml:228 +#: ../qml/components/workflow/AttachmentManager.qml:656 +#: ../qml/components/workflow/CreateUpdatePage.qml:178 +#: ../qml/features/settings/pages/Settings_Accounts.qml:109 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:470 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:520 +msgid "Cancel" +msgstr "キャンセル" + +#: ../qml/components/dialogs/ColorPicker.qml:24 +msgid "Select Color" +msgstr "色の選択" + +#: ../qml/components/dialogs/CreateUpdateDialog.qml:44 +#: ../qml/components/workflow/CreateUpdatePage.qml:19 +msgid "New Project Update" +msgstr "新しいプロジェクトの更新" + +#: ../qml/components/dialogs/CreateUpdateDialog.qml:73 +#: ../qml/components/workflow/CreateUpdatePage.qml:234 +#: ../qml/features/updates/pages/Updates.qml:421 +msgid "Update Title" +msgstr "タイトルを更新" + +#: ../qml/components/dialogs/CreateUpdateDialog.qml:93 +msgid "Progress:" +msgstr "進捗:" + +#: ../qml/components/dialogs/CreateUpdateDialog.qml:112 +#: ../qml/components/richtext/ReadMorePage.qml:139 +#: ../qml/components/richtext/RichTextPreview.qml:11 +#: ../qml/components/workflow/CreateUpdatePage.qml:318 +#: ../qml/features/updates/pages/Updates.qml:543 +msgid "Description" +msgstr "説明" + +#: ../qml/components/dialogs/SaveDiscardDialog.qml:49 +#: ../qml/components/workflow/CreateUpdatePage.qml:154 +msgid "Unsaved Changes" +msgstr "未保存の変更" + +#: ../qml/components/dialogs/SaveDiscardDialog.qml:53 +msgid "" +"You have unsaved changes. What would you like to do?\n" +"\n" +"Note: If you cancel, you can navigate back to continue editing." +msgstr "" +"未保存の変更があります。何をしたいですか?\n" +"\n" +"注: キャンセルした場合は、戻って編集を続けることができます。" + +#: ../qml/components/dialogs/SaveDiscardDialog.qml:65 +#: ../qml/features/activities/pages/Activities.qml:93 +#: ../qml/features/projects/pages/Projects.qml:68 +#: ../qml/features/settings/pages/Account_Page.qml:68 +#: ../qml/features/tasks/pages/Tasks.qml:71 +#: ../qml/features/updates/pages/Updates.qml:123 +msgid "Save" +msgstr "保存" + +#: ../qml/components/dialogs/SaveDiscardDialog.qml:80 +#: ../qml/components/workflow/CreateUpdatePage.qml:168 +msgid "Discard" +msgstr "破棄" + +#: ../qml/components/dialogs/TimePickerPopup.qml:44 +msgid "Select Hours" +msgstr "時間を選択してください" + +#: ../qml/components/dialogs/TimePickerPopup.qml:109 +#: ../qml/components/navigation/AccountDrawer.qml:333 +#: ../qml/components/pickers/CustomDatePicker.qml:167 +msgid "OK" +msgstr "わかりました" + +#: ../qml/components/feedback/LoadMoreFooter.qml:82 +msgid "Load more..." +msgstr "さらにロード..." + +#: ../qml/components/feedback/LoadMoreFooter.qml:91 +msgid "Loading..." +msgstr "読み込み中..." + +#: ../qml/components/feedback/NotificationBell.qml:168 +msgid "Just now" +msgstr "ちょうど今" + +#: ../qml/components/feedback/NotificationBell.qml:169 +msgid "m ago" +msgstr "何分前" + +#: ../qml/components/feedback/NotificationBell.qml:170 +msgid "h ago" +msgstr "時間前" + +#: ../qml/components/feedback/NotificationBell.qml:171 +msgid "d ago" +msgstr "一日前" + +#: ../qml/components/feedback/NotificationBell.qml:243 +#: ../qml/components/feedback/NotificationBell.qml:316 +#: ../qml/features/dashboard/pages/Dashboard.qml:116 +#: ../qml/features/dashboard/pages/Dashboard.qml:117 +#: ../qml/features/settings/pages/Settings_Notifications.qml:34 +#: ../qml/features/settings/pages/Settings_Page.qml:80 +msgid "Notifications" +msgstr "通知" + +#: ../qml/components/feedback/NotificationBell.qml:372 +msgid "Sync" +msgstr "同期" + +#: ../qml/components/feedback/NotificationBell.qml:435 +msgid "No sync notifications" +msgstr "同期通知はありません" + +#: ../qml/components/feedback/NotificationBell.qml:436 +msgid "No notifications" +msgstr "通知はありません" + +#: ../qml/components/feedback/NotificationBell.qml:444 +msgid "All syncs are running smoothly!" +msgstr "すべての同期はスムーズに実行されています。" + +#: ../qml/components/feedback/NotificationBell.qml:445 +msgid "You're all caught up!" +msgstr "皆さんも追いついてきました!" + +#: ../qml/components/feedback/NotificationBell.qml:484 +#: ../qml/components/workflow/AttachmentManager.qml:214 +#: ../qml/components/workflow/AttachmentManager.qml:661 +#: ../qml/features/settings/pages/Settings_Accounts.qml:118 +#: ../qml/features/settings/pages/Settings_Accounts.qml:317 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:475 +msgid "Delete" +msgstr "消去" + +#: ../qml/components/feedback/NotificationBell.qml:661 +#: ../qml/components/selectors/WorkItemSelector.qml:127 +#: ../qml/features/activities/pages/Activities.qml:591 +#: ../qml/features/dashboard/pages/Dashboard.qml:217 +#: ../qml/features/dashboard/pages/Dashboard3.qml:32 +#: ../qml/features/tasks/pages/Task_Page.qml:404 +#: ../qml/features/tasks/pages/Tasks.qml:47 +msgid "Task" +msgstr "タスク" + +#: ../qml/components/feedback/NotificationBell.qml:662 +#: ../qml/features/activities/pages/Activities.qml:20 +#: ../qml/features/dashboard/pages/Dashboard.qml:223 +msgid "Activity" +msgstr "活動" + +#: ../qml/components/feedback/NotificationBell.qml:663 +#: ../qml/components/selectors/WorkItemSelector.qml:125 +#: ../qml/features/activities/pages/Activities.qml:558 +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:611 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:96 +#: ../qml/features/projects/pages/Projects.qml:44 +msgid "Project" +msgstr "プロジェクト" + +#: ../qml/components/feedback/NotificationBell.qml:664 +#: ../qml/components/feedback/NotificationBell.qml:667 +#: ../qml/features/activities/pages/Activities.qml:630 +msgid "Update" +msgstr "アップデート" + +#: ../qml/components/feedback/NotificationBell.qml:665 +#: ../qml/features/dashboard/pages/Dashboard.qml:220 +#: ../qml/features/timesheets/pages/Timesheet.qml:46 +msgid "Timesheet" +msgstr "タイムシート" + +#: ../qml/components/feedback/NotificationBell.qml:666 +msgid "Sync Error" +msgstr "同期エラー" + +#: ../qml/components/feedback/NotificationBell.qml:739 +msgid "Clear Sync Errors" +msgstr "同期エラーをクリアする" + +#: ../qml/components/feedback/NotificationBell.qml:767 +#: ../qml/components/selectors/MultiAssigneeSelector.qml:423 +msgid "Clear All" +msgstr "すべてクリア" + +#: ../qml/components/navigation/AccountDrawer.qml:29 +#: ../qml/components/navigation/AccountDrawer.qml:62 +msgid "Account Selection" +msgstr "アカウントの選択" + +#: ../qml/components/navigation/AccountDrawer.qml:73 +#: ../qml/components/workflow/CreateUpdatePage.qml:138 +#: ../qml/features/activities/pages/Activities.qml:108 +#: ../qml/features/projects/pages/Projects.qml:143 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:431 +#: ../qml/features/tasks/pages/Tasks.qml:86 +#: ../qml/features/updates/pages/Updates.qml:138 +msgid "Close" +msgstr "近い" + +#: ../qml/components/navigation/AccountDrawer.qml:90 +msgid "Select Account:" +msgstr "アカウントを選択してください:" + +#: ../qml/components/navigation/AccountDrawer.qml:159 +msgid "Current Account:" +msgstr "現在の口座:" + +#: ../qml/components/navigation/AccountDrawer.qml:166 +msgid "No account selected" +msgstr "アカウントが選択されていません" + +#: ../qml/components/navigation/AccountDrawer.qml:184 +msgid "Last Sync:" +msgstr "最終同期:" + +#: ../qml/components/navigation/AccountDrawer.qml:191 +#: ../qml/components/navigation/AccountDrawer.qml:427 +msgid "Never" +msgstr "一度もない" + +#: ../qml/components/navigation/AccountDrawer.qml:213 +msgid "Sync Data" +msgstr "データを同期する" + +#: ../qml/components/navigation/AccountDrawer.qml:213 +msgid "Syncing..." +msgstr "同期中..." + +#: ../qml/components/navigation/AccountDrawer.qml:277 +msgid "Note: Sync is not available for 'All Accounts' view" +msgstr "注: 「すべてのアカウント」ビューでは同期は利用できません" + +#: ../qml/components/navigation/AccountDrawer.qml:286 +msgid "Please select a specific account to sync data" +msgstr "データを同期するには特定のアカウントを選択してください" + +#: ../qml/components/navigation/AccountDrawer.qml:307 +msgid "Syncing data..." +msgstr "データを同期中..." + +#: ../qml/components/navigation/AccountDrawer.qml:430 +msgid "N/A for All Accounts" +msgstr "すべてのアカウントに該当なし" + +#: ../qml/components/navigation/AccountDrawer.qml:453 +msgid "Account not found" +msgstr "アカウントが見つかりません" + +#: ../qml/components/navigation/AccountDrawer.qml:461 +#: ../qml/components/navigation/AccountDrawer.qml:463 +msgid "Local account - no sync required" +msgstr "ローカルアカウント - 同期は必要ありません" + +#: ../qml/components/navigation/AccountDrawer.qml:463 +msgid "Info" +msgstr "情報" + +#: ../qml/components/navigation/AccountDrawer.qml:473 +msgid "Connecting to server..." +msgstr "サーバーに接続しています..." + +#: ../qml/components/navigation/AccountDrawer.qml:473 +msgid "Syncing activities..." +msgstr "アクティビティを同期しています..." + +#: ../qml/components/navigation/AccountDrawer.qml:473 +msgid "Syncing projects..." +msgstr "プロジェクトを同期中..." + +#: ../qml/components/navigation/AccountDrawer.qml:473 +msgid "Syncing tasks..." +msgstr "タスクを同期中..." + +#: ../qml/components/navigation/AccountDrawer.qml:473 +msgid "Syncing timesheets..." +msgstr "タイムシートを同期しています..." + +#: ../qml/components/navigation/AccountDrawer.qml:473 +msgid "Updating local data..." +msgstr "ローカル データを更新しています..." + +#: ../qml/components/navigation/AccountDrawer.qml:482 +#: ../qml/components/navigation/AccountDrawer.qml:485 +msgid "Sync failed: " +msgstr "同期に失敗しました:" + +#: ../qml/components/navigation/AccountDrawer.qml:485 +#: ../qml/components/richtext/ReadMorePage.qml:114 +#: ../qml/components/richtext/RichTextEditor.qml:193 +#: ../qml/components/richtext/RichTextPreview.qml:165 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:484 +msgid "Error" +msgstr "エラー" + +#: ../qml/components/navigation/AccountDrawer.qml:501 +msgid "Sync completed successfully" +msgstr "同期が正常に完了しました" + +#: ../qml/components/navigation/AccountDrawer.qml:505 +msgid "Data synchronized successfully!" +msgstr "データは正常に同期されました。" + +#: ../qml/components/navigation/AccountDrawer.qml:505 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:600 +msgid "Success" +msgstr "成功" + +#: ../qml/components/navigation/ListHeader.qml:177 +msgid "Search..." +msgstr "検索..." + +#: ../qml/components/pickers/CustomDatePicker.qml:111 +#: ../qml/components/pickers/DateRangeSelector.qml:88 +msgid "Next Week" +msgstr "来週" + +#: ../qml/components/pickers/CustomDatePicker.qml:118 +#: ../qml/components/pickers/DateRangeSelector.qml:90 +msgid "Next Month" +msgstr "来月" + +#: ../qml/components/pickers/CustomDatePicker.qml:125 +msgid "Custom" +msgstr "カスタム" + +#: ../qml/components/pickers/DateRangeSelector.qml:86 +#: ../qml/features/activities/pages/Activity_Page.qml:617 +#: ../qml/features/tasks/pages/Task_Page.qml:259 +msgid "Today" +msgstr "今日" + +#: ../qml/components/pickers/DateRangeSelector.qml:87 +#: ../qml/features/activities/pages/Activity_Page.qml:618 +#: ../qml/features/tasks/pages/Task_Page.qml:260 +msgid "This Week" +msgstr "今週" + +#: ../qml/components/pickers/DateRangeSelector.qml:89 +#: ../qml/features/activities/pages/Activity_Page.qml:619 +#: ../qml/features/tasks/pages/Task_Page.qml:261 +msgid "This Month" +msgstr "今月" + +#: ../qml/components/richtext/ColorPickerDialog.qml:30 +msgid "Choose Color" +msgstr "色の選択" + +#: ../qml/components/richtext/FontSizeDialog.qml:25 +msgid "Font Size" +msgstr "フォントサイズ" + +#: ../qml/components/richtext/LinkDialog.qml:25 +msgid "Insert Link" +msgstr "リンクを挿入" + +#: ../qml/components/richtext/LinkDialog.qml:39 +msgid "URL (e.g., https://example.com)" +msgstr "URL (例: https://example.com))" + +#: ../qml/components/richtext/LinkDialog.qml:48 +msgid "Link text (optional)" +msgstr "リンクテキスト (オプション)" + +#: ../qml/components/richtext/LinkDialog.qml:59 +msgid "Insert" +msgstr "入れる" + +#: ../qml/components/richtext/ReadMorePage.qml:61 +#: ../qml/components/richtext/RichTextPreview.qml:88 +msgid "Listening..." +msgstr "リスニング..." + +#: ../qml/components/richtext/ReadMorePage.qml:112 +#: ../qml/components/richtext/RichTextEditor.qml:191 +#: ../qml/components/richtext/RichTextPreview.qml:163 +msgid "Action Required" +msgstr "必要なアクション" + +#: ../qml/components/richtext/ReadMorePage.qml:161 +msgid "Start Recording" +msgstr "録音を開始する" + +#: ../qml/components/richtext/ReadMorePage.qml:161 +msgid "Stop Recording" +msgstr "録音を停止する" + +#: ../qml/components/richtext/ReadMorePage.qml:166 +#: ../qml/components/richtext/RichTextPreview.qml:484 +#: ../qml/components/richtext/RichTextPreview.qml:550 +#: ../qml/components/workflow/AttachmentManager.qml:309 +msgid "Processing..." +msgstr "処理..." + +#: ../qml/components/richtext/ReadMorePage.qml:175 +#: ../qml/components/richtext/RichTextPreview.qml:495 +msgid "Starting..." +msgstr "起動..." + +#: ../qml/components/richtext/ReadMorePage.qml:185 +msgid "Hide Toolbar" +msgstr "ツールバーを隠す" + +#: ../qml/components/richtext/ReadMorePage.qml:185 +msgid "Show Toolbar" +msgstr "ツールバーを表示" + +#: ../qml/components/selectors/AccountSelector.qml:106 +msgid "Select an account" +msgstr "アカウントを選択してください" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:340 +msgid "Filter by Assignees" +msgstr "担当者によるフィルター" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:367 +msgid "Search assignees..." +msgstr "担当者を検索..." + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:455 +msgid "Others" +msgstr "その他" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:455 +msgid "Selected" +msgstr "選択済み" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:619 +msgid "Less" +msgstr "少ない" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:619 +msgid "More" +msgstr "もっと" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:680 +msgid "Apply Filter" +msgstr "フィルターを適用する" + +#: ../qml/components/selectors/AssigneeFilterMenu.qml:699 +msgid "Clear Filter" +msgstr "クリアフィルター" + +#: ../qml/components/selectors/InlineOptionSelector.qml:131 +msgid "Tap to select" +msgstr "タップして選択します" + +#: ../qml/components/selectors/MultiAssigneeSelector.qml:39 +msgid "Assignees" +msgstr "譲受人" + +#: ../qml/components/selectors/MultiAssigneeSelector.qml:123 +#: ../qml/components/selectors/MultiAssigneeSelector.qml:296 +msgid "Select Assignees" +msgstr "担当者の選択" + +#: ../qml/components/selectors/MultiAssigneeSelector.qml:436 +#: ../qml/components/system/ImagePreviewer.qml:185 +#: ../qml/features/activities/pages/Activity_Page.qml:623 +#: ../qml/features/tasks/pages/Task_Page.qml:263 +msgid "Done" +msgstr "終わり" + +#: ../qml/components/selectors/ProjectSelector.qml:55 +#: ../qml/features/updates/pages/Updates.qml:375 +msgid "Account:" +msgstr "アカウント:" + +#: ../qml/components/selectors/ProjectSelector.qml:77 +#: ../qml/features/updates/pages/Updates.qml:395 +msgid "Project:" +msgstr "プロジェクト:" + +#: ../qml/components/selectors/ProjectSelector.qml:84 +#: ../qml/components/selectors/ProjectSelector.qml:119 +#: ../qml/components/selectors/ProjectSelector.qml:148 +msgid "Select Project" +msgstr "プロジェクトの選択" + +#: ../qml/components/selectors/ProjectStageSelector.qml:55 +msgid "Change Project Stage" +msgstr "プロジェクトステージの変更" + +#: ../qml/components/selectors/ProjectStageSelector.qml:108 +msgid "Current Stage: " +msgstr "現在の段階:" + +#: ../qml/components/selectors/ProjectStageSelector.qml:115 +msgid "Select New Stage:" +msgstr "新しいステージを選択します:" + +#: ../qml/components/selectors/ProjectStageSelector.qml:173 +msgid "(Folded/Closed Stage)" +msgstr "(折りたたみ/クローズドステージ)" + +#: ../qml/components/selectors/ProjectStageSelector.qml:199 +msgid "No stages available" +msgstr "利用可能なステージはありません" + +#: ../qml/components/selectors/StageFilterMenu.qml:180 +msgid "Filter by Stage" +msgstr "ステージでフィルターする" + +#: ../qml/components/selectors/StageFilterMenu.qml:355 +msgid "Current: " +msgstr "現在:" + +#: ../qml/components/selectors/WorkItemSelector.qml:124 +#: ../qml/features/dashboard/pages/Dashboard.qml:83 +#: ../qml/features/dashboard/pages/Dashboard.qml:484 +msgid "Account" +msgstr "アカウント" + +#: ../qml/components/selectors/WorkItemSelector.qml:126 +msgid "Subproject" +msgstr "サブプロジェクト" + +#: ../qml/components/selectors/WorkItemSelector.qml:128 +msgid "Subtask" +msgstr "サブタスク" + +#: ../qml/components/selectors/WorkItemSelector.qml:129 +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:609 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:94 +msgid "Assignee" +msgstr "譲受人" + +#: ../qml/components/system/ImagePreviewer.qml:79 +msgid "Download" +msgstr "ダウンロード" + +#: ../qml/components/system/ImagePreviewer.qml:131 +msgid "No image to save" +msgstr "保存する画像がありません" + +#: ../qml/components/system/ImagePreviewer.qml:155 +msgid "Saved" +msgstr "保存されました" + +#: ../qml/components/system/ImagePreviewer.qml:158 +msgid "Choose where to save" +msgstr "保存先を選択する" + +#: ../qml/components/system/ImagePreviewer.qml:160 +msgid "Could not open export dialog" +msgstr "エクスポートダイアログを開けませんでした" + +#: ../qml/components/system/ImagePreviewer.qml:164 +msgid "Save failed" +msgstr "保存に失敗しました" + +#: ../qml/components/system/ImagePreviewer.qml:170 +msgid "No image to open" +msgstr "開く画像がありません" + +#: ../qml/components/system/ImagePreviewer.qml:188 +msgid "Could not open chooser" +msgstr "セレクターを開けませんでした" + +#: ../qml/components/system/ImagePreviewer.qml:192 +msgid "Open failed" +msgstr "オープンに失敗しました" + +#: ../qml/components/system/ModelDownloadTimerWidget.qml:51 +msgid "Initializing download..." +msgstr "ダウンロードを初期化しています..." + +#: ../qml/components/system/ModelDownloadTimerWidget.qml:53 +msgid "Downloading model files..." +msgstr "モデル ファイルをダウンロードしています..." + +#: ../qml/components/system/ModelDownloadTimerWidget.qml:55 +msgid "Extracting and installing..." +msgstr "抽出してインストールしています..." + +#: ../qml/components/system/ModelDownloadTimerWidget.qml:57 +msgid "Installation complete!" +msgstr "インストール完了!" + +#: ../qml/components/visualization/EHower.qml:101 +msgid "Time spent based on priorities" +msgstr "優先順位に基づいて費やす時間" + +#: ../qml/components/visualization/EHower.qml:131 +msgid "URGENT" +msgstr "緊急" + +#: ../qml/components/visualization/EHower.qml:141 +msgid "NOT URGENT" +msgstr "緊急ではありません" + +#: ../qml/components/visualization/EHower.qml:165 +msgid "IMPORTANT" +msgstr "重要" + +#: ../qml/components/visualization/EHower.qml:182 +msgid "NOT IMPORTANT" +msgstr "重要ではない" + +#: ../qml/components/visualization/EHower.qml:244 +msgid "Do First" +msgstr "まずやること" + +#: ../qml/components/visualization/EHower.qml:301 +msgid "Do Next" +msgstr "次の作業を行う" + +#: ../qml/components/visualization/EHower.qml:359 +msgid "Do Later" +msgstr "後で行う" + +#: ../qml/components/visualization/EHower.qml:416 +msgid "Don't do" +msgstr "しないでください" + +#: ../qml/components/visualization/ProjectList.qml:726 +msgid "Search projects" +msgstr "プロジェクトを検索する" + +#: ../qml/components/visualization/ProjectPieChart.qml:45 +#: ../qml/components/visualization/ProjectPieChart.qml:208 +msgid "Most Time-Consuming Projects" +msgstr "最も時間のかかるプロジェクト" + +#: ../qml/components/visualization/ProjectPieChart.qml:84 +#: ../qml/features/dashboard/charts/Charts1.qml:67 +#: ../qml/features/dashboard/charts/Charts2.qml:69 +#: ../qml/features/dashboard/charts/Charts3.qml:166 +#: ../qml/features/dashboard/pages/Dashboard3.qml:63 +msgid " hrs" +msgstr "時間" + +#: ../qml/components/visualization/ProjectPieChart.qml:205 +msgid "Most Time-Consuming Projects(No Data)" +msgstr "最も時間のかかるプロジェクト(データなし)" + +#: ../qml/components/workflow/AttachmentManager.qml:33 +msgid "Attachments" +msgstr "添付ファイル" + +#: ../qml/components/workflow/AttachmentManager.qml:53 +msgid "Starting upload..." +msgstr "アップロードを開始しています..." + +#: ../qml/components/workflow/AttachmentManager.qml:224 +#: ../qml/features/projects/pages/Projects.qml:759 +#: ../qml/features/projects/pages/Projects.qml:820 +#: ../qml/features/projects/pages/Projects.qml:882 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:106 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:145 +msgid "View" +msgstr "ビュー" + +#: ../qml/components/workflow/AttachmentManager.qml:367 +msgid "Failed to upload: Database path unresolved" +msgstr "アップロードに失敗しました: データベース パスが解決されていません" + +#: ../qml/components/workflow/AttachmentManager.qml:457 +msgid "Attachment has been processed" +msgstr "添付ファイルが処理されました" + +#: ../qml/components/workflow/AttachmentManager.qml:460 +#: ../qml/components/workflow/AttachmentManager.qml:462 +msgid "Failed to upload" +msgstr "アップロードに失敗しました" + +#: ../qml/components/workflow/AttachmentManager.qml:635 +msgid "Cannot delete: missing Odoo ID" +msgstr "削除できません: Odoo ID がありません" + +#: ../qml/components/workflow/AttachmentManager.qml:652 +msgid "Delete Attachment" +msgstr "添付ファイルの削除" + +#: ../qml/components/workflow/AttachmentManager.qml:653 +msgid "Are you sure you want to delete '%1'?" +msgstr "「%1」を削除してもよろしいですか?" + +#: ../qml/components/workflow/AttachmentManager.qml:673 +msgid "Deleting attachment..." +msgstr "添付ファイルを削除しています..." + +#: ../qml/components/workflow/AttachmentManager.qml:678 +msgid "Database not found" +msgstr "データベースが見つかりません" + +#: ../qml/components/workflow/AttachmentManager.qml:685 +msgid "Attachment deleted" +msgstr "添付ファイルが削除されました" + +#: ../qml/components/workflow/AttachmentManager.qml:688 +msgid "Failed to delete attachment" +msgstr "添付ファイルの削除に失敗しました" + +#: ../qml/components/workflow/CreateUpdatePage.qml:63 +msgid "Draft Recovered" +msgstr "草案が回収されました" + +#: ../qml/components/workflow/CreateUpdatePage.qml:64 +msgid "Your unsaved changes have been recovered." +msgstr "未保存の変更が復元されました。" + +#: ../qml/components/workflow/CreateUpdatePage.qml:88 +#: ../qml/features/activities/pages/Activities.qml:122 +#: ../qml/features/projects/pages/Projects.qml:58 +#: ../qml/features/tasks/pages/Tasks.qml:60 +#: ../qml/features/updates/pages/Updates.qml:151 +msgid "Back" +msgstr "戻る" + +#: ../qml/components/workflow/CreateUpdatePage.qml:103 +#: ../qml/features/projects/pages/Projects.qml:733 +#: ../qml/features/projects/pages/Projects.qml:792 +#: ../qml/features/projects/pages/Projects.qml:853 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:91 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:130 +#: ../qml/features/tasks/pages/MyTasksPage.qml:421 +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:359 +msgid "Create" +msgstr "作成する" + +#: ../qml/components/workflow/CreateUpdatePage.qml:107 +msgid "Validation Error" +msgstr "検証エラー" + +#: ../qml/components/workflow/CreateUpdatePage.qml:108 +msgid "Please fill in all required fields." +msgstr "すべての必須フィールドに入力してください。" + +#: ../qml/components/workflow/CreateUpdatePage.qml:155 +msgid "You have unsaved changes. What would you like to do?" +msgstr "未保存の変更があります。何をしたいですか?" + +#: ../qml/components/workflow/CreateUpdatePage.qml:158 +msgid "Save Draft" +msgstr "ドラフトの保存" + +#: ../qml/components/workflow/CreateUpdatePage.qml:241 +msgid "Enter update title..." +msgstr "アップデートのタイトルを入力してください..." + +#: ../qml/components/workflow/CreateUpdatePage.qml:261 +#: ../qml/features/updates/pages/Updates.qml:461 +msgid "Project Status" +msgstr "プロジェクトのステータス" + +#: ../qml/components/workflow/CreateUpdatePage.qml:287 +#: ../qml/features/updates/pages/Updates.qml:494 +msgid "Progress" +msgstr "進捗" + +#: ../qml/features/activities/pages/Activities.qml:516 +msgid "View Task" +msgstr "タスクの表示" + +#: ../qml/features/activities/pages/Activities.qml:518 +msgid "View Project" +msgstr "プロジェクトを見る" + +#: ../qml/features/activities/pages/Activities.qml:520 +msgid "View Update" +msgstr "アップデートを見る" + +#: ../qml/features/activities/pages/Activities.qml:543 +msgid "Connected to" +msgstr "に接続されています" + +#: ../qml/features/activities/pages/Activities.qml:656 +msgid "Other" +msgstr "他の" + +#: ../qml/features/activities/pages/Activities.qml:697 +msgid "Summary" +msgstr "まとめ" + +#: ../qml/features/activities/pages/Activities.qml:798 +msgid "Activity Type" +msgstr "アクティビティの種類" + +#: ../qml/features/activities/pages/Activity_Page.qml:41 +#: ../qml/features/projects/pages/Projects.qml:715 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:72 +msgid "Activities" +msgstr "活動内容" + +#: ../qml/features/activities/pages/Activity_Page.qml:620 +#: ../qml/features/tasks/pages/Task_Page.qml:262 +msgid "Later" +msgstr "後で" + +#: ../qml/features/activities/pages/Activity_Page.qml:621 +msgid "OverDue" +msgstr "期限超過" + +#: ../qml/features/activities/pages/Activity_Page.qml:622 +#: ../qml/features/tasks/pages/Task_Page.qml:264 +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:165 +#: ../qml/features/updates/pages/Updates_Page.qml:269 +msgid "All" +msgstr "全て" + +#: ../qml/features/activities/pages/Activity_Page.qml:754 +msgid "No Activities Available" +msgstr "利用可能なアクティビティはありません" + +#: ../qml/features/activities/pages/Activity_Page.qml:866 +msgid "Loading activities..." +msgstr "アクティビティを読み込んでいます..." + +#: ../qml/features/dashboard/charts/Charts3.qml:138 +msgid "Projectwise Time Spent" +msgstr "プロジェクトごとに費やした時間" + +#: ../qml/features/dashboard/charts/Charts3.qml:222 +msgid "Show fewer ↑" +msgstr "表示を少なくする ↑" + +#: ../qml/features/dashboard/charts/Charts3.qml:247 +msgid "Show next %1 ↓" +msgstr "次の%1を表示 ↓" + +#: ../qml/features/dashboard/charts/Charts4.qml:32 +msgid "Unnamed project" +msgstr "名前のないプロジェクト" + +#: ../qml/features/dashboard/charts/Charts4.qml:60 +msgid "Unnamed task" +msgstr "名前のないタスク" + +#: ../qml/features/dashboard/charts/Charts4.qml:64 +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:610 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:95 +msgid "Unknown" +msgstr "未知" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:166 +#: ../qml/features/dashboard/pages/Dashboard.qml:348 +#: ../qml/features/projects/pages/Project_Page.qml:43 +msgid "Projects" +msgstr "プロジェクト" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:248 +msgid "Search projects..." +msgstr "プロジェクトを検索..." + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:276 +msgid "Most time" +msgstr "ほとんどの時間" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:277 +#: ../qml/features/dashboard/pages/Dashboard.qml:360 +#: ../qml/features/projects/pages/Projects.qml:774 +msgid "Tasks" +msgstr "タスク" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:278 +msgid "A-Z" +msgstr "A-Z" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:329 +msgid "No projects found" +msgstr "プロジェクトが見つかりませんでした" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:390 +msgid "TOTAL" +msgstr "合計" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:391 +msgid "TASKS" +msgstr "タスク" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:392 +msgid "AVERAGE" +msgstr "平均" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:393 +msgid "TOP TASK" +msgstr "トップタスク" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:472 +msgid "Show all %1 tasks ↓" +msgstr "すべての%1タスクを表示 ↓" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:488 +msgid "No tasks with tracked time" +msgstr "時間を追跡できるタスクはありません" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:549 +msgid "TIME SPENT" +msgstr "費やした時間" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:575 +msgid "% OF PROJECT" +msgstr "プロジェクトの割合" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:610 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:95 +msgid "Status" +msgstr "状態" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:612 +msgid "Log entries" +msgstr "ログエントリ" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:646 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:129 +msgid "Time log" +msgstr "タイムログ" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:711 +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:159 +msgid "No note" +msgstr "メモなし" + +#: ../qml/features/dashboard/charts/TaskTimeChart.qml:731 +msgid "No log entries" +msgstr "ログエントリがありません" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:7 +msgid "Dashboard Charts Guide" +msgstr "ダッシュボード チャート ガイド" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:24 +msgid "" +"Welcome to the Dashboard! Here is a quick guide to help you understand your " +"data:" +msgstr "ダッシュボードへようこそ!データを理解するのに役立つクイックガイドは次のとおりです。" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:33 +msgid "Priority Matrix (Chart 1 & 2)" +msgstr "優先順位マトリックス (図 1 および 2)" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:40 +msgid "" +"These charts give you an overview of where your time goes. Check if you are " +"spending time on high-priority tasks or getting bogged down by low-priority " +"ones." +msgstr "" +"これらのグラフは、時間の経過の概要を示します。優先順位の高いタスクに時間を費やしていないか、優先順位の低いタスクに行き詰まっていないか確認してください。" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:49 +msgid "Projectwise Time Spent (Chart 3)" +msgstr "プロジェクトごとの費やした時間 (図 3)" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:56 +msgid "" +"This horizontal bar chart compares the total hours spent across different " +"projects. You can incrementally load more projects using the buttons at the " +"bottom. Hover over any bar to see the exact hours." +msgstr "" +"この横棒グラフは、さまざまなプロジェクトに費やされた合計時間を比較します。下部のボタンを使用して、さらに多くのプロジェクトを段階的に読み込むことができます。バーの上にマウスを置くと、正確な時間が表示されます。" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:65 +msgid "Task Drilldown (Chart 4)" +msgstr "タスクのドリルダウン (図 4)" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:72 +msgid "" +"Tap on any project to drill down into its tasks. You can further tap on a " +"task to view the detailed timesheet logs. This is highly useful for auditing" +" exactly where your time was spent." +msgstr "" +"任意のプロジェクトをタップして、そのタスクをドリルダウンします。さらにタスクをタップすると、詳細なタイムシート " +"ログを表示できます。これは、どこに時間が費やされたかを正確に監査するのに非常に役立ちます。" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:81 +msgid "What Do The Colors Mean?" +msgstr "色の意味は何ですか?" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:88 +msgid "" +"Projects and tasks are color-coded based on their current stage to give you " +"immediate visual feedback:" +msgstr "プロジェクトとタスクは現在の段階に基づいて色分けされており、視覚的なフィードバックがすぐに得られます。" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:95 +msgid "Green: Done / Completed" +msgstr "緑: 完了/完了" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:100 +msgid "Red: Cancelled" +msgstr "赤:キャンセル済み" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:105 +msgid "Orange: On Hold / Paused" +msgstr "オレンジ色: 保留中/一時停止中" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:110 +msgid "Blue / Other: In Progress" +msgstr "青/その他: 進行中" + +#: ../qml/features/dashboard/components/ChartInfoPopup.qml:118 +msgid "Got it!" +msgstr "わかった!" + +#: ../qml/features/dashboard/components/ProjectCard.qml:77 +msgid "tasks" +msgstr "タスク" + +#: ../qml/features/dashboard/pages/Dashboard.qml:39 +msgid "Time Manager - Time Management Dashboard" +msgstr "タイム マネージャー - 時間管理ダッシュボード" + +#: ../qml/features/dashboard/pages/Dashboard.qml:43 +#: ../qml/features/dashboard/pages/Dashboard.qml:169 +msgid "Loading dashboard..." +msgstr "ダッシュボードを読み込んでいます..." + +#: ../qml/features/dashboard/pages/Dashboard.qml:107 +#: ../qml/features/dashboard/pages/Dashboard2.qml:46 +msgid "Chart Info" +msgstr "チャート情報" + +#: ../qml/features/dashboard/pages/Dashboard.qml:129 +#: ../qml/features/dashboard/pages/Dashboard.qml:454 +msgid "New Timesheet" +msgstr "新しいタイムシート" + +#: ../qml/features/dashboard/pages/Dashboard.qml:157 +msgid "Preparing all-account dashboard..." +msgstr "全アカウントのダッシュボードを準備しています..." + +#: ../qml/features/dashboard/pages/Dashboard.qml:158 +msgid "Preparing dashboard..." +msgstr "ダッシュボードを準備しています..." + +#: ../qml/features/dashboard/pages/Dashboard.qml:181 +msgid "Loading project chart..." +msgstr "プロジェクト チャートを読み込んでいます..." + +#: ../qml/features/dashboard/pages/Dashboard.qml:191 +msgid "Loading additional charts..." +msgstr "追加のチャートを読み込んでいます..." + +#: ../qml/features/dashboard/pages/Dashboard.qml:336 +msgid "Overview" +msgstr "概要" + +#: ../qml/features/dashboard/pages/Dashboard.qml:502 +msgid "New Notifications" +msgstr "新しい通知" + +#: ../qml/features/dashboard/pages/Dashboard.qml:503 +msgid "You have %1 new notification(s)" +msgstr "%1 件の新しい通知があります" + +#: ../qml/features/dashboard/pages/Dashboard2.qml:31 +msgid "Charts" +msgstr "チャート" + +#: ../qml/features/dashboard/pages/Dashboard3.qml:45 +msgid "Taskwise Time Spent" +msgstr "タスクごとに費やした時間" + +#: ../qml/features/dashboard/pages/Dashboard3.qml:112 +msgid "Time" +msgstr "時間" + +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:42 +msgid "Time spent" +msgstr "費やした時間" + +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:46 +msgid "% of project" +msgstr "プロジェクトの割合" + +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:49 +msgid "0.0%" +msgstr "0.0%" + +#: ../qml/features/dashboard/pages/TaskDetailPage.qml:97 +msgid "Log entries count" +msgstr "ログエントリ数" + +#: ../qml/features/projects/pages/Project_Page.qml:68 +#: ../qml/features/updates/pages/Updates_Page.qml:88 +msgid "New" +msgstr "新しい" + +#: ../qml/features/projects/pages/Project_Page.qml:78 +#: ../qml/features/tasks/pages/MyTasksPage.qml:83 +#: ../qml/features/tasks/pages/Task_Page.qml:87 +msgid "Flat View" +msgstr "フラットビュー" + +#: ../qml/features/projects/pages/Project_Page.qml:78 +#: ../qml/features/tasks/pages/MyTasksPage.qml:83 +#: ../qml/features/tasks/pages/Task_Page.qml:87 +msgid "Tree View" +msgstr "ツリービュー" + +#: ../qml/features/projects/pages/Project_Page.qml:85 +#: ../qml/features/updates/pages/Updates_Page.qml:81 +msgid "Search" +msgstr "検索" + +#: ../qml/features/projects/pages/Project_Page.qml:137 +msgid "Loading projects..." +msgstr "プロジェクトを読み込んでいます..." + +#: ../qml/features/projects/pages/Projects.qml:585 +msgid "Project Name" +msgstr "プロジェクト名" + +#: ../qml/features/projects/pages/Projects.qml:648 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:27 +#: ../qml/features/tasks/components/TaskStagesDisplayGrid.qml:31 +msgid "Current Stage:" +msgstr "現在の段階:" + +#: ../qml/features/projects/pages/Projects.qml:688 +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:67 +msgid "Change" +msgstr "変化" + +#: ../qml/features/projects/pages/Projects.qml:835 +#: ../qml/features/updates/pages/Updates_Page.qml:39 +msgid "Project Updates" +msgstr "プロジェクトの更新" + +#: ../qml/features/projects/pages/Projects.qml:903 +msgid "Allocated Hours" +msgstr "割り当て時間" + +#: ../qml/features/projects/pages/Projects.qml:976 +msgid "Planned Dates" +msgstr "予定日" + +#: ../qml/features/settings/pages/Account_Page.qml:37 +msgid "Create Account" +msgstr "アカウントを作成する" + +#: ../qml/features/settings/pages/Account_Page.qml:37 +msgid "Edit Account" +msgstr "アカウントの編集" + +#: ../qml/features/settings/pages/Account_Page.qml:141 +msgid "Your account has been updated successfully!" +msgstr "アカウントは正常に更新されました。" + +#: ../qml/features/settings/pages/Account_Page.qml:169 +msgid "Your account has been saved, Enjoy using the app !" +msgstr "アカウントは保存されました。アプリをお楽しみください。" + +#: ../qml/features/settings/pages/Account_Page.qml:379 +msgid "Account Details" +msgstr "アカウントの詳細" + +#: ../qml/features/settings/pages/Account_Page.qml:389 +msgid "Account Name" +msgstr "アカウント名" + +#: ../qml/features/settings/pages/Account_Page.qml:413 +msgid "Server Connection" +msgstr "サーバー接続" + +#: ../qml/features/settings/pages/Account_Page.qml:423 +msgid "URL" +msgstr "URL" + +#: ../qml/features/settings/pages/Account_Page.qml:430 +msgid "Fetch Databases" +msgstr "データベースのフェッチ" + +#: ../qml/features/settings/pages/Account_Page.qml:476 +msgid "Database" +msgstr "データベース" + +#: ../qml/features/settings/pages/Account_Page.qml:486 +msgid "Database Name" +msgstr "データベース名" + +#: ../qml/features/settings/pages/Account_Page.qml:510 +msgid "Credentials" +msgstr "資格" + +#: ../qml/features/settings/pages/Account_Page.qml:520 +msgid "Username" +msgstr "ユーザー名" + +#: ../qml/features/settings/pages/Account_Page.qml:526 +msgid "Connect With" +msgstr "接続する" + +#: ../qml/features/settings/pages/Account_Page.qml:529 +msgid "Connect With Api Key" +msgstr "APIキーで接続する" + +#: ../qml/features/settings/pages/Account_Page.qml:530 +msgid "Connect With Password" +msgstr "パスワードで接続する" + +#: ../qml/features/settings/pages/Account_Page.qml:543 +msgid "API Key" +msgstr "APIキー" + +#: ../qml/features/settings/pages/Account_Page.qml:543 +msgid "Password" +msgstr "パスワード" + +#: ../qml/features/settings/pages/Account_Page.qml:583 +msgid "Sync Preferences" +msgstr "同期設定" + +#: ../qml/features/settings/pages/Account_Page.qml:593 +msgid "Custom Sync Settings" +msgstr "カスタム同期設定" + +#: ../qml/features/settings/pages/Account_Page.qml:612 +msgid "Using global sync defaults (interval: " +msgstr "グローバル同期のデフォルトを使用する (間隔:" + +#: ../qml/features/settings/pages/Account_Page.qml:614 +msgid ", direction: " +msgstr "、 方向:" + +#: ../qml/features/settings/pages/Account_Page.qml:627 +msgid "Enable Sync" +msgstr "同期を有効にする" + +#: ../qml/features/settings/pages/Account_Page.qml:645 +#: ../qml/features/settings/pages/Settings_Sync.qml:176 +msgid "Sync Interval" +msgstr "同期間隔" + +#: ../qml/features/settings/pages/Account_Page.qml:648 +msgid "1 minute" +msgstr "1分" + +#: ../qml/features/settings/pages/Account_Page.qml:649 +msgid "5 minutes" +msgstr "5分" + +#: ../qml/features/settings/pages/Account_Page.qml:650 +msgid "15 minutes" +msgstr "15分" + +#: ../qml/features/settings/pages/Account_Page.qml:651 +msgid "30 minutes" +msgstr "30分" + +#: ../qml/features/settings/pages/Account_Page.qml:652 +msgid "1 hour" +msgstr "1時間" + +#: ../qml/features/settings/pages/Account_Page.qml:653 +msgid "2 hours" +msgstr "2時間" + +#: ../qml/features/settings/pages/Account_Page.qml:654 +msgid "6 hours" +msgstr "6時間" + +#: ../qml/features/settings/pages/Account_Page.qml:655 +msgid "12 hours" +msgstr "12時間" + +#: ../qml/features/settings/pages/Account_Page.qml:656 +msgid "1 day" +msgstr "1日" + +#: ../qml/features/settings/pages/Account_Page.qml:657 +msgid "3 days" +msgstr "3日間" + +#: ../qml/features/settings/pages/Account_Page.qml:658 +msgid "1 week" +msgstr "1週間" + +#: ../qml/features/settings/pages/Account_Page.qml:669 +#: ../qml/features/settings/pages/Settings_Sync.qml:218 +msgid "Sync Direction" +msgstr "同期方向" + +#: ../qml/features/settings/pages/Account_Page.qml:672 +msgid "Both (Up & Down)" +msgstr "両方(上と下)" + +#: ../qml/features/settings/pages/Account_Page.qml:673 +msgid "Download Only" +msgstr "ダウンロードのみ" + +#: ../qml/features/settings/pages/Account_Page.qml:674 +msgid "Upload Only" +msgstr "アップロードのみ" + +#: ../qml/features/settings/pages/Settings_Accounts.qml:39 +#: ../qml/features/settings/pages/Settings_Page.qml:69 +msgid "Connected Accounts" +msgstr "接続されたアカウント" + +#: ../qml/features/settings/pages/Settings_Accounts.qml:101 +msgid "Delete Account" +msgstr "アカウントの削除" + +#: ../qml/features/settings/pages/Settings_Accounts.qml:104 +msgid "" +"Are you sure you want to delete this account? This will permanently remove " +"the account and all associated data including projects, tasks, and " +"timesheets." +msgstr "" +"このアカウントを削除してもよろしいですか?これにより、アカウントと、プロジェクト、タスク、タイムシートを含むすべての関連データが完全に削除されます。" + +#: ../qml/features/settings/pages/Settings_Accounts.qml:307 +msgid "Log" +msgstr "ログ" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:191 +msgid "Push Notifications" +msgstr "プッシュ通知" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:199 +msgid "Receive push notifications for task updates and reminders" +msgstr "タスクの更新とリマインダーのプッシュ通知を受け取る" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:216 +msgid "Enable Notifications" +msgstr "通知を有効にする" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:238 +msgid "" +"⚠ Notifications are disabled when Background Sync is set to Upload Only" +msgstr "⚠ バックグラウンド同期がアップロードのみに設定されている場合、通知は無効になります" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:272 +msgid "Notification Schedule" +msgstr "通知スケジュール" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:280 +msgid "" +"Set your timezone, working days and active hours to receive notifications " +"only during work time" +msgstr "勤務時間中にのみ通知を受け取るようにタイムゾーン、勤務日、アクティブ時間を設定します。" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:296 +msgid "Enable Schedule" +msgstr "スケジュールを有効にする" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:325 +msgid "Timezone" +msgstr "タイムゾーン" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:388 +msgid "Working Days" +msgstr "営業日" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:397 +msgid "Notifications will only be sent on selected days" +msgstr "通知は選択した日にのみ送信されます" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:485 +msgid "Working Hours (notifications allowed)" +msgstr "勤務時間(通知可能)" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:501 +msgid "From" +msgstr "から" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:539 +msgid "To" +msgstr "に" + +#: ../qml/features/settings/pages/Settings_Notifications.qml:579 +msgid "" +"Push notifications will only be sent during working hours on working days. " +"Overnight schedules (e.g., 22:00 to 06:00) are supported." +msgstr "プッシュ通知は営業日の営業時間内にのみ送信されます。夜間のスケジュール (22:00 から 06:00 など) がサポートされています。" + +#: ../qml/features/settings/pages/Settings_Page.qml:35 +msgid "Settings" +msgstr "設定" + +#: ../qml/features/settings/pages/Settings_Page.qml:91 +#: ../qml/features/settings/pages/Settings_Sync.qml:35 +msgid "Background Sync" +msgstr "バックグラウンド同期" + +#: ../qml/features/settings/pages/Settings_Page.qml:102 +#: ../qml/features/settings/pages/Settings_Theme.qml:33 +msgid "Theme Settings" +msgstr "テーマの設定" + +#: ../qml/features/settings/pages/Settings_Page.qml:114 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:36 +msgid "Voice Model (Beta)" +msgstr "音声モデル (ベータ版)" + +#: ../qml/features/settings/pages/Settings_Sync.qml:132 +msgid "Background Sync Settings" +msgstr "バックグラウンド同期設定" + +#: ../qml/features/settings/pages/Settings_Sync.qml:140 +msgid "Configure automatic synchronization with Odoo" +msgstr "Odoo との自動同期を構成する" + +#: ../qml/features/settings/pages/Settings_Sync.qml:156 +msgid "Enable AutoSync" +msgstr "自動同期を有効にする" + +#: ../qml/features/settings/pages/Settings_Sync.qml:261 +msgid "" +"✨ Recommended: Set sync interval to 5 or 15 minutes for the best balance of " +"performance and battery life." +msgstr "✨ 推奨: パフォーマンスとバッテリー寿命の最適なバランスを得るには、同期間隔を 5 分または 15 分に設定します。" + +#: ../qml/features/settings/pages/Settings_Sync.qml:274 +msgid "Restart Background Daemon" +msgstr "バックグラウンドデーモンの再起動" + +#: ../qml/features/settings/pages/Settings_Sync.qml:274 +msgid "Restarting..." +msgstr "再起動中..." + +#: ../qml/features/settings/pages/Settings_Sync.qml:295 +msgid "Daemon Restarted" +msgstr "デーモンが再起動されました" + +#: ../qml/features/settings/pages/Settings_Sync.qml:296 +msgid "" +"The background sync daemon has been successfully restarted and is running." +msgstr "バックグラウンド同期デーモンが正常に再起動され、実行中です。" + +#: ../qml/features/settings/pages/Settings_Sync.qml:301 +msgid "Daemon Restart" +msgstr "デーモンの再起動" + +#: ../qml/features/settings/pages/Settings_Sync.qml:302 +msgid "" +"The daemon restart was initiated. It may take a few moments to fully start. " +"If sync issues persist, try again." +msgstr "" +"デーモンの再起動が開始されました。完全に開始するまでに少し時間がかかる場合があります。同期の問題が解決しない場合は、もう一度試してください。" + +#: ../qml/features/settings/pages/Settings_Sync.qml:311 +msgid "" +"Note: These are global defaults. Individual accounts can override these " +"settings from their account edit page. Changes take effect on the next sync " +"cycle." +msgstr "" +"注: " +"これらはグローバルなデフォルトです。個々のアカウントは、アカウント編集ページからこれらの設定を上書きできます。変更は次の同期サイクルで有効になります。" + +#: ../qml/features/settings/pages/Settings_Theme.qml:106 +msgid "App Theme Preference" +msgstr "アプリのテーマの設定" + +#: ../qml/features/settings/pages/Settings_Theme.qml:114 +msgid "Choose your preferred theme for the application" +msgstr "アプリケーションの好みのテーマを選択してください" + +#: ../qml/features/settings/pages/Settings_Theme.qml:175 +msgid "Light Theme" +msgstr "ライトテーマ" + +#: ../qml/features/settings/pages/Settings_Theme.qml:240 +msgid "Dark Theme" +msgstr "ダークテーマ" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:256 +msgid "Starting download..." +msgstr "ダウンロードを開始しています..." + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:259 +msgid "Download Started" +msgstr "ダウンロード開始" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:259 +msgid "Downloading %1..." +msgstr "%1 をダウンロードしています..." + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:267 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:269 +msgid "Could not start download" +msgstr "ダウンロードを開始できませんでした" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:269 +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:594 +msgid "Download Failed" +msgstr "ダウンロードに失敗しました" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:281 +msgid "Download cancelled" +msgstr "ダウンロードがキャンセルされました" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:283 +msgid "Download Cancelled" +msgstr "ダウンロードがキャンセルされました" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:283 +msgid "Download of %1 was cancelled and partial data deleted." +msgstr "%1 のダウンロードはキャンセルされ、部分的なデータが削除されました。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:348 +msgid "About Voice Models" +msgstr "音声モデルについて" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:363 +msgid "" +"Voice models allow you to dictate text using your microphone directly into the app. Because processing happens locally on your device, your voice data remains completely private and no internet connection is required after the initial model download.\n" +"\n" +"Larger models provide higher accuracy but require more device memory and space. Smaller models are faster and use fewer resources but may be less accurate." +msgstr "" +"音声モデルを使用すると、マイクを使用してアプリに直接テキストを書き込むことができます。処理はデバイス上でローカルに行われるため、音声データは完全にプライベートのままであり、最初のモデルのダウンロード後はインターネット接続は必要ありません。\n" +"\n" +"モデルが大きいほど精度は高くなりますが、より多くのデバイス メモリとスペースが必要になります。モデルが小さいほど高速になり、使用するリソースも少なくなりますが、精度が低くなる可能性があります。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:377 +msgid "" +"Voice Feature Stages: When you click the voice icon, it will show " +"Starting, then Preparing. Only start speaking once it shows " +"Listening. When stopped, it will show Processing with a yellow" +" bar." +msgstr "" +"音声機能ステージ: 音声アイコンをクリックすると、Starting、次に Preparing が表示されます。" +" Listening と表示されてから話し始めてください。停止すると、Processing が黄色のバーで表示されます。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:387 +msgid "" +"Auto-Stop & Limits: If you do not speak for 7 seconds, the voice icon" +" will automatically stop. The maximum duration for a single recording is 5 " +"minutes." +msgstr "自動停止と制限: 7 秒間話さないと、音声アイコンは自動的に停止します。 1 回の録音の最大継続時間は 5 分です。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:397 +msgid "" +"Getting Started: Make sure you have enabled the \"Enable voice " +"input\" feature, under voice model (Beta) settings. " +msgstr "はじめに: 音声モデル (ベータ) 設定で [音声入力を有効にする] 機能が有効になっていることを確認してください。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:407 +msgid "" +"Compatibility & Errors: A red warning icon indicates the model is " +"incompatible with your device (usually due to RAM limits), but you can still" +" attempt to download it." +msgstr "" +"互換性とエラー: 赤い警告アイコンは、モデルがデバイスと互換性がないことを示します (通常は RAM " +"制限が原因です)。ただし、ダウンロードを試行することはできます。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:417 +msgid "" +"Managing Downloads:Check the internet connectivity before downloading" +" a file. Once the voice model is downloaded, select the model you want from " +"the installed models list. Even if only one model is installed, selecting " +"the model is mandatory. The selected model will be shown in bold text, with " +"a tick mark to its right. During download, you will see Loading " +"(downloading), Pause, and Cancel buttons. Pausing or losing " +"internet will preserve your progress, allowing you to resume later from this" +" page. Cancelling will delete the partial download." +msgstr "" +"ダウンロードの管理:ファイルをダウンロードする前にインターネット接続を確認してください。音声モデルをダウンロードしたら、インストールされているモデルのリストから必要なモデルを選択します。インストールされているモデルが" +" 1 " +"つだけの場合でも、モデルの選択は必須です。選択したモデルは太字で表示され、その右側にチェックマークが付きます。ダウンロード中に、Loading" +" (ダウンロード中)、Pause、および Cancel " +"ボタンが表示されます。インターネットを一時停止したり切断したりしても進行状況は保存され、後でこのページから再開できます。キャンセルすると部分ダウンロードが削除されます。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:427 +msgid "" +"Deleting Models: To remove an installed model, swipe its name to the " +"left and click the delete icon." +msgstr "モデルの削除: インストールされているモデルを削除するには、その名前を左にスワイプし、削除アイコンをクリックします。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:451 +msgid "Delete Model" +msgstr "モデルの削除" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:458 +msgid "Are you sure you want to delete the voice model '%1'?" +msgstr "音声モデル「%1」を削除してもよろしいですか?" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:481 +msgid "Deleted" +msgstr "削除されました" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:481 +msgid "Model %1 deleted successfully." +msgstr "モデル %1 は正常に削除されました。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:484 +msgid "Could not delete model" +msgstr "モデルを削除できませんでした" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:501 +msgid "Warning" +msgstr "警告" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:508 +msgid "" +"This model is incompatible with your device because it requires more RAM than available. It may cause the app to crash.\n" +"\n" +"But if you want to download it, you can." +msgstr "" +"このモデルは、利用可能な以上の RAM を必要とするため、お使いのデバイスと互換性がありません。アプリがクラッシュする可能性があります。\n" +"\n" +"ただし、ダウンロードしたい場合はダウンロードできます。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:525 +msgid "Download Anyway" +msgstr "とにかくダウンロード" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:572 +msgid "Downloading..." +msgstr "ダウンロード中..." + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:585 +msgid "Download Interrupted" +msgstr "ダウンロードが中断されました" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:585 +msgid "Failed to download. You can resume it." +msgstr "ダウンロードに失敗しました。再開できます。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:587 +msgid "Download Paused" +msgstr "ダウンロードが一時停止されました" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:587 +msgid "Download of %1 is paused." +msgstr "%1 のダウンロードは一時停止されています。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:600 +msgid "%1 installed successfully!" +msgstr "%1 は正常にインストールされました。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:644 +msgid "Enable Voice Input" +msgstr "音声入力を有効にする" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:667 +msgid "INSTALLED MODELS" +msgstr "搭載モデル" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:765 +msgid "No installed models found." +msgstr "インストールされているモデルが見つかりません。" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:765 +msgid "Scanning for models..." +msgstr "モデルをスキャンしています..." + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:777 +msgid "AVAILABLE FOR DOWNLOAD" +msgstr "ダウンロード可能" + +#: ../qml/features/settings/pages/Settings_VoiceModel.qml:963 +msgid "All available models are installed." +msgstr "利用可能なすべてのモデルがインストールされています。" + +#: ../qml/features/settings/pages/SyncLog.qml:7 +msgid "Sync Log" +msgstr "同期ログ" + +#: ../qml/features/settings/pages/SyncLog.qml:22 +msgid "Copy Logs" +msgstr "ログのコピー" + +#: ../qml/features/settings/pages/SyncLog.qml:147 +msgid "" +"No errors or warnings found for this account.\n" +"Sync logs will appear here when issues occur." +msgstr "" +"このアカウントにはエラーや警告は見つかりませんでした。\n" +"問題が発生すると、同期ログがここに表示されます。" + +#: ../qml/features/tasks/components/MyTasksClosedIndicator.qml:48 +msgid "Showing closed/completed tasks" +msgstr "終了/完了したタスクの表示" + +#: ../qml/features/tasks/components/PersonalStageSelector.qml:59 +#: ../qml/features/tasks/components/TaskStagesDisplayGrid.qml:128 +msgid "Change Personal Stage" +msgstr "個人ステージ変更" + +#: ../qml/features/tasks/components/TaskDetailsCard.qml:243 +#: ../qml/features/tasks/components/TaskDetailsCard.qml:252 +#: ../qml/features/timesheets/components/TimeSheetDetailsCard.qml:148 +#: ../qml/features/timesheets/components/TimeSheetDetailsCard.qml:157 +msgid "update Timesheet" +msgstr "タイムシートを更新する" + +#: ../qml/features/tasks/components/TaskDetailsCard.qml:348 +msgid "Unable to track progress – no planned hours" +msgstr "進捗状況を追跡できません – 計画された時間はありません" + +#: ../qml/features/tasks/components/TaskDetailsCard.qml:359 +msgid "No progress yet" +msgstr "まだ進歩はありません" + +#: ../qml/features/tasks/components/TaskDetailsCard.qml:630 +msgid "N/A" +msgstr "該当なし" + +#: ../qml/features/tasks/components/TaskInitialStageSelector.qml:27 +msgid "Initial Stage" +msgstr "初期" + +#: ../qml/features/tasks/components/TaskNameField.qml:26 +msgid "Name" +msgstr "名前" + +#: ../qml/features/tasks/components/TaskPrioritySelector.qml:28 +#: ../qml/features/timesheets/pages/Timesheet.qml:613 +msgid "Priority" +msgstr "優先度" + +#: ../qml/features/tasks/components/TaskRecordActionsGrid.qml:111 +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:41 +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:69 +msgid "Timesheets" +msgstr "タイムシート" + +#: ../qml/features/tasks/components/TaskScheduleFields.qml:43 +msgid "Planned Hours" +msgstr "予定時間" + +#: ../qml/features/tasks/components/TaskScheduleFields.qml:55 +msgid "e.g., 2:30 or 1.5" +msgstr "例: 2:30 または 1.5" + +#: ../qml/features/tasks/components/TaskScheduleFields.qml:133 +msgid "Deadline" +msgstr "締め切り" + +#: ../qml/features/tasks/components/TaskScheduleFields.qml:148 +msgid "Select" +msgstr "選択" + +#: ../qml/features/tasks/components/TaskScheduleFields.qml:162 +msgid "Select Deadline" +msgstr "期限を選択してください" + +#: ../qml/features/tasks/components/TaskStageSelector.qml:55 +msgid "Change Task Stage" +msgstr "タスクステージの変更" + +#: ../qml/features/tasks/components/TaskStagesDisplayGrid.qml:67 +msgid "Change Stage" +msgstr "チェンジステージ" + +#: ../qml/features/tasks/components/TaskStagesDisplayGrid.qml:87 +msgid "Current Personal Stage:" +msgstr "現在の個人ステージ:" + +#: ../qml/features/tasks/pages/MyTasksPage.qml:47 +msgid "My Tasks" +msgstr "私のタスク" + +#: ../qml/features/tasks/pages/MyTasksPage.qml:93 +msgid "Stage Help" +msgstr "ステージヘルプ" + +#: ../qml/features/tasks/pages/MyTasksPage.qml:96 +msgid "Personal Stages Help" +msgstr "パーソナルステージのヘルプ" + +#: ../qml/features/tasks/pages/MyTasksPage.qml:97 +msgid "" +"If personal stages are not visible, please check the following:

1)" +" Ensure stages exist in CURQ:
Ensure the stages are available in the " +"CURQ instance under 'My Tasks'.

2) Verify the app's Default " +"DB:
In the app, confirm that you are checked in to the correct " +"database as Default, as My Tasks displays tasks based on the selected " +"Default DB." +msgstr "" +"個人ステージが表示されない場合は、次の点を確認してください:

1) CURQ " +"にステージが存在することを確認します:
「マイ タスク」の下の CURQ " +"インスタンスでステージが利用可能であることを確認します。

2) アプリのデフォルト DB " +"を確認します:
アプリで、正しいデータベースにデフォルトとしてチェックインされていることを確認します。 [タスク] " +"には、選択したデフォルト DB に基づいてタスクが表示されます。" + +#: ../qml/features/tasks/pages/MyTasksPage.qml:405 +msgid "No Tasks Assigned to You" +msgstr "あなたに割り当てられたタスクはありません" + +#: ../qml/features/tasks/pages/MyTasksPage.qml:490 +#: ../qml/features/tasks/pages/Task_Page.qml:565 +msgid "Loading tasks..." +msgstr "タスクを読み込んでいます..." + +#: ../qml/features/tasks/pages/Task_Page.qml:43 +msgid "All Tasks" +msgstr "すべてのタスク" + +#: ../qml/features/tasks/pages/Task_Page.qml:388 +msgid "No tasks found." +msgstr "タスクが見つかりません。" + +#: ../qml/features/tasks/pages/Tasks.qml:654 +msgid "Parent Task" +msgstr "親タスク" + +#: ../qml/features/timesheets/components/TimeRecorderWidget.qml:84 +msgid "Time Tracking" +msgstr "時間の追跡" + +#: ../qml/features/timesheets/components/TimeRecorderWidget.qml:99 +msgid "Manual" +msgstr "マニュアル" + +#: ../qml/features/timesheets/components/TimeRecorderWidget.qml:115 +msgid "Automated" +msgstr "自動化" + +#: ../qml/features/timesheets/components/TimeSheetDescriptionPopup.qml:54 +msgid "Add Description to Timesheet" +msgstr "タイムシートに説明を追加" + +#: ../qml/features/timesheets/components/TimeSheetDetailsCard.qml:166 +msgid "Mark Ready for Sync" +msgstr "同期の準備ができたとマークする" + +#: ../qml/features/timesheets/pages/Timesheet.qml:629 +msgid "" +"What Is Important Is Seldom Urgent, and What Is Urgent Is Seldom " +"Important.

1. Important & Urgent:
Here you write down " +"important activities, which also have to be done immediately. These are " +"urgent problems or projects with a hard deadline. I.e. If you manage a " +"restaurant and an employee has not shown up, it is a rather urgent and acute" +" problem. All signals are on red, so this is a typical activity for the " +"first quadrant.

2. Important & Not Urgent:
If you leave the" +" activities in this quadrant for the coming week, nothing will immediately " +"go wrong. But be careful: These are activities and projects that will help " +"you in the long term. Think of thinking about a strategy, improving work " +"processes in your team, investing in relationships and investing in " +"yourself. i.e. You are a team leader who has just been told during his " +"performance review that more creative input is expected. Such an outcome of " +"a performance review is an assignment that will never feel urgent, but is " +"very important. You can quickly recognize the important & non-urgent " +"activities by answering the question: if I don't do this, will it get me " +"into trouble in the long run? If the answer is yes, then you have an " +"important & non-urgent activity. If the answer is no, then it is a non-" +"important & non-urgent activity.

3. Urgent & Not " +"Important:
This quadrant concerns activities that do not help you in " +"the long run, but that are screaming for your attention this week. An " +"adjustment in a presentation that has to be done for a colleague on the spur" +" of the moment or the milk that is almost empty. With tasks in this quadrant" +" it is very important to check whether they are actually urgent. Requests " +"from others in particular often seem very urgent, while they can sometimes " +"wait a day or a week. It is usually fine to postpone this work to a more " +"suitable moment, provided that I communicate well about this. If you have " +"the opportunity to delegate or outsource these tasks in this quadrant, do " +"so. If you work for yourself, this is not always possible. In that case, I " +"advise you to organize your working day in such a way that you are guided as" +" little as possible by these urgent tasks, if necessary by reserving a fixed" +" time block each day for these types of emergencies. That way you keep " +"control over your agenda.

4. Not Important & Not " +"Urgent:
This type of work that you want to have on your plate as " +"little as possible, because it does not help you in any way. Constantly " +"refreshing your mailbox, for example. But meetings without a clear goal also" +" fall into this category. You can undoubtedly point out more of these types " +"of 'busy work' examples yourself: things that you do, but that do not really" +" benefit anyone. Sometimes these activities are a great short break from " +"your work, but usually they are a great excuse to postpone your important " +"work for a while." +msgstr "" +"重要なものが緊急であることはほとんどありません。また、緊急であることが重要であることはほとんどありません。

1。重要および緊急:
ここでは、すぐに実行する必要がある重要なアクティビティを書き留めます。これらは緊急の問題や期限が厳しいプロジェクトです。つまり、レストランを経営していて従業員が来ない場合、それはかなり緊急かつ深刻な問題です。すべての信号が赤であるため、これは第" +" 1 " +"象限の典型的なアクティビティです。

2。重要かつ緊急ではありません:
この象限のアクティビティを来週まで放置しておいても、すぐに問題が起こることはありません。ただし、注意してください。これらは長期的に役立つ活動やプロジェクトです。戦略を考えること、チーム内の作業プロセスを改善すること、人間関係に投資すること、自分自身に投資することについて考えてみましょう。つまり、あなたはチームリーダーで、パフォーマンスレビュー中に、より創造的なインプットが期待されていると言われたばかりです。このような業績評価の結果は、決して緊急であるとは感じられない任務ですが、非常に重要です。" +" " +"「これを行わないと、長期的には問題が発生しますか?」という質問に答えることで、重要なアクティビティと緊急ではないアクティビティをすぐに認識できます。答えが「はい」の場合、重要かつ緊急ではないアクティビティがあることになります。答えが「いいえ」の場合、それは重要でも緊急でもないアクティビティです。

3。緊急かつ重要ではない:
この象限は、長期的には役に立たないが、今週あなたの注意を喚起する活動に関係します。同僚のために急遽やらなければならないプレゼンテーションの調整や、ほとんど空になった牛乳。この象限のタスクでは、実際に緊急であるかどうかを確認することが非常に重要です。特に他の人からのリクエストは非常に緊急であることが多いですが、場合によっては" +" 1 日または 1 " +"週間かかることもあります。このことについてよく伝えれば、この作業をより適切な時期に延期しても通常は問題ありません。この象限でこれらのタスクを委任またはアウトソーシングする機会がある場合は、そうしてください。自分で仕事をしている場合、これは常に可能であるとは限りません。その場合、必要に応じて、この種の緊急事態のために毎日一定の時間を確保して、これらの緊急タスクにできるだけ誘導されないように勤務日を編成することをお勧めします。そうすることで、自分の予定を常に管理できます。

4。重要ではなく緊急でもない:
このタイプの作業は、何の役にも立たないため、できるだけ実行しないようにしたいものです。たとえば、メールボックスを定期的に更新します。ただし、明確な目標のない会議もこのカテゴリに分類されます。このような「忙しい仕事」の例をもっと自分で指摘できることは間違いありません。つまり、自分がやっているけれど、実際には誰の利益にもならないことです。これらのアクティビティは、仕事の短い休暇として最適な場合もありますが、通常は、重要な仕事をしばらく延期する絶好の口実になります。" + +#: ../qml/features/timesheets/pages/Timesheet.qml:651 +msgid "Important, Urgent (1)" +msgstr "重要、緊急 (1)" + +#: ../qml/features/timesheets/pages/Timesheet.qml:670 +msgid "Important, Not Urgent (2)" +msgstr "重要ですが緊急ではありません (2)" + +#: ../qml/features/timesheets/pages/Timesheet.qml:689 +msgid "Urgent, Not Important (3)" +msgstr "緊急だが重要ではない (3)" + +#: ../qml/features/timesheets/pages/Timesheet.qml:708 +msgid "Not Urgent, Not Important (4)" +msgstr "緊急ではない、重要ではない (4)" + +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:166 +msgid "Active" +msgstr "アクティブ" + +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:167 +msgid "Draft" +msgstr "下書き" + +#: ../qml/features/timesheets/pages/Timesheet_Page.qml:400 +msgid "Loading timesheets..." +msgstr "タイムシートをロードしています..." + +#: ../qml/features/updates/pages/Updates.qml:42 +msgid "Project Update" +msgstr "プロジェクトの更新" + +#: ../qml/features/updates/pages/Updates.qml:82 +msgid "Unknown Account" +msgstr "不明なアカウント" + +#: ../qml/features/updates/pages/Updates_Page.qml:372 +msgid "Loading updates..." +msgstr "アップデートを読み込み中..." + +#: ubtms.desktop.in.h:1 +msgid "Time Management" +msgstr "時間管理" diff --git a/qml-notify-module/NotificationHelper.cpp b/qml-notify-module/NotificationHelper.cpp index c6f96114..6aa28374 100644 --- a/qml-notify-module/NotificationHelper.cpp +++ b/qml-notify-module/NotificationHelper.cpp @@ -193,11 +193,13 @@ void NotificationHelper::startDaemon() QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert("DBUS_SESSION_BUS_ADDRESS", dbusAddr); - // Get the click package path dynamically + // Get the click package path dynamically - prefer /opt/click.ubuntu.com/ubtms/current for click installs QString clickPath = "/opt/click.ubuntu.com/ubtms/current"; - QString appDir = QCoreApplication::applicationDirPath(); - if (QFile::exists(appDir + "/src/daemon.py")) { - clickPath = appDir; + if (!QFile::exists(clickPath + "/src/daemon.py")) { + QString appDir = QCoreApplication::applicationDirPath(); + if (QFile::exists(appDir + "/src/daemon.py")) { + clickPath = appDir; + } } qDebug() << "Using click path:" << clickPath; @@ -207,12 +209,25 @@ void NotificationHelper::startDaemon() QDir().mkpath(logDir); QString logFile = logDir + "/daemon.log"; - // Check if systemd service exists, if not create it via bootstrap + // Check if systemd service exists and is up to date QString serviceFile = QDir::homePath() + "/.config/systemd/user/ubtms-daemon.service"; QFileInfo serviceInfo(serviceFile); - if (!serviceInfo.exists()) { - qDebug() << "Systemd service not found, running bootstrap to create it..."; + bool needsBootstrap = !serviceInfo.exists(); + if (!needsBootstrap) { + QFile sf(serviceFile); + if (sf.open(QIODevice::ReadOnly | QIODevice::Text)) { + QString content = sf.readAll(); + sf.close(); + // If the service references an old version path instead of current + if (content.contains("/opt/click.ubuntu.com/ubtms/") && !content.contains("/opt/click.ubuntu.com/ubtms/current")) { + needsBootstrap = true; + } + } + } + + if (needsBootstrap) { + qDebug() << "Systemd service missing or outdated, running bootstrap to create/update it..."; // Run bootstrap to create the service file QString bootstrapScript = clickPath + "/src/daemon_bootstrap.py"; @@ -234,13 +249,14 @@ void NotificationHelper::startDaemon() qDebug() << "Bootstrap completed, service should be created"; } - // Try to start via systemd first (this ensures boot-time auto-start works) + // Try to start/restart via systemd first (this ensures boot-time auto-start works) QProcess systemctl; systemctl.setProcessEnvironment(env); - systemctl.start("systemctl", QStringList() << "--user" << "start" << "ubtms-daemon"); + QString action = needsBootstrap ? "restart" : "start"; + systemctl.start("systemctl", QStringList() << "--user" << action << "ubtms-daemon"); if (systemctl.waitForFinished(5000) && systemctl.exitCode() == 0) { - qDebug() << "Daemon started via systemd"; + qDebug() << "Daemon started/restarted via systemd"; return; } diff --git a/qml/TSApp.qml b/qml/TSApp.qml index 24f879a1..02e8cabb 100644 --- a/qml/TSApp.qml +++ b/qml/TSApp.qml @@ -72,6 +72,8 @@ MainView { signal globalAccountChanged(int accountId, string accountName) signal accountDataRefreshRequested(int accountId) signal globalDateRangeChanged(int presetId, string startDate, string endDate, string presetLabel) + signal projectDataChanged() + signal taskDataChanged() // Keep-alive heartbeat: Ensures Qt SceneGraph render thread remains active to safely process // EGL surface recreation during convergence / external monitor display output migration. // Prevents QtWebEngine / QSG crash when monitor is attached while app is idle. @@ -123,6 +125,17 @@ MainView { Qt.callLater(function () { apLayout.setFirstScreen(); }); + + postStartupMaintenanceTimer.start(); + } + + Timer { + id: postStartupMaintenanceTimer + interval: 1500 + repeat: false + onTriggered: { + DbInit.performPostStartupMaintenance(); + } } function checkStartupArguments() { diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 7611c20c..5c1f6982 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -39,84 +39,163 @@ Controls.Drawer { width: parent.width Rectangle { + id: drawerHeader width: parent.width height: units.gu(8) color: LomiriColors.orange - RowLayout { + Item { anchors.fill: parent - anchors.leftMargin: units.gu(2) - anchors.rightMargin: units.gu(1.2) - spacing: units.gu(1) - - Label { - text: i18n.dtr("ubtms", "Menu") - color: "white" - fontSize: "large" - font.bold: true - } - - Item { - Layout.fillWidth: true - } + anchors.leftMargin: drawerHeader.width < units.gu(32) ? units.gu(1.2) : units.gu(2) + anchors.rightMargin: drawerHeader.width < units.gu(32) ? units.gu(0.8) : units.gu(1.2) - // Account Selector button with Account label adjacent to icon + // Left section: Menu title RowLayout { - id: accountSelectorItem - spacing: units.gu(0.5) - Layout.alignment: Qt.AlignVCenter - - Icon { - name: "account" - width: units.gu(2.4) - height: units.gu(2.4) - color: "white" - Layout.alignment: Qt.AlignVCenter - } + id: drawerLeftSection + anchors.left: parent.left + anchors.right: drawerRightSection.left + anchors.rightMargin: units.gu(0.5) + anchors.verticalCenter: parent.verticalCenter Label { - id: accountNameLabel - Layout.alignment: Qt.AlignVCenter - text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" + text: i18n.dtr("ubtms", "Menu") color: "white" - font.pixelSize: units.dp(13) + fontSize: "large" font.bold: true elide: Text.ElideRight - maximumLineCount: 1 - Layout.maximumWidth: units.gu(12) + Layout.fillWidth: true + } + } + + // Right section: Account selector chip, local mode toggle, and theme toggle button + RowLayout { + id: drawerRightSection + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: drawerHeader.width < units.gu(34) ? units.gu(0.6) : units.gu(0.8) + + // Account Selector chip (collapses to circular avatar when width is restricted) + Rectangle { + id: accountSelectorItem + implicitWidth: accountNameLabel.visible ? (accountRow.implicitWidth + units.gu(1.6)) : units.gu(3.6) + implicitHeight: units.gu(3.6) + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + radius: height / 2 + color: accountMouseArea.pressed ? "#40ffffff" : (accountMouseArea.containsMouse ? "#30ffffff" : "#20ffffff") + border.color: "#35ffffff" + border.width: 1 + Layout.alignment: Qt.AlignVCenter + + Behavior on color { + ColorAnimation { duration: 100 } + } + + RowLayout { + id: accountRow + anchors.centerIn: parent + spacing: units.gu(0.5) + + Icon { + name: "account" + width: units.gu(2.2) + height: units.gu(2.2) + color: "white" + Layout.alignment: Qt.AlignVCenter + } + + Label { + id: accountNameLabel + visible: drawerHeader.width >= units.gu(29) + Layout.alignment: Qt.AlignVCenter + text: { + if (typeof accountPicker === "undefined" || !accountPicker.selectedAccountName) return ""; + return (accountPicker.selectedAccountId === 0 || accountPicker.selectedAccountName === "Local Account") ? "Local" : accountPicker.selectedAccountName; + } + color: "white" + font.pixelSize: units.dp(13) + font.bold: true + elide: Text.ElideRight + maximumLineCount: 1 + Layout.maximumWidth: Math.min(units.gu(10), Math.max(units.gu(3), drawerHeader.width - units.gu(24))) + } + } + + MouseArea { + id: accountMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + drawerRoot.close(); + if (typeof accountPicker !== "undefined") { + accountPicker.open(accountPicker.selectedAccountId); + } + } + + Controls.ToolTip.visible: accountMouseArea.containsMouse + Controls.ToolTip.text: { + var name = (typeof accountPicker !== "undefined" && accountPicker.selectedAccountName) ? accountPicker.selectedAccountName : ""; + if (!name || name === "Local Account" || (typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0)) { + return i18n.dtr("ubtms", "Local Account"); + } + return name; + } + Controls.ToolTip.delay: 400 + } } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor + // Local Account Toggle Switch (hidden on narrow widths to guarantee fit) + TSSwitch { + id: localToggleSwitch + visible: drawerHeader.width >= units.gu(25) + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: units.gu(4.2) + Layout.preferredHeight: units.gu(2.1) + checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + onClicked: { - drawerRoot.close(); if (typeof accountPicker !== "undefined") { - accountPicker.open(accountPicker.selectedAccountId); + accountPicker.toggleLocalMode(!checked); } } } - } - // Theme Toggle Button - Item { - width: units.gu(4) - height: units.gu(4) - Layout.alignment: Qt.AlignVCenter - - Image { - anchors.centerIn: parent - width: units.gu(2.2) - height: units.gu(2.2) - source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? Qt.resolvedUrl("../images/daymode.png") : Qt.resolvedUrl("../images/darkmode.png") - fillMode: Image.PreserveAspectFit - } + // Theme Toggle Button (guaranteed to be visible and anchored at the right edge) + Rectangle { + id: themeToggleBtn + implicitWidth: units.gu(3.6) + implicitHeight: units.gu(3.6) + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + radius: height / 2 + color: themeMouseArea.pressed ? "#40ffffff" : (themeMouseArea.containsMouse ? "#30ffffff" : "transparent") + Layout.alignment: Qt.AlignVCenter - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - Theme.name = theme.name === "Ubuntu.Components.Themes.SuruDark" ? "Ubuntu.Components.Themes.Ambiance" : "Ubuntu.Components.Themes.SuruDark"; + Behavior on color { + ColorAnimation { duration: 100 } + } + + Image { + anchors.centerIn: parent + width: units.gu(2.2) + height: units.gu(2.2) + source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? Qt.resolvedUrl("../images/daymode.png") : Qt.resolvedUrl("../images/darkmode.png") + fillMode: Image.PreserveAspectFit + } + + MouseArea { + id: themeMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + Theme.name = theme.name === "Ubuntu.Components.Themes.SuruDark" ? "Ubuntu.Components.Themes.Ambiance" : "Ubuntu.Components.Themes.SuruDark"; + } + + Controls.ToolTip.visible: themeMouseArea.containsMouse + Controls.ToolTip.text: theme.name === "Ubuntu.Components.Themes.SuruDark" ? i18n.dtr("ubtms", "Light mode") : i18n.dtr("ubtms", "Dark mode") + Controls.ToolTip.delay: 400 } } } diff --git a/qml/app/AppLayout.qml b/qml/app/AppLayout.qml index e17983e0..b29bc6f2 100644 --- a/qml/app/AppLayout.qml +++ b/qml/app/AppLayout.qml @@ -21,6 +21,13 @@ AdaptivePageLayout { property var rootApp property var globalDrawer property var navigationController + property bool userCollapsedPreference: false + readonly property bool canCollapse: columns > 1 + readonly property bool menuCollapsed: canCollapse && userCollapsedPreference + + function toggleMenuCollapsed() { + userCollapsedPreference = !userCollapsedPreference; + } anchors.top: parent.top anchors.left: parent.left @@ -45,9 +52,9 @@ AdaptivePageLayout { when: width > units.gu(80) && width < units.gu(130) PageColumn { - minimumWidth: units.gu(30) - maximumWidth: units.gu(50) - preferredWidth: width > units.gu(90) ? units.gu(20) : units.gu(15) + minimumWidth: apLayout.menuCollapsed ? units.gu(8) : units.gu(30) + maximumWidth: apLayout.menuCollapsed ? units.gu(8) : units.gu(50) + preferredWidth: apLayout.menuCollapsed ? units.gu(8) : (width > units.gu(90) ? units.gu(20) : units.gu(15)) } PageColumn { @@ -61,9 +68,9 @@ AdaptivePageLayout { when: width >= units.gu(130) PageColumn { - minimumWidth: units.gu(30) - maximumWidth: units.gu(50) - preferredWidth: units.gu(40) + minimumWidth: apLayout.menuCollapsed ? units.gu(8) : units.gu(30) + maximumWidth: apLayout.menuCollapsed ? units.gu(8) : units.gu(50) + preferredWidth: apLayout.menuCollapsed ? units.gu(8) : units.gu(40) } PageColumn { diff --git a/qml/app/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 410b5d9c..5b5b5df6 100644 --- a/qml/app/GlobalWidgets.qml +++ b/qml/app/GlobalWidgets.qml @@ -33,11 +33,15 @@ Item { id: modelDownloadTimerWidget z: 9999 anchors.bottom: parent.bottom - anchors.bottomMargin: (Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0) + units.gu(1) + anchors.bottomMargin: ((Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0) + units.gu(1)) + (globalTimerWidget.visible ? (globalTimerWidget.height + units.gu(1)) : 0) visible: false showNotification: function (title, message, type) { notifPopup.open(title, message, type); } + + Behavior on anchors.bottomMargin { + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } } BackendBridge { @@ -65,13 +69,17 @@ Item { return; } - if (rootApp.currentAccountId === id) { - return; + if (id > 0) { + accountPicker.lastRemoteAccountId = id; } + var accountChanged = (rootApp.currentAccountId !== id); rootApp.currentAccountId = id; rootApp.currentAccountName = name; - rootApp.globalAccountChanged(id, name); + + if (accountChanged) { + rootApp.globalAccountChanged(id, name); + } rootApp.accountDataRefreshRequested(id); } } diff --git a/qml/app/StartupManager.qml b/qml/app/StartupManager.qml index 9ed6bc6c..30b2885b 100644 --- a/qml/app/StartupManager.qml +++ b/qml/app/StartupManager.qml @@ -41,20 +41,20 @@ QtObject { try { var xhr = new XMLHttpRequest(); var setupFile = "/home/phablet/.ubtms_needs_setup"; - xhr.open("GET", "file://" + setupFile, false); - try { - xhr.send(); - if (xhr.status === 200 && xhr.responseText.length > 0) { - var missingDeps = xhr.responseText; - var message = "Background sync requires additional packages.\n\n" + - "To enable push notifications, connect via adb and run:\n\n" + - "sudo apt install python3-dbus python3-gi gir1.2-glib-2.0\n\n" + - "Then restart the app."; - if (notifPopup) - notifPopup.open("Setup Required", message, "warning"); + xhr.open("GET", "file://" + setupFile, true); + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200 && xhr.responseText.length > 0) { + var message = "Background sync requires additional packages.\n\n" + + "To enable push notifications, connect via adb and run:\n\n" + + "sudo apt install python3-dbus python3-gi gir1.2-glib-2.0\n\n" + + "Then restart the app."; + if (notifPopup) + notifPopup.open("Setup Required", message, "warning"); + } } - } catch (fileError) { - } + }; + xhr.send(); } catch (e) { } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 9cd83f68..b2904464 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -27,13 +27,16 @@ import Lomiri.Components 1.3 import QtCharts 2.0 import QtQuick.Layouts 1.11 import Qt.labs.settings 1.0 +import QtQuick.Controls 2.2 as Controls +import QtGraphicalEffects 1.0 import "../../components" import "NavigationRoutes.js" as NavigationRoutes Page { id: listpage - property bool isMultiColumn: apLayout.columns > 1 + property bool isMultiColumn: apLayout ? (apLayout.columns > 1) : false + readonly property bool menuCollapsed: apLayout ? apLayout.menuCollapsed : false property var navigationController title: i18n.dtr("ubtms", "Menu") @@ -48,79 +51,243 @@ Page { dividerColor: LomiriColors.slate } - contents: RowLayout { + // Overlay for collapsed mode to guarantee exact horizontal & vertical centering across 8 GU + Item { + parent: header anchors.fill: parent - anchors.leftMargin: units.gu(2) - anchors.rightMargin: units.gu(1) - spacing: units.gu(1) - - Label { - text: i18n.dtr("ubtms", "Menu") - color: "white" - fontSize: "large" - font.bold: true - } + visible: listpage.menuCollapsed + z: 100 - Item { - Layout.fillWidth: true - } + Rectangle { + anchors.fill: parent + color: collapseOverlayMouseArea.pressed ? "#40ffffff" : (collapseOverlayMouseArea.containsMouse ? "#30ffffff" : "transparent") - // Account Selector button with Account label adjacent to icon - RowLayout { - id: accountBtn - spacing: units.gu(0.5) - Layout.alignment: Qt.AlignVCenter + Behavior on color { + ColorAnimation { duration: 100 } + } Icon { - name: "account" + anchors.centerIn: parent + name: "navigation-menu" width: units.gu(2.4) height: units.gu(2.4) color: "white" + } + + MouseArea { + id: collapseOverlayMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (apLayout && typeof apLayout.toggleMenuCollapsed === "function") { + apLayout.toggleMenuCollapsed(); + } + } + + Controls.ToolTip.visible: collapseOverlayMouseArea.containsMouse + Controls.ToolTip.text: i18n.dtr("ubtms", "Expand menu") + Controls.ToolTip.delay: 400 + } + } + } + + contents: Item { + id: headerContents + visible: !listpage.menuCollapsed + anchors.fill: parent + anchors.leftMargin: header.width < units.gu(32) ? units.gu(1) : units.gu(1.5) + anchors.rightMargin: header.width < units.gu(32) ? units.gu(0.8) : units.gu(1) + + // Left section: Hamburger collapse/expand button and page title + RowLayout { + id: leftSection + anchors.left: parent.left + anchors.right: rightSection.left + anchors.rightMargin: units.gu(0.5) + anchors.verticalCenter: parent.verticalCenter + spacing: units.gu(0.6) + + // Collapse / expand sidebar button (multi-column only) + Rectangle { + id: collapseToggleBtn + visible: listpage.isMultiColumn + implicitWidth: units.gu(3.6) + implicitHeight: units.gu(3.6) + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + radius: height / 2 + color: collapseMouseArea.pressed ? "#40ffffff" : (collapseMouseArea.containsMouse ? "#30ffffff" : "transparent") Layout.alignment: Qt.AlignVCenter + + Behavior on color { + ColorAnimation { duration: 100 } + } + + Icon { + anchors.centerIn: parent + name: "navigation-menu" + width: units.gu(2.2) + height: units.gu(2.2) + color: "white" + } + + MouseArea { + id: collapseMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (apLayout && typeof apLayout.toggleMenuCollapsed === "function") { + apLayout.toggleMenuCollapsed(); + } + } + + Controls.ToolTip.visible: collapseMouseArea.containsMouse + Controls.ToolTip.text: i18n.dtr("ubtms", "Collapse menu") + Controls.ToolTip.delay: 400 + } } Label { - id: accountLabel - Layout.alignment: Qt.AlignVCenter - text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" + text: i18n.dtr("ubtms", "Menu") color: "white" - font.pixelSize: units.dp(13) + fontSize: "large" font.bold: true elide: Text.ElideRight - maximumLineCount: 1 - Layout.maximumWidth: units.gu(12) + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter } + } + + // Right section: Account selector chip, local mode toggle switch, and theme toggle button + RowLayout { + id: rightSection + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: header.width < units.gu(34) ? units.gu(0.6) : units.gu(0.8) + + // Account Selector chip (collapses to circular icon button when width is restricted) + Rectangle { + id: accountBtn + implicitWidth: accountLabel.visible ? (accountRow.implicitWidth + units.gu(1.6)) : units.gu(3.6) + implicitHeight: units.gu(3.6) + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + radius: height / 2 + color: accountMouseArea.pressed ? "#40ffffff" : (accountMouseArea.containsMouse ? "#30ffffff" : "#20ffffff") + border.color: "#35ffffff" + border.width: 1 + Layout.alignment: Qt.AlignVCenter + + Behavior on color { + ColorAnimation { duration: 100 } + } + + RowLayout { + id: accountRow + anchors.centerIn: parent + spacing: units.gu(0.5) + + Icon { + name: "account" + width: units.gu(2.2) + height: units.gu(2.2) + color: "white" + Layout.alignment: Qt.AlignVCenter + } + + Label { + id: accountLabel + visible: header.width >= units.gu(29) + Layout.alignment: Qt.AlignVCenter + text: { + if (typeof accountPicker === "undefined" || !accountPicker.selectedAccountName) return ""; + return (accountPicker.selectedAccountId === 0 || accountPicker.selectedAccountName === "Local Account") ? "Local" : accountPicker.selectedAccountName; + } + color: "white" + font.pixelSize: units.dp(13) + font.bold: true + elide: Text.ElideRight + maximumLineCount: 1 + Layout.maximumWidth: Math.min(units.gu(10), Math.max(units.gu(3), header.width - units.gu(24))) + } + } + + MouseArea { + id: accountMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (typeof accountPicker !== "undefined") { + accountPicker.open(accountPicker.selectedAccountId); + } + } + + Controls.ToolTip.visible: accountMouseArea.containsMouse + Controls.ToolTip.text: { + var name = (typeof accountPicker !== "undefined" && accountPicker.selectedAccountName) ? accountPicker.selectedAccountName : ""; + if (!name || name === "Local Account" || (typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0)) { + return i18n.dtr("ubtms", "Local Account"); + } + return name; + } + Controls.ToolTip.delay: 400 + } + } + + // Local Account Toggle Switch (hidden on narrow widths to guarantee fit) + TSSwitch { + id: localToggleSwitch + visible: header.width >= units.gu(25) + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: units.gu(4.2) + Layout.preferredHeight: units.gu(2.1) + checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor onClicked: { if (typeof accountPicker !== "undefined") { - accountPicker.open(accountPicker.selectedAccountId); + accountPicker.toggleLocalMode(!checked); } } } - } - // Theme Mode Toggle - Item { - width: units.gu(4) - height: units.gu(4) - Layout.alignment: Qt.AlignVCenter + // Theme Mode Toggle (guaranteed to be visible and anchored at the right edge) + Rectangle { + id: themeToggleBtn + implicitWidth: units.gu(3.6) + implicitHeight: units.gu(3.6) + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + radius: height / 2 + color: themeMouseArea.pressed ? "#40ffffff" : (themeMouseArea.containsMouse ? "#30ffffff" : "transparent") + Layout.alignment: Qt.AlignVCenter - Image { - anchors.centerIn: parent - width: units.gu(2.2) - height: units.gu(2.2) - source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "../../images/daymode.png" : "../../images/darkmode.png" - fillMode: Image.PreserveAspectFit - } + Behavior on color { + ColorAnimation { duration: 100 } + } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - Theme.name = theme.name === "Ubuntu.Components.Themes.SuruDark" ? "Ubuntu.Components.Themes.Ambiance" : "Ubuntu.Components.Themes.SuruDark"; + Image { + anchors.centerIn: parent + width: units.gu(2.2) + height: units.gu(2.2) + source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "../../images/daymode.png" : "../../images/darkmode.png" + fillMode: Image.PreserveAspectFit + } + + MouseArea { + id: themeMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + Theme.name = theme.name === "Ubuntu.Components.Themes.SuruDark" ? "Ubuntu.Components.Themes.Ambiance" : "Ubuntu.Components.Themes.SuruDark"; + } + + Controls.ToolTip.visible: themeMouseArea.containsMouse + Controls.ToolTip.text: theme.name === "Ubuntu.Components.Themes.SuruDark" ? i18n.dtr("ubtms", "Light mode") : i18n.dtr("ubtms", "Dark mode") + Controls.ToolTip.delay: 400 } } } @@ -136,8 +303,169 @@ Page { anchors.bottom: parent.bottom color: isDark ? "#111" : "#f2f2f7" + // Pinned bottom actions section when sidebar is collapsed + Rectangle { + id: collapsedBottomSection + visible: listpage.menuCollapsed + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: listpage.menuCollapsed ? (bottomActionsColumn.height + units.dp(1)) : 0 + color: isDark ? "#1e1e1e" : "#ffffff" + + // Divider above bottom actions + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: units.dp(1) + color: isDark ? "#333333" : "#e8e8e8" + } + + Column { + id: bottomActionsColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: units.dp(1) + spacing: 0 + + // 1. Account Action + Rectangle { + id: collapsedAccountBtn + width: parent.width + height: units.gu(5.5) + color: collapsedAccountArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedAccountArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") + + Icon { + anchors.centerIn: parent + name: "account" + width: units.gu(2.8) + height: units.gu(2.8) + color: (typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0) ? (isDark ? "#aaaaaa" : "#666666") : LomiriColors.orange + } + + MouseArea { + id: collapsedAccountArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (typeof accountPicker !== "undefined") { + accountPicker.open(accountPicker.selectedAccountId); + } + } + + Controls.ToolTip.visible: collapsedAccountArea.containsMouse + Controls.ToolTip.text: { + if (typeof accountPicker === "undefined" || !accountPicker.selectedAccountName) return i18n.dtr("ubtms", "Account"); + var accName = (accountPicker.selectedAccountId === 0 || accountPicker.selectedAccountName === "Local Account") ? i18n.dtr("ubtms", "Local") : accountPicker.selectedAccountName; + return i18n.dtr("ubtms", "Account: %1").arg(accName); + } + Controls.ToolTip.delay: 400 + } + } + + // Divider between Account and Local Account Switch + Rectangle { + id: dividerAccountLocal + width: parent.width + height: units.dp(1) + color: isDark ? "#333333" : "#e8e8e8" + } + + // 2. Local Account Toggle + Rectangle { + id: collapsedLocalBtn + width: parent.width + height: units.gu(5.5) + color: collapsedLocalArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedLocalArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") + + TSSwitch { + id: collapsedLocalSwitch + anchors.centerIn: parent + width: units.gu(4.2) + height: units.gu(2.1) + interactive: false + checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + uncheckedColor: isDark ? "#444444" : "#cccccc" + uncheckedBorderColor: isDark ? "#555555" : "#bbbbbb" + } + + MouseArea { + id: collapsedLocalArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (typeof accountPicker !== "undefined") { + accountPicker.toggleLocalMode(!collapsedLocalSwitch.checked); + } + } + + Controls.ToolTip.visible: collapsedLocalArea.containsMouse + Controls.ToolTip.text: collapsedLocalSwitch.checked ? i18n.dtr("ubtms", "Local Account (Active)") : i18n.dtr("ubtms", "Local Account (Disabled)") + Controls.ToolTip.delay: 400 + } + } + + // Divider between Local Account Switch and Theme Toggle + Rectangle { + id: dividerLocalTheme + width: parent.width + height: units.dp(1) + color: isDark ? "#333333" : "#e8e8e8" + } + + // 3. Theme Toggle + Rectangle { + id: collapsedThemeBtn + width: parent.width + height: units.gu(5.5) + color: collapsedThemeArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedThemeArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") + + Item { + anchors.centerIn: parent + width: units.gu(2.4) + height: units.gu(2.4) + + Image { + id: collapsedThemeImg + anchors.fill: parent + source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "../../images/daymode.png" : "../../images/darkmode.png" + fillMode: Image.PreserveAspectFit + visible: false + } + + ColorOverlay { + anchors.fill: collapsedThemeImg + source: collapsedThemeImg + color: isDark ? "#ffffff" : "#444444" + } + } + + MouseArea { + id: collapsedThemeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + Theme.name = theme.name === "Ubuntu.Components.Themes.SuruDark" ? "Ubuntu.Components.Themes.Ambiance" : "Ubuntu.Components.Themes.SuruDark"; + } + + Controls.ToolTip.visible: collapsedThemeArea.containsMouse + Controls.ToolTip.text: theme.name === "Ubuntu.Components.Themes.SuruDark" ? i18n.dtr("ubtms", "Day mode") : i18n.dtr("ubtms", "Dark mode") + Controls.ToolTip.delay: 400 + } + } + } + } + Flickable { - anchors.fill: parent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: listpage.menuCollapsed ? collapsedBottomSection.top : parent.bottom contentHeight: menuColumn.height + units.gu(4) clip: true @@ -157,6 +485,7 @@ Page { NavigationMenuList { width: parent.width + collapsed: listpage.menuCollapsed menuItems: NavigationRoutes.menuItems() selectedPageUrl: apLayout && apLayout.currentMenuPageUrl ? apLayout.currentMenuPageUrl : "" diff --git a/qml/app/pages/release_notes.txt b/qml/app/pages/release_notes.txt index 972efff5..c3e1443d 100644 --- a/qml/app/pages/release_notes.txt +++ b/qml/app/pages/release_notes.txt @@ -63,7 +63,7 @@

Time Management - Alpha Draft

- +

Time Management is a native time-tracking and productivity application built exclusively for Ubuntu Touch phones. @@ -141,7 +141,7 @@


- Time Management for Ubuntu Touch • Version 1.3.2 • © 2026 CIT Services + Time Management for Ubuntu Touch • Version 1.3.3 • © 2026 CIT Services

diff --git a/qml/components/base/TSIconButton.qml b/qml/components/base/TSIconButton.qml index da9672fa..f23a933c 100644 --- a/qml/components/base/TSIconButton.qml +++ b/qml/components/base/TSIconButton.qml @@ -23,6 +23,7 @@ */ import QtQuick 2.7 import QtQuick.Controls 2.2 +import QtGraphicalEffects 1.0 import Lomiri.Components 1.3 import "../../../models/constants.js" as AppConst import ".." @@ -65,13 +66,20 @@ Item { // } Icon { + id: iconItem visible: root.iconName !== "" name: root.iconName anchors.centerIn: parent width: root.iconSize height: root.iconSize color: root.fgColor - // font.bold: root.iconBold + } + + ColorOverlay { + anchors.fill: iconItem + source: iconItem + color: root.fgColor + visible: root.iconName !== "" && root.fgColor !== "transparent" } // Fallback to text if no iconName diff --git a/qml/components/base/TSSearchBar.qml b/qml/components/base/TSSearchBar.qml new file mode 100644 index 00000000..75f16adb --- /dev/null +++ b/qml/components/base/TSSearchBar.qml @@ -0,0 +1,182 @@ +/* + * 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 QtQuick.Controls 2.2 +import Lomiri.Components 1.3 +import "../../../models/constants.js" as AppConst +import ".." + +Item { + id: root + + property alias text: searchField.text + property string placeholderText: i18n.dtr("ubtms", "Search...") + property alias activeFocusOnPress: searchField.activeFocusOnPress + readonly property bool isInputActive: searchField.activeFocus + + readonly property bool isDark: typeof theme !== 'undefined' && theme.name === "Ubuntu.Components.Themes.SuruDark" + + property real capsuleRadius: units.gu(1.2) + property real topPadding: units.gu(1.2) + property real bottomPadding: units.gu(1.2) + property real horizontalPadding: units.gu(1.5) + + implicitHeight: topPadding + units.gu(4.6) + bottomPadding + implicitWidth: units.gu(30) + clip: true + + signal accepted(string query) + signal cleared() + + function clear() { + if (searchField.text !== "") { + searchField.text = ""; + } + } + + function forceActiveFocus() { + Qt.callLater(function() { + searchField.forceActiveFocus(); + }); + } + + Rectangle { + id: searchBarBox + anchors.fill: parent + anchors.leftMargin: root.horizontalPadding + anchors.rightMargin: root.horizontalPadding + anchors.topMargin: root.topPadding + anchors.bottomMargin: root.bottomPadding + radius: root.capsuleRadius + color: root.isDark ? "#1e1e1e" : "#f1f5f9" + border.color: searchField.activeFocus + ? AppConst.Colors.Orange + : (root.isDark ? "#2d2d2d" : "#e2e8f0") + border.width: searchField.activeFocus ? units.gu(0.18) : units.gu(0.1) + + Behavior on border.color { + ColorAnimation { duration: 150 } + } + + MouseArea { + anchors.fill: parent + z: 0 + cursorShape: Qt.IBeamCursor + onClicked: { + searchField.forceActiveFocus(); + } + } + + Icon { + id: searchIcon + anchors.left: parent.left + anchors.leftMargin: units.gu(1.4) + anchors.verticalCenter: parent.verticalCenter + width: units.gu(2) + height: units.gu(2) + name: "search" + color: searchField.activeFocus + ? AppConst.Colors.Orange + : (root.isDark ? "#71717a" : "#94a3b8") + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + TextInput { + id: searchField + anchors.left: searchIcon.right + anchors.leftMargin: units.gu(1.2) + anchors.right: clearSearchButton.visible ? clearSearchButton.left : parent.right + anchors.rightMargin: clearSearchButton.visible ? units.gu(0.5) : units.gu(1.4) + anchors.verticalCenter: parent.verticalCenter + verticalAlignment: TextInput.AlignVCenter + color: root.isDark ? "#f3f4f6" : "#0f172a" + font.pixelSize: units.gu(1.8) + selectByMouse: true + clip: true + inputMethodHints: Qt.ImhNoPredictiveText + + onAccepted: { + root.accepted(text); + } + + onTextChanged: { + if (text === "") { + root.cleared(); + } + } + } + + Text { + anchors.fill: searchField + verticalAlignment: Text.AlignVCenter + text: root.placeholderText + color: root.isDark ? "#71717a" : "#94a3b8" + font.pixelSize: searchField.font.pixelSize + visible: !searchField.text && !searchField.activeFocus + elide: Text.ElideRight + } + + Item { + id: clearSearchButton + visible: searchField.text.length > 0 + anchors.right: parent.right + anchors.rightMargin: units.gu(0.8) + anchors.verticalCenter: parent.verticalCenter + width: units.gu(3.2) + height: units.gu(3.2) + z: 1 + + Rectangle { + anchors.centerIn: parent + width: units.gu(2.4) + height: units.gu(2.4) + radius: width / 2 + color: clearMouseArea.pressed + ? (root.isDark ? "#444444" : "#cbd5e1") + : (root.isDark ? "#2a2a2a" : "#e2e8f0") + + Icon { + name: "close" + width: units.gu(1.3) + height: units.gu(1.3) + anchors.centerIn: parent + color: root.isDark ? "#a1a1aa" : "#64748b" + } + } + + MouseArea { + id: clearMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + root.clear(); + } + } + } + } +} diff --git a/qml/components/base/TSSwitch.qml b/qml/components/base/TSSwitch.qml new file mode 100644 index 00000000..cdc9423c --- /dev/null +++ b/qml/components/base/TSSwitch.qml @@ -0,0 +1,87 @@ +/* + * 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 Lomiri.Components 1.3 + +Item { + id: root + + implicitWidth: units.gu(4.2) + implicitHeight: units.gu(2.1) + + property bool checked: false + property bool enabled: true + property bool interactive: true + property color checkedColor: Qt.darker(LomiriColors.orange, 1.35) + property color uncheckedColor: "#35ffffff" + property color thumbColor: "white" + property color checkedBorderColor: "transparent" + property color uncheckedBorderColor: "#40ffffff" + property color borderColor: root.checked ? checkedBorderColor : uncheckedBorderColor + property int borderWidth: root.checked ? 0 : units.dp(1) + + signal clicked() + signal toggled(bool checked) + + opacity: root.enabled ? 1.0 : 0.6 + + Rectangle { + id: track + anchors.fill: parent + radius: height / 2 + color: root.checked ? root.checkedColor : root.uncheckedColor + border.color: root.borderColor + border.width: root.borderWidth + + Behavior on color { + ColorAnimation { duration: 150 } + } + + Rectangle { + id: thumb + width: parent.height - units.dp(4) + height: width + radius: width / 2 + color: root.thumbColor + anchors.verticalCenter: parent.verticalCenter + x: root.checked ? (parent.width - width - units.dp(2)) : units.dp(2) + + Behavior on x { + NumberAnimation { duration: 150; easing.type: Easing.InOutQuad } + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.enabled && root.interactive + visible: root.interactive + cursorShape: (root.enabled && root.interactive) ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + root.clicked(); + root.toggled(!root.checked); + } + } +} diff --git a/qml/components/cards/ActivityDetailsCard.qml b/qml/components/cards/ActivityDetailsCard.qml index 997d8a8c..7cccd322 100644 --- a/qml/components/cards/ActivityDetailsCard.qml +++ b/qml/components/cards/ActivityDetailsCard.qml @@ -345,7 +345,7 @@ ListItem { spacing: units.gu(0.4) Text { - text: root.activity_type_name || (i18n.dtr("ubtms", "Type ID: ") + root.activity_type_id) + text: root.activity_type_name || i18n.dtr("ubtms", "No Type") font.pixelSize: units.gu(1.5) horizontalAlignment: Text.AlignRight width: units.gu(6) diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index 75694c49..f2e6ae11 100644 --- a/qml/components/cards/ProjectDetailsCard.qml +++ b/qml/components/cards/ProjectDetailsCard.qml @@ -35,8 +35,18 @@ import ".." ListItem { id: projectCard - width: parent.width - height: units.gu(20) + width: parent ? parent.width : units.gu(40) + height: units.gu(8.8) + divider.visible: false + color: "transparent" + highlightColor: "transparent" + + readonly property bool isDark: theme.name === "Ubuntu.Components.Themes.SuruDark" + readonly property color bgColor: isDark ? "#121212" : "#ffffff" + readonly property color bgPressedColor: isDark ? "#222222" : "#f5f5f5" + readonly property color dividerColor: isDark ? "#2c2c2e" : "#e5e7eb" + readonly property color baseTextColor: isDark ? "#f3f4f6" : "#111827" + readonly property color subTextColor: isDark ? "#9ca3af" : "#6b7280" property bool isFavorite: true property string projectName: "" @@ -54,13 +64,110 @@ ListItem { property bool hasChildren: false property int childCount: 0 property int stage: 0 + property int taskCount: 0 property bool timer_on: false property bool timer_paused: false property bool hasDraft: false signal editRequested(int recordId) signal viewRequested(int recordId) signal timesheetRequested(int localId) - signal navigationRequested(int projectId, int accountId) + signal navigationRequested(int projectId, int accountId, string projectName) + + property string stageName: (stage && stage !== 0) ? (Project.getProjectStageName(stage, accountId) || "") : "" + property bool isStageDone: { + if (!stageName) return false; + var lower = stageName.toLowerCase(); + return lower === "completed" || lower === "finished" || lower === "closed" || lower === "verified" || lower === "done"; + } + + property string timeStatus: Utils.getTimeStatusInText(deadline || endDate) + property bool hasValidTimeStatus: timeStatus !== "N/A" && timeStatus !== "Invalid" + property bool isOverdue: timeStatus.indexOf("overdue") !== -1 + property bool isDueToday: timeStatus === "Due today" + + property string descriptionSnippet: { + if (!description) return ""; + var str = String(description).trim(); + if (str === "" || str === "0" || str === "false" || str === "null" || str === "undefined") return ""; + var stripped = Utils.stripHtmlTags ? Utils.stripHtmlTags(str) : str; + var cleaned = Utils.cleanText ? Utils.cleanText(stripped) : stripped; + cleaned = cleaned.trim(); + if (cleaned === "" || cleaned === "0" || cleaned === "false") return ""; + return cleaned; + } + + property string dateRangeFormatted: { + if (!startDate && !endDate) return ""; + var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if (startDate && endDate) { + var s = new Date(startDate); + var e = new Date(endDate); + if (!isNaN(s.getTime()) && !isNaN(e.getTime())) { + if (s.getFullYear() === e.getFullYear()) { + return months[s.getMonth()] + " " + s.getDate() + " – " + months[e.getMonth()] + " " + e.getDate() + ", " + e.getFullYear(); + } else { + return months[s.getMonth()] + " " + s.getDate() + ", " + s.getFullYear() + " – " + months[e.getMonth()] + " " + e.getDate() + ", " + e.getFullYear(); + } + } + return startDate + " – " + endDate; + } + if (endDate) { + var eOnly = new Date(endDate); + if (!isNaN(eOnly.getTime())) { + return i18n.dtr("ubtms", "Due ") + months[eOnly.getMonth()] + " " + eOnly.getDate() + ", " + eOnly.getFullYear(); + } + return i18n.dtr("ubtms", "Due ") + endDate; + } + return i18n.dtr("ubtms", "Starts ") + startDate; + } + + property string plannedHoursText: { + if (allocatedHours === undefined || allocatedHours === null) return ""; + var num = parseFloat(allocatedHours); + if (!isNaN(num) && num > 0) { + var rounded = Math.round(num * 10) / 10; + return rounded + "h planned"; + } + return ""; + } + + property string formattedAccount: { + var name = accountName !== "" ? accountName : "Local"; + return name.toUpperCase(); + } + + property string remainingSubtitle: { + var parts = []; + + // 1. If no urgency status, show date range + if (!hasValidTimeStatus && dateRangeFormatted !== "") { + parts.push(dateRangeFormatted); + } + + // 2. Tasks count + if (taskCount > 0) { + parts.push(taskCount + (taskCount === 1 ? " task" : " tasks")); + } + + // 3. Planned hours + if (plannedHoursText !== "") { + parts.push(plannedHoursText); + } + + // 4. Subprojects count + if (hasChildren && childCount > 0) { + parts.push(childCount + (childCount === 1 ? " subproject" : " subprojects")); + } + + // 5. Description preview + if (descriptionSnippet !== "") { + parts.push(descriptionSnippet); + } + + return parts.join(" • "); + } + + property int effectiveProjectId: (projectCard.accountId === 0 || recordId <= 0) ? localId : recordId Connections { target: globalTimerWidget @@ -69,33 +176,38 @@ ListItem { timer_paused = false; } onTimerStarted: { - if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesProjectIdMatchSheetInActive(effectiveProjectId, TimerService.getActiveTimesheetId())) { timer_on = true; } } onTimerPaused: { - if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesProjectIdMatchSheetInActive(effectiveProjectId, TimerService.getActiveTimesheetId())) { timer_paused = true; } } onTimerResumed: { - if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesProjectIdMatchSheetInActive(effectiveProjectId, TimerService.getActiveTimesheetId())) { timer_paused = false; } } } + Component.onCompleted: { + if (TimerService.isRunning() && Timesheet.doesProjectIdMatchSheetInActive(effectiveProjectId, TimerService.getActiveTimesheetId())) { + timer_on = true; + timer_paused = TimerService.isPaused(); + } + } + function play_pause_workflow() { - if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesProjectIdMatchSheetInActive(effectiveProjectId, TimerService.getActiveTimesheetId())) { if (TimerService.isRunning() && !TimerService.isPaused()) { - // If running and not paused, pause it TimerService.pause(); } else if (TimerService.isPaused()) { - // If paused, resume it TimerService.start(TimerService.getActiveTimesheetId()); } } else { - let result = Timesheet.createTimesheetFromProject(recordId); + let result = Timesheet.createTimesheetFromProject(effectiveProjectId); if (result.success) { const result_start = TimerService.start(result.id); if (!result_start.success) { @@ -108,8 +220,12 @@ ListItem { } function stop_workflow() { - if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + var activeId = TimerService.getActiveTimesheetId(); + if (Timesheet.doesProjectIdMatchSheetInActive(effectiveProjectId, activeId)) { TimerService.stop(); + if (projectCard.accountId === 0) { + Timesheet.markTimesheetAsSavedById(activeId); + } } } @@ -122,7 +238,7 @@ ListItem { Action { id: playpauseaction iconSource: timer_on ? (timer_paused ? "../../images/play.png" : "../../images/pause.png") : "../../images/play.png" - visible: recordId > 0 + visible: (projectCard.accountId === 0 && localId > 0) || (projectCard.accountId > 0 && recordId > 0) text: "Start Timer" onTriggered: { play_pause_workflow(); @@ -130,7 +246,7 @@ ListItem { }, Action { id: startstopaction - visible: recordId > 0 + visible: (projectCard.accountId === 0 && localId > 0) || (projectCard.accountId > 0 && recordId > 0) iconSource: "../../images/stop.png" text: i18n.dtr("ubtms", "Stop Timer") onTriggered: { @@ -141,266 +257,284 @@ ListItem { } Rectangle { + id: itemBackground anchors.fill: parent - border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#444" : "#dcdcdc" - radius: units.gu(0.2) - anchors.leftMargin: units.gu(0.2) - anchors.rightMargin: units.gu(0.2) - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#111" : "#fff" + color: itemMouseArea.pressed ? projectCard.bgPressedColor : projectCard.bgColor + + Behavior on color { + ColorAnimation { duration: 100 } + } - // subtle color fade on the left + // Left accent capsule bar for project color (Taste skill: tactile floating capsule) Rectangle { - width: parent.width * 0.025 - height: parent.height + id: accentBar anchors.left: parent.left - gradient: Gradient { - orientation: Gradient.Horizontal - GradientStop { - position: 0.0 - color: Utils.getColorFromOdooIndex(colorPallet) - } - GradientStop { - position: 1.0 - color: Qt.rgba(Utils.getColorFromOdooIndex(colorPallet).r, Utils.getColorFromOdooIndex(colorPallet).g, Utils.getColorFromOdooIndex(colorPallet).b, 0.0) + anchors.leftMargin: units.gu(0.35) + anchors.verticalCenter: parent.verticalCenter + width: units.gu(0.4) + height: parent.height - units.gu(3.2) + radius: units.gu(0.2) + color: Utils.getColorFromOdooIndex(colorPallet) + } + + // Card tap area + MouseArea { + id: itemMouseArea + anchors.fill: parent + z: 1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (hasChildren) { + var navId = (projectCard.accountId === 0 || recordId <= 0) ? localId : recordId; + navigationRequested(navId, projectCard.accountId || 0, projectName); + } else { + viewRequested(localId); } } } - Row { - anchors.fill: parent - spacing: 2 + // Left icon container: Favorite Star / Timer active indicator + // Perfectly top-anchored and aligned with Title row (height: 2.8 GU) + Item { + id: leftIconArea + anchors.left: parent.left + anchors.leftMargin: units.gu(1.6) + anchors.top: parent.top + anchors.topMargin: units.gu(1.5) + width: units.gu(3.4) + height: units.gu(2.8) + z: 10 + + Image { + id: starIcon + anchors.centerIn: parent + source: isFavorite ? "../../images/star.png" : "../../images/star-inactive.png" + fillMode: Image.PreserveAspectFit + width: units.gu(2.4) + height: units.gu(2.4) + visible: !timer_on + } Rectangle { - width: parent.width - units.gu(17) - height: parent.height - color: "transparent" - z: 1 + id: indicator + width: units.gu(2.0) + height: units.gu(2.0) + radius: units.gu(1.0) + color: "#ffa500" + anchors.centerIn: parent + visible: timer_on + + SequentialAnimation on opacity { + loops: Animation.Infinite + running: indicator.visible + NumberAnimation { from: 0.3; to: 1.0; duration: 800; easing.type: Easing.InOutQuad } + NumberAnimation { from: 1.0; to: 0.3; duration: 800; easing.type: Easing.InOutQuad } + } + } + + // Expanded tap target for easy one-handed thumb toggle + MouseArea { + anchors.fill: parent + anchors.margins: -units.gu(0.8) + enabled: !timer_on + cursorShape: Qt.PointingHandCursor + onClicked: { + mouse.accepted = true; + var newFavoriteState = !isFavorite; + var result = Project.toggleProjectFavorite(localId, newFavoriteState, "updated"); + if (result.success) { + isFavorite = newFavoriteState; + starIcon.source = isFavorite ? "../../images/star.png" : "../../images/star-inactive.png"; + } else { + console.warn("Failed to toggle project favorite:", result.message); + } + } + } + } + + // Right content column: Top-anchored at 1.5 GU with 8.8 GU card height + Column { + id: contentColumn + anchors.left: leftIconArea.right + anchors.leftMargin: units.gu(1.2) + anchors.right: parent.right + anchors.rightMargin: units.gu(1.8) + anchors.top: parent.top + anchors.topMargin: units.gu(1.5) + spacing: units.gu(0.7) + z: 5 + + // ROW 1: Header (Title, Draft Badge, Stage Pill, Chevron) + Row { + id: titleRow + width: parent.width + height: units.gu(2.8) + spacing: units.gu(0.8) + + Text { + id: titleText + text: projectName !== "" ? projectName : i18n.dtr("ubtms", "Unnamed Project") + color: hasChildren ? AppConst.Colors.Orange : projectCard.baseTextColor + font.pixelSize: units.gu(1.85) + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + width: parent.width - headerRightRow.width - parent.spacing + anchors.verticalCenter: parent.verticalCenter + } Row { - width: parent.width - height: parent.height - spacing: units.gu(1) + id: headerRightRow + anchors.verticalCenter: parent.verticalCenter + spacing: units.gu(0.6) - Item { - width: units.gu(4) - height: parent.height - z: 2 - - Image { - id: starIcon - anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: parent.horizontalCenter - anchors.leftMargin: units.gu(0.5) - source: isFavorite ? "../../images/star.png" : "../../images/star-inactive.png" - fillMode: Image.PreserveAspectFit - width: units.gu(2) - height: units.gu(2) - visible: !timer_on - } + // Draft indicator badge + Rectangle { + visible: hasDraft + height: units.gu(2.2) + width: draftLabel.width + units.gu(1.2) + radius: height / 2 + anchors.verticalCenter: parent.verticalCenter + color: projectCard.isDark ? "#312010" : "#fff7ed" + border.color: "#fb923c" + border.width: 1 - // Large clickable area for the star - MouseArea { - anchors.fill: parent - - enabled: !timer_on // Only enabled when star is visible - onClicked: { - mouse.accepted = true; // Prevent event propagation to parent MouseArea - var newFavoriteState = !isFavorite; - var result = Project.toggleProjectFavorite(localId, newFavoriteState, "updated"); - - if (result.success) { - isFavorite = newFavoriteState; - starIcon.source = isFavorite ? "../../images/star.png" : "../../images/star-inactive.png"; - } else { - console.warn("Failed to toggle project favorite:", result.message); - } - } - } - Rectangle { - id: indicator - width: units.gu(2) - height: units.gu(2) - radius: units.gu(1) - color: "#ffa500" - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - visible: timer_on - - SequentialAnimation on opacity { - loops: Animation.Infinite - running: indicator.visible - NumberAnimation { - from: 0.3 - to: 1 - duration: 800 - easing.type: Easing.InOutQuad - } - NumberAnimation { - from: 1 - to: 0.3 - duration: 800 - easing.type: Easing.InOutQuad - } - } + Text { + id: draftLabel + text: i18n.dtr("ubtms", "DRAFT") + font.pixelSize: units.gu(1.05) + font.bold: true + color: "#ea580c" + anchors.centerIn: parent } } + // Stage pill with high-contrast styling for high-DPI Rectangle { - width: units.gu(25) - height: parent.height - color: 'transparent' - - // Main content area MouseArea for navigation - MouseArea { - anchors.fill: parent - z: 1 // Much lower than star MouseArea - onClicked: { - if (hasChildren && recordId > 0) { - // For projects with children, emit navigation signal - navigationRequested(recordId, projectCard.accountId || 0); - } else { - // For leaf projects, show details (same as View-On action) - viewRequested(localId); - } - } - } + visible: stageName !== "" + height: units.gu(2.5) + width: stageText.width + units.gu(1.6) + radius: height / 2 + anchors.verticalCenter: parent.verticalCenter + color: isStageDone ? (projectCard.isDark ? "#064e3b" : "#ecfdf5") + : (projectCard.isDark ? "#1e293b" : "#f1f5f9") + border.color: isStageDone ? (projectCard.isDark ? "#059669" : "#a7f3d0") + : (projectCard.isDark ? "#334155" : "#cbd5e1") + border.width: 1 - Column { - width: parent.width - height: parent.height - spacing: units.gu(0.2) - - Text { - text: projectName !== "" ? projectName : "Unnamed Project" - color: hasChildren ? AppConst.Colors.Orange : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") - font.pixelSize: units.gu(2) - wrapMode: Text.WordWrap - maximumLineCount: 2 - clip: true - width: parent.width - units.gu(2) - } - - Text { - text: Utils.truncateText(accountName !== "" ? accountName : "Local", 20) - font.pixelSize: units.gu(1.6) - wrapMode: Text.Wrap - maximumLineCount: 2 - width: parent.width - units.gu(2) - height: units.gu(2) - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#222" - } - - // Label { - // id: details - // text: "Details" - // width: parent.width - units.gu(2) - // font.pixelSize: units.gu(1.6) - // height: units.gu(3) - // color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#80bfff" : "blue" - // font.underline: true - // MouseArea { - // anchors.fill: parent - // onClicked: { - // mouse.accepted = true; // Prevent event propagation to parent MouseArea - // viewRequested(localId); - // } - // } - // } - - Text { - text: (childCount > 0 ? " [+" + childCount + "] Projects " : "") - visible: childCount > 0 - color: hasChildren ? AppConst.Colors.Orange : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") - font.pixelSize: units.gu(1.5) - // horizontalAlignment: Text.AlignRight - width: parent.width - } - - Text { - - text: Project.getProjectStageName(stage) - color: Project.getProjectStageName(stage).toLowerCase() === "completed" || Project.getProjectStageName(stage).toLowerCase() === "finished" || Project.getProjectStageName(stage).toLowerCase() === "closed" || Project.getProjectStageName(stage).toLowerCase() === "verified" || Project.getProjectStageName(stage).toLowerCase() === "done" ? "green" : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#555") - font.pixelSize: units.gu(1.75) - font.bold: Project.getProjectStageName(stage).toLowerCase() === "completed" || Project.getProjectStageName(stage).toLowerCase() === "finished" || Project.getProjectStageName(stage).toLowerCase() === "closed" || Project.getProjectStageName(stage).toLowerCase() === "verified" || Project.getProjectStageName(stage).toLowerCase() === "done" ? true : false - } - - Rectangle { - id: draftIndicator - visible: hasDraft - width: draftLabel.width + units.gu(1.2) - height: units.gu(2) - radius: height / 2 - color: "#FFF3E0" - border.color: "#FF9800" - border.width: units.gu(0.15) - anchors.left: parent.left - - Text { - id: draftLabel - text: i18n.dtr("ubtms", "DRAFT") - font.pixelSize: units.gu(1.1) - font.bold: true - color: "#F57C00" - anchors.centerIn: parent - } + Text { + id: stageText + text: stageName + font.pixelSize: units.gu(1.2) + font.bold: true + anchors.centerIn: parent + color: isStageDone ? (projectCard.isDark ? "#6ee7b7" : "#047857") + : (projectCard.isDark ? "#cbd5e1" : "#475569") } + } + + // Progression chevron for projects with subprojects (like SettingsListItem) + Item { + visible: hasChildren + width: units.gu(1.6) + height: parent.height + + Text { + anchors.centerIn: parent + text: "›" + font.pixelSize: units.gu(2.4) + color: projectCard.isDark ? "#888888" : "#c7c7cc" } } } } - Rectangle { - width: units.gu(15) - height: parent.height - color: 'transparent' + // ROW 2: Subtitle (Workspace • Urgency • Tasks • Subprojects • Description) + Row { + id: subtitleRow + width: parent.width + height: units.gu(2.3) + spacing: units.gu(0.6) + + // Formatted uppercase account name (e.g. "CIT") + Text { + id: accountLabel + text: formattedAccount + font.pixelSize: units.gu(1.3) + font.bold: true + color: projectCard.isDark ? "#a1a1aa" : "#475569" + anchors.verticalCenter: parent.verticalCenter + } - Item { - anchors.right: parent.right + // Urgency status indicator & text + Row { + id: urgencyRow + visible: hasValidTimeStatus + spacing: units.gu(0.35) anchors.verticalCenter: parent.verticalCenter - width: parent.width - height: childrenRect.height - Column { - spacing: units.gu(0.4) - width: parent.width + Text { + text: "•" + font.pixelSize: units.gu(1.25) + color: projectCard.isDark ? "#4b5563" : "#cbd5e1" + anchors.verticalCenter: parent.verticalCenter + } - Text { - text: i18n.dtr("ubtms", "Planned (H): ") + Utils.truncateText(allocatedHours, 6) - font.pixelSize: units.gu(1.5) - horizontalAlignment: Text.AlignRight - width: parent.width - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#555" - } + Icon { + name: isOverdue ? "dialog-warning" : "appointment" + width: units.gu(1.3) + height: units.gu(1.3) + color: isOverdue ? (projectCard.isDark ? "#f87171" : "#dc2626") + : (isDueToday ? (projectCard.isDark ? "#fbbf24" : "#d97706") + : (projectCard.isDark ? "#4ade80" : "#16a34a")) + anchors.verticalCenter: parent.verticalCenter + } - Text { - text: i18n.dtr("ubtms", "Start Date: ") + (startDate !== "" ? startDate : i18n.dtr("ubtms", "Not set")) - font.pixelSize: units.gu(1.5) - horizontalAlignment: Text.AlignRight - width: parent.width - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#222" - } + Text { + text: timeStatus + font.pixelSize: units.gu(1.25) + font.bold: isOverdue || isDueToday + color: isOverdue ? (projectCard.isDark ? "#f87171" : "#dc2626") + : (isDueToday ? (projectCard.isDark ? "#fbbf24" : "#d97706") + : (projectCard.isDark ? "#4ade80" : "#16a34a")) + anchors.verticalCenter: parent.verticalCenter + } + } - Text { - text: i18n.dtr("ubtms", "End Date: ") + (endDate !== "" ? endDate : i18n.dtr("ubtms", "Not set")) - font.pixelSize: units.gu(1.5) - horizontalAlignment: Text.AlignRight - width: parent.width - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#222" - } + // Separator dot before remaining metadata + Text { + id: sepDot + visible: remainingSubtitle !== "" + text: "•" + font.pixelSize: units.gu(1.25) + color: projectCard.isDark ? "#4b5563" : "#cbd5e1" + anchors.verticalCenter: parent.verticalCenter + } - Text { - text: Utils.getTimeStatusInText(projectCard.deadline || projectCard.endDate) - font.pixelSize: units.gu(1.5) - horizontalAlignment: Text.AlignRight - width: parent.width - color: { - var statusText = Utils.getTimeStatusInText(projectCard.deadline || projectCard.endDate); - return (statusText === "N/A" || statusText === "Invalid") ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#555") : (statusText.indexOf("overdue") !== -1 ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#ff6666" : "#e53935") : "green"); - } - } - } + // Remaining metadata (tasks, hours, subprojects, description) + Text { + id: remainingText + visible: remainingSubtitle !== "" + text: remainingSubtitle + font.pixelSize: units.gu(1.25) + color: projectCard.subTextColor + elide: Text.ElideRight + maximumLineCount: 1 + width: Math.max(units.gu(5), parent.width - accountLabel.width - (urgencyRow.visible ? urgencyRow.width + parent.spacing : 0) - (sepDot.visible ? sepDot.width + parent.spacing : 0) - parent.spacing) + anchors.verticalCenter: parent.verticalCenter } } } + + // Clean bottom divider line matching Settings / MenuPage (indented past star icon area) + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: units.gu(6.2) + height: units.dp(1) + color: projectCard.dividerColor + } } } diff --git a/qml/components/dialogs/AccountSelectorDialog.qml b/qml/components/dialogs/AccountSelectorDialog.qml index 4134c590..66f72059 100644 --- a/qml/components/dialogs/AccountSelectorDialog.qml +++ b/qml/components/dialogs/AccountSelectorDialog.qml @@ -24,17 +24,58 @@ Item { /** Persist last accepted choice (set when user taps an account) */ property int selectedAccountId: Accounts.getDefaultAccountId() property string selectedAccountName: Accounts.getAccountName(Accounts.getDefaultAccountId()) + property int lastRemoteAccountId: -1 + + Component.onCompleted: { + if (selectedAccountId > 0) { + lastRemoteAccountId = selectedAccountId + } else { + lastRemoteAccountId = Accounts.getDefaultRemoteAccountId() + } + } signal accepted(int accountId, string accountName) signal canceled() // carry initial request until dialog is visible property int _initialAccountId: -2 // -2 = none, -1 = "All" + property var activeDialog: null /** Show dialog; optionally preselect an account id */ function open(initialAccountId) { + if (activeDialog) + return activeDialog _initialAccountId = (typeof initialAccountId === "number") ? initialAccountId : -2 - PopupUtils.open(dialogComponent) + activeDialog = PopupUtils.open(dialogComponent) + return activeDialog + } + + /** Toggle between Local Account (0) and last active remote account */ + function toggleLocalMode(enableLocal) { + if (enableLocal) { + if (selectedAccountId !== 0) { + selectedAccountId = 0 + selectedAccountName = Accounts.getAccountName(0) + accepted(0, selectedAccountName) + } + return true + } else { + var targetId = (lastRemoteAccountId > 0 && Accounts.getAccountName(lastRemoteAccountId)) + ? lastRemoteAccountId + : Accounts.getDefaultRemoteAccountId() + if (targetId > 0 && selectedAccountId !== targetId) { + selectedAccountId = targetId + selectedAccountName = Accounts.getAccountName(targetId) + accepted(targetId, selectedAccountName) + return true + } else if (targetId <= 0) { + if (typeof notifPopup !== "undefined") { + notifPopup.open(i18n.dtr("ubtms", "Notice"), i18n.dtr("ubtms", "You don't have any account logged in"), "warning") + } + return false + } + return true + } } // ---------- Private ---------- @@ -46,11 +87,6 @@ Item { title: root.titleText modal: true - StyleHints { - backgroundColor: theme.palette.normal.background - foregroundColor: theme.palette.normal.backgroundText - } - property bool isLoadingAccounts: false property int preselectedId: -2 @@ -178,6 +214,9 @@ Item { onClicked: { var accountId = model.accountId var accountName = model.name + if (accountId > 0) { + root.lastRemoteAccountId = accountId + } root.selectedAccountId = accountId root.selectedAccountName = accountName PopupUtils.close(dlg) @@ -258,6 +297,10 @@ Item { loadAccounts() } } + + Component.onDestruction: { + root.activeDialog = null + } } } } diff --git a/qml/components/dialogs/ContentPickerDialog.qml b/qml/components/dialogs/ContentPickerDialog.qml index 82e0e037..af9d5d9f 100644 --- a/qml/components/dialogs/ContentPickerDialog.qml +++ b/qml/components/dialogs/ContentPickerDialog.qml @@ -16,6 +16,7 @@ Popups.PopupBase { signal complete signal filesImported(var files) property var host + property bool _deliveryHandled: false // --- Simple type resolver (no singleton) --- function resolveType(fileUrl) { @@ -92,6 +93,7 @@ Popups.PopupBase { function _startWithPeer(peer) { try { + picker._deliveryHandled = false; peer.selectionType = ContentTransfer.Single; picker.activeTransfer = peer.request(); stateChangeConnection.target = picker.activeTransfer; @@ -146,6 +148,7 @@ Popups.PopupBase { onPeerSelected: { // normal manual selection + picker._deliveryHandled = false; peer.selectionType = (isExport ? ContentTransfer.Single : ContentTransfer.Multiple); picker.activeTransfer = peer.request(); stateChangeConnection.target = picker.activeTransfer; @@ -171,9 +174,11 @@ Popups.PopupBase { picker.activeTransfer.state = ContentTransfer.Charged; closeTimer.start(); } else if (!isExport && picker.activeTransfer.state === ContentTransfer.Charged) { - // Import: deliver selected items, then close - picker.filesImported(picker.activeTransfer.items); - closeTimer.start(); + if (!picker._deliveryHandled) { + picker._deliveryHandled = true; + picker.filesImported(picker.activeTransfer.items); + closeTimer.start(); + } } } } diff --git a/qml/components/feedback/NotificationPopup.qml b/qml/components/feedback/NotificationPopup.qml index 16b4d70f..189e7cfe 100644 --- a/qml/components/feedback/NotificationPopup.qml +++ b/qml/components/feedback/NotificationPopup.qml @@ -37,6 +37,8 @@ Item { property string type: "info" // "success", "error", "warning", "info" property string titleText: "Notice" property string messageText: "Something happened." + property var activeDialog: null + readonly property bool isOpen: activeDialog !== null signal closed Component { @@ -45,12 +47,7 @@ Item { Dialog { id: popupDialog title: popupWrapper.titleText - - // Dark mode friendly styling - StyleHints { - backgroundColor: theme.palette.normal.background - foregroundColor: theme.palette.normal.backgroundText - } + modal: true Text { id: messageText @@ -68,7 +65,11 @@ Item { // Color logic based on type (optional, add custom styling if needed) Button { text: "OK" - onClicked: PopupUtils.close(popupDialog) + onClicked: { + PopupUtils.close(popupDialog); + popupWrapper.activeDialog = null; + popupWrapper.closed(); + } // Dark mode friendly button styling StyleHints { @@ -77,6 +78,10 @@ Item { backgroundColor: LomiriColors.orange } } + + Component.onDestruction: { + popupWrapper.activeDialog = null; + } } } @@ -87,6 +92,11 @@ Item { messageText = messageArg; if (typeArg) type = typeArg; - PopupUtils.open(dialogComponent); + + if (activeDialog) + return activeDialog; + + activeDialog = PopupUtils.open(dialogComponent); + return activeDialog; } } diff --git a/qml/components/navigation/DialerMenu.qml b/qml/components/navigation/DialerMenu.qml index 36bdf8bb..a00abbb7 100644 --- a/qml/components/navigation/DialerMenu.qml +++ b/qml/components/navigation/DialerMenu.qml @@ -32,81 +32,275 @@ import ".." Item { id: dialerMenu - width: parent.width - height: parent.height + anchors.fill: parent signal menuItemSelected(int index) property alias menuModel: repeater.model - //property alias text: fab.text property bool expanded: false - property int fabSize: units.gu(7) - property int itemSize: units.gu(6) - - // Floating Action Button (FAB) - TSIconButton { - id: fab - width: fabSize - height: fabSize - radius: width / 2 - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.margins: units.gu(2) - iconName: "open-menu-symbolic" + property int fabSize: units.gu(6.5) + property int itemSize: units.gu(5) + // Backdrop dismiss scrim (captures taps outside menu to dismiss) + Rectangle { + id: backdropScrim + anchors.fill: parent + color: "#000000" + opacity: dialerMenu.expanded ? 0.35 : 0.0 + visible: opacity > 0.01 z: 10 - //text: "+" - //fontSize: units.gu(3) - onClicked: { - expanded = !expanded; + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } + + MouseArea { + anchors.fill: parent + enabled: dialerMenu.expanded + cursorShape: Qt.ArrowCursor + onClicked: dialerMenu.expanded = false } } - // Professional vertical list menu + // Action menu items column Column { id: menuList - visible: expanded - spacing: 8 - anchors.right: fab.left - anchors.bottom: fab.top - anchors.margins: 12 - z: 9 + visible: dialerMenu.expanded + spacing: units.gu(1.2) + anchors.right: fabContainer.right + anchors.bottom: fabContainer.top + anchors.bottomMargin: units.gu(1.5) + z: 20 Repeater { id: repeater - model: menuModel - delegate: Rectangle { - width: units.gu(17.5) - height: units.gu(5) - radius: units.gu(1) - color: LomiriColors.orange - //border.color: "#cccccc" - //border.width: 1 - opacity: expanded ? 1 : 0 + delegate: Item { + id: itemDelegate + width: cardRow.implicitWidth + units.gu(2.8) + height: dialerMenu.itemSize + anchors.right: parent ? parent.right : undefined + + readonly property bool isDarkTheme: theme.name === "Ubuntu.Components.Themes.SuruDark" + readonly property string itemIcon: (modelData && modelData.iconName) ? modelData.iconName : "add" + readonly property string itemLabel: (modelData && modelData.label) ? modelData.label : "" + + opacity: dialerMenu.expanded ? 1.0 : 0.0 + scale: dialerMenu.expanded ? 1.0 : 0.85 + transform: Translate { + y: dialerMenu.expanded ? 0 : units.gu(1.5) + Behavior on y { + NumberAnimation { + duration: 180 + (repeater.count - 1 - index) * 35 + easing.type: Easing.OutBack + } + } + } Behavior on opacity { + id: opacityAnim NumberAnimation { - duration: 150 + duration: 150 + (repeater.count - 1 - index) * 30 + easing.type: Easing.OutQuad } } - Text { - text: modelData.label - anchors.centerIn: parent - font.pixelSize: units.gu(2) - color: "white" - horizontalAlignment: Text.AlignHCenter + Behavior on scale { + NumberAnimation { + duration: 180 + (repeater.count - 1 - index) * 35 + easing.type: Easing.OutBack + } + } + + Rectangle { + id: cardBg + anchors.fill: parent + radius: height / 2 + color: itemMouseArea.pressed + ? (itemDelegate.isDarkTheme ? "#383838" : "#ebebeb") + : (itemMouseArea.containsMouse + ? (itemDelegate.isDarkTheme ? "#303030" : "#f7f7f7") + : (itemDelegate.isDarkTheme ? "#222222" : "#ffffff")) + border.color: itemDelegate.isDarkTheme ? "#404040" : "#e0e0e0" + border.width: 1 + + layer.enabled: true + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 0 + verticalOffset: units.gu(0.3) + radius: units.gu(1.0) + samples: 16 + color: itemDelegate.isDarkTheme ? "#60000000" : "#25000000" + } + + Behavior on color { + ColorAnimation { duration: 100 } + } + + Row { + id: cardRow + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: units.gu(2.2) + spacing: units.gu(1.2) + + Text { + id: labelText + anchors.verticalCenter: parent.verticalCenter + text: itemDelegate.itemLabel + color: itemDelegate.isDarkTheme ? "#f5f5f5" : "#1a1a1a" + font.pixelSize: units.gu(1.8) + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + // Circular icon chip + Rectangle { + id: iconChip + width: units.gu(3.8) + height: units.gu(3.8) + radius: width / 2 + anchors.verticalCenter: parent.verticalCenter + color: LomiriColors.orange + + Icon { + id: actionIcon + anchors.centerIn: parent + width: units.gu(2) + height: units.gu(2) + name: itemDelegate.itemIcon + color: "#ffffff" + } + + ColorOverlay { + anchors.fill: actionIcon + source: actionIcon + color: "#ffffff" + } + } + } } MouseArea { + id: itemMouseArea anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor onClicked: { - expanded = false; - dialerMenu.menuItemSelected(model.index); + dialerMenu.expanded = false; + dialerMenu.menuItemSelected(index); + } + } + } + } + } + + // Main Floating Action Button Container + Item { + id: fabContainer + width: dialerMenu.fabSize + height: dialerMenu.fabSize + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: units.gu(2.5) + z: 20 + + scale: fabMouseArea.pressed ? 0.92 : (fabMouseArea.containsMouse ? 1.05 : 1.0) + Behavior on scale { + NumberAnimation { duration: 120; easing.type: Easing.OutQuad } + } + + Rectangle { + id: fabCircle + anchors.fill: parent + radius: width / 2 + color: fabMouseArea.containsMouse ? Qt.darker(LomiriColors.orange, 1.1) : LomiriColors.orange + + Behavior on color { + ColorAnimation { duration: 150 } + } + + layer.enabled: true + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 0 + verticalOffset: units.gu(0.4) + radius: units.gu(1.2) + samples: 16 + color: "#45000000" + } + + // Dual-icon container with smooth rotation and cross-fade morph + Item { + id: iconContainer + anchors.centerIn: parent + width: units.gu(3) + height: units.gu(3) + + // Hamburger icon layer + Item { + anchors.fill: parent + opacity: dialerMenu.expanded ? 0.0 : 1.0 + rotation: dialerMenu.expanded ? 90 : 0 + + Behavior on opacity { + NumberAnimation { duration: 180; easing.type: Easing.OutQuad } + } + Behavior on rotation { + NumberAnimation { duration: 220; easing.type: Easing.OutBack } + } + + Icon { + id: hamburgerIcon + anchors.fill: parent + name: "open-menu-symbolic" + color: "#ffffff" + } + + ColorOverlay { + anchors.fill: hamburgerIcon + source: hamburgerIcon + color: "#ffffff" } } + + // Close 'x' icon layer + Item { + anchors.fill: parent + opacity: dialerMenu.expanded ? 1.0 : 0.0 + rotation: dialerMenu.expanded ? 0 : -90 + + Behavior on opacity { + NumberAnimation { duration: 180; easing.type: Easing.OutQuad } + } + Behavior on rotation { + NumberAnimation { duration: 220; easing.type: Easing.OutBack } + } + + Icon { + id: closeIcon + anchors.fill: parent + name: "close" + color: "#ffffff" + } + + ColorOverlay { + anchors.fill: closeIcon + source: closeIcon + color: "#ffffff" + } + } + } + } + + MouseArea { + id: fabMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + dialerMenu.expanded = !dialerMenu.expanded; } } } diff --git a/qml/components/navigation/ListHeader.qml b/qml/components/navigation/ListHeader.qml index 9b6d5997..3115bfcc 100644 --- a/qml/components/navigation/ListHeader.qml +++ b/qml/components/navigation/ListHeader.qml @@ -6,7 +6,7 @@ import ".." Rectangle { id: topFilterBar width: parent ? parent.width : Screen.width - height: (showSearchBox ? units.gu(5) : 0) + (filterModel && filterModel.length > 0 ? units.gu(6) : 0) + height: (showSearchBox ? searchBar.implicitHeight : 0) + (filterModel && filterModel.length > 0 ? units.gu(6) : 0) color: "transparent" // Helper property to check if dark mode is active @@ -33,6 +33,8 @@ Rectangle { property string filter7: "" property bool showSearchBox: true + property string searchPlaceholderText: i18n.dtr("ubtms", "Search...") + property alias searchText: searchBar.text property string currentFilter: "" // Track currently selected filter signal filterSelected(string filterKey) @@ -125,13 +127,18 @@ Rectangle { showSearchBox = !showSearchBox; if (!showSearchBox) { clearSearch(); + } else { + searchBar.forceActiveFocus(); } } // Add function to clear search and reset filters function clearSearch() { - searchField.text = ""; - customSearch(""); + if (searchBar.text !== "") { + searchBar.clear(); + } else { + customSearch(""); + } } Column { @@ -139,69 +146,21 @@ Rectangle { anchors.fill: parent spacing: 0 - // Search field at the top - Rectangle { - visible: topFilterBar.showSearchBox - height: units.gu(5) + // Search bar + TSSearchBar { + id: searchBar width: parent.width - anchors.left: parent.left - anchors.right: parent.right - color: topFilterBar.isDark ? "#1E1E1E" : "#FFFFFF" - border.width: 0 - - Rectangle { - width: parent.width - height: 1 - anchors.bottom: parent.bottom - color: searchField.activeFocus ? "#FF6B35" : (topFilterBar.isDark ? "#48484A" : "#E0E0E0") - } + height: topFilterBar.showSearchBox ? implicitHeight : 0 + visible: topFilterBar.showSearchBox + placeholderText: topFilterBar.searchPlaceholderText + bottomPadding: units.gu(1.2) - TextField { - id: searchField - anchors.fill: parent - anchors.rightMargin: units.gu(4) // Space for clear button - anchors.leftMargin: units.gu(1) - background: Rectangle { - color: "transparent" - } - color: topFilterBar.isDark ? "#FFFFFF" : "#333333" - selectByMouse: true - onAccepted: topFilterBar.customSearch(text) - - // Custom placeholder text to guarantee color on Ubuntu Touch - Text { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: units.gu(0.5) - text: i18n.dtr("ubtms", "Search...") - color: topFilterBar.isDark ? "#CCCCCC" : "#888888" - font: searchField.font - visible: !searchField.text && !searchField.activeFocus - elide: Text.ElideRight - } + onAccepted: { + topFilterBar.customSearch(query); } - Button { - id: clearButton - visible: searchField.text.length > 0 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.rightMargin: units.gu(0.5) - width: units.gu(3) - height: units.gu(3) - text: "x" - background: Rectangle { - color: "transparent" - } - contentItem: Text { - text: parent.text - color: "#888888" - font.pixelSize: units.gu(2) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: topFilterBar.clearSearch() + onCleared: { + topFilterBar.customSearch(""); } } diff --git a/qml/components/navigation/NavigationMenuList.qml b/qml/components/navigation/NavigationMenuList.qml index 1da1ed30..5a347ada 100644 --- a/qml/components/navigation/NavigationMenuList.qml +++ b/qml/components/navigation/NavigationMenuList.qml @@ -8,6 +8,7 @@ Column { width: parent ? parent.width : 0 property var menuItems: [] + property bool collapsed: false property string selectedPageUrl: "" signal itemSelected(var item) @@ -16,6 +17,7 @@ Column { SettingsComponents.SettingsListItem { width: root.width + collapsed: root.collapsed iconName: modelData.iconName iconColor: modelData.iconColor text: i18n.dtr("ubtms", modelData.textKey) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index 9880376d..ab210212 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -1,7 +1,7 @@ import QtQuick 2.12 -import QtQuick.Controls 2.12 import QtQuick.Layouts 1.3 import QtQuick.Dialogs 1.2 +import Lomiri.Components 1.3 import Lomiri.Components.Pickers 1.3 import ".." @@ -18,6 +18,8 @@ Item { property bool isStartDateValid: true property bool isEndDateValid: true + readonly property bool isDarkTheme: (typeof Theme !== "undefined" && Theme.name === "Ubuntu.Components.Themes.SuruDark") || (typeof theme !== "undefined" && theme.name === "Ubuntu.Components.Themes.SuruDark") + /** * Returns the selected start date as a formatted string (yyyy-MM-dd). * @returns {string} @@ -106,9 +108,8 @@ Item { Layout.fillWidth: true spacing: units.gu(0.5) TSLabel { - text: "Start Date" + text: i18n.dtr("ubtms", "Start Date") enabled: !dateRangeSelector.readOnly - // font.pixelSize: units.gu(1.8) } Item { @@ -117,13 +118,24 @@ Item { Layout.fillWidth: true Layout.preferredHeight: units.gu(5) - TextField { + Rectangle { + id: startDateField anchors.fill: parent - readOnly: true - enabled: !dateRangeSelector.readOnly - text: isStartDateValid ? Qt.formatDate(startDateItem.date, "dd-MM-yyyy") : "" - placeholderText: isStartDateValid ? "" : "No date set" - color: isStartDateValid ? "black" : "gray" + radius: units.gu(0.5) + color: !dateRangeSelector.readOnly ? (isDarkTheme ? "#1b1b1f" : "#ffffff") : (isDarkTheme ? "#2a2a2a" : "#eeeeee") + border.width: units.gu(0.1) + border.color: isDarkTheme ? "#3a3a3f" : "#c0c0c0" + + Label { + anchors.fill: parent + anchors.leftMargin: units.gu(1.5) + anchors.rightMargin: units.gu(1.5) + verticalAlignment: Text.AlignVCenter + text: isStartDateValid ? Qt.formatDate(startDateItem.date, "dd-MM-yyyy") : i18n.dtr("ubtms", "No date set") + color: isStartDateValid ? (isDarkTheme ? "#ebebef" : "#333333") : (isDarkTheme ? "#9a9aa2" : "#888888") + font.pixelSize: units.gu(1.8) + elide: Text.ElideRight + } } MouseArea { @@ -153,9 +165,8 @@ Item { Layout.fillWidth: true spacing: units.gu(0.5) TSLabel { - text: "End Date" + text: i18n.dtr("ubtms", "End Date") enabled: !dateRangeSelector.readOnly - // font.pixelSize: units.gu(1.8) } Item { @@ -164,13 +175,24 @@ Item { Layout.fillWidth: true Layout.preferredHeight: units.gu(5) - TextField { + Rectangle { + id: endDateField anchors.fill: parent - readOnly: true - enabled: !dateRangeSelector.readOnly - text: isEndDateValid ? Qt.formatDate(endDateItem.date, "dd-MM-yyyy") : "" - placeholderText: isEndDateValid ? "" : "No date set" - color: isEndDateValid ? "black" : "gray" + radius: units.gu(0.5) + color: !dateRangeSelector.readOnly ? (isDarkTheme ? "#1b1b1f" : "#ffffff") : (isDarkTheme ? "#2a2a2a" : "#eeeeee") + border.width: units.gu(0.1) + border.color: isDarkTheme ? "#3a3a3f" : "#c0c0c0" + + Label { + anchors.fill: parent + anchors.leftMargin: units.gu(1.5) + anchors.rightMargin: units.gu(1.5) + verticalAlignment: Text.AlignVCenter + text: isEndDateValid ? Qt.formatDate(endDateItem.date, "dd-MM-yyyy") : i18n.dtr("ubtms", "No date set") + color: isEndDateValid ? (isDarkTheme ? "#ebebef" : "#333333") : (isDarkTheme ? "#9a9aa2" : "#888888") + font.pixelSize: units.gu(1.8) + elide: Text.ElideRight + } } MouseArea { diff --git a/qml/components/pickers/DaySelector.qml b/qml/components/pickers/DaySelector.qml index f95146e5..7e76a63d 100644 --- a/qml/components/pickers/DaySelector.qml +++ b/qml/components/pickers/DaySelector.qml @@ -12,6 +12,8 @@ Item { property string labelText: "Date" property date selectedDate: new Date() + property date customDate: selectedDate + property bool showTomorrow: false property bool readOnly: false signal dateChanged(date selectedDate) @@ -19,6 +21,38 @@ Item { return Qt.formatDate(selectedDate, "yyyy-MM-dd"); } + function getRelativeDate(baseDate) { + const d = new Date(baseDate); + d.setDate(d.getDate() + (showTomorrow ? 1 : -1)); + return d; + } + + function getRelativeLabel() { + return showTomorrow ? i18n.dtr("ubtms", "Tomorrow") : i18n.dtr("ubtms", "Yesterday"); + } + + function isSameDate(d1, d2) { + if (!d1 || !d2 || !(d1 instanceof Date) || !(d2 instanceof Date) || isNaN(d1.getTime()) || isNaN(d2.getTime())) { + return false; + } + return d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate(); + } + + function syncComboSelection(targetDate) { + const today = new Date(); + const relativeDate = getRelativeDate(today); + + if (isSameDate(targetDate, today)) { + dayCombo.applyDeferredSelection(0, false); + } else if (isSameDate(targetDate, relativeDate)) { + dayCombo.applyDeferredSelection(1, false); + } else { + dayCombo.applyDeferredSelection(2, false); + } + } + function setSelectedDate(val) { function toDate(input) { if (input instanceof Date) @@ -34,20 +68,9 @@ Item { if (parsed) { selectedDate = parsed; - - // Update dayCombo selection - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - if (isSameDate(parsed, today)) { - dayCombo.applyDeferredSelection(0, false); - } else if (isSameDate(parsed, yesterday)) { - dayCombo.applyDeferredSelection(1, false); - } else { - dayCombo.applyDeferredSelection(2, false); - } - + customDate = parsed; + + syncComboSelection(parsed); updateModelData(); dateChanged(selectedDate); } else { @@ -55,25 +78,18 @@ Item { } } - function isSameDate(d1, d2) { - return d1.getFullYear() === d2.getFullYear() && - d1.getMonth() === d2.getMonth() && - d1.getDate() === d2.getDate(); - } - function updateModelData() { const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - + const relativeDate = getRelativeDate(today); + const todayStr = Qt.formatDate(today, "dd-MM-yyyy"); - const yesterdayStr = Qt.formatDate(yesterday, "dd-MM-yyyy"); + const relativeStr = Qt.formatDate(relativeDate, "dd-MM-yyyy"); const currentStr = Qt.formatDate(selectedDate, "dd-MM-yyyy"); - + dayCombo.modelData = [ - { id: 0, name: "Today (" + todayStr + ")" }, - { id: 1, name: "Yesterday (" + yesterdayStr + ")" }, - { id: 2, name: "Custom (" + currentStr + ")" } + { id: 0, name: i18n.dtr("ubtms", "Today") + " (" + todayStr + ")" }, + { id: 1, name: getRelativeLabel() + " (" + relativeStr + ")" }, + { id: 2, name: i18n.dtr("ubtms", "Custom") + " (" + currentStr + ")" } ]; } @@ -84,8 +100,8 @@ Item { switch (dayCombo.selectedId) { case 0: // Today break; - case 1: // Yesterday - newDate.setDate(newDate.getDate() - 1); + case 1: // Relative (Tomorrow or Yesterday) + newDate = getRelativeDate(today); break; case 2: // Custom openCustomDatePicker(); @@ -93,24 +109,36 @@ Item { } selectedDate = newDate; + customDate = newDate; updateModelData(); dateChanged(selectedDate); } function openCustomDatePicker() { - let result = PickerPanel.openDatePicker(daySelector, "selectedDate", "Years|Months|Days"); + customDate = selectedDate; + let result = PickerPanel.openDatePicker(daySelector, "customDate", "Years|Months|Days"); if (result) { if (result.picker) { result.picker.minimum = new Date(2000, 0, 1); } - result.closed.connect(() => { - dayCombo.applyDeferredSelection(2, false); - updateModelData(); - dateChanged(selectedDate); + result.closed.connect(function() { + if (customDate && !isNaN(customDate.getTime())) { + selectedDate = customDate; + dayCombo.applyDeferredSelection(2, false); + updateModelData(); + dateChanged(selectedDate); + } }); } } + onShowTomorrowChanged: { + if (selectedDate && !isNaN(selectedDate.getTime())) { + syncComboSelection(selectedDate); + } + updateModelData(); + } + InlineOptionSelector { id: dayCombo width: parent.width @@ -118,7 +146,7 @@ Item { selectorType: "date_type" readOnly: daySelector.readOnly enabledState: !daySelector.readOnly - + onSelectionMade: function(id, name, selectorType) { updateDate(); } @@ -126,20 +154,14 @@ Item { Component.onCompleted: { const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); if (!selectedDate || isNaN(selectedDate.getTime())) { selectedDate = today; + customDate = today; dayCombo.applyDeferredSelection(0, false); } else { - if (isSameDate(selectedDate, today)) { - dayCombo.applyDeferredSelection(0, false); - } else if (isSameDate(selectedDate, yesterday)) { - dayCombo.applyDeferredSelection(1, false); - } else { - dayCombo.applyDeferredSelection(2, false); - } + customDate = selectedDate; + syncComboSelection(selectedDate); } updateModelData(); } diff --git a/qml/components/qmldir b/qml/components/qmldir index 776e98df..74b41510 100644 --- a/qml/components/qmldir +++ b/qml/components/qmldir @@ -49,6 +49,8 @@ TSCombobox 1.0 base/TSCombobox.qml TSIconButton 1.0 base/TSIconButton.qml TSLabel 1.0 base/TSLabel.qml TSProgressbar 1.0 base/TSProgressbar.qml +TSSearchBar 1.0 base/TSSearchBar.qml +TSSwitch 1.0 base/TSSwitch.qml UbuntuShape 1.0 base/UbuntuShape.qml TasksForDayWidget 1.0 cards/TasksForDayWidget.qml TimePickerPopup 1.0 dialogs/TimePickerPopup.qml diff --git a/qml/components/selectors/InlineOptionSelector.qml b/qml/components/selectors/InlineOptionSelector.qml index fe0f9249..871e4ea1 100644 --- a/qml/components/selectors/InlineOptionSelector.qml +++ b/qml/components/selectors/InlineOptionSelector.qml @@ -77,20 +77,37 @@ Item { // Update model when modelData changes onModelDataChanged: { optionsModel.clear(); + if (!modelData || !Array.isArray(modelData)) { + expandedHeight = units.gu(15); + return; + } + + var validCount = 0; for (var i = 0; i < modelData.length; i++) { + var item = modelData[i]; + if (!item || item.name === undefined || item.name === null) { + continue; + } + var cleanName = String(item.name).trim(); + if (cleanName === "") { + continue; + } + optionsModel.append({ - itemId: modelData[i].id, - name: modelData[i].name + itemId: item.id !== undefined ? item.id : -1, + name: cleanName }); + validCount++; } - // Auto-adjust expanded height based on item count - var calculatedHeight = Math.min(modelData.length * units.gu(5) + units.gu(6), maxExpandedHeight); + + // Auto-adjust expanded height based on valid item count + var calculatedHeight = Math.min(validCount * units.gu(5) + units.gu(6), maxExpandedHeight); expandedHeight = Math.max(calculatedHeight, units.gu(15)); // If selectedId is already set, resolve its name from the new data if (selectedId !== -1) { for (var j = 0; j < modelData.length; j++) { - if (modelData[j].id === selectedId) { + if (modelData[j] && modelData[j].id === selectedId) { selectedName = modelData[j].name; break; } @@ -102,7 +119,7 @@ Item { onSelectedIdChanged: { if (selectedId !== -1 && modelData && modelData.length > 0) { for (var i = 0; i < modelData.length; i++) { - if (modelData[i].id === selectedId) { + if (modelData[i] && modelData[i].id === selectedId) { selectedName = modelData[i].name; break; } @@ -182,7 +199,7 @@ Item { anchors.fill: parent enabled: enabledState && !readOnly onClicked: { - if (modelData.length > 0) { + if (optionsModel.count > 0) { collapsed = !collapsed; } } @@ -213,7 +230,8 @@ Item { delegate: Rectangle { width: optionsList.width - height: units.gu(5) + height: (model.name && String(model.name).trim().length > 0) ? units.gu(5) : 0 + visible: (model.name && String(model.name).trim().length > 0) color: { if (model.itemId === selectedId) { return Qt.rgba(selectedColor.r, selectedColor.g, selectedColor.b, 0.15); @@ -316,7 +334,7 @@ Item { } for (var i = 0; i < modelData.length; i++) { - if (modelData[i].id === id) { + if (modelData[i] && modelData[i].id === id) { selectedId = id; selectedName = modelData[i].name; // Only emit signal if explicitly requested (default: false) diff --git a/qml/components/selectors/MultiAssigneeSelector.qml b/qml/components/selectors/MultiAssigneeSelector.qml index b074cea1..d594314b 100644 --- a/qml/components/selectors/MultiAssigneeSelector.qml +++ b/qml/components/selectors/MultiAssigneeSelector.qml @@ -79,11 +79,10 @@ Item { for (let i = 0; i < availableAssignees.length; i++) { let assignee = availableAssignees[i]; let id = (accountId === 0) ? assignee.id : assignee.odoo_record_id; - if (id > 0) { - // Skip invalid/placeholder entries + if (id > 0 && assignee.name && String(assignee.name).trim() !== "") { filteredAssignees.push({ id: id, - name: assignee.name + name: String(assignee.name).trim() }); } } @@ -92,16 +91,6 @@ Item { property var availableAssignees: [] - // function updateDisplayText() { - // if (selectedAssignees.length === 0) { - // displayButton.text = "Select Assignees"; - // } else if (selectedAssignees.length === 1) { - // displayButton.text = selectedAssignees[0].name + " ⬇️"; - // } else { - // displayButton.text = selectedAssignees.length + " assignees selected ⬇️"; - // } - // } - Column { id: mainColumn anchors.fill: parent @@ -285,12 +274,9 @@ Item { anchors.margins: units.gu(2) spacing: units.gu(1) - Row { + Item { width: parent.width height: units.gu(5) - spacing: units.gu(2) - - anchors.margins: units.gu(2) Label { text: i18n.dtr("ubtms", "Select Assignees") diff --git a/qml/components/selectors/ProjectSelector.qml b/qml/components/selectors/ProjectSelector.qml index 8843d8db..c636b4db 100644 --- a/qml/components/selectors/ProjectSelector.qml +++ b/qml/components/selectors/ProjectSelector.qml @@ -172,8 +172,9 @@ Item { Component.onCompleted: { var projects = Project.getUserProjects(selectedAccountId); for (var i = 0; i < projects.length; i++) { + var effectiveId = (selectedAccountId === 0 || !projects[i].odoo_record_id) ? projects[i].id : projects[i].odoo_record_id; projectListModel.append({ - id: projects[i].odoo_record_id, + id: effectiveId, name: projects[i].name }); } diff --git a/qml/components/selectors/ProjectStageSelector.qml b/qml/components/selectors/ProjectStageSelector.qml index e7004e05..0491b4b6 100644 --- a/qml/components/selectors/ProjectStageSelector.qml +++ b/qml/components/selectors/ProjectStageSelector.qml @@ -66,7 +66,7 @@ Dialog { */ function loadStages() { // Load available project stages for this specific account - if (accountId > 0) { + if (accountId >= 0) { availableStages = Project.getProjectStagesForAccount(accountId); } else { // Fallback to all stages if no account specified @@ -104,7 +104,7 @@ Dialog { width: parent.width wrapMode: Text.WordWrap text: { - var currentStageName = Project.getProjectStageName(currentStageOdooRecordId); + var currentStageName = Project.getProjectStageName(currentStageOdooRecordId, accountId); return i18n.dtr("ubtms", "Current Stage: ") + "" + (currentStageName || i18n.dtr("ubtms", "Not set")) + ""; } font.pixelSize: units.gu(2) diff --git a/qml/components/selectors/StageFilterMenu.qml b/qml/components/selectors/StageFilterMenu.qml index 25dc223a..236e40e5 100644 --- a/qml/components/selectors/StageFilterMenu.qml +++ b/qml/components/selectors/StageFilterMenu.qml @@ -164,7 +164,9 @@ Item { color: "transparent" Row { - anchors.fill: parent + anchors.left: parent.left + anchors.right: clearFilterButton.left + anchors.verticalCenter: parent.verticalCenter anchors.margins: units.gu(1) spacing: units.gu(1) @@ -183,31 +185,29 @@ Item { color: theme.palette.normal.backgroundText anchors.verticalCenter: parent.verticalCenter } + } - Item { - Layout.fillWidth: true - } // Spacer - - // Clear filter button - TSIconButton { - visible: selectedIndex > 0 - width: units.gu(3.5) - height: units.gu(3.5) - radius: width / 2 - iconName: "edit-clear" - bgColor: LomiriColors.orange - fgColor: "white" - hoverColor: Qt.darker(bgColor, 1.2) - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - - onClicked: { - selectedIndex = 0; - selectedFilterName = menuModel.length > 0 ? menuModel[0].label : "All Stages"; - expanded = false; - filterCleared(); - menuItemSelected(0); - } + // Clear filter button + TSIconButton { + id: clearFilterButton + visible: selectedIndex > 0 + width: units.gu(3.5) + height: units.gu(3.5) + radius: width / 2 + iconName: "edit-clear" + bgColor: LomiriColors.orange + fgColor: "white" + hoverColor: Qt.darker(bgColor, 1.2) + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: units.gu(1) + + onClicked: { + selectedIndex = 0; + selectedFilterName = menuModel.length > 0 ? menuModel[0].label : "All Stages"; + expanded = false; + filterCleared(); + menuItemSelected(0); } } } @@ -266,7 +266,7 @@ Item { delegate: Rectangle { - width: parent.width + width: menuListView.width height: units.gu(5.5) color: mouseArea.pressed ? theme.palette.selected.background : (selectedIndex === model.originalIndex ? theme.palette.selected.background : "transparent") radius: units.gu(0.5) diff --git a/qml/components/selectors/UserSelector.qml b/qml/components/selectors/UserSelector.qml index 633fc052..80f6ae15 100644 --- a/qml/components/selectors/UserSelector.qml +++ b/qml/components/selectors/UserSelector.qml @@ -132,10 +132,11 @@ ComboBox { const users = Accounts.getUsers(accountId); for (let i = 0; i < users.length; i++) { + let effectiveId = (accountId === 0 || !users[i].odoo_record_id || users[i].odoo_record_id <= 0) ? users[i].id : users[i].odoo_record_id; internalUserModel.append({ - id: users[i].odoo_record_id, + id: effectiveId, name: users[i].name, - remoteid: users[i].odoo_record_id + remoteid: effectiveId }); } diff --git a/qml/components/selectors/WorkItemSelector.qml b/qml/components/selectors/WorkItemSelector.qml index 5863b86a..b12ec991 100644 --- a/qml/components/selectors/WorkItemSelector.qml +++ b/qml/components/selectors/WorkItemSelector.qml @@ -132,7 +132,7 @@ Rectangle { signal multiAssigneesChanged(var assignees) // Selected IDs - property int selectedAccountId: -1 + property alias selectedAccountId: account_component.selectedId property int selectedProjectId: -1 property int selectedSubProjectId: -1 property int selectedTaskId: -1 @@ -392,6 +392,12 @@ Rectangle { }); } + // Fallback to the first available account if default_id was invalid/unmatched + if (default_name === "" && accountList.length > 0) { + default_id = accountList[0].id; + default_name = accountList[0].name; + } + selectorModelMap["Account"] = accountList; account_component.modelData = accountList; account_component.applyDeferredSelection(default_id); @@ -681,15 +687,20 @@ Rectangle { let id = (accountId === 0) ? rawAssignees[i].id : rawAssignees[i].odoo_record_id; let name = rawAssignees[i].name; + if (!name || String(name).trim() === "") { + continue; + } + + let cleanName = String(name).trim(); assigneeList.push({ id: id, - name: name, + name: cleanName, parent_id: null // no hierarchy for assignees }); if (selectedId === id) { default_id = id; - default_name = name; + default_name = cleanName; } } diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 6037fb20..80021eb0 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -2,6 +2,7 @@ import QtQuick 2.7 import QtQuick.Controls 2.2 import Lomiri.Components 1.3 import "../../../models/timer_service.js" as TimerService +import "../../../models/timesheet.js" as TimeSheet import "../../../models/utils.js" as Utils import "../../features/timesheets/components" as TimesheetComponents import ".." @@ -43,6 +44,15 @@ Rectangle { property bool syncFailed: false property string syncStatusMessage: "" + // Customization properties for derived widgets + property string syncTitlePrefix: i18n.dtr("ubtms", "Syncing ") + property string defaultSyncTitle: i18n.dtr("ubtms", "Cloud Sync") + property string defaultSyncingSubtitle: i18n.dtr("ubtms", "Synchronizing...") + property string syncSuccessSubtitle: i18n.dtr("ubtms", "All items up to date") + property string defaultSyncFailedText: i18n.dtr("ubtms", "Sync failed") + property string syncIconSource: "../../images/refresh.svg" + property bool rotateIcon: true + // BackendBridge for real-time sync communication (connect to global bridge) property var backendBridge: null @@ -96,10 +106,10 @@ Rectangle { if (data.payload === true) completeSyncSuccessfully(); else - failSync("Sync Failed "); + failSync(defaultSyncFailedText); break; case "sync_error": - failSync("Failed " + data.payload); + failSync(data.payload ? (defaultSyncFailedText + ": " + data.payload) : defaultSyncFailedText); break; } } @@ -112,15 +122,15 @@ Rectangle { var progressPercent = Math.round(syncProgress * 100); if (progressPercent < 25) { - syncStatusMessage = "Initializing sync..."; + syncStatusMessage = i18n.dtr("ubtms", "Initializing sync..."); } else if (progressPercent < 50) { - syncStatusMessage = "Downloading from server..."; + syncStatusMessage = i18n.dtr("ubtms", "Downloading from server..."); } else if (progressPercent < 90) { - syncStatusMessage = "Uploading to server..."; + syncStatusMessage = i18n.dtr("ubtms", "Uploading to server..."); } else if (progressPercent < 100) { - syncStatusMessage = "Finalizing sync..."; + syncStatusMessage = i18n.dtr("ubtms", "Finalizing sync..."); } else { - syncStatusMessage = "Sync complete!"; + syncStatusMessage = syncSuccessSubtitle; } } @@ -129,7 +139,7 @@ Rectangle { syncSuccessful = true; syncFailed = false; syncProgress = 1.0; - syncStatusMessage = "Sync Complete!"; + syncStatusMessage = syncSuccessSubtitle; // Auto-hide after 3 seconds autoHideTimer.interval = 3000; @@ -140,7 +150,7 @@ Rectangle { function failSync(errorMessage) { syncSuccessful = false; syncFailed = true; - syncStatusMessage = errorMessage || "Sync Failed"; + syncStatusMessage = errorMessage || defaultSyncFailedText; // Auto-hide after 5 seconds autoHideTimer.interval = 5000; @@ -281,12 +291,12 @@ Rectangle { id: syncIcon visible: globalTimer.isSyncing && !globalTimer.isTimerRunning && !globalTimer.syncSuccessful && !globalTimer.syncFailed anchors.fill: parent - source: "../../images/refresh.svg" + source: globalTimer.syncIconSource fillMode: Image.PreserveAspectFit RotationAnimation on rotation { loops: Animation.Infinite - running: globalTimer.visible && globalTimer.isSyncing && !globalTimer.isTimerRunning && !globalTimer.syncSuccessful && !globalTimer.syncFailed + running: globalTimer.rotateIcon && globalTimer.visible && globalTimer.isSyncing && !globalTimer.isTimerRunning && !globalTimer.syncSuccessful && !globalTimer.syncFailed from: 0 to: 360 duration: 1200 @@ -334,7 +344,7 @@ Rectangle { if (globalTimer.isTimerRunning) { return globalTimer.activeTitle; } else if (globalTimer.isSyncing) { - return globalTimer.syncAccountName ? ("Syncing " + globalTimer.syncAccountName) : "Cloud Sync"; + return globalTimer.syncAccountName ? (globalTimer.syncTitlePrefix + globalTimer.syncAccountName) : globalTimer.defaultSyncTitle; } return ""; } @@ -380,9 +390,9 @@ Rectangle { visible: globalTimer.isSyncing && !globalTimer.isTimerRunning width: parent.width text: { - if (globalTimer.syncFailed) return globalTimer.syncStatusMessage || "Sync failed"; - if (globalTimer.syncSuccessful) return "All items up to date"; - return globalTimer.syncStatusMessage || "Synchronizing..."; + if (globalTimer.syncFailed) return globalTimer.syncStatusMessage || globalTimer.defaultSyncFailedText; + if (globalTimer.syncSuccessful) return globalTimer.syncSuccessSubtitle; + return globalTimer.syncStatusMessage || globalTimer.defaultSyncingSubtitle; } color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#D0CBC5") font.pixelSize: units.gu(1.3) @@ -518,7 +528,11 @@ Rectangle { onSaved: function (description, status) { Logger.debug("GlobalTimerWidget", "Timesheet description saved:", description, "Status:", status) + var targetId = descriptionPopup.timesheetId; TimerService.stop(); + if (status === "saved" && targetId > 0) { + TimeSheet.markTimesheetAsSavedById(targetId); + } } onFinalized: function (success, message) { diff --git a/qml/components/system/ModelDownloadTimerWidget.qml b/qml/components/system/ModelDownloadTimerWidget.qml index 279ccd31..7f5ac264 100644 --- a/qml/components/system/ModelDownloadTimerWidget.qml +++ b/qml/components/system/ModelDownloadTimerWidget.qml @@ -4,7 +4,16 @@ import Lomiri.Components 1.3 GlobalTimerWidget { id: downloadWidget enableTimesheetTimer: false - + function handleSyncEvent(data) { + } + syncTitlePrefix: i18n.dtr("ubtms", "Downloading ") + defaultSyncTitle: i18n.dtr("ubtms", "Voice Model Download") + defaultSyncingSubtitle: i18n.dtr("ubtms", "Downloading...") + syncSuccessSubtitle: i18n.dtr("ubtms", "Installation complete!") + defaultSyncFailedText: i18n.dtr("ubtms", "Download failed") + syncIconSource: "../../images/downloadWhite.svg" + rotateIcon: false + // Override completion logic to connect to our specific download events Component.onCompleted: { var root = downloadWidget; diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index c68099f2..662060ac 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -30,6 +30,7 @@ import ".." as Components import QtQuick.LocalStorage 2.7 as Sql import "../../../models/accounts.js" as Accounts import "../../../models/project.js" as Project +import "../../../models/constants.js" as AppConst import ".." /* @@ -90,29 +91,86 @@ Item { id: projectList anchors.fill: parent + function getSelectedAccountId() { + if (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) { + return accountPicker.selectedAccountId; + } + if (typeof rootApp !== "undefined" && rootApp && rootApp.currentAccountId !== undefined) { + return rootApp.currentAccountId; + } + if (typeof mainView !== "undefined" && mainView && mainView.currentAccountId !== undefined) { + return mainView.currentAccountId; + } + return -1; + } + + function handleAccountChanged(id) { + currentAccountId = id; + navigationStackModel.clear(); + currentParentId = -1; + currentParentName = ""; + + // Reset pagination + currentOffset = 0; + hasMoreItems = true; + isLoadingMore = false; + + // Reset to default "Open" filter + stageFilter.enabled = true; + stageFilter.odoo_record_id = -2; + stageFilter.is_stage = false; + stageFilter.name = "Open"; + + // Clear search + searchQuery = ""; + + populateProjectChildrenMap(); + } + Connections { - target: accountPicker + target: typeof accountPicker !== "undefined" ? accountPicker : null onAccepted: function (id, name) { - console.log("Projects getting updated for Account chosen:", id, name); - currentAccountId = id; - navigationStackModel.clear(); - currentParentId = -1; + handleAccountChanged(id); + } - // Reset to default "Open" filter - stageFilter.enabled = true; - stageFilter.odoo_record_id = -2; - stageFilter.name = "Open"; + onSelectedAccountIdChanged: { + var activeId = getSelectedAccountId(); + if (currentParentId === -1 && currentAccountId !== activeId) { + handleAccountChanged(activeId); + } + } + } - // Clear search - searchQuery = ""; + Connections { + target: typeof rootApp !== "undefined" ? rootApp : (typeof mainView !== "undefined" ? mainView : null) + + onGlobalAccountChanged: function (id, name) { + if (currentParentId === -1 && currentAccountId !== id) { + handleAccountChanged(id); + } + } + onAccountDataRefreshRequested: function (id) { + if (currentParentId === -1) { + if (currentAccountId !== id) { + currentAccountId = id; + } + populateProjectChildrenMap(); + } + } + + onProjectDataChanged: { + if (currentParentId === -1) { + currentAccountId = getSelectedAccountId(); + } populateProjectChildrenMap(); } } property int currentParentId: -1 - property int currentAccountId: accountPicker.selectedAccountId + property int currentAccountId: getSelectedAccountId() + property string currentParentName: "" property ListModel navigationStackModel: ListModel {} property var childrenMap: ({}) property bool childrenMapReady: false @@ -147,7 +205,7 @@ Item { signal projectTimesheetRequested(int localId) signal customSearch(string query) - function navigateToProject(projectId, accountId) { + function navigateToProject(projectId, accountId, projectName) { // Ensure we have valid IDs before proceeding if (projectId === undefined || accountId === undefined) { console.error("navigateToProject called with undefined values:", projectId, accountId); @@ -156,10 +214,26 @@ Item { navigationStackModel.append({ parentId: currentParentId !== undefined ? currentParentId : -1, - accountId: currentAccountId !== undefined ? currentAccountId : -1 + accountId: currentAccountId !== undefined ? currentAccountId : -1, + parentName: currentParentName || "" }); currentParentId = projectId; currentAccountId = accountId; + currentParentName = projectName || ""; + } + + function navigateBackInHierarchy() { + if (navigationStackModel.count > 0) { + var last = navigationStackModel.get(navigationStackModel.count - 1); + navigationStackModel.remove(navigationStackModel.count - 1); + currentParentId = last.parentId !== undefined ? last.parentId : -1; + if (currentParentId === -1) { + currentAccountId = getSelectedAccountId(); + } else { + currentAccountId = last.accountId !== undefined ? last.accountId : -1; + } + currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + } } function selectProject(localId) { @@ -177,7 +251,8 @@ Item { function refresh() { navigationStackModel.clear(); currentParentId = -1; - currentAccountId = accountPicker.selectedAccountId; + currentParentName = ""; + currentAccountId = getSelectedAccountId(); // Reset pagination currentOffset = 0; @@ -187,6 +262,7 @@ Item { // Reset to default "Open" filter stageFilter.enabled = true; stageFilter.odoo_record_id = -2; + stageFilter.is_stage = false; stageFilter.name = "Open"; // Clear search @@ -202,6 +278,7 @@ Item { if (flatViewMode) { navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; } // Refresh the model @@ -213,19 +290,34 @@ Item { // Search functions function toggleSearchVisibility() { showSearchBox = !showSearchBox; + if (!showSearchBox) { + if (searchQuery !== "" || searchBar.text !== "") { + clearSearch(); + } + } else { + searchBar.forceActiveFocus(); + } } function clearSearch() { - searchField.text = ""; - searchQuery = ""; - customSearch(""); - // Reload from DB without search filter - populateProjectChildrenMap(); + if (searchBar.text !== "") { + searchBar.clear(); + } + if (searchQuery !== "") { + searchQuery = ""; + customSearch(""); + // Reload from DB without search filter + populateProjectChildrenMap(); + } } function performSearch(query) { - searchQuery = query; - customSearch(query); + var trimmed = query ? query.trim() : ""; + if (trimmed === searchQuery) { + return; + } + searchQuery = trimmed; + customSearch(trimmed); // Reload from DB with search filter applied at SQL level populateProjectChildrenMap(); } @@ -267,16 +359,23 @@ Item { } function _doPaginatedProjectLoad() { + if (currentParentId === -1) { + currentAccountId = getSelectedAccountId(); + } + // Determine which stage filter to pass to SQL var sqlStageId = undefined; // undefined = no stage filter + var isStage = false; if (stageFilter.enabled) { - sqlStageId = stageFilter.odoo_record_id; // -2 = open, >=0 = specific + sqlStageId = stageFilter.odoo_record_id; // -2 = open, specific stage otherwise + isStage = stageFilter.is_stage || false; } var result = Project.getProjectsFilteredPaginated({ accountId: currentAccountId, searchQuery: searchQuery || "", stageId: sqlStageId, + isStage: isStage, openStageIds: _getOpenStageIds(), limit: pageSize, offset: currentOffset @@ -318,15 +417,17 @@ Item { } var tempMap = {}; + var taskCountMap = Project.getProjectTaskCountMap ? Project.getProjectTaskCountMap(currentAccountId) : {}; // First pass: Create project color map for inheritance lookup var projectColorMap = {}; allProjects.forEach(function (row) { - projectColorMap[row.odoo_record_id] = row.color_pallet ? parseInt(row.color_pallet) : 0; + var effectiveId = (row.account_id === 0 || !row.odoo_record_id) ? row.id : row.odoo_record_id; + projectColorMap[effectiveId] = row.color_pallet ? parseInt(row.color_pallet) : 0; }); allProjects.forEach(function (row) { - var odooId = row.odoo_record_id; + var effectiveId = (row.account_id === 0 || !row.odoo_record_id) ? row.id : row.odoo_record_id; var parentOdooId = (row.parent_id === null || row.parent_id === 0) ? -1 : row.parent_id; var accountId = row.account_id; @@ -340,15 +441,17 @@ Item { inheritedColor = projectColorMap[parentOdooId] || 0; } + var taskCount = taskCountMap[effectiveId] || (taskCountMap[row.id] || 0); + var item = { - id_val: odooId, + id_val: effectiveId, local_id: row.id, parent_id: parentOdooId, account_id: accountId, name: row.name || "Untitled", projectName: row.name || "Untitled", accountName: accountName, - recordId: odooId, + recordId: effectiveId, allocatedHours: row.allocated_hours ? row.allocated_hours : 0, remainingHours: row.remaining_hours ? row.remaining_hours : 0, startDate: row.planned_start_date || "", @@ -356,10 +459,11 @@ Item { deadline: row.planned_end_date || "", description: row.description || "", colorPallet: inheritedColor, - stage: row.stage, + stage: row.stage || 0, isFavorite: row.favorites === 1, hasDraft: row.has_draft === 1, - hasChildren: false + hasChildren: false, + taskCount: taskCount }; // Use compound key: parent_id + account_id for proper hierarchy grouping @@ -415,11 +519,14 @@ Item { var flatModel = Qt.createQmlObject('import QtQuick 2.0; ListModel {}', projectList); var allFlatProjects = []; - // Collect all projects from childrenMap + // Collect projects matching current account filter from childrenMap for (var key in childrenMap) { var model = childrenMap[key]; for (var i = 0; i < model.count; i++) { - allFlatProjects.push(model.get(i)); + var proj = model.get(i); + if (currentAccountId === -1 || currentAccountId === undefined || proj.account_id === currentAccountId) { + allFlatProjects.push(proj); + } } } @@ -442,8 +549,17 @@ Item { return true; } - // Special case for "Open" filter (odoo_record_id = -2) - if (stageFilter.odoo_record_id === -2) { + // Special case for "Open" filter (odoo_record_id = -2 and not a specific stage) + if (!stageFilter.is_stage && stageFilter.odoo_record_id === -2) { + if (!project.stage || project.stage === 0) { + return true; + } + + // For local projects, stage -1 (Planning) and -2 (In Progress) are open stages + if (project.account_id === 0 && (project.stage === -1 || project.stage === -2)) { + return true; + } + // Check if the project's stage is in the list of open stages (fold = 0) for (var i = 0; i < openStagesList.length; i++) { if (openStagesList[i].odoo_record_id === project.stage) { @@ -488,6 +604,9 @@ Item { var childModel = childrenMap[mapKey]; for (var j = 0; j < childModel.count; j++) { var childProject = childModel.get(j); + if (currentAccountId >= 0 && childProject.account_id !== currentAccountId) { + continue; + } if (matchesStageFilter(childProject)) { // Mark its parent to be included var parentId = childProject.parent_id; @@ -535,13 +654,19 @@ Item { } } - // Gather all root level projects + // Gather root level projects matching current account for (var key in childrenMap) { if (key.startsWith("-1_")) { + if (currentAccountId >= 0 && key !== ("-1_" + currentAccountId)) { + continue; + } // Root level projects var model = childrenMap[key]; for (var i = 0; i < model.count; i++) { - allRootProjects.push(model.get(i)); + var p = model.get(i); + if (currentAccountId === -1 || currentAccountId === undefined || p.account_id === currentAccountId) { + allRootProjects.push(p); + } } } } @@ -553,7 +678,8 @@ Item { // Apply filter or include if it's a parent of a matching project allRootProjects.forEach(function (project) { - if ((!stageFilter.enabled || matchesStageFilter(project) || includeParentIds[project.id_val + "_" + project.account_id]) && matchesSearchQuery(project)) { + var accountMatches = (currentAccountId === -1 || currentAccountId === undefined || project.account_id === currentAccountId); + if (accountMatches && (!stageFilter.enabled || matchesStageFilter(project) || includeParentIds[project.id_val + "_" + project.account_id]) && matchesSearchQuery(project)) { combinedModel.append(project); } }); @@ -703,86 +829,201 @@ Item { } } - // Timer for debounced search - Timer { - id: searchTimer - interval: 2000 // 2 sec delay - repeat: false - onTriggered: performSearch(searchField.text) - } - Column { anchors.fill: parent spacing: units.gu(1) - // Search field - - TextField { - id: searchField - visible: showSearchBox - height: units.gu(5) + // Search field container + Components.TSSearchBar { + id: searchBar width: parent.width - anchors.rightMargin: units.gu(4) // Space for clear button - placeholderText: i18n.dtr("ubtms", "Search projects") - selectByMouse: true - onAccepted: performSearch(text) - //Todo: Later Experiment with Debouncing search , solve performance issues causing the crash - // onTextChanged: { - // searchQuery = text; - // // Debounced search - only search after user stops typing - // searchTimer.restart(); - // } + height: showSearchBox ? implicitHeight : 0 + visible: showSearchBox + placeholderText: i18n.dtr("ubtms", "Search projects...") + bottomPadding: units.gu(0.4) - Rectangle { - - height: parent.height - width: parent.width - anchors.left: parent.left - anchors.right: parent.right - color: "transparent" - border.color: searchField.activeFocus ? "#FF6B35" : "#CCCCCC" - border.width: searchField.activeFocus ? 2 : 1 - - Button { - id: clearSearchButton - z: 10 - visible: searchField.text.length > 0 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.rightMargin: units.gu(0.5) - width: units.gu(3) - height: units.gu(3) - text: "×" - onClicked: { - clearSearch(); - } + onAccepted: { + performSearch(query); + } + + onCleared: { + if (searchQuery !== "") { + clearSearch(); } } } - // Header row with back button - TSButton { - id: backbutton - text: "← Back" + // Hierarchical breadcrumb navigation bar + Rectangle { + id: breadcrumbBar width: parent.width - height: units.gu(4) - visible: !flatViewMode && navigationStackModel.count - onClicked: { - if (navigationStackModel.count > 0) { - var last = navigationStackModel.get(navigationStackModel.count - 1); - navigationStackModel.remove(navigationStackModel.count - 1); - currentParentId = last.parentId !== undefined ? last.parentId : -1; - currentAccountId = last.accountId !== undefined ? last.accountId : -1; + height: visible ? units.gu(5) : 0 + visible: !flatViewMode && navigationStackModel.count > 0 + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#1e1e1e" : "#f8fafc" + radius: units.gu(0.6) + border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d2d2d" : "#e2e8f0" + border.width: units.gu(0.1) + clip: true + + Row { + anchors.fill: parent + anchors.leftMargin: units.gu(1) + anchors.rightMargin: units.gu(1) + spacing: units.gu(1) + + // Back button with tactile styling + Rectangle { + id: backBtn + width: units.gu(9) + height: units.gu(3.6) + anchors.verticalCenter: parent.verticalCenter + radius: units.gu(0.5) + color: backMouseArea.pressed ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#333333" : "#e2e8f0") + : (backMouseArea.containsMouse ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#262626" : "#edf2f7") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#222222" : "#ffffff")) + border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#3d3d3d" : "#cbd5e1" + border.width: 1 + + Row { + anchors.centerIn: parent + spacing: units.gu(0.5) + + Icon { + name: "back" + width: units.gu(1.6) + height: units.gu(1.6) + color: AppConst.Colors.Orange + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: i18n.dtr("ubtms", "Back") + font.pixelSize: units.gu(1.4) + font.bold: true + color: AppConst.Colors.Orange + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: backMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: navigateBackInHierarchy() + } + } + + // Breadcrumb path display + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: units.gu(0.6) + width: parent.width - backBtn.width - units.gu(2) + clip: true + + Text { + text: i18n.dtr("ubtms", "Projects") + font.pixelSize: units.gu(1.3) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#9ca3af" : "#64748b" + anchors.verticalCenter: parent.verticalCenter + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + navigationStackModel.clear(); + currentParentId = -1; + currentParentName = ""; + } + } + } + + Text { + text: "/" + font.pixelSize: units.gu(1.3) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#6b7280" : "#94a3b8" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: currentParentName !== "" ? currentParentName : i18n.dtr("ubtms", "Subprojects") + font.pixelSize: units.gu(1.4) + font.bold: true + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#f3f4f6" : "#1e293b" + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + maximumLineCount: 1 + width: Math.min(implicitWidth, parent.width - units.gu(12)) + } + + // Count badge + Rectangle { + visible: projectListView.count > 0 + height: units.gu(2) + width: childCountBadgeText.width + units.gu(1) + radius: height / 2 + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d2013" : "#fff7ed" + border.color: AppConst.Colors.Orange + border.width: 1 + anchors.verticalCenter: parent.verticalCenter + + Text { + id: childCountBadgeText + text: String(projectListView.count) + font.pixelSize: units.gu(1.1) + font.bold: true + color: AppConst.Colors.Orange + anchors.centerIn: parent + } + } } } } + LomiriListView { id: projectListView width: parent.width - height: parent.height - (backbutton.visible ? units.gu(4) : 0) - (showSearchBox ? units.gu(6) : 0) // Account for back button and search field heights + height: parent.height - (breadcrumbBar.visible ? breadcrumbBar.height + units.gu(1) : 0) - (showSearchBox ? searchBar.height + units.gu(1) : 0) clip: true + spacing: 0 model: getCurrentModel() + // Empty search results indicator + Item { + anchors.centerIn: parent + visible: searchQuery !== "" && projectListView.count === 0 && !isLoading + width: parent.width - units.gu(4) + height: units.gu(12) + + Column { + anchors.centerIn: parent + spacing: units.gu(0.8) + + Icon { + name: "search" + width: units.gu(3.5) + height: units.gu(3.5) + anchors.horizontalCenter: parent.horizontalCenter + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#52525b" : "#cbd5e1" + } + + Text { + text: i18n.dtr("ubtms", "No projects found") + font.pixelSize: units.gu(1.8) + font.bold: true + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#a1a1aa" : "#64748b" + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + text: i18n.dtr("ubtms", "Try searching with a different term") + font.pixelSize: units.gu(1.4) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#71717a" : "#94a3b8" + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + footer: LoadMoreFooter { isLoading: isLoadingMore hasMore: hasMoreItems @@ -795,58 +1036,54 @@ Item { } } - delegate: Item { - width: parent.width - height: units.gu(13) - - ProjectDetailsCard { - id: projectCard - height: parent.height - width: parent.width - recordId: model.recordId - projectName: model.projectName - allocatedHours: model.allocatedHours - remainingHours: model.remainingHours - deadline: model.deadline - startDate: model.startDate - endDate: model.endDate - accountName: model.accountName - accountId: model.account_id - description: model.description - colorPallet: model.colorPallet - isFavorite: model.isFavorite - hasDraft: model.hasDraft - // Hide children navigation in flat view mode - hasChildren: flatViewMode ? false : (model.hasChildren || false) - stage: model.stage - childCount: (model.hasChildren) ? model.childCount : 0 - localId: model.local_id - - // Store model properties in the delegate scope for signal handlers - property bool projectHasChildren: model.hasChildren || false - property int projectIdVal: model.id_val || 0 - property int projectAccountId: model.account_id || 0 - property int projectLocalId: model.local_id || 0 - - onEditRequested: id => { - editProject(projectLocalId); - } - onViewRequested: id => { - selectProject(projectLocalId); - } + delegate: ProjectDetailsCard { + id: projectCard + width: projectListView.width + height: units.gu(8.8) + recordId: model.recordId + projectName: model.projectName + allocatedHours: model.allocatedHours + remainingHours: model.remainingHours + deadline: model.deadline + startDate: model.startDate + endDate: model.endDate + accountName: model.accountName + accountId: model.account_id + description: model.description + colorPallet: model.colorPallet + isFavorite: model.isFavorite + hasDraft: model.hasDraft + // Hide children navigation in flat view mode + hasChildren: flatViewMode ? false : (model.hasChildren || false) + stage: model.stage + childCount: (model.hasChildren) ? model.childCount : 0 + localId: model.local_id + taskCount: model.taskCount !== undefined ? model.taskCount : 0 + + // Store model properties in the delegate scope for signal handlers + property bool projectHasChildren: model.hasChildren || false + property int projectIdVal: model.id_val || 0 + property int projectAccountId: model.account_id || 0 + property int projectLocalId: model.local_id || 0 + + onEditRequested: id => { + editProject(projectLocalId); + } + onViewRequested: id => { + selectProject(projectLocalId); + } - onNavigationRequested: (projectId, accountId) => { - // Disable navigation in flat view mode - if (!flatViewMode) { - console.log("Navigation requested - projectId:", projectId, "accountId:", accountId); - navigateToProject(projectId, accountId); - } - } - onTimesheetRequested: localId => { - // Forward the signal to the parent page - requestTimesheet(localId); + onNavigationRequested: (projectId, accountId, projectName) => { + // Disable navigation in flat view mode + if (!flatViewMode) { + console.log("Navigation requested - projectId:", projectId, "accountId:", accountId, "projectName:", projectName); + navigateToProject(projectId, accountId, projectName); } } + onTimesheetRequested: localId => { + projectTimesheetRequested(localId); + requestTimesheet(localId); + } } } } @@ -861,12 +1098,14 @@ Item { // Add "Open" as the first option menuModel.push({ label: "Open Projects", - value: -2 + value: -2, + is_stage: false }); menuModel.push({ label: "All Stages", - value: -1 + value: -1, + is_stage: false }); // Track both unique odoo_record_id+name combinations @@ -892,7 +1131,9 @@ Item { // Add stage to menu model with its odoo_record_id as value menuModel.push({ label: label, - value: s.odoo_record_id + value: s.odoo_record_id, + account_id: s.account_id, + is_stage: true }); } return menuModel; @@ -904,12 +1145,12 @@ Item { if (!selectedItem) return; - if (selectedItem.value === -2) { + if (!selectedItem.is_stage && selectedItem.value === -2) { // Open Projects filter stageFilter.enabled = true; stageFilter.odoo_record_id = -2; stageFilter.name = "Open"; - } else if (selectedItem.value === -1) { + } else if (!selectedItem.is_stage && selectedItem.value === -1) { stageFilter.enabled = false; stageFilter.odoo_record_id = -1; stageFilter.account_id = -1; @@ -917,7 +1158,7 @@ Item { } else { stageFilter.enabled = true; stageFilter.odoo_record_id = selectedItem.value; - stageFilter.account_id = selectedItem.account_id || 0; + stageFilter.account_id = (selectedItem.account_id !== undefined) ? selectedItem.account_id : 0; stageFilter.name = selectedItem.label; } diff --git a/qml/components/workflow/AttachmentManager.qml b/qml/components/workflow/AttachmentManager.qml index b4212e3a..cc6013a7 100644 --- a/qml/components/workflow/AttachmentManager.qml +++ b/qml/components/workflow/AttachmentManager.qml @@ -336,6 +336,9 @@ Item { console.log("backend imported"); }); } + onReceived: function (data) { + _handleSyncEvent(data); + } onError: { console.log("python error: " + traceback); attachmentManager._busy = false; @@ -350,7 +353,7 @@ Item { id: dlg isExport: false // importing from device/apps onFilesImported: function (files) { - if (!files || !files.length) + if (!files || !files.length || attachmentManager._busy) return; if (host) { @@ -358,8 +361,12 @@ Item { host.uploadStarted(); } + var seenPaths = {}; for (var i = 0; i < files.length; i++) { var filePath = (files[i].url || "").toString().replace(/^file:\/\//, ""); + if (!filePath || seenPaths[filePath]) + continue; + seenPaths[filePath] = true; python.call("backend.resolve_qml_db_path", ["ubtms"], function (path) { if (!path) { @@ -398,6 +405,15 @@ Item { } function openContentPicker() { + if (_busy) { + return; + } + + if (account_id !== 0 && resource_id <= 0) { + _notify(i18n.dtr("ubtms", "Please save first before adding attachments to a server account"), 3500); + return; + } + try { PopupUtils.open(contentPickerComponent, attachmentManager, { host: attachmentManager @@ -477,9 +493,16 @@ Item { internalModel.clear(); if (!items || !items.length) return; + var seenKeys = {}; for (var i = 0; i < items.length; i++) { - //Do a duplicate name check to ensure the double entries doesnot present : TODO . GK - internalModel.append(_normalizeItem(items[i])); + var item = items[i]; + var key = item.odoo_record_id && item.odoo_record_id > 0 + ? ("odoo_" + item.odoo_record_id + "_" + (item.account_id !== undefined ? item.account_id : attachmentManager.account_id)) + : ("local_" + (item.name || "") + "_" + (item.size || 0) + "_" + (item.account_id !== undefined ? item.account_id : attachmentManager.account_id)); + if (!seenKeys[key]) { + seenKeys[key] = true; + internalModel.append(_normalizeItem(item)); + } } } @@ -760,6 +783,11 @@ Item { openFileSmart(existingRec); return; } + if ((rec.account_id === 0) || (!rec.account_id && attachmentManager.account_id === 0)) { + attachmentManager._notify(i18n.dtr("ubtms", "Local file not found on device"), 2500); + return; + } + console.log("Local copy not found; downloading…"); _busy = true; diff --git a/qml/features/activities/pages/Activities.qml b/qml/features/activities/pages/Activities.qml index 85b312e7..cb884e69 100644 --- a/qml/features/activities/pages/Activities.qml +++ b/qml/features/activities/pages/Activities.qml @@ -88,7 +88,7 @@ Page { } trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" visible: !isReadOnly text: i18n.dtr("ubtms", "Save") onTriggered: { @@ -820,6 +820,7 @@ Page { DaySelector { id: date_widget + showTomorrow: true readOnly: isReadOnly width: flickable.width - units.gu(2) onDateChanged: function(selectedDate) { @@ -922,8 +923,8 @@ Page { date_widget.setSelectedDate(currentActivity.due_date); } else { // For new activities - let account = Accounts.getAccountsList(); - reloadActivityTypeSelector(account, -1); + let defaultAccId = Accounts.getDefaultAccountId(); + reloadActivityTypeSelector(defaultAccId, -1); // For new activities, show both selectors with task selected by default taskRadio.checked = true; @@ -1057,9 +1058,14 @@ Page { let id = accountId === 0 ? rawTypes[i].id : rawTypes[i].odoo_record_id; let name = rawTypes[i].name; + if (!name || String(name).trim() === "") { + continue; + } + + let cleanName = String(name).trim(); flatModel.push({ id: id, - name: name, + name: cleanName, parent_id: null // no hierarchy assumed }); diff --git a/qml/features/activities/pages/Activity_Page.qml b/qml/features/activities/pages/Activity_Page.qml index d4bf5d7e..5b82488b 100644 --- a/qml/features/activities/pages/Activity_Page.qml +++ b/qml/features/activities/pages/Activity_Page.qml @@ -49,11 +49,24 @@ Page { } leadingActionBar.actions: [ + Action { + id: backAction + iconName: "back" + text: i18n.dtr("ubtms", "Back") + visible: filterByProject || filterByTasks + onTriggered: { + if (typeof apLayout !== "undefined" && apLayout && apLayout.removePages) { + apLayout.removePages(activity); + } else if (typeof pageStack !== "undefined" && pageStack && pageStack.pop) { + pageStack.pop(); + } + } + }, Action { id: drawerAction iconName: "navigation-menu" text: i18n.dtr("ubtms", "Menu") - visible: !isMultiColumn + visible: !filterByProject && !filterByTasks && !isMultiColumn onTriggered: { apLayout.openGlobalDrawer() } @@ -292,18 +305,37 @@ Page { for (let i = 0; i < allActivities.length; i++) { var item = allActivities[i]; var matchesSelectedAssignee = false; + var itemUserId = (item.user_id !== undefined && item.user_id !== null && item.user_id !== "") ? parseInt(item.user_id) : -1; + var itemAccountId = (item.account_id !== undefined && item.account_id !== null && item.account_id !== "") ? parseInt(item.account_id) : -1; + for (let j = 0; j < menuSelectedIds.length; j++) { var selectedId = menuSelectedIds[j]; - if (typeof selectedId === 'object') { - if (item.user_id && item.account_id && parseInt(item.user_id) === selectedId.user_id && parseInt(item.account_id) === selectedId.account_id) { - matchesSelectedAssignee = true; - break; - } - } else { - if (item.user_id && parseInt(item.user_id) === parseInt(selectedId)) { - matchesSelectedAssignee = true; - break; - } + var selUserId = -1; + var selAccountId = -1; + + if (typeof selectedId === 'object' && selectedId !== null) { + selUserId = (selectedId.user_id !== undefined && selectedId.user_id !== null && selectedId.user_id !== "") ? parseInt(selectedId.user_id) : -1; + selAccountId = (selectedId.account_id !== undefined && selectedId.account_id !== null && selectedId.account_id !== "") ? parseInt(selectedId.account_id) : -1; + } else if (selectedId !== undefined && selectedId !== null && selectedId !== "") { + selUserId = parseInt(selectedId); + } + + if (selUserId === -1) { + continue; + } + + // Check account match if account was specified in selection + var accountMatches = (selAccountId === -1 || itemAccountId === -1 || itemAccountId === selAccountId); + + // Check user match (handle local account where user_id could be 1 or -1) + var userMatches = (itemUserId !== -1 && ( + itemUserId === selUserId || + (itemAccountId === 0 && (itemUserId === 1 || itemUserId === -1) && (selUserId === 1 || selUserId === -1)) + )); + + if (accountMatches && userMatches) { + matchesSelectedAssignee = true; + break; } } if (matchesSelectedAssignee) { @@ -337,7 +369,7 @@ Page { summary: item.summary, due_date: item.due_date, notes: item.notes, - activity_type_name: Activity.getActivityTypeName(item.activity_type_id), + activity_type_name: Activity.getActivityTypeName(item.activity_type_id, item.account_id), state: item.state, task_id: safeTaskId, task_name: taskName, @@ -403,12 +435,11 @@ Page { for (var i = 0; i < rawAssignees.length; i++) { var assignee = rawAssignees[i]; var id = (projectAccountId === 0) ? assignee.id : assignee.odoo_record_id; - if (id > 0) { - // Skip invalid/placeholder entries + if (id > 0 && assignee.name && String(assignee.name).trim() !== "") { filteredAssignees.push({ id: id, odoo_record_id: id, - name: assignee.name, + name: String(assignee.name).trim(), email: assignee.email || "", account_name: assignee.account_name || "", account_id: projectAccountId @@ -428,12 +459,11 @@ Page { for (var i = 0; i < rawAssignees.length; i++) { var assignee = rawAssignees[i]; var id = (currentAccountId === 0) ? assignee.id : assignee.odoo_record_id; - if (id > 0) { - // Skip invalid/placeholder entries + if (id > 0 && assignee.name && String(assignee.name).trim() !== "") { filteredAssignees.push({ id: id, odoo_record_id: id, - name: assignee.name, + name: String(assignee.name).trim(), email: assignee.email || "", account_name: assignee.account_name || "", account_id: currentAccountId @@ -575,7 +605,7 @@ Page { } // Search in activity type name - var activityTypeName = Activity.getActivityTypeName(item.activity_type_id); + var activityTypeName = Activity.getActivityTypeName(item.activity_type_id, item.account_id); if (activityTypeName && activityTypeName.toLowerCase().indexOf(query) >= 0) { return true; } @@ -640,6 +670,10 @@ Page { } activity.currentFilter = nextFilter; + var activeSearch = (listheader.searchText !== undefined && listheader.searchText !== null) + ? listheader.searchText + : (activity.currentSearchQuery || ""); + activity.currentSearchQuery = activeSearch; get_activity_list(); } diff --git a/qml/features/dashboard/charts/Charts3.qml b/qml/features/dashboard/charts/Charts3.qml index 6a5fc20f..0a457d7e 100644 --- a/qml/features/dashboard/charts/Charts3.qml +++ b/qml/features/dashboard/charts/Charts3.qml @@ -44,9 +44,14 @@ Item { property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate) { + function reloadData(startDate, endDate, accountId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; + if (accountId !== undefined && accountId !== null) { + selectedAccountId = accountId; + } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { + selectedAccountId = accountPicker.selectedAccountId; + } var t_proj = []; var maxVal = 0; @@ -108,14 +113,34 @@ Item { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - reloadData(sDate, eDate); + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId); } Connections { target: root.autoRefreshOnAccountChange && typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: { + root.selectedAccountId = accountPicker.selectedAccountId; + root.displayLimit = 10; + reloadData(root.filterStartDate, root.filterEndDate, accountPicker.selectedAccountId); + } onAccepted: function (accountId, accountName) { + root.selectedAccountId = accountId; root.displayLimit = 10; // Reset to top 10 on account change - reloadData(root.filterStartDate, root.filterEndDate); + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + + Connections { + target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: function (accountId, accountName) { + root.selectedAccountId = accountId; + root.displayLimit = 10; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + onAccountDataRefreshRequested: function (accountId) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); } } diff --git a/qml/features/dashboard/charts/Charts4.qml b/qml/features/dashboard/charts/Charts4.qml index e8917868..c53ef0aa 100644 --- a/qml/features/dashboard/charts/Charts4.qml +++ b/qml/features/dashboard/charts/Charts4.qml @@ -21,9 +21,14 @@ Item { property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate) { + function reloadData(startDate, endDate, accountId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; + if (accountId !== undefined && accountId !== null) { + selectedAccountId = accountId; + } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { + selectedAccountId = accountPicker.selectedAccountId; + } projectsModel = buildProjectsModel(filterStartDate, filterEndDate); } @@ -48,7 +53,8 @@ Item { return []; } - var taskRows = TaskModel.getTasksForProject(project.odooRecordId, project.accountId, filterStartDate, filterEndDate); + var projectRecordId = (project.accountId === 0 || !project.odooRecordId) ? project.localId : project.odooRecordId; + var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate); var mappedTasks = []; for (var i = 0; i < taskRows.length; i++) { @@ -59,7 +65,7 @@ Item { var assignee = Utils.getTaskAssignerName(project.accountId, task.id); mappedTasks.push({ - id: String(project.id) + ":" + String(task.odoo_record_id), + id: String(project.id) + ":" + String(task.odoo_record_id || task.id), localId: task.id, odooRecordId: task.odoo_record_id, projectId: project.id, @@ -67,7 +73,7 @@ Item { totalHours: Number(task.spent_hours || 0), description: task.description || "", assignee: assignee || i18n.dtr("ubtms", "Unassigned"), - status: (task.state && task.state > 0) ? TaskModel.getTaskStageName(task.state, project.accountId) : (task.status || i18n.dtr("ubtms", "Unknown")), + status: (task.state && task.state !== 0) ? TaskModel.getTaskStageName(task.state, project.accountId) : (task.status || i18n.dtr("ubtms", "Unknown")), projectName: project.name, logs: [], _logsLoaded: false @@ -190,13 +196,31 @@ Item { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - reloadData(sDate, eDate); + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId); } Connections { target: root.autoRefreshOnAccountChange && typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: { + root.selectedAccountId = accountPicker.selectedAccountId; + reloadData(root.filterStartDate, root.filterEndDate, accountPicker.selectedAccountId); + } onAccepted: function(accountId, accountName) { - reloadData(root.filterStartDate, root.filterEndDate); + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + + Connections { + target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: function (accountId, accountName) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + onAccountDataRefreshRequested: function (accountId) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); } } } diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index 422983ba..f567f7dc 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -139,16 +139,7 @@ Page { text: i18n.dtr("ubtms", "New Timesheet") visible: !headerContents.showDateFilter onTriggered: { - const defaultAccountId = Account.getDefaultAccountId(); - const result = TimesheetModel.createTimesheet(defaultAccountId, Account.getCurrentUserOdooId(defaultAccountId)); - if (result.success) { - apLayout.addPageToCurrentColumn(mainPage, Qt.resolvedUrl("../../timesheets/pages/Timesheet.qml"), { - "recordid": result.id, - "isReadOnly": false - }); - } else { - Logger.error("Dashboard", "Error creating timesheet: " + result.message) - } + openNewTimesheetPage(false); } }, Action { @@ -165,6 +156,33 @@ Page { ] } + function openNewTimesheetPage(useNextColumn) { + var targetAccountId = (accountPicker && accountPicker.selectedAccountId >= 0) ? accountPicker.selectedAccountId : Account.getDefaultAccountId(); + if (targetAccountId < 0) { + targetAccountId = 0; + } + var targetUserId = Account.getCurrentUserOdooId(targetAccountId); + if (targetAccountId === 0 && (!targetUserId || targetUserId <= 0)) { + targetUserId = 1; + } + const result = TimesheetModel.createTimesheet(targetAccountId, targetUserId); + if (result.success) { + if (useNextColumn) { + apLayout.addPageToNextColumn(mainPage, Qt.resolvedUrl("../../timesheets/pages/Timesheet.qml"), { + "recordid": result.id, + "isReadOnly": false + }); + } else { + apLayout.addPageToCurrentColumn(mainPage, Qt.resolvedUrl("../../timesheets/pages/Timesheet.qml"), { + "recordid": result.id, + "isReadOnly": false + }); + } + } else { + Logger.error("Dashboard", "Error creating timesheet: " + (result.error || result.message)); + } + } + function refreshData() { Logger.debug("Dashboard", "Refreshing Dashboard data...") var targetAccountId = accountPicker.selectedAccountId; @@ -219,10 +237,15 @@ Page { return; case 2: Logger.debug("Dashboard", "Dashboard refresh stage 2: additional charts") - if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") - mobileProjectChartLoader.item.reloadData(sDate, eDate); - if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") - mobileTaskChartLoader.item.reloadData(sDate, eDate); + 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; @@ -239,13 +262,16 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Task") + label: i18n.dtr("ubtms", "Task"), + iconName: "scope-manager" }, { - label: i18n.dtr("ubtms", "Timesheet") + label: i18n.dtr("ubtms", "Timesheet"), + iconName: "alarm-clock" }, { - label: i18n.dtr("ubtms", "Activity") + label: i18n.dtr("ubtms", "Activity"), + iconName: "calendar" } ] onMenuItemSelected: { @@ -256,15 +282,7 @@ Page { }); } if (index === 1) { - const result = TimesheetModel.createTimesheet(Account.getDefaultAccountId(), Account.getCurrentUserOdooId(Account.getDefaultAccountId())); - if (result.success) { - apLayout.addPageToNextColumn(mainPage, Qt.resolvedUrl("../../timesheets/pages/Timesheet.qml"), { - "recordid": result.id, - "isReadOnly": false - }); - } else { - Logger.error("Dashboard", "Error creating timesheet: " + result.message) - } + openNewTimesheetPage(true); } if (index === 2) { apLayout.addPageToNextColumn(mainPage, Qt.resolvedUrl("../../activities/pages/Activities.qml"), { @@ -428,6 +446,8 @@ Page { onLoaded: { if (item) { item.autoRefreshOnAccountChange = false; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + item.selectedAccountId = accId; } } } @@ -442,6 +462,8 @@ Page { onLoaded: { if (item) { item.autoRefreshOnAccountChange = false; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + item.selectedAccountId = accId; } } } @@ -490,27 +512,36 @@ Page { } onCommitCompleted: { - const result = TimesheetModel.createTimesheet(Account.getDefaultAccountId(), Account.getCurrentUserOdooId(Account.getDefaultAccountId())); - if (result.success) { - apLayout.addPageToNextColumn(mainPage, Qt.resolvedUrl("../../timesheets/pages/Timesheet.qml"), { - "recordid": result.id, - "isReadOnly": false - }); - } else { - Logger.error("Dashboard", "Error creating timesheet: " + result.message) - } + openNewTimesheetPage(true); collapse(); } } Connections { - target: accountPicker + target: typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: { + if (accountPicker.selectedAccountName) { + header.title = i18n.dtr("ubtms", "Account") + " [" + accountPicker.selectedAccountName + "]"; + } + refreshData(); + } onAccepted: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; refreshData(); } } + Connections { + target: typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: function (accountId, accountName) { + header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; + refreshData(); + } + onAccountDataRefreshRequested: function (accountId) { + refreshData(); + } + } + // NotificationBell component handles all notification UI NotificationBell { id: notificationBell diff --git a/qml/features/dashboard/pages/Dashboard2.qml b/qml/features/dashboard/pages/Dashboard2.qml index 07d932ca..2e501b8a 100644 --- a/qml/features/dashboard/pages/Dashboard2.qml +++ b/qml/features/dashboard/pages/Dashboard2.qml @@ -80,30 +80,56 @@ Page { ] } + Timer { + id: debounceRefreshTimer + interval: 100 + repeat: false + onTriggered: _doRefreshCharts() + } + function refreshData(force) { var accountId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; if (!force && lastRefreshAccountId === accountId) { return; } + debounceRefreshTimer.restart(); + } + function _doRefreshCharts() { + var accountId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; lastRefreshAccountId = accountId; console.log("Refreshing Dashboard2 charts for account: " + accountId); var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - if (load3.item && typeof load3.item.reloadData === "function") - load3.item.reloadData(sDate, eDate); - if (load4.item && typeof load4.item.reloadData === "function") - load4.item.reloadData(sDate, eDate); + if (load3.item && typeof load3.item.reloadData === "function") { + load3.item.selectedAccountId = accountId; + load3.item.reloadData(sDate, eDate, accountId); + } + if (load4.item && typeof load4.item.reloadData === "function") { + load4.item.selectedAccountId = accountId; + load4.item.reloadData(sDate, eDate, accountId); + } } Connections { target: typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: refreshData(true) onAccepted: function (accountId, accountName) { refreshData(true); } } + Connections { + target: typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: function (accountId, accountName) { + refreshData(true); + } + onAccountDataRefreshRequested: function (accountId) { + refreshData(true); + } + } + Connections { target: typeof mainView !== "undefined" ? mainView : null onGlobalDateRangeChanged: function (presetId, startDate, endDate, presetLabel) { @@ -154,6 +180,12 @@ Page { onLoaded: { if (item) { item.autoRefreshOnAccountChange = false; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + item.selectedAccountId = accId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId); } } } @@ -172,6 +204,12 @@ Page { onLoaded: { if (item) { item.autoRefreshOnAccountChange = false; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + item.selectedAccountId = accId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId); } } } diff --git a/qml/features/projects/pages/Project_Page.qml b/qml/features/projects/pages/Project_Page.qml index ff0682ca..e13ba1e0 100644 --- a/qml/features/projects/pages/Project_Page.qml +++ b/qml/features/projects/pages/Project_Page.qml @@ -41,6 +41,7 @@ Page { property bool isMultiColumn: typeof apLayout !== "undefined" ? apLayout.columns > 1 : false id: project title: i18n.dtr("ubtms", "Projects") + property alias projectlist: projectlist header: PageHeader { id: projectheader StyleHints { diff --git a/qml/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index 69dd71c9..af94da52 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -65,70 +65,11 @@ Page { trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" text: i18n.dtr("ubtms", "Save") visible: !isReadOnly onTriggered: { - const ids = workItem.getIds(); - - if (!ids.assignee_id) { - notifPopup.open("Error", "Please select the assignee", "error"); - return; - } - - // Validate hours format before saving - if (!hours_text.isValid) { - notifPopup.open("Error", "Please enter allocated hours in HH:MM format (e.g., 1000:30 for large projects)", "error"); - return; - } - - // isReadOnly = !isReadOnly - // Preserve existing favorites value when editing, default to 0 for new projects - var currentFavorites = (project && project.favorites !== undefined) ? project.favorites : 0; - - var project_data = { - 'account_id': ids.account_id >= 0 ? ids.account_id : 0, - 'name': project_name.text, - 'planned_start_date': date_range_widget.formattedStartDate(), - 'planned_end_date': date_range_widget.formattedEndDate(), - 'parent_id': ids.project_id, - 'allocated_hours': hours_text.text, - 'description': description_text.getFormattedText ? description_text.getFormattedText() : description_text.text, - 'favorites': currentFavorites, - 'color': project_color, - 'stage': (project && project.stage !== undefined) ? project.stage : 0, - 'status': "updated", - 'user_id': ids.assignee_id - }; - // console.log(JSON.stringify(project_data, null, 4)); - - // Use the current recordid (0 for new projects, existing ID for updates) - var response = Project.createUpdateProject(project_data, recordid); - if (response) { - if (response.is_success) { - notifPopup.open("Saved", response.message, "success"); - - // Update recordid if it was a new project creation - if (recordid === 0 && response.record_id) { - recordid = response.record_id; - } - - // Reload the project data to reflect the saved state - if (recordid !== 0) { - loadProjectData(recordid); - } - - // Clear draft after successful save - draftHandler.clearDraft(); - - // Switch back to read-only mode after saving - isReadOnly = true; - } else { - notifPopup.open("Failed", response.message, "error"); - } - } else { - notifPopup.open("Failed", "Unable to save project", "error"); - } + saveProjectData(); } }, Action { @@ -454,6 +395,10 @@ Page { loadProjectData(recordid); } + if (typeof mainView !== "undefined" && mainView && mainView.projectDataChanged) { + mainView.projectDataChanged(); + } + draftHandler.clearDraft(); isReadOnly = true; return true; @@ -530,7 +475,8 @@ Page { project_color_label.color = colorpicker.getColorByIndex(projectColor); date_range_widget.setDateRange(project.planned_start_date || "", project.planned_end_date || ""); hours_text.text = project.allocated_hours !== undefined && project.allocated_hours !== null ? String(project.allocated_hours) : "01:00"; - attachments_widget.setAttachments(Project.getAttachmentsForProject(project.odoo_record_id, project.account_id)); + var attResId = (project.odoo_record_id && project.odoo_record_id > 0) ? project.odoo_record_id : (project.id || recordid); + attachments_widget.setAttachments(Project.getAttachmentsForProject(attResId, project.account_id !== undefined ? project.account_id : 0)); return true; } return false; @@ -656,7 +602,7 @@ Page { } TSLabel { - text: project && project.stage ? Project.getProjectStageName(project.stage) : i18n.dtr("ubtms", "Not set") + text: project && project.stage ? Project.getProjectStageName(project.stage, project.account_id) : i18n.dtr("ubtms", "Not set") width: (parent.width - (2 * parent.spacing)) / 3 height: units.gu(6) fontBold: true @@ -664,7 +610,7 @@ Page { if (!project || !project.stage) { return theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#888" : "#666"; } - var stageName = Project.getProjectStageName(project.stage).toLowerCase(); + var stageName = Project.getProjectStageName(project.stage, project.account_id).toLowerCase(); if (stageName === "completed" || stageName === "finished" || stageName === "closed" || stageName === "verified" || stageName === "done") { return "green"; } @@ -695,7 +641,7 @@ Page { var dialog = PopupUtils.open(projectStageSelector, projectCreate, { projectId: project.id, - accountId: project.account_id, + accountId: (project && project.account_id !== undefined) ? project.account_id : (selectedAccountId !== undefined ? selectedAccountId : 0), currentStageOdooRecordId: project.stage || -1 }); } @@ -734,7 +680,8 @@ Page { text: i18n.dtr("ubtms","Create") onClicked: { let project = Project.getProjectDetails(recordid); - let result = Activity.createActivityFromProjectOrTask(true, project.account_id, project.odoo_record_id); + let projectRecordId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; + let result = Activity.createActivityFromProjectOrTask(true, project.account_id, projectRecordId); if (result.success) { apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../activities/pages/Activities.qml"), { "recordid": result.record_id, @@ -760,9 +707,10 @@ Page { text: i18n.dtr("ubtms","View") onClicked: { let project = Project.getProjectDetails(recordid); + let projectRecordId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../activities/pages/Activity_Page.qml"), { "filterByProject": true, - "projectOdooRecordId": project.odoo_record_id, + "projectOdooRecordId": projectRecordId, "projectAccountId": project.account_id, "projectName": project.name }); @@ -795,13 +743,14 @@ Page { let project = Project.getProjectDetails(recordid); let isSubProject = project.parent_id && project.parent_id > 0; let parentProjectId = isSubProject ? project.parent_id : -1; + let projectRecordId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../tasks/pages/Tasks.qml"), { "recordid": 0, "isReadOnly": false, "prefilledAccountId": project.account_id, - "prefilledProjectId": isSubProject ? -1 : project.odoo_record_id, - "prefilledSubProjectId": isSubProject ? project.odoo_record_id : -1, + "prefilledProjectId": isSubProject ? -1 : projectRecordId, + "prefilledSubProjectId": isSubProject ? projectRecordId : -1, "prefilledParentProjectId": parentProjectId, "prefilledProjectName": project.name }); @@ -821,9 +770,10 @@ Page { text: i18n.dtr("ubtms","View") onClicked: { let project = Project.getProjectDetails(recordid); + let projectRecordId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../tasks/pages/Task_Page.qml"), { "filterByProject": true, - "projectOdooRecordId": project.odoo_record_id, + "projectOdooRecordId": projectRecordId, "projectAccountId": project.account_id, "projectName": project.name }); @@ -854,18 +804,21 @@ Page { text: i18n.dtr("ubtms","Create") onClicked: { let project = Project.getProjectDetails(recordid); - Global.createUpdateCallback = function(updateData) { - let result = Project.createUpdateSnapShot(updateData); - if (result['is_success'] === false) { - notifPopup.open("Failed", result['message'], "error"); - } else { - notifPopup.open("Saved", "Project update has been saved", "success"); - } - Global.createUpdateCallback = null; + let projectRecordId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; + var newUpdate = { + account_id: project.account_id, + project_id: projectRecordId, + name: "", + description: "", + project_status: "on_track", + progress: 0, + user_id: Accounts.getCurrentUserOdooId(project.account_id) }; - apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../../components/CreateUpdatePage.qml"), { - "projectId": project.odoo_record_id, - "accountId": project.account_id + apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../updates/pages/Updates.qml"), { + "recordid": 0, + "accountid": project.account_id, + "currentUpdate": newUpdate, + "isReadOnly": false }); } } @@ -883,9 +836,11 @@ Page { text: i18n.dtr("ubtms","View") onClicked: { let project = Project.getProjectDetails(recordid); + let projectRecordId = (project.account_id === 0 || !project.odoo_record_id) ? project.id : project.odoo_record_id; apLayout.addPageToNextColumn(projectCreate, Qt.resolvedUrl("../../updates/pages/Updates_Page.qml"), { "filterByProject": true, - "projectOdooRecordId": project.odoo_record_id, + "projectRecordId": projectRecordId, + "projectOdooRecordId": projectRecordId, "projectAccountId": project.account_id, "projectName": project.name }); @@ -997,11 +952,13 @@ Page { width: parent.width height: units.gu(50) resource_type: "project.project" - resource_id: project.odoo_record_id - account_id: project.account_id + resource_id: (project && project.odoo_record_id > 0) ? project.odoo_record_id : (project && project.id ? project.id : recordid) + account_id: (project && project.account_id !== undefined) ? project.account_id : 0 notifier: infobar onUploadCompleted: { - attachments_widget.setAttachments(Project.getAttachmentsForProject(project.odoo_record_id, project.account_id)); + var resId = (project && project.odoo_record_id > 0) ? project.odoo_record_id : (project && project.id ? project.id : recordid); + var accId = (project && project.account_id !== undefined) ? project.account_id : 0; + attachments_widget.setAttachments(Project.getAttachmentsForProject(resId, accId)); } } } @@ -1048,6 +1005,10 @@ Page { // Reload project data to ensure UI is updated loadProjectData(recordid); + if (typeof mainView !== "undefined" && mainView && mainView.projectDataChanged) { + mainView.projectDataChanged(); + } + notifPopup.open("Success", "Project stage changed to: " + stageName, "success"); } else { notifPopup.open("Error", "Failed to update project stage: " + (result.error || "Unknown error"), "error"); diff --git a/qml/features/settings/components/SettingsListItem.qml b/qml/features/settings/components/SettingsListItem.qml index 15f289dd..b4073d38 100644 --- a/qml/features/settings/components/SettingsListItem.qml +++ b/qml/features/settings/components/SettingsListItem.qml @@ -51,6 +51,7 @@ Item { property bool showProgression: true property bool showDivider: true property bool active: false + property bool collapsed: false signal clicked() @@ -84,13 +85,13 @@ Item { Row { anchors.fill: parent - anchors.leftMargin: units.gu(2) - anchors.rightMargin: units.gu(2) - spacing: units.gu(2) + anchors.leftMargin: root.collapsed ? 0 : units.gu(2) + anchors.rightMargin: root.collapsed ? 0 : units.gu(2) + spacing: root.collapsed ? 0 : units.gu(2) // Icon container Item { - width: units.gu(4) + width: root.collapsed ? parent.width : units.gu(4) height: parent.height Icon { @@ -104,7 +105,8 @@ Item { // Label Item { - width: parent.width - units.gu(4) - units.gu(3) - units.gu(6) // icon + chevron + margins + visible: !root.collapsed + width: root.collapsed ? 0 : (parent.width - units.gu(4) - units.gu(3) - units.gu(6)) height: parent.height Text { @@ -119,9 +121,9 @@ Item { // Chevron / progression indicator Item { - width: units.gu(3) + visible: !root.collapsed && root.showProgression + width: root.collapsed ? 0 : units.gu(3) height: parent.height - visible: root.showProgression Text { anchors.centerIn: parent @@ -138,7 +140,7 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - anchors.leftMargin: units.gu(8) // Indent divider past icon area + anchors.leftMargin: root.collapsed ? 0 : units.gu(8) height: units.dp(1) color: root.dividerColor } @@ -146,7 +148,13 @@ Item { MouseArea { id: mouseArea anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor onClicked: root.clicked() + + ToolTip.visible: root.collapsed && mouseArea.containsMouse && root.text.length > 0 + ToolTip.text: root.text + ToolTip.delay: 400 } } } diff --git a/qml/features/settings/components/SettingsToggleItem.qml b/qml/features/settings/components/SettingsToggleItem.qml index e2a95b5a..51f06694 100644 --- a/qml/features/settings/components/SettingsToggleItem.qml +++ b/qml/features/settings/components/SettingsToggleItem.qml @@ -25,6 +25,7 @@ import QtQuick 2.7 import QtQuick.Controls 2.2 import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 /* * SettingsToggleItem - A settings list item with an inline Switch toggle. @@ -134,6 +135,11 @@ Item { anchors.centerIn: parent checked: root.checked enabled: root.enabled + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onClicked: { root.checked = checked; root.toggled(checked); diff --git a/qml/features/settings/pages/Account_Page.qml b/qml/features/settings/pages/Account_Page.qml index 7e711cc0..3d7eb5f5 100644 --- a/qml/features/settings/pages/Account_Page.qml +++ b/qml/features/settings/pages/Account_Page.qml @@ -25,6 +25,7 @@ import QtQuick 2.7 import QtQuick.Controls 2.2 import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 import QtQuick.Window 2.2 import QtQuick.LocalStorage 2.7 as Sql import io.thp.pyotherside 1.4 @@ -63,7 +64,7 @@ Page { } trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" visible: !isReadOnly text: i18n.dtr("ubtms","Save") @@ -87,6 +88,7 @@ Page { } function handleAccountSave() { + accountNameInput.text = (accountNameInput.text || "").trim(); if (!accountNameInput.text) { notifPopup.open("Error", "Account name cannot be empty", "error"); return; @@ -94,27 +96,38 @@ Page { var dbname = ""; if (isManualDbMode) { - dbname = manualDbInput.text; + dbname = manualDbInput.text || ""; } else { - dbname = databaseSelector.selectedName; + dbname = databaseSelector.selectedName || ""; } - if (!linkInput.text.trim()) { + if (!linkInput.text || !linkInput.text.trim()) { notifPopup.open("Error", "Server URL cannot be empty", "error"); return; } - if (!usernameInput.text.trim()) { + var urlResult = Utils.validateAndCleanOdooURL(linkInput.text); + if (!urlResult.isValid) { + notifPopup.open("Error", "The Odoo Server URL is Wrong", "error"); + return; + } + + linkInput.text = urlResult.cleanedUrl; + usernameInput.text = (usernameInput.text || "").trim(); + passwordInput.text = (passwordInput.text || "").trim(); + dbname = dbname.trim(); + + if (!usernameInput.text) { notifPopup.open("Error", "Username cannot be empty", "error"); return; } - if (!passwordInput.text.trim()) { + if (!passwordInput.text) { notifPopup.open("Error", "Password/API Key cannot be empty", "error"); return; } - if (!dbname.trim()) { + if (!dbname) { notifPopup.open("Error", "Database name cannot be empty", "error"); return; } @@ -601,6 +614,11 @@ Page { checked: useCustomSyncSettings enabled: !isReadOnly anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onCheckedChanged: { useCustomSyncSettings = checked; } @@ -635,6 +653,11 @@ Page { checked: true enabled: !isReadOnly anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } } } diff --git a/qml/features/settings/pages/Settings_Accounts.qml b/qml/features/settings/pages/Settings_Accounts.qml index 89de5c2f..e5eb26a9 100644 --- a/qml/features/settings/pages/Settings_Accounts.qml +++ b/qml/features/settings/pages/Settings_Accounts.qml @@ -119,10 +119,46 @@ Page { color: LomiriColors.red onClicked: { if (accountToDelete !== -1) { - Accounts.deleteAccountAndRelatedData(accountToDelete); + var deletedId = accountToDelete; + Accounts.deleteAccountAndRelatedData(deletedId); if (accountIndexToDelete !== -1) { accountListModel.remove(accountIndexToDelete); } + + // Cleanup orphan attachment files from disk + if (typeof backend_bridge !== "undefined" && backend_bridge) { + backend_bridge.call("backend.resolve_qml_db_path", ["ubtms"], function (path) { + if (path) { + backend_bridge.call("backend.cleanup_orphan_attachment_files", [path], function (res) { + // Cleanup completed + }); + } + }); + } + + // Check if the deleted account was the active account or no longer exists + var currentActiveId = (typeof accountPicker !== "undefined" && accountPicker) ? accountPicker.selectedAccountId : -1; + if (currentActiveId === deletedId || !Accounts.getAccountName(currentActiveId)) { + var nextAccountId = Accounts.getDefaultAccountId(); + if (nextAccountId === -1) nextAccountId = 0; + var nextAccountName = Accounts.getAccountName(nextAccountId) || "Local Account"; + + if (typeof accountPicker !== "undefined" && accountPicker) { + accountPicker.selectedAccountId = nextAccountId; + accountPicker.selectedAccountName = nextAccountName; + accountPicker.accepted(nextAccountId, nextAccountName); + } + } else { + if (typeof mainView !== "undefined" && mainView) { + mainView.accountDataRefreshRequested(currentActiveId); + } + } + + if (typeof mainView !== "undefined" && mainView) { + mainView.projectDataChanged(); + mainView.taskDataChanged(); + } + accountToDelete = -1; accountIndexToDelete = -1; } @@ -282,13 +318,14 @@ Page { }); } } + leadingActions: model.id === 0 ? null : accountLeadingActions - // ── Swipe Left → Edit ── - leadingActions: ListItemActions { + ListItemActions { + id: accountLeadingActions actions: [ Action { iconName: "edit" - enabled: model.id !== 0 + text: i18n.dtr("ubtms", "Edit") onTriggered: { apLayout.addPageToNextColumn(accountsSettingsPage, Qt.resolvedUrl('Account_Page.qml'), { "accountId": model.id, @@ -298,14 +335,14 @@ Page { } ] } + trailingActions: model.id === 0 ? null : accountTrailingActions - // ── Swipe Right → Log, Delete ── - trailingActions: ListItemActions { + ListItemActions { + id: accountTrailingActions actions: [ Action { iconName: "note" text: i18n.dtr("ubtms", "Log") - enabled: model.id !== 0 onTriggered: { apLayout.addPageToNextColumn(accountsSettingsPage, Qt.resolvedUrl("SyncLog.qml"), { "recordid": model.id @@ -315,7 +352,6 @@ Page { Action { iconName: "delete" text: i18n.dtr("ubtms", "Delete") - enabled: model.id !== 0 onTriggered: { accountToDelete = model.id; accountIndexToDelete = index; diff --git a/qml/features/settings/pages/Settings_Notifications.qml b/qml/features/settings/pages/Settings_Notifications.qml index 1c08e075..24837d10 100644 --- a/qml/features/settings/pages/Settings_Notifications.qml +++ b/qml/features/settings/pages/Settings_Notifications.qml @@ -26,6 +26,7 @@ import QtQuick 2.7 import QtQuick.Controls 2.2 import QtQuick.LocalStorage 2.7 as Sql import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 import "../../../components" import "../components" @@ -225,6 +226,11 @@ Page { checked: notificationSettingsPage.notificationsEnabled && !notificationSettingsPage.isSyncUploadOnly enabled: !notificationSettingsPage.isSyncUploadOnly anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onClicked: { notificationSettingsPage.notificationsEnabled = checked; saveAutoSyncSetting("notifications_enabled", checked ? "true" : "false"); @@ -305,6 +311,11 @@ Page { checked: getAutoSyncSetting("notification_schedule_enabled") === "true" enabled: notificationSettingsPage.notificationsEffectivelyEnabled anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onClicked: { saveAutoSyncSetting("notification_schedule_enabled", checked ? "true" : "false"); } @@ -444,13 +455,13 @@ Page { if (!notificationScheduleSection.scheduleActive) return theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#222" : "#e0e0e0"; if (isSelected) - return theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d7d46" : "#4CAF50"; + return theme.name === "Ubuntu.Components.Themes.SuruDark" ? Qt.darker(LomiriColors.orange, 1.15) : LomiriColors.orange; return theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#333" : "#fff"; } border.color: { if (isSelected && notificationScheduleSection.scheduleActive) - return theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#4CAF50" : "#388E3C"; + return theme.name === "Ubuntu.Components.Themes.SuruDark" ? LomiriColors.orange : Qt.darker(LomiriColors.orange, 1.15); return theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#555" : "#ccc"; } diff --git a/qml/features/settings/pages/Settings_Page.qml b/qml/features/settings/pages/Settings_Page.qml index d56ed61b..302ce534 100644 --- a/qml/features/settings/pages/Settings_Page.qml +++ b/qml/features/settings/pages/Settings_Page.qml @@ -97,7 +97,7 @@ Page { } SettingsListItem { - iconName: "preferences-desktop-theme" + iconName: "preferences-color-symbolic" iconColor: "#8e44ad" text: i18n.dtr("ubtms", "Theme Settings") active: settings.selectedSettingsPageUrl === "Settings_Theme.qml" diff --git a/qml/features/settings/pages/Settings_Sync.qml b/qml/features/settings/pages/Settings_Sync.qml index 53492e6a..ba4e4a70 100644 --- a/qml/features/settings/pages/Settings_Sync.qml +++ b/qml/features/settings/pages/Settings_Sync.qml @@ -26,6 +26,7 @@ import QtQuick 2.7 import QtQuick.Controls 2.2 import QtQuick.LocalStorage 2.7 as Sql import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 import Pparent.Notifications 1.0 import "../components" import "../../../components" @@ -164,6 +165,11 @@ Page { id: autoSyncSwitch checked: getAutoSyncSetting("autosync_enabled") === "true" anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onCheckedChanged: { saveAutoSyncSetting("autosync_enabled", checked ? "true" : "false"); } diff --git a/qml/features/settings/pages/Settings_VoiceModel.qml b/qml/features/settings/pages/Settings_VoiceModel.qml index 4ac46753..d05cfe3d 100644 --- a/qml/features/settings/pages/Settings_VoiceModel.qml +++ b/qml/features/settings/pages/Settings_VoiceModel.qml @@ -26,6 +26,7 @@ import QtQuick 2.7 import QtQuick.Controls 2.2 import QtQuick.LocalStorage 2.7 as Sql import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 import Lomiri.Components.Popups 1.3 import QtGraphicalEffects 1.0 import "../components" @@ -737,6 +738,11 @@ Page { anchors.rightMargin: units.gu(2) anchors.verticalCenter: parent.verticalCenter checked: isVoiceInputEnabled + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onCheckedChanged: { if (checked !== isVoiceInputEnabled) { saveVoiceInputEnabledSetting(checked); @@ -765,6 +771,11 @@ Page { anchors.rightMargin: units.gu(2) anchors.verticalCenter: parent.verticalCenter checked: isVoiceLowMemoryMode + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onCheckedChanged: { if (checked !== isVoiceLowMemoryMode) { saveVoiceLowMemoryModeSetting(checked); diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 81d610e3..d9085a9b 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -54,12 +54,22 @@ ListItem { property int stage: -1 property bool hasChildren: false property int childCount: 0 + property bool flatViewMode: false property bool timer_on: false property bool timer_paused: false property bool starInteractionActive: false property bool isMyTasksContext: false // Set to true when used in MyTasks page property int accountId: -1 // Account ID for the task property bool hasDraft: false // Indicates if this task has unsaved draft changes + property int idVal: -1 + property int effectiveTaskId: idVal > 0 ? idVal : ((taskCard.accountId === 0 || recordId <= 0) ? localId : recordId) + + property string stageName: (stage && stage !== 0) ? (Task.getTaskStageName(stage, accountId) || "") : "" + property bool isStageDone: { + if (!stageName) return false; + var lower = stageName.toLowerCase(); + return lower === "completed" || lower === "finished" || lower === "closed" || lower === "verified" || lower === "done"; + } signal editRequested(int localId) signal deleteRequested(int localId) @@ -67,6 +77,7 @@ ListItem { signal timesheetRequested(int localId) signal taskUpdated(int localId) signal taskStageChanged(int localId) // Emitted when personal stage changes in MyTasks + signal navigationRequested(int taskId, int accountId, string taskName) NotificationPopup { id: notifPopup @@ -102,17 +113,17 @@ ListItem { timer_paused = false; } onTimerStarted: { - if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, TimerService.getActiveTimesheetId())) { timer_on = true; } } onTimerPaused: { - if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, TimerService.getActiveTimesheetId())) { timer_paused = true; } } onTimerResumed: { - if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, TimerService.getActiveTimesheetId())) { timer_paused = false; } } @@ -173,7 +184,7 @@ ListItem { } function play_pause_workflow() { - if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { + if (Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, TimerService.getActiveTimesheetId())) { if (TimerService.isRunning() && !TimerService.isPaused()) { // If running and not paused, pause it TimerService.pause(); @@ -182,7 +193,7 @@ ListItem { TimerService.start(TimerService.getActiveTimesheetId()); } } else { - let result = Timesheet.createTimesheetFromTask(recordId); + let result = Timesheet.createTimesheetFromTask(effectiveTaskId); if (result.success) { const result_start = TimerService.start(result.id); if (!result_start.success) { @@ -197,8 +208,13 @@ ListItem { } function stop_workflow() { - if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) + var activeId = TimerService.getActiveTimesheetId(); + if (Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, activeId)) { TimerService.stop(); + if (taskCard.accountId === 0) { + Timesheet.markTimesheetAsSavedById(activeId); + } + } } function handlePersonalStageChange(personalStageOdooRecordId, personalStageName) { @@ -237,8 +253,8 @@ ListItem { }, Action { id: playpauseaction - iconSource: (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) ? (timer_paused ? "../../../images/play.png" : "../../../images/pause.png") : "../../../images/play.png" - visible: recordId > 0 + iconSource: (Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, TimerService.getActiveTimesheetId())) ? (timer_paused ? "../../../images/play.png" : "../../../images/pause.png") : "../../../images/play.png" + visible: (taskCard.accountId === 0 && localId > 0) || recordId > 0 text: i18n.dtr("ubtms", "update Timesheet") onTriggered: { play_pause_workflow(); @@ -246,7 +262,7 @@ ListItem { }, Action { id: startstopaction - visible: recordId > 0 + visible: (taskCard.accountId === 0 && localId > 0) || recordId > 0 iconSource: "../../../images/stop.png" text: i18n.dtr("ubtms", "update Timesheet") onTriggered: { @@ -304,6 +320,22 @@ ListItem { anchors.leftMargin: units.gu(0.2) anchors.rightMargin: units.gu(0.2) color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#111" : "#fff" + + MouseArea { + id: cardTapArea + anchors.fill: parent + z: 0 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !starInteractionActive + onClicked: { + if (hasChildren && !flatViewMode) { + taskCard.navigationRequested(taskCard.effectiveTaskId, (taskCard.accountId !== undefined && taskCard.accountId !== null) ? taskCard.accountId : -1, taskName); + } else { + viewRequested(localId); + } + } + } // subtle color fade on the left Rectangle { width: parent.width * 0.025 @@ -572,21 +604,83 @@ ListItem { // } } - Text { - text: (childCount > 0 ? " [+" + childCount + "] Tasks" : "") - visible: childCount > 0 - color: hasChildren ? AppConst.Colors.Orange : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") - font.pixelSize: units.gu(1.5) - // horizontalAlignment: Text.AlignRight - width: parent.width - } + Row { + spacing: units.gu(0.8) + visible: (stageName !== "") || (hasChildren && !flatViewMode) - Text { + Rectangle { + visible: stageName !== "" + height: units.gu(2.4) + width: taskStageText.width + units.gu(1.6) + radius: height / 2 + color: isStageDone ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#064e3b" : "#ecfdf5") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#1e293b" : "#f1f5f9") + border.color: isStageDone ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#059669" : "#a7f3d0") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#334155" : "#cbd5e1") + border.width: 1 + + Text { + id: taskStageText + text: stageName + font.pixelSize: units.gu(1.2) + font.bold: true + anchors.centerIn: parent + color: isStageDone ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#6ee7b7" : "#047857") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#cbd5e1" : "#475569") + } + } + + // Interactive Subtasks badge + Rectangle { + id: subtasksBadge + visible: hasChildren && !flatViewMode + height: units.gu(2.4) + width: subtasksBadgeRow.implicitWidth + units.gu(1.6) + radius: height / 2 + color: subtasksMouseArea.pressed + ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#3d2a1a" : "#fed7aa") + : (subtasksMouseArea.containsMouse + ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#352414" : "#ffedd5") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#24180d" : "#fff7ed")) + border.color: AppConst.Colors.Orange + border.width: 1 + + Row { + id: subtasksBadgeRow + anchors.centerIn: parent + spacing: units.gu(0.4) + + Text { + text: childCount > 0 ? (i18n.dtr("ubtms", "Subtasks") + " (" + childCount + ")") : i18n.dtr("ubtms", "Subtasks") + font.pixelSize: units.gu(1.2) + font.bold: true + color: AppConst.Colors.Orange + anchors.verticalCenter: parent.verticalCenter + } - text: Task.getTaskStageName(stage, accountId) - color: Task.getTaskStageName(stage, accountId).toLowerCase() === "completed" || Task.getTaskStageName(stage, accountId).toLowerCase() === "finished" || Task.getTaskStageName(stage, accountId).toLowerCase() === "closed" || Task.getTaskStageName(stage, accountId).toLowerCase() === "verified" || Task.getTaskStageName(stage, accountId).toLowerCase() === "done" ? "green" : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#555") - font.pixelSize: units.gu(1.75) - font.bold: Task.getTaskStageName(stage, accountId).toLowerCase() === "completed" || Task.getTaskStageName(stage, accountId).toLowerCase() === "finished" || Task.getTaskStageName(stage, accountId).toLowerCase() === "closed" || Task.getTaskStageName(stage, accountId).toLowerCase() === "verified" || Task.getTaskStageName(stage, accountId).toLowerCase() === "done" ? true : false + Icon { + name: "next" + width: units.gu(1.2) + height: units.gu(1.2) + color: AppConst.Colors.Orange + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: subtasksMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + z: 10 + propagateComposedEvents: false + preventStealing: true + onClicked: { + mouse.accepted = true; + taskCard.navigationRequested(taskCard.effectiveTaskId, (taskCard.accountId !== undefined && taskCard.accountId !== null) ? taskCard.accountId : -1, taskName); + } + } + } } } } @@ -674,7 +768,7 @@ anchors.right: parent.right } Component.onCompleted: { - taskCard.timer_on = Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.activeTimesheetId); + taskCard.timer_on = Timesheet.doesTaskIdMatchSheetInActive(effectiveTaskId, TimerService.activeTimesheetId); // If we have a localId, get the task details to set the priority if (localId > 0) { diff --git a/qml/features/tasks/components/TaskInitialStageSelector.qml b/qml/features/tasks/components/TaskInitialStageSelector.qml index 6290c2ab..6266f825 100644 --- a/qml/features/tasks/components/TaskInitialStageSelector.qml +++ b/qml/features/tasks/components/TaskInitialStageSelector.qml @@ -25,7 +25,7 @@ Item { anchors.right: parent.right labelText: i18n.dtr("ubtms", "Initial Stage") - enabledState: !root.isReadOnly + enabledState: !root.isReadOnly && stageListModel.count > 0 readOnly: root.isReadOnly onSelectionMade: { @@ -61,9 +61,11 @@ Item { if (currentIndex >= 0 && currentIndex < stageListModel.count) { var stage = stageListModel.get(currentIndex); inlineSelector.selectedId = stage.odoo_record_id; + inlineSelector.selectedName = stage.name; root.stageSelected(stage.odoo_record_id); } else { inlineSelector.selectedId = -1; + inlineSelector.selectedName = ""; } } } diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index b7e7c642..cc09229b 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -26,6 +26,7 @@ import QtQuick.Controls 2.2 import QtQuick.Layouts 1.1 import Lomiri.Components 1.3 import QtQuick.LocalStorage 2.7 as Sql +import "../../../../models/constants.js" as AppConst import "../../../../models/task.js" as Task import "../../../../models/project.js" as Project import "../../../components" @@ -36,6 +37,8 @@ Item { anchors.fill: parent property int currentParentId: -1 + property string currentParentName: "" + property int currentAccountId: -1 property ListModel navigationStackModel: ListModel {} property var childrenMap: ({}) property bool childrenMapReady: false @@ -45,11 +48,26 @@ Item { property int currentOffset: 0 property bool hasMoreItems: true property bool isLoadingMore: false + property var fallbackEmptyModel: ListModel {} onCurrentParentIdChanged: { currentOffset = 0; - hasMoreItems = true; - _doPopulateTaskChildrenMap(); + hasMoreItems = (currentParentId === -1); + + if (currentParentId === -1) { + currentParentName = ""; + currentAccountId = -1; + refreshWithFilter(); + return; + } + + _loadSubtasksForCurrentParent(); + } + + onCurrentAccountIdChanged: { + if (currentParentId !== -1) { + _loadSubtasksForCurrentParent(); + } } // Optional delegate for external data loading (function(limit, offset)) @@ -105,20 +123,38 @@ Item { } } + Connections { + target: typeof mainView !== "undefined" ? mainView : null + + onTaskDataChanged: { + refreshWithFilter(); + } + } + + function _resetHierarchyNavigation() { + navigationStackModel.clear(); + currentParentId = -1; + currentAccountId = -1; + currentParentName = ""; + } + // Add the applyFilter method function applyFilter(filterKey) { + _resetHierarchyNavigation(); currentFilter = filterKey; refreshWithFilter(); } // Add the applySearch method function applySearch(searchQuery) { + _resetHierarchyNavigation(); currentSearchQuery = searchQuery; refreshWithFilter(); } // Add the applyProjectFilter method function applyProjectFilter(projectOdooId, projectAccountId) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = projectAccountId; @@ -130,17 +166,18 @@ Item { // Add combined project and time filter method function applyProjectAndTimeFilter(projectOdooId, accountId, timeFilter) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = accountId; currentFilter = timeFilter; - currentSearchQuery = ""; refreshWithFilter(); } // Add combined project and search filter method function applyProjectAndSearchFilter(projectOdooId, accountId, searchQuery) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = accountId; @@ -223,7 +260,8 @@ Item { // Create lookup maps for (var i = 0; i < tasks.length; i++) { var task = tasks[i]; - var compositeId = task.odoo_record_id + "_" + task.account_id; + var effectiveId = (task.account_id === 0 || !task.odoo_record_id) ? task.id : task.odoo_record_id; + var compositeId = effectiveId + "_" + task.account_id; taskById[compositeId] = task; var parentId = (task.parent_id === null || task.parent_id === 0) ? -1 : task.parent_id; @@ -247,11 +285,17 @@ Item { if (typeof selectedId === 'object' && selectedId !== null) { // New format: {user_id: X, account_id: Y} var taskUserIds = parseUserIds(task.user_id); - var taskAccountId = task.account_id ? parseInt(task.account_id) : null; - var selectedUserId = selectedId.user_id ? parseInt(selectedId.user_id) : null; - var selectedAccountId = selectedId.account_id ? parseInt(selectedId.account_id) : null; + var taskAccountId = (task.account_id !== undefined && task.account_id !== null && task.account_id !== "") ? parseInt(task.account_id) : null; + var selectedUserId = (selectedId.user_id !== undefined && selectedId.user_id !== null && selectedId.user_id !== "") ? parseInt(selectedId.user_id) : null; + var selectedAccountId = (selectedId.account_id !== undefined && selectedId.account_id !== null && selectedId.account_id !== "") ? parseInt(selectedId.account_id) : null; + + var userMatches = (taskUserIds.length > 0 && selectedUserId !== null && ( + taskUserIds.indexOf(selectedUserId) >= 0 || + (taskAccountId === 0 && (selectedUserId === 1 || selectedUserId === -1) && (taskUserIds.indexOf(1) >= 0 || taskUserIds.indexOf(-1) >= 0)) + )); + var accountMatches = (taskAccountId !== null && selectedAccountId !== null && taskAccountId === selectedAccountId); - if (taskUserIds.length > 0 && taskAccountId !== null && selectedUserId !== null && selectedAccountId !== null && taskUserIds.indexOf(selectedUserId) >= 0 && taskAccountId === selectedAccountId) { + if (userMatches && accountMatches) { matchesSelectedAssignee = true; break; } @@ -268,7 +312,8 @@ Item { } if (matchesSelectedAssignee) { - var compositeId = task.odoo_record_id + "_" + task.account_id; + var effectiveId = (task.account_id === 0 || !task.odoo_record_id) ? task.id : task.odoo_record_id; + var compositeId = effectiveId + "_" + task.account_id; matchingTaskIds.add(compositeId); //console.log("TaskList: Direct match found for task:", task.name, "ID:", compositeId); } @@ -296,7 +341,8 @@ Item { var filteredTasks = []; for (var i = 0; i < tasks.length; i++) { var task = tasks[i]; - var compositeId = task.odoo_record_id + "_" + task.account_id; + var effectiveId = (task.account_id === 0 || !task.odoo_record_id) ? task.id : task.odoo_record_id; + var compositeId = effectiveId + "_" + task.account_id; if (matchingTaskIds.has(compositeId)) { filteredTasks.push(task); @@ -316,6 +362,10 @@ Item { } function refreshWithFilter() { + if (currentParentId !== -1) { + _loadSubtasksForCurrentParent(); + return; + } isLoading = true; // Use Timer to defer the actual data loading, // giving QML time to render the loading indicator first @@ -369,6 +419,7 @@ Item { } function applyAccountFilter(accountId) { + _resetHierarchyNavigation(); filterByAccount = (accountId >= 0); selectedAccountId = accountId; filterByProject = false; @@ -376,13 +427,99 @@ Item { refreshWithFilter(); } + function navigateToTask(taskId, accountId, taskName) { + if (taskId === undefined || taskId === null) { + return; + } + + navigationStackModel.append({ + parentId: currentParentId !== undefined ? currentParentId : -1, + accountId: currentAccountId !== undefined ? currentAccountId : -1, + parentName: currentParentName || "" + }); + currentAccountId = (accountId !== undefined && accountId !== null) ? accountId : -1; + currentParentName = taskName || ""; + currentParentId = taskId; + } + + function navigateBackInHierarchy() { + if (navigationStackModel.count > 0) { + var last = navigationStackModel.get(navigationStackModel.count - 1); + navigationStackModel.remove(navigationStackModel.count - 1); + currentAccountId = last.accountId !== undefined ? last.accountId : -1; + currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + currentParentId = last.parentId !== undefined ? last.parentId : -1; + } + } + + function _loadSubtasksForCurrentParent() { + if (currentParentId === -1) { + return; + } + + hasMoreItems = false; + + var acc = (currentAccountId !== undefined && currentAccountId >= 0) + ? currentAccountId + : (filterByAccount && selectedAccountId >= 0 ? selectedAccountId : (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId >= 0 ? accountPicker.selectedAccountId : -1)); + + var subtasks = Task.getSubtasksForParent(currentParentId, acc); + + var model = childrenMap[currentParentId]; + if (!model) { + model = Qt.createQmlObject('import QtQuick 2.0; ListModel {}', taskNavigator); + childrenMap[currentParentId] = model; + } else { + model.clear(); + } + + subtasks.forEach(function (row) { + var effectiveId = (row.account_id === 0 || !row.odoo_record_id) ? row.id : row.odoo_record_id; + var parentEffectiveId = (row.parent_id === null || row.parent_id === 0) ? -1 : row.parent_id; + + var projectIdToUse = row.project_id || row.sub_project_id; + var projectName = Project.getProjectName(projectIdToUse, row.account_id); + if (projectName === "Unknown Project") { + projectName = Project.getProjectDetails(projectIdToUse).name || i18n.dtr("ubtms", "Unknown Project"); + } + + var childCheck = Task.checkTaskHasChildren(row.id); + + model.append({ + id_val: effectiveId, + local_id: row.id, + account_id: row.account_id, + project: projectName, + parent_id: parentEffectiveId, + name: row.name || "Untitled", + taskName: row.name || "Untitled", + recordId: (row.odoo_record_id) ? row.odoo_record_id : -1, + allocatedHours: row.initial_planned_hours ? row.initial_planned_hours : 0, + spentHours: row.spent_hours ? row.spent_hours : 0, + startDate: row.start_date || "", + endDate: row.end_date || "", + deadline: row.deadline || "", + description: row.description || "", + hasChildren: childCheck.hasChildren, + childCount: childCheck.childCount, + priority: row.priority ? parseInt(row.priority) : 0, + stage: row.state || -1, + color_pallet: row.color_pallet ? parseInt(row.color_pallet) : 0, + last_modified: row.last_modified || "", + has_draft: row.has_draft === 1 + }); + }); + + isLoading = false; + taskListView.model = getCurrentModel(); + } + function toggleFlatView() { flatViewMode = !flatViewMode; // Reset navigation when switching modes if (flatViewMode) { - navigationStackModel.clear(); - currentParentId = -1; + _resetHierarchyNavigation(); } // Refresh the model @@ -439,8 +576,8 @@ Item { var tempMap = {}; tasks.forEach(function (row) { - var odooId = row.odoo_record_id; - var parentOdooId = (row.parent_id === null || row.parent_id === 0) ? -1 : row.parent_id; + var effectiveId = (row.account_id === 0 || !row.odoo_record_id) ? row.id : row.odoo_record_id; + var parentEffectiveId = (row.parent_id === null || row.parent_id === 0) ? -1 : row.parent_id; var projectIdToUse = row.project_id; @@ -456,14 +593,14 @@ Item { } var item = { - id_val: odooId, + id_val: effectiveId, local_id: row.id, account_id: row.account_id, project: projectName, - parent_id: parentOdooId, + parent_id: parentEffectiveId, name: row.name || "Untitled", taskName: row.name || "Untitled", - recordId: odooId, + recordId: (row.odoo_record_id) ? row.odoo_record_id : -1, allocatedHours: row.initial_planned_hours ? row.initial_planned_hours : 0, spentHours: row.spent_hours ? row.spent_hours : 0, startDate: row.start_date || "", @@ -471,15 +608,17 @@ Item { deadline: row.deadline || "", description: row.description || "", hasChildren: false, + childCount: 0, + priority: row.priority ? parseInt(row.priority) : 0, stage: row.state || -1, color_pallet: row.color_pallet ? parseInt(row.color_pallet) : 0, last_modified: row.last_modified || "", has_draft: row.has_draft === 1 }; - if (!tempMap[parentOdooId]) - tempMap[parentOdooId] = []; - tempMap[parentOdooId].push(item); + if (!tempMap[parentEffectiveId]) + tempMap[parentEffectiveId] = []; + tempMap[parentEffectiveId].push(item); }); // Build a set of all task IDs present in this batch (and existing data for append) @@ -504,7 +643,7 @@ Item { var orphanedParentKeys = []; for (var pKey in tempMap) { var numKey = parseInt(pKey); - if (numKey !== -1 && !knownTaskIds[numKey]) { + if (numKey !== -1 && numKey !== currentParentId && !knownTaskIds[numKey]) { orphanedParentKeys.push(pKey); } } @@ -521,9 +660,9 @@ Item { // Mark children for (var parent in tempMap) { tempMap[parent].forEach(function (child) { - var children = tempMap[child.id_val]; - child.hasChildren = !!children; - child.childCount = children ? children.length : 0; + var childCheck = Task.checkTaskHasChildren(child.local_id); + child.hasChildren = childCheck.hasChildren; + child.childCount = childCheck.childCount; }); } @@ -551,6 +690,7 @@ Item { isLoading = true; navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; // Reset pagination currentOffset = 0; @@ -582,6 +722,10 @@ Item { if (isLoadingMore || !hasMoreItems) return; isLoadingMore = true; currentOffset += pageSize; + if (currentParentId !== -1) { + isLoadingMore = false; + return; + } // Route to proper paginated loader based on context if (filterByAssignees && selectedAssigneeIds && selectedAssigneeIds.length > 0) { _doPaginatedAssigneeLoad(); @@ -602,7 +746,7 @@ Item { // Delegate is responsible for calling updateDisplayedTasks and managing hasMoreItems/isLoading flags return; } - + var tasks = []; // This function is now only called for "all" filter or when no date filter is active // So we can always paginate when we reach this point @@ -689,24 +833,151 @@ Item { // Hierarchical view: return tasks for current parent var model = childrenMap[currentParentId]; - return model || Qt.createQmlObject('import QtQuick 2.0; ListModel {}', taskNavigator); + return model || fallbackEmptyModel; } Column { anchors.fill: parent spacing: units.gu(1) - TSButton { - id: backbutton - text: "← Back" + // Hierarchical breadcrumb navigation bar + Rectangle { + id: breadcrumbBar width: parent.width - height: units.gu(4) - visible: !flatViewMode && navigationStackModel.count - onClicked: { - if (navigationStackModel.count > 0) { - var last = navigationStackModel.get(navigationStackModel.count - 1).parentId; - navigationStackModel.remove(navigationStackModel.count - 1); - currentParentId = last; + height: visible ? units.gu(5) : 0 + visible: !flatViewMode && navigationStackModel.count > 0 + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#1e1e1e" : "#f8fafc" + radius: units.gu(0.6) + border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d2d2d" : "#e2e8f0" + border.width: units.gu(0.1) + clip: true + + Row { + anchors.fill: parent + anchors.leftMargin: units.gu(1) + anchors.rightMargin: units.gu(1) + spacing: units.gu(1) + + // Back button with tactile styling + Rectangle { + id: backBtn + width: units.gu(9) + height: units.gu(3.6) + anchors.verticalCenter: parent.verticalCenter + radius: units.gu(0.5) + color: backMouseArea.pressed ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#333333" : "#e2e8f0") + : (backMouseArea.containsMouse ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#262626" : "#edf2f7") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#222222" : "#ffffff")) + border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#3d3d3d" : "#cbd5e1" + border.width: 1 + + Row { + anchors.centerIn: parent + spacing: units.gu(0.5) + + Icon { + name: "back" + width: units.gu(1.6) + height: units.gu(1.6) + color: AppConst.Colors.Orange + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: i18n.dtr("ubtms", "Back") + font.pixelSize: units.gu(1.4) + font.bold: true + color: AppConst.Colors.Orange + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: backMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: navigateBackInHierarchy() + } + } + + // Breadcrumb path display + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: units.gu(0.6) + width: parent.width - backBtn.width - units.gu(2) + clip: true + + Text { + text: i18n.dtr("ubtms", "Tasks") + font.pixelSize: units.gu(1.3) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#9ca3af" : "#64748b" + anchors.verticalCenter: parent.verticalCenter + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + _resetHierarchyNavigation(); + } + } + } + + Text { + text: "/" + font.pixelSize: units.gu(1.3) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#6b7280" : "#94a3b8" + anchors.verticalCenter: parent.verticalCenter + } + + // For deep nesting (> 1 level above): show "..." + Text { + visible: navigationStackModel.count > 1 + text: "..." + font.pixelSize: units.gu(1.3) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#9ca3af" : "#64748b" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + visible: navigationStackModel.count > 1 + text: "/" + font.pixelSize: units.gu(1.3) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#6b7280" : "#94a3b8" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: currentParentName !== "" ? currentParentName : i18n.dtr("ubtms", "Subtasks") + font.pixelSize: units.gu(1.4) + font.bold: true + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#f3f4f6" : "#1e293b" + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + maximumLineCount: 1 + width: Math.min(implicitWidth, parent.width - (navigationStackModel.count > 1 ? units.gu(16) : units.gu(10))) + } + + // Count badge + Rectangle { + visible: taskListView.count > 0 + height: units.gu(2) + width: childCountBadgeText.width + units.gu(1) + radius: height / 2 + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d2013" : "#fff7ed" + border.color: AppConst.Colors.Orange + border.width: 1 + anchors.verticalCenter: parent.verticalCenter + + Text { + id: childCountBadgeText + text: String(taskListView.count) + font.pixelSize: units.gu(1.1) + font.bold: true + color: AppConst.Colors.Orange + anchors.centerIn: parent + } + } } } } @@ -714,18 +985,18 @@ Item { LomiriListView { id: taskListView width: parent.width - height: parent.height - backbutton.height + height: parent.height - (breadcrumbBar.visible ? breadcrumbBar.height + units.gu(1) : 0) clip: true model: getCurrentModel() footer: LoadMoreFooter { isLoading: isLoadingMore - hasMore: hasMoreItems + hasMore: (currentParentId === -1) && hasMoreItems onLoadMore: loadMoreTasks() } onAtYEndChanged: { - if (taskListView.atYEnd && !isLoadingMore && hasMoreItems) { + if (currentParentId === -1 && taskListView.atYEnd && !isLoadingMore && hasMoreItems) { loadMoreTasks(); } } @@ -737,6 +1008,7 @@ Item { TaskDetailsCard { id: taskCard localId: model.local_id + idVal: model.id_val || -1 height: parent.height width: parent.width recordId: (model.recordId) ? (model.recordId) : -1 @@ -751,6 +1023,7 @@ Item { // Hide children navigation in flat view mode hasChildren: flatViewMode ? false : (model.hasChildren || false) childCount: flatViewMode ? 0 : (model.childCount || 0) + flatViewMode: taskNavigator.flatViewMode projectName: model.project colorPallet: model.color_pallet stage: model.stage @@ -767,6 +1040,11 @@ Item { onViewRequested: d => { taskSelected(local_id); } + onNavigationRequested: (taskId, accountId, taskName) => { + if (!flatViewMode) { + navigateToTask(taskId, accountId, taskName); + } + } onTimesheetRequested: localId => { taskTimesheetRequested(localId); } @@ -774,35 +1052,42 @@ Item { // Remove the task from the current list display removeTaskFromList(localId); } - - // MouseArea for task interaction - navigation for parent tasks, view for regular tasks - MouseArea { - // Only cover the text area, not the whole card - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.leftMargin: units.gu(15) // Skip the star area - enabled: !taskCard.starInteractionActive - onClicked: { - // In flat view mode, always go to task view (no navigation) - if (flatViewMode) { - taskCard.viewRequested(model.local_id); - } else if (model.hasChildren) { - navigationStackModel.append({ - parentId: currentParentId - }); - currentParentId = model.id_val; - } else { - taskCard.viewRequested(model.local_id); - } - } + onTaskUpdated: localId => { + refreshWithFilter(); } } } } } + // Empty state when drilled down into a parent task with no subtasks + Item { + anchors.centerIn: parent + visible: currentParentId !== -1 && taskListView.count === 0 && !isLoading + width: parent.width - units.gu(4) + height: units.gu(12) + + Column { + anchors.centerIn: parent + spacing: units.gu(1) + + Icon { + name: "info" + width: units.gu(3) + height: units.gu(3) + anchors.horizontalCenter: parent.horizontalCenter + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#6b7280" : "#9ca3af" + } + + Text { + text: i18n.dtr("ubtms", "No subtasks found") + font.pixelSize: units.gu(1.5) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#9ca3af" : "#64748b" + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + Connections { target: taskNavigator onChildrenMapReadyChanged: { diff --git a/qml/features/tasks/components/TaskPrioritySelector.qml b/qml/features/tasks/components/TaskPrioritySelector.qml index f339b869..b533a630 100644 --- a/qml/features/tasks/components/TaskPrioritySelector.qml +++ b/qml/features/tasks/components/TaskPrioritySelector.qml @@ -14,59 +14,52 @@ Row { width: parent ? parent.width : 0 height: units.gu(6) - spacing: units.gu(2) + spacing: units.gu(1) - Column { - width: units.gu(15) + Item { + width: units.gu(11) height: parent.height - LomiriShape { - width: units.gu(15) - height: units.gu(5) - aspect: LomiriShape.Flat - Label { - text: i18n.dtr("ubtms", "Priority") - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - } + Label { + text: i18n.dtr("ubtms", "Priority") + anchors.left: parent.left + anchors.leftMargin: units.gu(1) + anchors.verticalCenter: parent.verticalCenter } } - Column { - leftPadding: units.gu(3) - height: parent.height - - Row { - spacing: units.gu(2) - height: units.gu(5) + Row { + spacing: units.gu(2) + height: units.gu(5) + anchors.verticalCenter: parent.verticalCenter - Repeater { - model: 3 + Repeater { + model: 3 - Image { - property int starIndex: index - source: ((index + 1) <= root.priority) ? "../../../images/star.png" : "../../../images/star-inactive.png" - width: units.gu(3.5) - height: units.gu(3.5) - opacity: root.isReadOnly ? 0.7 : 1.0 + Image { + property int starIndex: index + source: ((index + 1) <= root.priority) ? "../../../images/star.png" : "../../../images/star-inactive.png" + width: units.gu(3.5) + height: units.gu(3.5) + anchors.verticalCenter: parent.verticalCenter + opacity: root.isReadOnly ? 0.7 : 1.0 - MouseArea { - anchors.fill: parent - enabled: !root.isReadOnly - onClicked: { - var clickedPriority = index + 1; - root.priority = (clickedPriority === root.priority) ? 0 : clickedPriority; - } + MouseArea { + anchors.fill: parent + enabled: !root.isReadOnly + onClicked: { + var clickedPriority = index + 1; + root.priority = (clickedPriority === root.priority) ? 0 : clickedPriority; } } } + } - Label { - text: "(Level: " + root.priority + ")" - anchors.verticalCenter: parent.verticalCenter - font.pixelSize: units.gu(1.5) - visible: root.priority > 0 - } + Label { + text: "(Level: " + root.priority + ")" + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: units.gu(1.5) + visible: root.priority > 0 } } } diff --git a/qml/features/tasks/js/taskFormUtils.js b/qml/features/tasks/js/taskFormUtils.js index 39762079..6642acac 100644 --- a/qml/features/tasks/js/taskFormUtils.js +++ b/qml/features/tasks/js/taskFormUtils.js @@ -21,7 +21,7 @@ function restoreWorkItemSelection(workItem, snapshot) { var subtaskId = normalizeIdForRestore(snapshot.subtaskId); var assigneeId = normalizeIdForRestore(snapshot.assigneeId); - if (accountId > 0 || projectId > 0) { + if ((accountId !== null && accountId !== undefined && accountId >= 0) || projectId > 0) { workItem.deferredLoadExistingRecordSet(accountId, projectId, subprojectId, taskId, subtaskId, assigneeId); if (workItem.enableMultipleAssignees && snapshot.multipleAssignees) { @@ -129,17 +129,29 @@ function buildSaveData(params) { plannedHours: Utils.convertDurationToFloat(params.plannedHours), description: params.description, assigneeUserId: params.ids.assignee_id, - status: "updated" + status: (params.ids && params.ids.account_id === 0) ? "saved" : "updated" }; + var isLocalAccount = (params.ids && params.ids.account_id === 0); var stageToAssign = params.selectedStageOdooRecordId; - if (params.recordId === 0 && stageToAssign <= 0 && params.stageListCount > 0) { - var firstStage = params.firstStage; - stageToAssign = firstStage ? firstStage.odoo_record_id : stageToAssign; + + if (params.recordId === 0 && params.stageListCount > 0) { + var hasNoValidStage = isLocalAccount + ? (stageToAssign === undefined || stageToAssign === null || stageToAssign === 0) + : (stageToAssign === undefined || stageToAssign === null || stageToAssign <= 0); + + if (hasNoValidStage && params.firstStage) { + stageToAssign = params.firstStage.odoo_record_id; + } } - if (stageToAssign > 0) + if (isLocalAccount) { + if (stageToAssign !== undefined && stageToAssign !== null && stageToAssign !== 0) { + saveData.stageOdooRecordId = stageToAssign; + } + } else if (stageToAssign > 0) { saveData.stageOdooRecordId = stageToAssign; + } if (params.selectedPersonalStageOdooRecordId !== undefined && params.selectedPersonalStageOdooRecordId !== null) { saveData.personalStageOdooRecordId = params.selectedPersonalStageOdooRecordId > 0 diff --git a/qml/features/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index 59b01c5b..53332c64 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -72,10 +72,17 @@ Page { iconName: "add" text: "New" onTriggered: { - apLayout.addPageToNextColumn(myTasksPage, Qt.resolvedUrl("Tasks.qml"), { + var initialData = { "recordid": 0, "isReadOnly": false - }); + }; + if (!myTasksList.flatViewMode && myTasksList.currentParentId > 0) { + initialData["selectedparentId"] = myTasksList.currentParentId; + if (myTasksList.currentAccountId !== undefined && myTasksList.currentAccountId !== null) { + initialData["selectedparentAccountId"] = myTasksList.currentAccountId; + } + } + apLayout.addPageToNextColumn(myTasksPage, Qt.resolvedUrl("Tasks.qml"), initialData); } }, Action { @@ -305,7 +312,10 @@ Page { var stageId = filterKey === "null" ? null : parseInt(filterKey); myTasksPage.currentPersonalStageId = stageId; - myTasksPage.currentSearchQuery = ""; + var activeSearch = (myTaskListHeader.searchText !== undefined && myTaskListHeader.searchText !== null) + ? myTaskListHeader.searchText + : (myTasksPage.currentSearchQuery || ""); + myTasksPage.currentSearchQuery = activeSearch; updateCurrentUser(); if (currentUserOdooId > 0) { @@ -418,7 +428,8 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Create") + label: i18n.dtr("ubtms", "Create Task"), + iconName: "add" } ] @@ -454,6 +465,12 @@ Page { handleAccountChange(accountId); } } + + function onTaskDataChanged() { + if (myTasksPage.visible) { + refreshData(); + } + } } onVisibleChanged: { diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 0722a5a5..0be7c344 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -51,11 +51,24 @@ Page { } leadingActionBar.actions: [ + Action { + id: backAction + iconName: "back" + text: i18n.dtr("ubtms", "Back") + visible: filterByProject + onTriggered: { + if (typeof apLayout !== "undefined" && apLayout && apLayout.removePages) { + apLayout.removePages(task); + } else if (typeof pageStack !== "undefined" && pageStack && pageStack.pop) { + pageStack.pop(); + } + } + }, Action { id: drawerAction iconName: "navigation-menu" text: i18n.dtr("ubtms", "Menu") - visible: !isMultiColumn + visible: !filterByProject && !isMultiColumn onTriggered: { apLayout.openGlobalDrawer() } @@ -76,10 +89,17 @@ Page { iconName: "add" text: "New" onTriggered: { - apLayout.addPageToNextColumn(task, Qt.resolvedUrl("Tasks.qml"), { + var initialData = { "recordid": 0, "isReadOnly": false - }); + }; + if (!tasklist.flatViewMode && tasklist.currentParentId > 0) { + initialData["selectedparentId"] = tasklist.currentParentId; + if (tasklist.currentAccountId !== undefined && tasklist.currentAccountId !== null) { + initialData["selectedparentAccountId"] = tasklist.currentAccountId; + } + } + apLayout.addPageToNextColumn(task, Qt.resolvedUrl("Tasks.qml"), initialData); } }, Action { @@ -285,6 +305,12 @@ Page { tasklist.filterByAssignees = task.filterByAssignees; tasklist.selectedAssigneeIds = task.selectedAssigneeIds; + var activeSearch = (taskListHeader.searchText !== undefined && taskListHeader.searchText !== null) + ? taskListHeader.searchText + : (task.currentSearchQuery || ""); + task.currentSearchQuery = activeSearch; + tasklist.currentSearchQuery = activeSearch; + // Apply the appropriate filter with assignee filtering if (filterByProject) { tasklist.applyProjectAndTimeFilter(projectOdooRecordId, projectAccountId, filterKey); @@ -401,7 +427,8 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Task") + label: i18n.dtr("ubtms", "Create Task"), + iconName: "add" } ] onMenuItemSelected: { @@ -522,7 +549,7 @@ Page { } else { if (currentSearchQuery) { // Reapply search if there was one - tasklist.searchTasks(currentSearchQuery); + tasklist.applySearch(currentSearchQuery); } else { // Reapply current filter tasklist.applyFilter(currentFilter); diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 2bc32f18..279cb69e 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -67,7 +67,7 @@ Page { trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" visible: !isReadOnly text: i18n.dtr("ubtms", "Save") onTriggered: { @@ -96,12 +96,14 @@ Page { } property var recordid: 0 //0 means creation mode property bool isOdooRecordId: false // If true, recordid is an odoo_record_id, not local id + property int tempAttachmentId: recordid === 0 ? -(Math.floor(Date.now() % 100000000) + 1) : 0 property string currentEditingField: "" property bool workpersonaSwitchState: true property bool isReadOnly: recordid != 0 // Set read-only immediately based on recordid property int selectedProjectId: 0 property int selectedparentId: 0 + property int selectedparentAccountId: -1 property int selectedTaskId: 0 property int priority: 0 property bool editVisible: true @@ -310,7 +312,7 @@ Page { var projectId = TaskFormUtils.normalizeIdForRestore(draftData.projectId); if (TaskFormUtils.restoreWorkItemSelection(workItem, draftData)) { - if (projectId > 0 && accountId > 0) { + if (projectId > 0 && accountId !== null && accountId !== undefined && accountId >= 0) { var savedStageId = draftData.selectedStageOdooRecordId; var savedPersonalStageId = draftData.selectedPersonalStageOdooRecordId; @@ -370,6 +372,11 @@ Page { if (originalData.selectedPersonalStageOdooRecordId !== undefined) { selectedPersonalStageOdooRecordId = originalData.selectedPersonalStageOdooRecordId; } + + if (recordid === 0 && tempAttachmentId !== 0) { + Task.cleanupTemporaryAttachments("project.task", tempAttachmentId, workItem ? workItem.selectedAccountId : 0); + attachments_widget.clearAttachments(); + } } function getCurrentFormData() { @@ -479,6 +486,10 @@ Page { notifPopup.open("Error", "Unable to Save the Task", "error"); return false; } else { + if (recordid === 0 && result.taskId && tempAttachmentId !== 0) { + var accIdToLink = ids.account_id !== null && ids.account_id !== undefined ? ids.account_id : 0; + Task.updateAttachmentResourceId("project.task", tempAttachmentId, result.taskId, accIdToLink); + } notifPopup.open("Saved", "Task has been saved successfully", "success"); // Prevent programmatic UI normalization from creating a fresh draft. @@ -494,6 +505,10 @@ Page { draftHandler.updateOriginalData(getCurrentFormData()); draftHandler.trackingSuspended = false; + if (typeof mainView !== "undefined" && mainView && mainView.taskDataChanged) { + mainView.taskDataChanged(); + } + // Navigate back to list view after successful save (unless skipNavigation is true) if (!skipNavigation) { navigateBack(); @@ -561,6 +576,10 @@ Page { // Reload the task to reflect changes loadTask(); + if (typeof mainView !== "undefined" && mainView && mainView.taskDataChanged) { + mainView.taskDataChanged(); + } + notifPopup.open("Success", "Task stage changed to: " + stageName, "success"); } else { notifPopup.open("Error", "Failed to change stage: " + (result.error || "Unknown error"), "error"); @@ -585,6 +604,10 @@ Page { // Reload the task to reflect changes loadTask(); + if (typeof mainView !== "undefined" && mainView && mainView.taskDataChanged) { + mainView.taskDataChanged(); + } + var message = personalStageOdooRecordId === null ? "Personal stage cleared" : "Personal stage changed to: " + personalStageName; notifPopup.open("Success", message, "success"); } else { @@ -594,7 +617,7 @@ Page { function loadStagesForProject(projectOdooRecordId, accountId) { - if (projectOdooRecordId <= 0 || accountId <= 0) { + if (!projectOdooRecordId || projectOdooRecordId <= 0 || accountId === undefined || accountId === null || accountId < 0) { initialStageSelector.model.clear(); initialStageSelector.currentIndex = -1; selectedStageOdooRecordId = -1; @@ -615,6 +638,7 @@ Page { // Automatically select first stage as default (user can change it) if (initialStageSelector.model.count > 0) { + initialStageSelector.currentIndex = -1; initialStageSelector.currentIndex = 0; var firstStage = initialStageSelector.model.get(0); selectedStageOdooRecordId = firstStage.odoo_record_id; @@ -702,12 +726,16 @@ Page { var ids = workItem.getIds(); var projectId = data.id; var accountId = ids.account_id; - if (projectId > 0 && accountId > 0) { + if (projectId > 0 && accountId !== null && accountId !== undefined && accountId >= 0) { loadStagesForProject(projectId, accountId); + } else { + initialStageSelector.model.clear(); + initialStageSelector.currentIndex = -1; + selectedStageOdooRecordId = -1; } } else if (newState === "SubprojectSelected") { var ids2 = workItem.getIds(); - if (ids2.project_id > 0 && ids2.account_id > 0) { + if (ids2.project_id > 0 && ids2.account_id !== null && ids2.account_id !== undefined && ids2.account_id >= 0) { loadStagesForProject(ids2.project_id, ids2.account_id); } } else if (newState === "AccountSelected") { @@ -819,7 +847,8 @@ Page { }); } onCreateActivityRequested: { - let result = Activity.createActivityFromProjectOrTask(false, currentTask.account_id, currentTask.odoo_record_id); + let taskEffectiveId = (currentTask.account_id === 0 || !currentTask.odoo_record_id) ? currentTask.id : currentTask.odoo_record_id; + let result = Activity.createActivityFromProjectOrTask(false, currentTask.account_id, taskEffectiveId); if (result.success) { apLayout.addPageToNextColumn(taskCreate, Qt.resolvedUrl("../../activities/pages/Activities.qml"), { "recordid": result.record_id, @@ -831,16 +860,18 @@ Page { } } onViewActivitiesRequested: { - Logger.debug("Tasks", "Viewing activities for task:", currentTask.id, "odoo_record_id:", currentTask.odoo_record_id) + let taskEffectiveId = (currentTask.account_id === 0 || !currentTask.odoo_record_id) ? currentTask.id : currentTask.odoo_record_id; + Logger.debug("Tasks", "Viewing activities for task:", currentTask.id, "effectiveId:", taskEffectiveId) apLayout.addPageToNextColumn(taskCreate, Qt.resolvedUrl("../../activities/pages/Activity_Page.qml"), { "filterByTasks": true, - "taskOdooRecordId": currentTask.odoo_record_id, + "taskOdooRecordId": taskEffectiveId, "projectAccountId": currentTask.account_id, "projectName": currentTask.name || "Task" }); } onCreateTimesheetRequested: { - const result = Timesheet.createTimesheetFromTask(currentTask.odoo_record_id); + var effectiveTaskId = (currentTask.account_id === 0 || !currentTask.odoo_record_id) ? currentTask.id : currentTask.odoo_record_id; + const result = Timesheet.createTimesheetFromTask(effectiveTaskId); if (result.success) { apLayout.addPageToNextColumn(taskCreate, Qt.resolvedUrl("../../timesheets/pages/Timesheet.qml"), { "recordid": result.id, @@ -886,7 +917,6 @@ Page { //changed the attachment color Rectangle { id: attachmentRow - anchors.top: deadlineRow.bottom height: units.gu(50) width: parent.width anchors.margins: units.gu(0.1) @@ -894,14 +924,23 @@ Page { AttachmentManager { id: attachments_widget anchors.fill: parent - resource_type: "project.task" // keep as-is if that's your default - resource_id: (currentTask && currentTask.odoo_record_id) ? currentTask.odoo_record_id : 0 - account_id: (currentTask && currentTask.account_id) ? currentTask.account_id : 0 + resource_type: "project.task" + resource_id: (currentTask && currentTask.odoo_record_id > 0) + ? currentTask.odoo_record_id + : ((currentTask && currentTask.id) ? currentTask.id : (recordid !== 0 ? recordid : tempAttachmentId)) + account_id: (currentTask && currentTask.account_id !== undefined && currentTask.account_id !== null) + ? currentTask.account_id + : (workItem && workItem.selectedAccountId !== -1 ? workItem.selectedAccountId : 0) notifier: infobar onUploadCompleted: { - //kinda refresh - attachments_widget.setAttachments(Task.getAttachmentsForTask(currentTask.odoo_record_id, currentTask.account_id)); + var resId = (currentTask && currentTask.odoo_record_id > 0) + ? currentTask.odoo_record_id + : ((currentTask && currentTask.id) ? currentTask.id : (recordid !== 0 ? recordid : tempAttachmentId)); + var accId = (currentTask && currentTask.account_id !== undefined && currentTask.account_id !== null) + ? currentTask.account_id + : (workItem && workItem.selectedAccountId !== -1 ? workItem.selectedAccountId : 0); + attachments_widget.setAttachments(Task.getAttachmentsForTask(resId, accId)); } onItemClicked: function (rec) { @@ -999,10 +1038,12 @@ Page { workItem.setMultipleAssignees(existingAssignees); } - attachments_widget.setAttachments(Task.getAttachmentsForTask(currentTask.odoo_record_id, currentTask.account_id)); + var attResId = (currentTask.odoo_record_id && currentTask.odoo_record_id > 0) ? currentTask.odoo_record_id : (currentTask.id || recordid); + attachments_widget.setAttachments(Task.getAttachmentsForTask(attResId, currentTask.account_id !== undefined ? currentTask.account_id : 0)); }); } else { // We are creating a new task + attachments_widget.clearAttachments(); workItem.loadAccounts(); taskScheduleFields.deadlineText = "Not set"; @@ -1019,10 +1060,48 @@ Page { } // Load stages for the prefilled project - if (mainProjectId > 0 && prefilledAccountId > 0) { + if (mainProjectId > 0 && prefilledAccountId !== null && prefilledAccountId !== undefined && prefilledAccountId >= 0) { Logger.debug("Tasks", "Loading stages for prefilled project:", mainProjectId, "account:", prefilledAccountId) loadStagesForProject(mainProjectId, prefilledAccountId); } + } else if (selectedparentId > 0) { + // Prefill parent task when creating subtask while drilled down + var parentTaskDetails = null; + if (selectedparentAccountId > 0) { + parentTaskDetails = Task.getTaskDetailsByOdooId(selectedparentId, selectedparentAccountId); + if (!parentTaskDetails || !parentTaskDetails.id) { + parentTaskDetails = Task.getTaskDetails(selectedparentId); + } + } else if (selectedparentAccountId === 0) { + parentTaskDetails = Task.getTaskDetails(selectedparentId); + if (!parentTaskDetails || !parentTaskDetails.id) { + parentTaskDetails = Task.getTaskDetailsByOdooId(selectedparentId); + } + } else { + var activeAcc = (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId >= 0) ? accountPicker.selectedAccountId : -1; + if (activeAcc > 0) { + parentTaskDetails = Task.getTaskDetailsByOdooId(selectedparentId, activeAcc); + } + if (!parentTaskDetails || !parentTaskDetails.id) { + parentTaskDetails = Task.getTaskDetails(selectedparentId); + } + if (!parentTaskDetails || !parentTaskDetails.id) { + parentTaskDetails = Task.getTaskDetailsByOdooId(selectedparentId); + } + } + if (parentTaskDetails && parentTaskDetails.id) { + var pAccountId = (parentTaskDetails.account_id !== undefined && parentTaskDetails.account_id !== null) ? parentTaskDetails.account_id : -1; + var pProjectId = (parentTaskDetails.project_id !== undefined && parentTaskDetails.project_id !== null && parentTaskDetails.project_id > 0) ? parentTaskDetails.project_id : -1; + var pSubProjectId = (parentTaskDetails.sub_project_id !== undefined && parentTaskDetails.sub_project_id !== null) ? parentTaskDetails.sub_project_id : -1; + var pTaskId = (parentTaskDetails.odoo_record_id && parentTaskDetails.odoo_record_id > 0) ? parentTaskDetails.odoo_record_id : parentTaskDetails.id; + + if (workItem.deferredLoadExistingRecordSet) { + workItem.deferredLoadExistingRecordSet(pAccountId, pProjectId, pSubProjectId, pTaskId, -1, -1); + } + if (pProjectId > 0 && pAccountId >= 0) { + loadStagesForProject(pProjectId, pAccountId); + } + } } } diff --git a/qml/features/timesheets/components/TimeRecorderWidget.qml b/qml/features/timesheets/components/TimeRecorderWidget.qml index 42a16020..22a96f51 100644 --- a/qml/features/timesheets/components/TimeRecorderWidget.qml +++ b/qml/features/timesheets/components/TimeRecorderWidget.qml @@ -25,6 +25,26 @@ Item { property int timesheetId: 0 signal invalidtimesheet signal requestAutoSave + signal beforeStop + signal stopped + + onTimesheetIdChanged: { + syncTimerState(); + } + + function syncTimerState() { + if (timesheetId > 0 && timesheetId === TimerService.getActiveTimesheetId()) { + isRecording = TimerService.isRunning() && !TimerService.isPaused(); + autoMode = true; + timeDisplay.text = TimerService.getElapsedTime(); + elapsedTime = timeDisplay.text; + } else if (timesheetId > 0) { + isRecording = false; + var savedTime = TimeSheet.getTimesheetUnitAmount(timesheetId); + timeDisplay.text = Utils.convertDecimalHoursToHHMM(savedTime); + elapsedTime = timeDisplay.text; + } + } function tryStartTimer() { if (timesheetId <= 0) { @@ -50,10 +70,16 @@ Item { target: globalTimerWidget onTimerStopped: { - updateTimer.running = false; + syncTimerState(); } onTimerStarted: { - updateTimer.running = true; + syncTimerState(); + } + onTimerPaused: { + syncTimerState(); + } + onTimerResumed: { + syncTimerState(); } } @@ -84,9 +110,9 @@ Item { text: i18n.dtr("ubtms", "Time Tracking") anchors.left: parent.left anchors.right: parent.right + horizontalAlignment: Text.AlignHCenter font.bold: true color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "#444" - anchors.horizontalCenter: parent.horizontalCenter height: units.gu(2) } @@ -219,16 +245,25 @@ Item { return; } + autoRecorder.beforeStop(); + if (TimerService.isRunning() && TimerService.getActiveTimesheetId() === timesheetId) { TimerService.stop(); } const result = TimeSheet.markTimesheetAsReadyById(timesheetId); if (!result.success) { - notifPopup.open("Error", "Both Project and Task must be selected before finalizing", "error"); + notifPopup.open("Error", result.error || "Both Project and Task must be selected before finalizing", "error"); } else { - notifPopup.open("Saved", "Timesheet has been finalised successfully", "success"); + var accountId = TimeSheet.getTimesheetAccountId ? TimeSheet.getTimesheetAccountId(timesheetId) : -1; + if (accountId === 0) { + notifPopup.open("Saved", "Timesheet has been saved successfully", "success"); + } else { + notifPopup.open("Saved", "Timesheet has been finalised successfully", "success"); + } } + + autoRecorder.stopped(); } } } @@ -261,14 +296,6 @@ Item { } Component.onCompleted: { - if (timesheetId > 0 && timesheetId === TimerService.getActiveTimesheetId()) { - isRecording = true; - autoMode = true; - if (autoMode) - updateTimer.start(); - } else { - isRecording = false; - updateTimer.stop(); - } + syncTimerState(); } } diff --git a/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml b/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml index e454d862..67ef762e 100644 --- a/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml +++ b/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml @@ -40,6 +40,7 @@ Item { property string timesheetName: "" property string elapsedTime: "" property bool hasTask: false + property bool isLocalAccount: false // Signals signal saved(string description, string status) @@ -109,7 +110,7 @@ Item { // Help text Label { - text: popupWrapper.hasTask ? "• Save: Keeps timesheet as draft for later editing\n• Finalize: Marks timesheet as ready for sync" : "• Save: Keeps timesheet as draft for later editing\n• Note: Finalize is only available for timesheets with tasks" + text: popupWrapper.isLocalAccount ? "• Save as Draft: Keeps timesheet as draft for later editing\n• Finalize: Marks timesheet as saved" : (popupWrapper.hasTask ? "• Save: Keeps timesheet as draft for later editing\n• Finalize: Marks timesheet as ready for sync" : "• Save: Keeps timesheet as draft for later editing\n• Note: Finalize is only available for timesheets with tasks") color: theme.palette.normal.backgroundText opacity: 0.7 font.pixelSize: units.gu(1.8) @@ -131,27 +132,27 @@ Item { PopupUtils.close(popupDialog); } else { console.error("Failed to save timesheet:", result.error); - // Could show an error notification here + popupWrapper.finalized(false, result.error || "Failed to save draft"); } } } Button { id: finalizeButton - text: "Finalize" + text: popupWrapper.isLocalAccount ? "Save & Finalize" : "Finalize" color: LomiriColors.green - visible: popupWrapper.hasTask + visible: popupWrapper.isLocalAccount || popupWrapper.hasTask onClicked: { var description = descriptionText.text.trim(); - var result = updateTimesheetDescription(description, "updated"); + var targetStatus = popupWrapper.isLocalAccount ? "saved" : "updated"; + var result = updateTimesheetDescription(description, targetStatus); if (result.success) { - popupWrapper.saved(description, "updated"); - popupWrapper.finalized(true, "Timesheet is now ready to be synced to Odoo"); + popupWrapper.saved(description, targetStatus); + popupWrapper.finalized(true, popupWrapper.isLocalAccount ? "Timesheet has been saved successfully" : "Timesheet is now ready to be synced to Odoo"); PopupUtils.close(popupDialog); } else { console.error("Failed to finalize timesheet:", result.error); popupWrapper.finalized(false, result.error || "Both Project and Task must be selected before syncing"); - // Don't close popup on error to allow user to fix issues } } } @@ -181,7 +182,7 @@ Item { var currentDetails = Model.getTimeSheetDetails(popupWrapper.timesheetId); console.log("TimeSheetDescriptionPopup: Retrieved timesheet details:", JSON.stringify(currentDetails)); - if (!currentDetails || !currentDetails.instance_id) { + if (!currentDetails || currentDetails.instance_id === undefined || currentDetails.instance_id === null) { return { success: false, error: "Could not retrieve timesheet details" @@ -190,7 +191,9 @@ Item { // Determine user_id with fallback var userId = currentDetails.user_id; - if (!userId || userId <= 0) { + if (currentDetails.instance_id === 0) { + if (!userId || userId <= 0) userId = 1; + } else if (!userId || userId <= 0) { userId = Accounts.getCurrentUserOdooId(currentDetails.instance_id); console.log("TimeSheetDescriptionPopup: Using fallback current user:", userId); } else { @@ -220,11 +223,15 @@ Item { console.log("TimeSheetDescriptionPopup: Saving timesheet data:", JSON.stringify(timesheet_data)); // Use the appropriate function based on status - if (status === "updated") { - // For finalize: first save the description, then mark as ready + if (status === "updated" || status === "saved") { + // For finalize: first save the description, then mark as ready/saved var saveResult = Model.saveTimesheet(timesheet_data); if (saveResult.success) { - return Model.markTimesheetAsReadyById(popupWrapper.timesheetId); + if (currentDetails.instance_id === 0 || status === "saved") { + return Model.markTimesheetAsSavedById(popupWrapper.timesheetId); + } else { + return Model.markTimesheetAsReadyById(popupWrapper.timesheetId); + } } else { return saveResult; } @@ -251,9 +258,11 @@ Item { // Check if timesheet has a task to determine if Finalize button should be shown if (popupWrapper.timesheetId > 0) { var timesheetDetails = Model.getTimeSheetDetails(popupWrapper.timesheetId); + popupWrapper.isLocalAccount = (timesheetDetails.instance_id === 0); popupWrapper.hasTask = (timesheetDetails.task_id && timesheetDetails.task_id > 0) || (timesheetDetails.sub_task_id && timesheetDetails.sub_task_id > 0); - console.log("TimeSheetDescriptionPopup: Timesheet", popupWrapper.timesheetId, "hasTask:", popupWrapper.hasTask, "task_id:", timesheetDetails.task_id, "sub_task_id:", timesheetDetails.sub_task_id); + console.log("TimeSheetDescriptionPopup: Timesheet", popupWrapper.timesheetId, "isLocalAccount:", popupWrapper.isLocalAccount, "hasTask:", popupWrapper.hasTask, "task_id:", timesheetDetails.task_id, "sub_task_id:", timesheetDetails.sub_task_id); } else { + popupWrapper.isLocalAccount = false; popupWrapper.hasTask = false; } diff --git a/qml/features/timesheets/components/TimeSheetDetailsCard.qml b/qml/features/timesheets/components/TimeSheetDetailsCard.qml index 9b2d118e..64d066e7 100644 --- a/qml/features/timesheets/components/TimeSheetDetailsCard.qml +++ b/qml/features/timesheets/components/TimeSheetDetailsCard.qml @@ -44,6 +44,7 @@ ListItem { property string spentHours: "0" property string quadrant: "Do" property int recordId: -1 + property int accountId: -1 property string status: "" property bool timer_on: false property bool timer_paused: false @@ -77,8 +78,13 @@ ListItem { } function stop_workflow() { - if (TimerService.isRunning() && (recordId === TimerService.getActiveTimesheetId())) + if (TimerService.isRunning() && (recordId === TimerService.getActiveTimesheetId())) { TimerService.stop(); + if (accountId === 0 || instance === "Local" || instance === "local") { + Timesheet.markTimesheetAsSavedById(recordId); + } + timesheetItem.refresh(); + } } function save_workflow() { @@ -87,10 +93,14 @@ ListItem { } const result = Timesheet.markTimesheetAsReadyById(recordId); if (result.success) { - notifPopup.open("Success", "Timesheet is now ready to be synced to Odoo", "success"); + if (accountId === 0 || instance === "Local" || instance === "local") { + notifPopup.open("Saved", "Timesheet has been saved successfully", "success"); + } else { + notifPopup.open("Success", "Timesheet is now ready to be synced to Odoo", "success"); + } timesheetItem.refresh(); } else { - notifPopup.open("Update needed", "Both Project and Task must be selected before syncing", "error"); + notifPopup.open("Update needed", result.error || "Both Project and Task must be selected before syncing", "error"); } } @@ -163,8 +173,8 @@ ListItem { }, Action { id: readyAction - visible: (recordId !== TimerService.getActiveTimesheetId()) //Dont show this for the active running entry - iconSource: "../../../images/save.svg" + visible: (recordId !== TimerService.getActiveTimesheetId()) && status !== "saved" && status !== "updated" && status !== "synced" && (accountId > 0 || (accountId < 0 && instance !== "Local" && instance !== "local")) + iconName: "tick" text: i18n.dtr("ubtms", "Mark Ready for Sync") onTriggered: { save_workflow(); diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 7323e1ca..c515de2f 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -78,7 +78,7 @@ Page { trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" visible: !isReadOnly text: "Save" onTriggered: { @@ -120,7 +120,11 @@ Page { } const ids = workItem.getIds(); - const user = Accounts.getCurrentUserOdooId(ids.account_id); + var isLocal = (ids.account_id === 0); + var user = Accounts.getCurrentUserOdooId(ids.account_id); + if (isLocal && (!user || user <= 0)) { + user = 1; + } if (!user) { notifPopup.open("Error", "Unable to find the user, cannot save", "error"); @@ -132,18 +136,19 @@ Page { return false; } - if (ids.task_id === null) { - notifPopup.open("Error", "You need to select a task to save timesheet", "error"); + // For remote accounts that are finalized/updated, task is mandatory. For drafts, running timers, or local accounts, task is optional. + if (!isLocal && currentStatus === "updated" && ids.task_id === null) { + notifPopup.open("Error", "You need to select a task to save finalized timesheet", "error"); return false; } - let correctTaskId; + let correctTaskId = null; let correctSubTaskId = null; if (ids.subtask_id !== null && ids.subtask_id !== undefined && ids.subtask_id !== -1 && ids.subtask_id > 0) { correctTaskId = ids.subtask_id; correctSubTaskId = null; - } else { + } else if (ids.task_id !== null && ids.task_id !== undefined && ids.task_id !== -1 && ids.task_id > 0) { correctTaskId = ids.task_id; correctSubTaskId = ids.subtask_id; } @@ -153,6 +158,17 @@ Page { description = description.trim(); } + var determinedStatus = "draft"; + if (isTimerActive) { + determinedStatus = "active"; + } else if (currentStatus === "ready" || currentStatus === "updated" || currentStatus === "saved") { + determinedStatus = currentStatus; + } else if (isLocal) { + determinedStatus = "saved"; + } else if (ids.task_id !== null) { + determinedStatus = "updated"; + } + var timesheet_data = { 'record_date': date_widget.formattedDate(), 'instance_id': ids.account_id < 0 ? 0 : ids.account_id, @@ -165,7 +181,7 @@ Page { 'quadrant': priorityGrid.currentIndex + 1, 'user_id': user, 'timer_type': isTimerActive ? "automatic" : "manual", - 'status': isTimerActive ? "active" : (currentStatus === "ready" || currentStatus === "updated" ? currentStatus : "draft") + 'status': determinedStatus }; if (recordid && recordid !== 0) { timesheet_data.id = recordid; @@ -176,6 +192,13 @@ Page { notifPopup.open("Error", "Unable to Save the Timesheet: " + result.error, "error"); return false; } else { + if (!isTimerActive) { + if (isLocal) { + Model.markTimesheetAsSavedById(recordid); + } else if (ids.task_id !== null) { + Model.markTimesheetAsReadyById(recordid); + } + } notifPopup.open("Saved", "Timesheet has been saved successfully", "success"); // If timer is running, update the active timer title in TimerService @@ -198,11 +221,98 @@ Page { } } + function finalize_timesheet() { + let isTimerActive = (recordid > 0 && recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning()); + if (isTimerActive) { + TimerService.stop(); + } + + const ids = workItem.getIds(); + var isLocal = (ids.account_id === 0); + + if (!isLocal && ids.task_id === null) { + notifPopup.open("Error", "Both Project and Task must be selected before finalizing", "error"); + return false; + } + + var user = Accounts.getCurrentUserOdooId(ids.account_id); + if (isLocal && (!user || user <= 0)) { + user = 1; + } + + if (!user) { + notifPopup.open("Error", "Unable to find the user, cannot finalize", "error"); + return false; + } + + if (ids.project_id === null) { + notifPopup.open("Error", "You need to select a project to finalize timesheet", "error"); + return false; + } + + let correctTaskId = null; + let correctSubTaskId = null; + if (ids.subtask_id !== null && ids.subtask_id !== undefined && ids.subtask_id !== -1 && ids.subtask_id > 0) { + correctTaskId = ids.subtask_id; + } else if (ids.task_id !== null && ids.task_id !== undefined && ids.task_id !== -1 && ids.task_id > 0) { + correctTaskId = ids.task_id; + correctSubTaskId = ids.subtask_id; + } + + var description = description_text.getFormattedText ? description_text.getFormattedText() : description_text.text; + if (typeof description === "string") { + description = description.trim(); + } + + var finalStatus = isLocal ? "saved" : "updated"; + var finalTime = time_sheet_widget.elapsedTime; + + var timesheet_data = { + 'record_date': date_widget.formattedDate(), + 'instance_id': ids.account_id < 0 ? 0 : ids.account_id, + 'project': ids.project_id, + 'task': correctTaskId, + 'subTask': correctSubTaskId, + 'subprojectId': ids.subproject_id, + 'description': description, + 'unit_amount': Utils.convertHHMMtoDecimalHours(finalTime), + 'quadrant': priorityGrid.currentIndex + 1, + 'user_id': user, + 'timer_type': "manual", + 'status': finalStatus + }; + if (recordid && recordid !== 0) { + timesheet_data.id = recordid; + } + + const saveResult = Model.saveTimesheet(timesheet_data); + if (!saveResult.success) { + notifPopup.open("Error", "Unable to finalize: " + saveResult.error, "error"); + return false; + } + + var markResult = isLocal ? Model.markTimesheetAsSavedById(recordid) : Model.markTimesheetAsReadyById(recordid); + if (!markResult.success) { + notifPopup.open("Error", markResult.error || "Finalize failed", "error"); + return false; + } + + draftHandler.clearDraft(); + var newBaseline = getCurrentFormData(); + draftHandler.initialize(newBaseline); + + notifPopup.open("Finalized", isLocal ? "Timesheet has been saved successfully" : "Timesheet is now ready to sync", "success"); + return true; + } + // Auto-save all form fields to DB so the timer can start. // Only requires a project (task can be filled in later before syncing). function auto_save_for_timer() { const ids = workItem.getIds(); - const user = Accounts.getCurrentUserOdooId(ids.account_id); + var user = Accounts.getCurrentUserOdooId(ids.account_id); + if (ids.account_id === 0 && (!user || user <= 0)) { + user = 1; + } if (!user) { notifPopup.open("Error", "Unable to find the user", "error"); @@ -486,7 +596,7 @@ Page { var taskId = normalizeIdForRestore(draftData.taskId); var subtaskId = normalizeIdForRestore(draftData.subtaskId); - if (accountId > 0 || projectId > 0) { + if (accountId >= 0 || projectId > 0) { workItem.deferredLoadExistingRecordSet(accountId, projectId, subprojectId, taskId, subtaskId, -1); } } @@ -752,20 +862,18 @@ Page { } } - Row { + Item { id: time_sheet_row anchors.top: myRow7.bottom anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: units.gu(1) anchors.rightMargin: units.gu(1) - spacing: units.gu(2) - topPadding: units.gu(1) height: recordid ? units.gu(20) : units.gu(5) TimeRecorderWidget { id: time_sheet_widget enabled: !isReadOnly - anchors.fill: time_sheet_row + anchors.fill: parent timesheetId: recordid visible: recordid onInvalidtimesheet: { @@ -776,6 +884,16 @@ Page { onRequestAutoSave: { auto_save_for_timer(); } + + onBeforeStop: { + save_timesheet(); + } + + onStopped: { + var newBaseline = getCurrentFormData(); + draftHandler.initialize(newBaseline); + draftHandler.clearDraft(); + } // Track elapsed time changes for draft management (only manual edits, not timer ticks) onElapsedTimeChanged: { diff --git a/qml/features/timesheets/pages/Timesheet_Page.qml b/qml/features/timesheets/pages/Timesheet_Page.qml index 54fb043e..7ac58613 100644 --- a/qml/features/timesheets/pages/Timesheet_Page.qml +++ b/qml/features/timesheets/pages/Timesheet_Page.qml @@ -90,8 +90,15 @@ Page { iconName: "reminder-new" text: "New" onTriggered: { - // Use DEFAULT account for creating new timesheets (not the filter selection) - const result = Model.createTimesheet(defaultAccountId, Account.getCurrentUserOdooId(defaultAccountId)); + var targetAccountId = (selectedAccountId >= 0) ? selectedAccountId : defaultAccountId; + if (targetAccountId < 0) { + targetAccountId = 0; + } + var targetUserId = Account.getCurrentUserOdooId(targetAccountId); + if (targetAccountId === 0 && (!targetUserId || targetUserId <= 0)) { + targetUserId = 1; + } + const result = Model.createTimesheet(targetAccountId, targetUserId); if (result.success) { apLayout.addPageToNextColumn(timesheets, Qt.resolvedUrl("Timesheet.qml"), { "recordid": result.id, @@ -254,10 +261,11 @@ Page { 'name': t.name, 'id': t.id, 'instance': t.instance, + 'account_id': t.account_id !== undefined ? t.account_id : -1, 'project': t.project, 'spentHours': t.spentHours, 'quadrant': t.quadrant || "Do", - 'task': t.task || "Unknown Task", + 'task': t.task || "", 'date': t.date, 'user': t.user, 'status': t.status, @@ -310,6 +318,7 @@ Page { width: parent.width name: model.name instance: model.instance + accountId: model.account_id project: model.project spentHours: model.spentHours date: model.date || "" @@ -356,13 +365,21 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Create"), + label: i18n.dtr("ubtms", "Create Timesheet"), + iconName: "alarm-clock" }, ] onMenuItemSelected: { if (index === 0) { - // Use DEFAULT account for creating new timesheets (not the filter selection) - const result = Model.createTimesheet(defaultAccountId, Account.getCurrentUserOdooId(defaultAccountId)); + var targetAccountId = (selectedAccountId >= 0) ? selectedAccountId : defaultAccountId; + if (targetAccountId < 0) { + targetAccountId = 0; + } + var targetUserId = Account.getCurrentUserOdooId(targetAccountId); + if (targetAccountId === 0 && (!targetUserId || targetUserId <= 0)) { + targetUserId = 1; + } + const result = Model.createTimesheet(targetAccountId, targetUserId); if (result.success) { apLayout.addPageToNextColumn(timesheets, Qt.resolvedUrl("Timesheet.qml"), { "recordid": result.id, diff --git a/qml/features/updates/pages/Updates.qml b/qml/features/updates/pages/Updates.qml index 2bee649c..927ea81c 100644 --- a/qml/features/updates/pages/Updates.qml +++ b/qml/features/updates/pages/Updates.qml @@ -119,7 +119,7 @@ Page { } trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" visible: !isReadOnly text: i18n.dtr("ubtms", "Save") onTriggered: { @@ -740,6 +740,7 @@ Page { // Update original data in draft handler draftHandler.updateOriginalData(newBaseline); + isReadOnly = true; notifPopup.open("Saved", "Project Update has been saved successfully", "success"); } } diff --git a/qml/features/updates/pages/Updates_Page.qml b/qml/features/updates/pages/Updates_Page.qml index 8db8a2f0..1411c8ae 100644 --- a/qml/features/updates/pages/Updates_Page.qml +++ b/qml/features/updates/pages/Updates_Page.qml @@ -40,10 +40,15 @@ Page { // Properties for filtering by project property bool filterByProject: false - property string projectOdooRecordId: "" + property var projectOdooRecordId: "" + property var projectRecordId: projectOdooRecordId property int projectAccountId: accountPicker.selectedAccountId property string projectName: "" + readonly property var effectiveProjectId: (projectRecordId !== undefined && projectRecordId !== "" && projectRecordId !== null) + ? projectRecordId + : projectOdooRecordId + // Use numeric -1 as default (All accounts). Do NOT initialize from default account (that's for creation only). property int selectedAccountId: accountPicker.selectedAccountId @@ -64,11 +69,24 @@ Page { } leadingActionBar.actions: [ + Action { + id: backAction + iconName: "back" + text: i18n.dtr("ubtms", "Back") + visible: filterByProject + onTriggered: { + if (typeof apLayout !== "undefined" && apLayout && apLayout.removePages) { + apLayout.removePages(updates); + } else if (typeof pageStack !== "undefined" && pageStack && pageStack.pop) { + pageStack.pop(); + } + } + }, Action { id: drawerAction iconName: "navigation-menu" text: i18n.dtr("ubtms", "Menu") - visible: !isMultiColumn + visible: !filterByProject && !isMultiColumn onTriggered: { apLayout.openGlobalDrawer() } @@ -87,11 +105,11 @@ Page { iconName: "add" text: i18n.dtr("ubtms", "New") onTriggered: { - if (filterByProject && projectOdooRecordId && projectAccountId >= 0) { + if (filterByProject && effectiveProjectId && projectAccountId >= 0) { // Direct creation when viewing updates for a specific project var newUpdate = { account_id: projectAccountId, - project_id: projectOdooRecordId, + project_id: effectiveProjectId, name: "", description: "", project_status: "on_track", @@ -135,6 +153,27 @@ Page { } // React to global account changes (numeric normalization) + Connections { + target: typeof accountPicker !== "undefined" ? accountPicker : null + + onAccepted: function (id, name) { + if (!filterByProject) { + selectedAccountId = id; + fetchupdates(); + } + } + + onSelectedAccountIdChanged: { + if (!filterByProject && typeof accountPicker !== "undefined" && accountPicker) { + var activeId = accountPicker.selectedAccountId; + if (selectedAccountId !== activeId) { + selectedAccountId = activeId; + fetchupdates(); + } + } + } + } + Connections { target: mainView onAccountDataRefreshRequested: function (accountId) { @@ -149,8 +188,9 @@ Page { } catch (e) { acctNum = -1; } - console.log("Updates_Page: AccountDataRefreshRequested ->", acctNum); - selectedAccountId = acctNum; + if (!filterByProject) { + selectedAccountId = acctNum; + } fetchupdates(); } onGlobalAccountChanged: function (accountId, accountName) { @@ -165,8 +205,9 @@ Page { } catch (e) { acctNum = -1; } - console.log("Updates_Page: GlobalAccountChanged ->", acctNum, accountName); - selectedAccountId = acctNum; + if (!filterByProject) { + selectedAccountId = acctNum; + } fetchupdates(); } } @@ -204,8 +245,8 @@ Page { var filterAccountId = (filterByProject && projectAccountId >= 0) ? projectAccountId : selectedAccountId; try { - if (filterByProject && projectOdooRecordId && filterAccountId >= 0) { - updates_list = Project.getProjectUpdatesByProject(projectOdooRecordId, filterAccountId); + if (filterByProject && effectiveProjectId && filterAccountId >= 0) { + updates_list = Project.getProjectUpdatesByProject(effectiveProjectId, filterAccountId); } else { updates_list = Project.getAllProjectUpdates(filterAccountId); } @@ -285,6 +326,10 @@ Page { onFilterSelected: { updates.currentStatusFilter = filterKey; + var activeSearch = (updatesListHeader.searchText !== undefined && updatesListHeader.searchText !== null) + ? updatesListHeader.searchText + : (updates.currentSearchQuery || ""); + updates.currentSearchQuery = activeSearch; fetchupdates(); } @@ -356,12 +401,17 @@ Page { onVisibleChanged: { if (visible) { + if (!filterByProject && typeof accountPicker !== "undefined" && accountPicker) { + selectedAccountId = accountPicker.selectedAccountId; + } fetchupdates(); } } Component.onCompleted: { - selectedAccountId = accountPicker.selectedAccountId; + if (!filterByProject && typeof accountPicker !== "undefined" && accountPicker) { + selectedAccountId = accountPicker.selectedAccountId; + } fetchupdates(); } diff --git a/qml/images/downloadWhite.svg b/qml/images/downloadWhite.svg new file mode 100644 index 00000000..391ff369 --- /dev/null +++ b/qml/images/downloadWhite.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/backend.py b/src/backend.py index 42f00e65..9fda42f9 100755 --- a/src/backend.py +++ b/src/backend.py @@ -386,15 +386,10 @@ def attachment_upload(settings_db, account_id, filepath, res_type, res_id): selected = acc break - if not selected: - send("ondemand_upload_message", "Error: Account not found") - send("ondemand_upload_completed", False) - return None + is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) - # Check server reachability before proceeding - send("ondemand_upload_message", "Checking server connection...") - if not check_server_reachability(selected["link"]): - send("ondemand_upload_message", "Error: No internet connection or server unreachable") + if not selected and not is_local_account: + send("ondemand_upload_message", "Error: Account not found") send("ondemand_upload_completed", False) return None @@ -428,6 +423,60 @@ def attachment_upload(settings_db, account_id, filepath, res_type, res_id): ext = os.path.splitext(filename)[1].lower() mimetype = EXT_TO_MIME.get(ext, 'application/octet-stream') + # Handle Local Account attachment save + if is_local_account: + send("ondemand_upload_message", "Saving attachment locally...") + dest_path = _export_path_for(filename, mimetype) + import shutil + if Path(filepath).resolve() != Path(dest_path).resolve(): + shutil.copy2(filepath, dest_path) + + import sqlite3 + db_path = settings_db or resolve_qml_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("SELECT COALESCE(MAX(odoo_record_id), 0) + 1 FROM ir_attachment_app WHERE account_id = 0") + row = cursor.fetchone() + next_local_record_id = row[0] if (row and row[0] is not None and row[0] > 0) else 1 + + cursor.execute(""" + INSERT INTO ir_attachment_app ( + account_id, name, res_model, res_id, file_path, local_url, url, + file_size, mimetype, odoo_record_id, last_modified, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 'local') + """, ( + 0, + filename, + res_type, + res_id, + str(dest_path), + f"file://{dest_path}", + f"file://{dest_path}", + file_size, + mimetype, + next_local_record_id + )) + + cursor.execute(""" + INSERT OR REPLACE INTO attachment_download_app (account_id, record_id, file_name, downloaded) + VALUES (0, ?, ?, 1) + """, (next_local_record_id, filename)) + + conn.commit() + conn.close() + + send("ondemand_upload_message", "Attachment saved successfully") + send("ondemand_upload_completed", True) + return next_local_record_id + + # Remote Odoo Account upload + send("ondemand_upload_message", "Checking server connection...") + if not check_server_reachability(selected["link"]): + send("ondemand_upload_message", "Error: No internet connection or server unreachable") + send("ondemand_upload_completed", False) + return None + file_bytes = None with open(filepath, 'rb') as f: file_bytes = f.read() @@ -489,6 +538,32 @@ def attachment_delete(settings_db, account_id, remote_record_id): selected = acc break + is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) + + if is_local_account: + try: + import sqlite3 + db_path = settings_db or resolve_qml_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + "DELETE FROM ir_attachment_app WHERE account_id = 0 AND (odoo_record_id = ? OR id = ?)", + (remote_record_id, remote_record_id) + ) + cursor.execute( + "DELETE FROM attachment_download_app WHERE account_id = 0 AND record_id = ?", + (remote_record_id,) + ) + conn.commit() + conn.close() + + cleanup_orphan_attachment_files(settings_db) + return {"success": True} + except Exception as e: + log.exception(f"[ATTACHMENT] Failed to delete local attachment {remote_record_id}: {e}") + return {"success": False, "error": str(e)} + if not selected: return {"success": False, "error": "Account not found"} @@ -528,6 +603,59 @@ def attachment_delete(settings_db, account_id, remote_record_id): return {"success": False, "error": friendly_error} +def cleanup_orphan_attachment_files(settings_db=None): + """ + Removes cached temporary/exported attachment files from disk that are no longer referenced + by any active attachment record in the database. + """ + try: + tmp_dir = _app_data_dir() / "ubtms" / "tmp" + if not tmp_dir.exists(): + return {"success": True, "deleted_count": 0} + + db_path = settings_db or resolve_qml_db_path() + if not db_path or not Path(db_path).exists(): + return {"success": False, "error": "Database not found"} + + import sqlite3 + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + active_filenames = set() + try: + cursor.execute("SELECT file_name FROM attachment_download_app WHERE downloaded = 1") + for row in cursor.fetchall(): + if row[0]: + active_filenames.add(str(row[0]).strip().replace("/", "_")) + except Exception: + pass + + try: + cursor.execute("SELECT name FROM ir_attachment_app") + for row in cursor.fetchall(): + if row[0]: + active_filenames.add(str(row[0]).strip().replace("/", "_")) + except Exception: + pass + + conn.close() + + deleted_count = 0 + for file in tmp_dir.iterdir(): + if file.is_file(): + if file.name not in active_filenames and file.stem not in active_filenames: + try: + file.unlink() + deleted_count += 1 + log.info(f"[ATTACHMENT] Cleaned up orphan file on disk: {file.name}") + except Exception as err: + log.warning(f"[ATTACHMENT] Could not delete {file.name}: {err}") + + return {"success": True, "deleted_count": deleted_count} + except Exception as e: + log.exception(f"[ATTACHMENT] Error cleaning orphan attachment files: {e}") + return {"success": False, "error": str(e)} + def sync(settings_db, account_id): """ Perform synchronous bidirectional sync between local database and Odoo. @@ -684,120 +812,218 @@ def stop_voice_recognition(): def get_voice_models_dir(): """ Returns the writable directory for voice models. - On Ubuntu Touch, this is in ~/.local/share/ubtms/voice_models + + On Ubuntu Touch this is normally: + ~/.local/share/ubtms/voice_models """ - data_home = os.environ.get('XDG_DATA_HOME') + data_home = os.environ.get("XDG_DATA_HOME") + if data_home: base_dir = Path(data_home) / "ubtms" else: base_dir = Path.home() / ".local" / "share" / "ubtms" - + models_dir = base_dir / "voice_models" models_dir.mkdir(parents=True, exist_ok=True) + return models_dir +def is_valid_vosk_model(model_path): + """ + Check whether a directory contains a supported Vosk model layout. + + Supported layouts: + 1. Mobile/legacy layout: + model/am/ + model/graph/ + + 2. Flat Kaldi/Vosk layout: + model/final.mdl + model/HCLG.fst + or + model/HCLr.fst + """ + try: + path = Path(model_path) + + if not path.is_dir(): + return False + + # Layout used by some Vosk mobile models + has_am_graph = ( + (path / "am").is_dir() and + (path / "graph").is_dir() + ) + + # Flat Kaldi/Vosk model layout + has_flat_model = ( + (path / "final.mdl").is_file() + and ( + (path / "HCLG.fst").is_file() + or (path / "HCLr.fst").is_file() + ) + ) + + return has_am_graph or has_flat_model + + except Exception as e: + log.warning(f"[VOICE] Error validating model {model_path}: {e}") + return False def list_installed_models(): """ - Scans for installed Vosk models in both the app directory and writable data directory. - Returns a list of dictionaries with model names, paths, and sizes. + Scans for installed Vosk models in both the app directory + and writable data directory. + Returns a list of dictionaries with model names, paths, + sizes, and sources. """ - # 1. App directory models (Read-only on device) + + # App directory models (read-only) app_models_dir = root_dir / "voice_to_text" - - # 2. User data directory models (Writable) + + # User/downloaded models directory user_models_dir = get_voice_models_dir() - + search_paths = [ (app_models_dir, "App"), - (user_models_dir, "User") + (user_models_dir, "User"), + (Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "voice_models", "Clickable") ] - + models = [] - seen_paths = set() - + seen_names = set() + + available_models = list_available_models() + + known_names = { + m["id"]: m["name"] + for m in available_models + if "id" in m + } + + # Bundled model + known_names["model"] = "Indian English" + for root_dir_to_scan, source_label in search_paths: if not root_dir_to_scan.exists(): continue - - # Standard Vosk models are directories containing 'am' and 'graph' subdirectories + for item in root_dir_to_scan.iterdir(): - if item.is_dir() and item not in seen_paths: - am_dir = item / "am" - graph_dir = item / "graph" - - if am_dir.exists() and graph_dir.exists(): - available_models = list_available_models() - known_names = {m["id"]: m["name"] for m in available_models if "id" in m} - known_names["model"] = "Indian English" # The bundled one is usually named 'model' - - if item.name in known_names: - model_name = known_names[item.name] - else: - # Fallback to cleaning README or using folder name - model_name = item.name - readme_path = item / "README" - if readme_path.exists(): - try: - with open(readme_path, 'r') as f: - first_line = f.readline().strip() - if first_line: - clean_name = first_line - noise_phrases = [ - "for mobile Vosk applications", - "for Android and iOS", - "Vosk mobile model", - "Vosk model", - "Vosk", - "model" - ] - for phrase in noise_phrases: - clean_name = clean_name.replace(phrase, "").strip() - - if clean_name: - model_name = clean_name - except Exception as e: - log.error(f"[VOICE] Error reading README for {item.name}: {e}") - - # Add (Default) suffix for bundled models - display_name = model_name + if item.is_dir() and item.name not in seen_names: + + # Supported Vosk model layouts: + # + # 1. Mobile layout: + # model/am/ + # model/graph/ + # + # 2. Standard Vosk/Kaldi layout: + # model/final.mdl + # model/HCLG.fst + # + # or: + # model/final.mdl + # model/HCLr.fst + + if not is_valid_vosk_model(item): + continue + + if item.name in known_names: + model_name = known_names[item.name] + + else: + # Fallback to folder name + model_name = item.name + + # Try README for a nicer display name + readme_path = item / "README" + + if readme_path.exists(): + try: + with open( + readme_path, + "r", + encoding="utf-8" + ) as f: + first_line = f.readline().strip() + + if first_line: + clean_name = first_line + + noise_phrases = [ + "for mobile Vosk applications", + "for Android and iOS", + "Vosk mobile model", + "Vosk model", + "Vosk", + "model" + ] + + for phrase in noise_phrases: + clean_name = clean_name.replace( + phrase, + "" + ).strip() + + if clean_name: + model_name = clean_name + + except Exception as e: + log.error( + f"[VOICE] Error reading README " + f"for {item.name}: {e}" + ) + + # Bundled models get "(Default)" + display_name = model_name + + if source_label == "App": + display_name = f"{model_name} (Default)" + + # Calculate directory size + total_size = 0 + + try: + for f in item.rglob("*"): + if f.is_file(): + total_size += f.stat().st_size + + size_mb = total_size / (1024 * 1024) + model_size = f"{size_mb:.1f} MB" + + except Exception: + model_size = "Unknown" + + # Store model information + try: if source_label == "App": - display_name = f"{model_name} (Default)" + rel_path = item.relative_to(root_dir) + else: + rel_path = item - # Calculate directory size - total_size = 0 - try: - for f in item.rglob('*'): - if f.is_file(): - total_size += f.stat().st_size - size_mb = total_size / (1024 * 1024) - model_size = f"{size_mb:.1f} MB" - except Exception: - model_size = "Unknown" + models.append({ + "m_name": display_name, + "m_path": str(rel_path), + "m_size": model_size, + "m_source": source_label + }) + + except ValueError: + models.append({ + "m_name": display_name, + "m_path": str(item), + "m_size": model_size, + "m_source": source_label + }) + + seen_names.add(item.name) - try: - if source_label == "App": - rel_path = item.relative_to(root_dir) - else: - rel_path = item - - models.append({ - "m_name": display_name, - "m_path": str(rel_path), - "m_size": model_size, - "m_source": source_label - }) - except ValueError: - models.append({ - "m_name": display_name, - "m_path": str(item), - "m_size": model_size, - "m_source": source_label - }) - seen_paths.add(item) - models.sort(key=lambda x: x["m_name"].lower()) - log.info(f"[VOICE] Found {len(models)} installed models") + + log.info( + f"[VOICE] Found {len(models)} installed models" + ) + return models def get_installed_voice_models(): @@ -1141,37 +1367,52 @@ def delete_voice_model(model_path): """ try: path = Path(model_path) + + user_models_dir = get_voice_models_dir() + clickable_models_dir = ( + Path.home() / ".clickable" / "home" / + ".local" / "share" / "ubtms" / "voice_models" + ) # If relative, it might be a bundled model or a legacy relative path if not path.is_absolute(): - # Check if it's relative to user models dir - user_models_dir = get_voice_models_dir() + # Check user and Clickable model directories potential_path = user_models_dir / model_path if potential_path.exists(): path = potential_path else: - # Check if it's relative to root (bundled models) - potential_path = root_dir / model_path + potential_path = clickable_models_dir / model_path if potential_path.exists(): path = potential_path + else: + # Check if it's relative to root (bundled models) + potential_path = root_dir / model_path + if potential_path.exists(): + path = potential_path if not path.exists(): return {"status": "error", "message": "Model path not found"} - # Security check: only allow deleting from the user models directory - user_models_dir = get_voice_models_dir() - if user_models_dir in path.parents: + # Security check: resolve paths before validation + resolved_path = path.resolve() + resolved_user_dir = user_models_dir.resolve() + resolved_clickable_dir = clickable_models_dir.resolve() + + if ( + resolved_user_dir in resolved_path.parents + or resolved_clickable_dir in resolved_path.parents + ): import shutil - if path.is_dir(): - shutil.rmtree(path) + if resolved_path.is_dir(): + shutil.rmtree(resolved_path) else: - path.unlink() + resolved_path.unlink() log.info(f"[VOICE] Deleted user model: {model_path}") return {"status": "success"} # Check if it's in the app dir - app_models_dir = root_dir / "voice_to_text" - if app_models_dir in path.parents or path == app_models_dir: + app_models_dir = (root_dir / "voice_to_text").resolve() + if app_models_dir in resolved_path.parents or resolved_path == app_models_dir: log.warning(f"[VOICE] Attempted to delete bundled model: {model_path}") return {"status": "error", "message": "Cannot delete bundled system models"} diff --git a/src/daemon.py b/src/daemon.py index d6f20b97..ff7a190f 100644 --- a/src/daemon.py +++ b/src/daemon.py @@ -150,7 +150,7 @@ def get_app_version(): if Path(MANIFEST_PATH).exists(): with open(MANIFEST_PATH, 'r') as f: manifest = json.load(f) - return manifest.get('version', '1.3.2') + return manifest.get('version', '1.3.3') # Fallback to development path dev_manifest = Path(__file__).parent.parent / "manifest.json.in" if dev_manifest.exists(): @@ -163,7 +163,7 @@ def get_app_version(): return match.group(1) except Exception as e: log.error(f"[DAEMON] Failed to read app version: {e}") - return "1.3.2" # Fallback version + return "1.3.3" # Fallback version APP_VERSION = get_app_version() diff --git a/src/daemon_bootstrap.py b/src/daemon_bootstrap.py index f58cae08..ed2e8839 100755 --- a/src/daemon_bootstrap.py +++ b/src/daemon_bootstrap.py @@ -26,6 +26,14 @@ def log(message): pass +def get_service_target_path(): + """Get stable click path for systemd service across app updates.""" + current_symlink = Path("/opt/click.ubuntu.com/ubtms/current") + if current_symlink.exists(): + return current_symlink + return CLICK_PATH + + def setup_systemd_service(): """Create systemd user service for auto-restart.""" systemd_dir = HOME / ".config" / "systemd" / "user" @@ -36,6 +44,7 @@ def setup_systemd_service(): log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "daemon.log" + target_path = get_service_target_path() service_file = systemd_dir / "ubtms-daemon.service" service_content = f"""[Unit] Description=TimeManagement Background Sync Daemon @@ -43,8 +52,8 @@ def setup_systemd_service(): [Service] Type=simple -ExecStart=/usr/bin/python3 {CLICK_PATH}/src/daemon.py -WorkingDirectory={CLICK_PATH} +ExecStart=/usr/bin/python3 {target_path}/src/daemon.py +WorkingDirectory={target_path} Environment="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus" Restart=always RestartSec=10 @@ -85,15 +94,26 @@ def setup_systemd_service(): def main(): log("Bootstrap starting...") - # Set up systemd service if not exists + # Set up or update systemd service if missing or pointing to outdated path + target_path = get_service_target_path() service_file = HOME / ".config" / "systemd" / "user" / "ubtms-daemon.service" - if not service_file.exists(): + need_setup = True + if service_file.exists(): + try: + content = service_file.read_text() + if f"WorkingDirectory={target_path}" in content: + need_setup = False + except Exception: + need_setup = True + + if need_setup: setup_systemd_service() # Run daemon log("Starting daemon...") - os.chdir(CLICK_PATH) - os.execv(sys.executable, [sys.executable, str(DAEMON_PATH)] + sys.argv[1:]) + run_path = target_path if (target_path / "src" / "daemon.py").exists() else CLICK_PATH + os.chdir(str(run_path)) + os.execv(sys.executable, [sys.executable, str(run_path / "src" / "daemon.py")] + sys.argv[1:]) if __name__ == "__main__":