diff --git a/models/accounts.js b/models/accounts.js index 617e79f1..257bbc65 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -444,18 +444,19 @@ function deleteAccountAndRelatedData(userId) { "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); + DBCommon.log("Deleting data from account " + numericUserId); try { - tx.executeSql(`DELETE FROM ${table} WHERE account_id = ?`, [userId]); + 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]); // Ensure a valid default account exists var defaultCheck = tx.executeSql("SELECT id FROM users WHERE is_default = 1 LIMIT 1"); @@ -466,11 +467,20 @@ function deleteAccountAndRelatedData(userId) { } } - DBCommon.log(`Account and related data deleted for account_id: ${userId}`); + // 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); } } diff --git a/models/dbinit.js b/models/dbinit.js index 9b416963..0d72fe82 100644 --- a/models/dbinit.js +++ b/models/dbinit.js @@ -647,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") } /** @@ -802,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 567e48e0..7d33f945 100644 --- a/models/project.js +++ b/models/project.js @@ -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)") } @@ -712,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++) { @@ -756,6 +756,8 @@ 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 @@ -871,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]); } @@ -917,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) diff --git a/models/task.js b/models/task.js index f4923dd2..2915680f 100644 --- a/models/task.js +++ b/models/task.js @@ -526,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] ); @@ -540,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 @@ -666,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]; + + if (accountId !== undefined && accountId !== null && accountId >= 0) { + query += " AND account_id = ?"; + params.push(accountId); + } - for (var i = 0; i < childResult1.rows.length; i++) { - var row = childResult1.rows.item(i); + 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, @@ -690,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; @@ -730,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; @@ -747,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 }; } } @@ -1544,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. 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 3cbc63d3..02e8cabb 100644 --- a/qml/TSApp.qml +++ b/qml/TSApp.qml @@ -125,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/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 522da04a..5b5b5df6 100644 --- a/qml/app/GlobalWidgets.qml +++ b/qml/app/GlobalWidgets.qml @@ -73,13 +73,13 @@ Item { accountPicker.lastRemoteAccountId = id; } - if (rootApp.currentAccountId === id) { - return; - } - + 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/components/feedback/NotificationPopup.qml b/qml/components/feedback/NotificationPopup.qml index 16b4d70f..d389b85a 100644 --- a/qml/components/feedback/NotificationPopup.qml +++ b/qml/components/feedback/NotificationPopup.qml @@ -46,12 +46,6 @@ Item { id: popupDialog title: popupWrapper.titleText - // Dark mode friendly styling - StyleHints { - backgroundColor: theme.palette.normal.background - foregroundColor: theme.palette.normal.backgroundText - } - Text { id: messageText text: popupWrapper.messageText diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index b3d0d3e2..ab210212 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -1,5 +1,4 @@ import QtQuick 2.12 -import QtQuick.Controls 2.12 import QtQuick.Layouts 1.3 import QtQuick.Dialogs 1.2 import Lomiri.Components 1.3 @@ -119,24 +118,23 @@ 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 ? "" : i18n.dtr("ubtms", "No date set") - color: isStartDateValid ? (isDarkTheme ? "#ebebef" : "#333333") : (isDarkTheme ? "#9a9aa2" : "#888888") - font.pixelSize: units.gu(1.8) - verticalAlignment: TextInput.AlignVCenter - leftPadding: units.gu(1.5) - rightPadding: units.gu(1.5) + 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" - background: Rectangle { - 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 } } @@ -177,24 +175,23 @@ 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 ? "" : i18n.dtr("ubtms", "No date set") - color: isEndDateValid ? (isDarkTheme ? "#ebebef" : "#333333") : (isDarkTheme ? "#9a9aa2" : "#888888") - font.pixelSize: units.gu(1.8) - verticalAlignment: TextInput.AlignVCenter - leftPadding: units.gu(1.5) - rightPadding: units.gu(1.5) + 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" - background: Rectangle { - 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 } } diff --git a/qml/components/selectors/StageFilterMenu.qml b/qml/components/selectors/StageFilterMenu.qml index 0f8c9bf3..236e40e5 100644 --- a/qml/components/selectors/StageFilterMenu.qml +++ b/qml/components/selectors/StageFilterMenu.qml @@ -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/features/dashboard/pages/Dashboard2.qml b/qml/features/dashboard/pages/Dashboard2.qml index 600bbc82..2e501b8a 100644 --- a/qml/features/dashboard/pages/Dashboard2.qml +++ b/qml/features/dashboard/pages/Dashboard2.qml @@ -80,12 +80,23 @@ 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(); diff --git a/qml/features/settings/pages/Settings_Accounts.qml b/qml/features/settings/pages/Settings_Accounts.qml index 4e20c92f..e5eb26a9 100644 --- a/qml/features/settings/pages/Settings_Accounts.qml +++ b/qml/features/settings/pages/Settings_Accounts.qml @@ -154,6 +154,11 @@ Page { } } + if (typeof mainView !== "undefined" && mainView) { + mainView.projectDataChanged(); + mainView.taskDataChanged(); + } + accountToDelete = -1; accountIndexToDelete = -1; } diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 678b0776..d9085a9b 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -54,13 +54,15 @@ 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 effectiveTaskId: (taskCard.accountId === 0 || recordId <= 0) ? localId : recordId + 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: { @@ -75,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 @@ -317,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 @@ -585,34 +604,82 @@ 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) - 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 + 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") + } + } - 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 + } + + 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); + } + } } } } diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index eb22f9f3..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)) @@ -113,20 +131,30 @@ Item { } } + 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; @@ -138,6 +166,7 @@ Item { // Add combined project and time filter method function applyProjectAndTimeFilter(projectOdooId, accountId, timeFilter) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = accountId; @@ -148,6 +177,7 @@ Item { // Add combined project and search filter method function applyProjectAndSearchFilter(projectOdooId, accountId, searchQuery) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = accountId; @@ -332,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 @@ -385,6 +419,7 @@ Item { } function applyAccountFilter(accountId) { + _resetHierarchyNavigation(); filterByAccount = (accountId >= 0); selectedAccountId = accountId; filterByProject = false; @@ -392,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 @@ -487,6 +608,8 @@ 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 || "", @@ -520,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); } } @@ -537,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; }); } @@ -567,6 +690,7 @@ Item { isLoading = true; navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; // Reset pagination currentOffset = 0; @@ -598,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(); @@ -618,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 @@ -705,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 + } + } } } } @@ -730,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(); } } @@ -753,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 @@ -767,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 @@ -783,6 +1040,11 @@ Item { onViewRequested: d => { taskSelected(local_id); } + onNavigationRequested: (taskId, accountId, taskName) => { + if (!flatViewMode) { + navigateToTask(taskId, accountId, taskName); + } + } onTimesheetRequested: localId => { taskTimesheetRequested(localId); } @@ -793,35 +1055,39 @@ Item { onTaskUpdated: localId => { refreshWithFilter(); } - - // 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); - } - } - } } } } } + // 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/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index cc230703..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 { diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 7efc6ff7..0be7c344 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -89,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 { diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 72963771..279cb69e 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -103,6 +103,7 @@ Page { 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 @@ -1063,6 +1064,44 @@ Page { 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 68a4fc5e..22a96f51 100644 --- a/qml/features/timesheets/components/TimeRecorderWidget.qml +++ b/qml/features/timesheets/components/TimeRecorderWidget.qml @@ -110,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) } diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index f374cebe..c515de2f 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -862,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: { diff --git a/qml/features/updates/pages/Updates_Page.qml b/qml/features/updates/pages/Updates_Page.qml index 5f37fb0e..1411c8ae 100644 --- a/qml/features/updates/pages/Updates_Page.qml +++ b/qml/features/updates/pages/Updates_Page.qml @@ -153,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) { @@ -167,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) { @@ -183,8 +205,9 @@ Page { } catch (e) { acctNum = -1; } - console.log("Updates_Page: GlobalAccountChanged ->", acctNum, accountName); - selectedAccountId = acctNum; + if (!filterByProject) { + selectedAccountId = acctNum; + } fetchupdates(); } } @@ -378,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/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__":