From ee6df7f20a8388c4c4ceaa91e7a7986a9edb4523 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Tue, 18 Aug 2026 17:22:59 +0530 Subject: [PATCH 001/105] [Fix task list pagination limit and search refresh errors] --- models/task.js | 2 +- qml/features/tasks/pages/Task_Page.qml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/models/task.js b/models/task.js index c0f70e23..ac91efa5 100644 --- a/models/task.js +++ b/models/task.js @@ -1730,7 +1730,7 @@ function getFilteredTasksPaginated(filterType, searchQuery, accountId, limit, of var dbOffset = 0; var skipped = 0; var hasMore = true; - var maxIterations = 10; // Safety limit to prevent infinite loops + var maxIterations = 50; // Safety limit to prevent infinite loops var iteration = 0; try { diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 0722a5a5..e34fb0a7 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -522,7 +522,7 @@ Page { } else { if (currentSearchQuery) { // Reapply search if there was one - tasklist.searchTasks(currentSearchQuery); + tasklist.applySearch(currentSearchQuery); } else { // Reapply current filter tasklist.applyFilter(currentFilter); From 909617a7ea2e8edec1e2a504fb0df248e1e5a9bd Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 11:05:12 +0530 Subject: [PATCH 002/105] fix: resolve timer stop reference errors, duration parsing, and draft state handling --- models/accounts.js | 1 + models/timer_service.js | 5 +- models/utils.js | 60 ++++++++++++++----- qml/components/cards/ProjectDetailsCard.qml | 5 +- .../tasks/components/TaskDetailsCard.qml | 5 +- .../components/TimeRecorderWidget.qml | 6 +- .../components/TimeSheetDescriptionPopup.qml | 1 + .../components/TimeSheetDetailsCard.qml | 5 +- 8 files changed, 62 insertions(+), 26 deletions(-) diff --git a/models/accounts.js b/models/accounts.js index ae6b9931..cf1d4983 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -1,4 +1,5 @@ .import "database.js" as DBCommon +.import "logger.js" as Logger .import QtQuick.LocalStorage 2.7 as Sql /** diff --git a/models/timer_service.js b/models/timer_service.js index 68bf2165..c4594836 100644 --- a/models/timer_service.js +++ b/models/timer_service.js @@ -4,8 +4,9 @@ .pragma library -.import "../models/timesheet.js" as Model -.import "../models/utils.js" as Utils +.import "logger.js" as Logger +.import "timesheet.js" as Model +.import "utils.js" as Utils var timerRunning = false; var startTime = 0; // Epoch milliseconds diff --git a/models/utils.js b/models/utils.js index fd624b29..138c02b7 100644 --- a/models/utils.js +++ b/models/utils.js @@ -431,17 +431,26 @@ function getFormattedTimestampUTC() { function convertHHMMtoDecimalHours(hhmmString) { Logger.debug("Utils", "Input string is " + hhmmString) + if (typeof hhmmString === "number") { + return parseFloat(hhmmString.toFixed(4)); + } if (typeof hhmmString !== "string") { - Logger.error("Utils", "Input is not a string:", hhmmString) - return 0; - } + Logger.error("Utils", "Input is not a string or number:", hhmmString) + return 0; + } - var parts = hhmmString.split(":"); - if (parts.length !== 2) { - Logger.error("Utils", "Invalid HH:MM string:", hhmmString) - return 0; - } + hhmmString = hhmmString.trim(); + if (hhmmString === "") { + return 0; + } + + // Handle numeric string directly if no colons + if (!hhmmString.includes(":") && !isNaN(Number(hhmmString))) { + return parseFloat(Number(hhmmString).toFixed(4)); + } + var parts = hhmmString.split(":"); + if (parts.length === 2) { var hours = parseInt(parts[0], 10); var minutes = parseInt(parts[1], 10); @@ -451,6 +460,21 @@ function convertHHMMtoDecimalHours(hhmmString) { } return parseFloat((hours + (minutes / 60)).toFixed(4)); + } else if (parts.length === 3) { + var hours = parseInt(parts[0], 10); + var minutes = parseInt(parts[1], 10); + var seconds = parseInt(parts[2], 10); + + if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) { + Logger.error("Utils", "Invalid numeric values in HH:MM:SS string:", hhmmString) + return 0; + } + + return parseFloat((hours + (minutes / 60) + (seconds / 3600)).toFixed(4)); + } else { + Logger.error("Utils", "Invalid HH:MM string:", hhmmString) + return 0; + } } /** @@ -467,18 +491,26 @@ function convertDecimalHoursToHHMM(decimalHours) { /* Name: convertDurationToFloat -* This function will return float value from HH:MM format -* -> value -> HH:MM format to convert float value +* This function will return float value from HH:MM or HH:MM:SS format +* -> value -> HH:MM / HH:MM:SS format to convert float value */ function convertDurationToFloat(value) { - let vals = value.split(":"); - let hours = parseFloat(vals[0]); - let minutes = parseFloat(vals[1]); + if (typeof value === "number") { + return value; + } + if (typeof value !== "string" || value.trim() === "") { + return 0; + } + let vals = value.trim().split(":"); + let hours = parseFloat(vals[0]) || 0; + let minutes = (vals.length > 1) ? (parseFloat(vals[1]) || 0) : 0; + let seconds = (vals.length > 2) ? (parseFloat(vals[2]) || 0) : 0; // Remove the day calculation and modulo operation for project hours // Project allocation can be any number of hours, not limited to 24-hour days let convertedMinutes = minutes / 60.0; - return hours + convertedMinutes; + let convertedSeconds = seconds / 3600.0; + return hours + convertedMinutes + convertedSeconds; } function formatDate(date) { diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index ead3c19c..75694c49 100644 --- a/qml/components/cards/ProjectDetailsCard.qml +++ b/qml/components/cards/ProjectDetailsCard.qml @@ -65,9 +65,8 @@ ListItem { Connections { target: globalTimerWidget onTimerStopped: { - if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { - timer_on = false; - } + timer_on = false; + timer_paused = false; } onTimerStarted: { if (Timesheet.doesProjectIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 9329c231..1a3170f8 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -98,9 +98,8 @@ ListItem { target: globalTimerWidget onTimerStopped: { - if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { - timer_on = false; - } + timer_on = false; + timer_paused = false; } onTimerStarted: { if (Timesheet.doesTaskIdMatchSheetInActive(recordId, TimerService.getActiveTimesheetId())) { diff --git a/qml/features/timesheets/components/TimeRecorderWidget.qml b/qml/features/timesheets/components/TimeRecorderWidget.qml index 7ced382d..42a16020 100644 --- a/qml/features/timesheets/components/TimeRecorderWidget.qml +++ b/qml/features/timesheets/components/TimeRecorderWidget.qml @@ -219,6 +219,10 @@ Item { return; } + 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"); @@ -259,7 +263,7 @@ Item { Component.onCompleted: { if (timesheetId > 0 && timesheetId === TimerService.getActiveTimesheetId()) { isRecording = true; - automode = true; + autoMode = true; if (autoMode) updateTimer.start(); } else { diff --git a/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml b/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml index 11c32b88..e454d862 100644 --- a/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml +++ b/qml/features/timesheets/components/TimeSheetDescriptionPopup.qml @@ -213,6 +213,7 @@ Item { 'unit_amount': Utils.convertHHMMtoDecimalHours(popupWrapper.elapsedTime), 'quadrant': currentDetails.quadrant_id || 1, 'status': status, + 'timer_type': currentDetails.timer_type || "manual", 'user_id': userId }; diff --git a/qml/features/timesheets/components/TimeSheetDetailsCard.qml b/qml/features/timesheets/components/TimeSheetDetailsCard.qml index d5e4733d..e86d89cd 100644 --- a/qml/features/timesheets/components/TimeSheetDetailsCard.qml +++ b/qml/features/timesheets/components/TimeSheetDetailsCard.qml @@ -95,9 +95,8 @@ ListItem { target: globalTimerWidget onTimerStopped: { - if (recordId === TimerService.getActiveTimesheetId()) { - timer_on = false; - } + timer_on = false; + timer_paused = false; } onTimerStarted: { if (recordId === TimerService.getActiveTimesheetId()) { From a5b94cf8f4a593ae2dede9d2ecd8e8c63dd13024 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 12:59:13 +0530 Subject: [PATCH 003/105] feat: enhance timesheet creation and live timer sync UX --- models/timer_service.js | 17 ++++++- models/timesheet.js | 38 +++++++++++++- .../components/TimeSheetDetailsCard.qml | 3 ++ qml/features/timesheets/pages/Timesheet.qml | 50 ++++++++++++++++--- 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/models/timer_service.js b/models/timer_service.js index c4594836..7ad34040 100644 --- a/models/timer_service.js +++ b/models/timer_service.js @@ -64,7 +64,7 @@ function start(timesheetId) { timerRunning = true; paused = false; pauseStartTime = 0; - activeSheetname = Model.getTimesheetNameById(activeTimesheetId); + activeSheetname = Model.getTimesheetDisplayName ? Model.getTimesheetDisplayName(activeTimesheetId) : Model.getTimesheetNameById(activeTimesheetId); Model.markTimesheetAsActiveById(activeTimesheetId); Logger.debug("Timer_service", "Timer started for timesheet ID:", activeTimesheetId, "Previously tracked:", previouslyTrackedHours) @@ -278,3 +278,18 @@ function getStartTime() { function getActiveTimesheetName() { return activeSheetname; } + +/** + * Dynamically update the active timesheet name/description in memory. + * Called in real-time as user edits description in Timesheet form. + * + * @param {string} newName - The updated name or description. + */ +function updateActiveTimesheetName(newName) { + if (typeof newName === "string") { + activeSheetname = newName.trim(); + } else { + activeSheetname = ""; + } + Logger.debug("Timer_service", "Live updated active timesheet name to:", activeSheetname); +} diff --git a/models/timesheet.js b/models/timesheet.js index e2effa73..3c99beb4 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -809,13 +809,49 @@ function getAttachmentsForTimesheet(odooRecordId) { } +function getTimesheetDisplayName(timesheetId) { + if (!timesheetId || timesheetId <= 0) return ""; + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + var displayName = ""; + try { + db.transaction(function (tx) { + var rs = tx.executeSql( + "SELECT t.name AS ts_name, p.name AS project_name, tk.name AS task_name " + + "FROM account_analytic_line_app t " + + "LEFT JOIN project_project_app p ON (t.project_id = p.odoo_record_id OR t.project_id = p.id) " + + "LEFT JOIN project_task_app tk ON (t.task_id = tk.odoo_record_id OR t.task_id = tk.id) " + + "WHERE t.id = ? LIMIT 1", + [timesheetId] + ); + if (rs.rows.length > 0) { + var row = rs.rows.item(0); + if (row.ts_name && row.ts_name.trim() !== "") { + displayName = row.ts_name.trim(); + } else if (row.project_name && row.project_name.trim() !== "") { + displayName = row.project_name.trim() + (row.task_name ? " - " + row.task_name.trim() : ""); + } else if (row.task_name && row.task_name.trim() !== "") { + displayName = row.task_name.trim(); + } + } + }); + } catch (e) { + DBCommon.logException("getTimesheetDisplayName", e); + } + return displayName; +} + function getTimesheetNameById(timesheetId) { + var displayName = getTimesheetDisplayName(timesheetId); + if (displayName && displayName.trim() !== "") { + return displayName; + } + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var name = ""; try { db.transaction(function (tx) { var rs = tx.executeSql("SELECT name FROM account_analytic_line_app WHERE id = ?", [timesheetId]); - if (rs.rows.length > 0) { + if (rs.rows.length > 0 && rs.rows.item(0).name) { name = rs.rows.item(0).name; } }); diff --git a/qml/features/timesheets/components/TimeSheetDetailsCard.qml b/qml/features/timesheets/components/TimeSheetDetailsCard.qml index e86d89cd..4bfbe03e 100644 --- a/qml/features/timesheets/components/TimeSheetDetailsCard.qml +++ b/qml/features/timesheets/components/TimeSheetDetailsCard.qml @@ -82,6 +82,9 @@ ListItem { } function save_workflow() { + if (TimerService.isRunning() && (recordId === TimerService.getActiveTimesheetId())) { + TimerService.stop(); + } const result = Timesheet.markTimesheetAsReadyById(recordId); if (result.success) { notifPopup.open("Success", "Timesheet is now ready to be synced to Odoo", "success"); diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 0ca9c7ae..0332390d 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -106,6 +106,7 @@ Page { } function save_timesheet() { + let isTimerActive = (recordid > 0 && recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning()); let time = time_sheet_widget.elapsedTime; const currentStatus = getCurrentTimesheetStatus(); @@ -113,8 +114,9 @@ Page { if (currentStatus === "updated") { const savedTime = Model.getTimesheetUnitAmount(recordid); time = Utils.convertDecimalHoursToHHMM(savedTime); - } else if (recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning()) { - time = TimerService.stop(); + } else if (isTimerActive) { + // Keep timer running continuously. Capture current elapsed duration for DB save + time = TimerService.getElapsedDuration(); } const ids = workItem.getIds(); @@ -146,6 +148,8 @@ Page { correctSubTaskId = ids.subtask_id; } + var description = description_text.getFormattedText ? description_text.getFormattedText() : description_text.text; + var timesheet_data = { 'record_date': date_widget.formattedDate(), 'instance_id': ids.account_id < 0 ? 0 : ids.account_id, @@ -153,10 +157,11 @@ Page { 'task': correctTaskId, 'subTask': correctSubTaskId, 'subprojectId': ids.subproject_id, - 'description': description_text.getFormattedText ? description_text.getFormattedText() : description_text.text, + 'description': description, 'unit_amount': Utils.convertHHMMtoDecimalHours(time), 'quadrant': priorityGrid.currentIndex + 1, 'user_id': user, + 'timer_type': isTimerActive ? "automatic" : "manual", 'status': "draft" // WORKFLOW status (not submitted yet), NOT form draft status }; if (recordid && recordid !== 0) { @@ -170,10 +175,17 @@ Page { } else { notifPopup.open("Saved", "Timesheet has been saved successfully", "success"); + // If timer is running, update the active timer title in TimerService + if (isTimerActive) { + TimerService.updateActiveTimesheetName(description); + } + // Clear form draft (unsaved changes) after successful database save draftHandler.clearDraft(); - time_sheet_widget.elapsedTime = time; + if (!isTimerActive) { + time_sheet_widget.elapsedTime = time; + } // Re-initialize draft tracking with current saved state as new baseline var newBaseline = getCurrentFormData(); @@ -208,6 +220,8 @@ Page { correctTaskId = ids.task_id; } + var description = description_text.getFormattedText ? description_text.getFormattedText() : description_text.text; + var timesheet_data = { 'record_date': date_widget.formattedDate(), 'instance_id': ids.account_id < 0 ? 0 : ids.account_id, @@ -215,10 +229,11 @@ Page { 'task': correctTaskId, 'subTask': correctSubTaskId, 'subprojectId': ids.subproject_id, - 'description': description_text.getFormattedText ? description_text.getFormattedText() : description_text.text, + 'description': description, 'unit_amount': Utils.convertHHMMtoDecimalHours(time_sheet_widget.elapsedTime), 'quadrant': priorityGrid.currentIndex + 1, 'user_id': user, + 'timer_type': "automatic", 'status': "draft" }; @@ -241,6 +256,10 @@ Page { // Now that project is in DB, retry starting the timer time_sheet_widget.tryStartTimer(); + + if (description && description.trim() !== "") { + TimerService.updateActiveTimesheetName(description); + } return true; } @@ -326,7 +345,11 @@ Page { // Handle back navigation with unsaved changes check function handleBackNavigation() { - if (draftHandler.hasUnsavedChanges) { + if (recordid > 0 && recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning() && draftHandler.hasUnsavedChanges) { + // Auto-save changes so work is not lost while timer continues running in background + save_timesheet(); + navigateBack(); + } else if (draftHandler.hasUnsavedChanges) { unsavedChangesDialog.open("timesheet"); } else { navigateBack(); @@ -814,11 +837,14 @@ Page { }); } - // Track inline text changes for draft management + // Track inline text changes for draft management and live timer sync onTextChanged: { if (draftHandler.enabled && draftHandler._initialized) { draftHandler.markFieldChanged("description", getFormattedText()); } + if (recordid > 0 && recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning()) { + TimerService.updateActiveTimesheetName(getFormattedText()); + } } } } @@ -851,10 +877,18 @@ Page { if (draftHandler.enabled) { draftHandler.markFieldChanged("description", description_text.getFormattedText()); } + + if (recordid > 0 && recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning()) { + TimerService.updateActiveTimesheetName(description_text.getFormattedText()); + } } } else { if (!isReadOnly && draftHandler.hasUnsavedChanges) { - draftHandler.saveDraft(); + if (recordid > 0 && recordid === TimerService.getActiveTimesheetId() && TimerService.isRunning()) { + save_timesheet(); + } else { + draftHandler.saveDraft(); + } } } // Don't clear Global.description_temporary_holder when page becomes invisible From 08ee2f7d58352becbfd3b5bf9d06e8cb5afcf14d Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:10:58 +0530 Subject: [PATCH 004/105] style: redesign GlobalTimerWidget with two-line layout and dark theme --- qml/components/system/GlobalTimerWidget.qml | 366 ++++++++++---------- 1 file changed, 179 insertions(+), 187 deletions(-) diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 6e73abe2..e2cb25a6 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -9,10 +9,12 @@ import "../../../models/logger.js" as Logger Rectangle { id: globalTimer - width: units.gu(47) - height: units.gu(8) - color: "#2d2d2d" // semi-transparent dark - radius: units.gu(1) + width: Math.min(parent ? parent.width - units.gu(4) : units.gu(46), units.gu(46)) + height: units.gu(7.2) + color: "#1e222b" + border.color: "#333a46" + border.width: 1 + radius: units.gu(1.6) anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.margins: units.gu(1) @@ -60,8 +62,6 @@ Rectangle { if (!data || !data.event || !isSyncing) return; - // console.log("GlobalTimer: Received sync event:", data.event, "Payload:", data.payload); - switch (data.event) { case "sync_progress": syncProgress = data.payload / 100.0; // Convert to 0.0-1.0 range @@ -104,8 +104,6 @@ Rectangle { // Complete sync successfully function completeSyncSuccessfully() { - // console.log("GlobalTimer: Sync completed successfully for account", syncAccountId); - syncSuccessful = true; syncFailed = false; syncProgress = 1.0; @@ -118,8 +116,6 @@ Rectangle { // Fail sync with error message function failSync(errorMessage) { - //console.log("GlobalTimer: Sync failed for account", syncAccountId, ":", errorMessage); - syncSuccessful = false; syncFailed = true; syncStatusMessage = "❌ " + (errorMessage || "Sync Failed"); @@ -143,8 +139,6 @@ Rectangle { // Function to start sync indication with BackendBridge integration function startSync(accountId, accountName) { - // console.log("GlobalTimer: Starting enhanced sync indication for account", accountId, "("+ accountName + ")"); - syncAccountId = accountId; syncAccountName = accountName || "Account " + accountId; isSyncing = true; @@ -157,9 +151,6 @@ Rectangle { // Enhanced function to stop sync indication function stopSync() { - //console.log("GlobalTimer: Stopping sync indication for account", syncAccountId); - - // Stop auto-hide timer autoHideTimer.stop(); isSyncing = false; @@ -189,19 +180,16 @@ Rectangle { if (currentlyRunning || isSyncing) { globalTimer.visible = true; if (isSyncing && !currentlyRunning) { - // Show enhanced sync status when no timer is running if (syncFailed) { globalTimer.elapsedDisplay = syncStatusMessage + " - " + syncAccountName; } else if (syncSuccessful) { globalTimer.elapsedDisplay = "✅ Sync Complete - " + syncAccountName; } else { - // Show detailed progress message var progressPercent = Math.round(syncProgress * 100); var statusMsg = syncStatusMessage || "Syncing..."; globalTimer.elapsedDisplay = statusMsg + " (" + progressPercent + "%) - " + syncAccountName; } } else if (currentlyRunning) { - // Show timer status (prioritize timer over sync) globalTimer.elapsedDisplay = TimerService.getElapsedTime() + " " + TimerService.getActiveTimesheetName(); } } else { @@ -218,10 +206,8 @@ Rectangle { // Emit paused/resumed signals if (currentlyPaused && !globalTimer.previousPausedState) { globalTimer.timerPaused(); - pausebutton.source = "../../images/play.png"; } else if (!currentlyPaused && globalTimer.previousPausedState) { globalTimer.timerResumed(); - pausebutton.source = "../../images/pause.png"; } // Update previous states @@ -231,243 +217,252 @@ Rectangle { } } - // Animated dot - changes behavior based on sync status + // Animated indicator dot Rectangle { id: indicator - width: units.gu(1.5) - height: units.gu(1.5) - radius: units.gu(.75) + width: units.gu(1.4) + height: units.gu(1.4) + radius: units.gu(0.7) color: { if (isSyncing && !TimerService.isRunning()) { if (syncFailed) - return "#dc3545"; // Red for error + return "#ef4444"; // Red for error if (syncSuccessful) - return "#28a745"; // Green for success - return "#0078d4"; // Blue for syncing + return "#22c55e"; // Green for success + return "#3b82f6"; // Blue for syncing } - return "#ffa500"; // Orange for timer + return TimerService.isPaused() ? "#f59e0b" : "#10b981"; // Amber for paused, Green for running } anchors.left: parent.left - anchors.margins: units.gu(2) + anchors.leftMargin: units.gu(1.5) anchors.verticalCenter: parent.verticalCenter // Pulsing animation SequentialAnimation on opacity { loops: Animation.Infinite - running: globalTimer.visible + running: globalTimer.visible && !TimerService.isPaused() NumberAnimation { from: 0.3 - to: 1 + to: 1.0 duration: { if (isSyncing && !TimerService.isRunning()) { - return syncSuccessful ? 400 : 600; // Faster pulse for success + return syncSuccessful ? 400 : 600; } return 800; } easing.type: Easing.InOutQuad } NumberAnimation { - from: 1 + from: 1.0 to: 0.3 duration: { if (isSyncing && !TimerService.isRunning()) { - return syncSuccessful ? 400 : 600; // Faster pulse for success + return syncSuccessful ? 400 : 600; } return 800; } easing.type: Easing.InOutQuad } } + } - // Add rotating animation for sync status only (not for success) - RotationAnimation on rotation { - running: isSyncing && !TimerService.isRunning() && !syncSuccessful - loops: Animation.Infinite - from: 0 - to: 360 - duration: 2000 - easing.type: Easing.Linear + // Main Content Section (Two-Line Layout) + Column { + id: textContent + anchors.left: indicator.right + anchors.leftMargin: units.gu(1.2) + anchors.right: buttonRow.visible ? buttonRow.left : parent.right + anchors.rightMargin: units.gu(1.2) + anchors.verticalCenter: parent.verticalCenter + spacing: units.gu(0.3) + + // Line 1: Timesheet / Account Title + Label { + id: titleLabel + width: parent.width + text: { + if (TimerService.isRunning()) { + var name = TimerService.getActiveTimesheetName(); + return (name && name.trim() !== "") ? name.trim() : "Active Timesheet"; + } else if (isSyncing) { + return syncAccountName || "Cloud Sync"; + } + return ""; + } + color: "#f3f4f6" + font.pixelSize: units.gu(1.6) + font.weight: Font.DemiBold + elide: Text.ElideRight + maximumLineCount: 1 } - // Scale animation - different for success vs syncing - SequentialAnimation on scale { - running: isSyncing && !TimerService.isRunning() - loops: Animation.Infinite - NumberAnimation { - from: 1.0 - to: syncSuccessful ? 1.5 : 1.3 // Bigger scale for success - duration: syncSuccessful ? 800 : 1000 - easing.type: Easing.InOutQuad + // Line 2: Timer Duration + Status Badge (or Sync Status Message) + Row { + id: subtitleRow + spacing: units.gu(1) + width: parent.width + + // Digital Clock + Label { + id: timerClock + visible: TimerService.isRunning() + text: TimerService.getElapsedTime() + color: TimerService.isPaused() ? "#f59e0b" : "#38bdf8" + font.pixelSize: units.gu(1.8) + font.family: "Ubuntu Mono, DejaVu Sans Mono, monospace" + font.weight: Font.Bold + anchors.verticalCenter: parent.verticalCenter } - NumberAnimation { - from: syncSuccessful ? 1.5 : 1.3 - to: 1.0 - duration: syncSuccessful ? 800 : 1000 - easing.type: Easing.InOutQuad + + // Status Badge (RECORDING / PAUSED) + Rectangle { + id: statusBadge + visible: TimerService.isRunning() + radius: units.gu(0.4) + height: units.gu(1.8) + width: statusBadgeText.implicitWidth + units.gu(1) + color: TimerService.isPaused() ? "#451a03" : "#064e3b" + border.color: TimerService.isPaused() ? "#78350f" : "#047857" + border.width: 1 + anchors.verticalCenter: parent.verticalCenter + + Label { + id: statusBadgeText + anchors.centerIn: parent + text: TimerService.isPaused() ? "PAUSED" : "RECORDING" + font.pixelSize: units.gu(1.0) + font.weight: Font.Bold + color: TimerService.isPaused() ? "#fbbf24" : "#34d399" + } + } + + // Sync Status Subtitle (when syncing without timer) + Label { + id: syncSubtitle + visible: isSyncing && !TimerService.isRunning() + width: parent.width + text: { + if (syncFailed) return syncStatusMessage || "Sync Failed"; + if (syncSuccessful) return "✅ All items up to date"; + var progressPercent = Math.round(syncProgress * 100); + return (syncStatusMessage || "Syncing...") + " (" + progressPercent + "%)"; + } + color: syncFailed ? "#ef4444" : (syncSuccessful ? "#22c55e" : "#9ca3af") + font.pixelSize: units.gu(1.3) + elide: Text.ElideRight + maximumLineCount: 1 + anchors.verticalCenter: parent.verticalCenter } } } - // Play/Pause Button - hide during sync-only mode - Image { - id: pausebutton + // Action Buttons Container + Row { + id: buttonRow + anchors.right: parent.right + anchors.rightMargin: units.gu(1.2) anchors.verticalCenter: parent.verticalCenter - anchors.right: stopbutton.left - anchors.margins: units.gu(1) - width: units.gu(5) - height: units.gu(5) - source: "../../images/pause.png" - fillMode: Image.PreserveAspectFit - + spacing: units.gu(1) visible: !isSyncing || TimerService.isRunning() - MouseArea { - anchors.fill: parent - - onPressed: pausebutton.opacity = 0.5 - onReleased: pausebutton.opacity = 1.0 - onCanceled: pausebutton.opacity = 1.0 + // Pause/Resume Button + Rectangle { + id: pausebutton + width: units.gu(4.2) + height: units.gu(4.2) + radius: width / 2 + color: TimerService.isPaused() ? "#10b981" : "#f59e0b" + + Image { + id: pauseIcon + anchors.centerIn: parent + width: units.gu(2.4) + height: units.gu(2.4) + source: TimerService.isPaused() ? "../../images/play.png" : "../../images/pause.png" + fillMode: Image.PreserveAspectFit + } - onClicked: { - if (TimerService.isPaused()) - TimerService.start(TimerService.getActiveTimesheetId()); - else - TimerService.pause(); + MouseArea { + anchors.fill: parent + onPressed: pausebutton.scale = 0.92 + onReleased: pausebutton.scale = 1.0 + onCanceled: pausebutton.scale = 1.0 + onClicked: { + if (TimerService.isPaused()) + TimerService.start(TimerService.getActiveTimesheetId()); + else + TimerService.pause(); + } } } - } - // Stop Button - hide during sync-only mode - Image { - id: stopbutton - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - anchors.margins: units.gu(1) - width: units.gu(5) - height: units.gu(5) - source: "../../images/stop.png" - fillMode: Image.PreserveAspectFit - visible: !isSyncing || TimerService.isRunning() + // Stop Button + Rectangle { + id: stopbutton + width: units.gu(4.2) + height: units.gu(4.2) + radius: width / 2 + color: "#ef4444" + + Image { + id: stopIcon + anchors.centerIn: parent + width: units.gu(2.4) + height: units.gu(2.4) + source: "../../images/stop.png" + fillMode: Image.PreserveAspectFit + } - MouseArea { - anchors.fill: parent - onPressed: stopbutton.opacity = 0.5 - onReleased: stopbutton.opacity = 1.0 - onCanceled: stopbutton.opacity = 1.0 - onClicked: { - // Show description popup before stopping timer - var activeTimesheetId = TimerService.getActiveTimesheetId(); - var activeTimesheetName = TimerService.getActiveTimesheetName(); - var elapsedTime = TimerService.getElapsedTime(); - - if (activeTimesheetId && activeTimesheetId > 0) { - descriptionPopup.open(activeTimesheetId, activeTimesheetName, elapsedTime); - } else { - // Fallback: just stop the timer if no active timesheet - TimerService.stop(); + MouseArea { + anchors.fill: parent + onPressed: stopbutton.scale = 0.92 + onReleased: stopbutton.scale = 1.0 + onCanceled: stopbutton.scale = 1.0 + onClicked: { + var activeTimesheetId = TimerService.getActiveTimesheetId(); + var activeTimesheetName = TimerService.getActiveTimesheetName(); + var elapsedTime = TimerService.getElapsedTime(); + + if (activeTimesheetId && activeTimesheetId > 0) { + descriptionPopup.open(activeTimesheetId, activeTimesheetName, elapsedTime); + } else { + TimerService.stop(); + } } } } } - // Name Label - Label { - text: isSyncing ? Utils.truncateText(globalTimer.elapsedDisplay, 40) : Utils.truncateText(globalTimer.elapsedDisplay, 20) - color: "white" - font.pixelSize: units.gu(2) - anchors.top: parent.top - anchors.topMargin: units.gu(3) - anchors.left: indicator.right - anchors.verticalCenter: parent.verticalCenter - // anchors.margins: units.gu(-12) - anchors.right: (TimerService.isRunning() || TimerService.isPaused()) ? pausebutton.left : parent.right - anchors.rightMargin: units.gu(1) - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - } - - // Enhanced progress indicator - changes based on timer state + // Progress Bar Indicator at Bottom Rectangle { id: progressContainer visible: isSyncing anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - height: units.gu(0.5) - color: "#333333" - opacity: 0.7 - - // Progress bar background - Rectangle { - id: progressBackground - anchors.fill: parent - color: { - if (TimerService.isRunning()) { - return TimerService.isPaused() ? "#ff6b35" : "#ffa500"; // Orange/red for timer - } else if (isSyncing) { - return syncSuccessful ? "#28a745" : "#0078d4"; // Green for success, blue for syncing - } - return "#0078d4"; - } - opacity: 0.3 - } + anchors.bottomMargin: units.gu(0.2) + anchors.leftMargin: units.gu(1) + anchors.rightMargin: units.gu(1) + height: units.gu(0.4) + radius: units.gu(0.2) + color: "#111827" + clip: true - // Animated progress indicator Rectangle { id: progressIndicator anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom - width: { - if (isSyncing) { - // For sync: show actual progress - return parent.width * syncProgress; - } - return 0; - } - color: { - if (isSyncing) { - return syncSuccessful ? "#28a745" : "#ffffff"; // Green for success, white for syncing - } - return "#ffffff"; - } - opacity: 0.9 - - // Sliding animation for timer mode only - SequentialAnimation on x { - running: progressContainer.visible && TimerService.isRunning() - loops: Animation.Infinite - NumberAnimation { - from: -progressIndicator.width - to: progressContainer.width - duration: TimerService.isPaused() ? 3000 : 2000 // Slower when paused - easing.type: Easing.InOutQuad - } - } + width: isSyncing ? parent.width * syncProgress : 0 + radius: parent.radius + color: syncSuccessful ? "#22c55e" : (syncFailed ? "#ef4444" : "#3b82f6") - // Smooth width animation for sync progress Behavior on width { NumberAnimation { duration: 200 easing.type: Easing.OutQuad } } - - // Success completion animation - SequentialAnimation on opacity { - running: syncSuccessful - loops: 3 // Flash 3 times when successful - NumberAnimation { - from: 0.9 - to: 0.3 - duration: 200 - } - NumberAnimation { - from: 0.3 - to: 0.9 - duration: 200 - } - } } } @@ -480,13 +475,11 @@ Rectangle { onSaved: function (description, status) { Logger.debug("GlobalTimerWidget", "Timesheet description saved:", description, "Status:", status) - // Stop the timer after saving TimerService.stop(); } onFinalized: function (success, message) { Logger.debug("GlobalTimerWidget", "Timesheet finalized:", success, "Message:", message) - // Show notification if function is available if (globalTimer.showNotification) { if (success) { globalTimer.showNotification("Success", message, "success"); @@ -498,7 +491,6 @@ Rectangle { onCancelled: { Logger.debug("GlobalTimerWidget", "Description popup cancelled - timer continues running") - // Don't stop timer if user cancels } } } \ No newline at end of file From d42bad512ec504b0fa0beb4f148323e632998432 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:14:25 +0530 Subject: [PATCH 005/105] fix: make GlobalTimerWidget reactive and restore crisp action button icons --- qml/components/system/GlobalTimerWidget.qml | 141 ++++++++++---------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index e2cb25a6..0d159e0a 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -10,7 +10,7 @@ import "../../../models/logger.js" as Logger Rectangle { id: globalTimer width: Math.min(parent ? parent.width - units.gu(4) : units.gu(46), units.gu(46)) - height: units.gu(7.2) + height: units.gu(7.5) color: "#1e222b" border.color: "#333a46" border.width: 1 @@ -21,6 +21,11 @@ Rectangle { z: 999 property string elapsedDisplay: "" + property string activeTitle: "Active Timesheet" + property string activeTime: "00:00:00" + property bool isTimerRunning: false + property bool isTimerPaused: false + signal timerStopped signal timerStarted signal timerPaused @@ -42,6 +47,22 @@ Rectangle { // BackendBridge for real-time sync communication (connect to global bridge) property var backendBridge: null + // Public function to immediately sync UI state with TimerService + function refreshDisplay() { + const currentlyRunning = TimerService.isRunning(); + const currentlyPaused = TimerService.isPaused(); + isTimerRunning = currentlyRunning; + isTimerPaused = currentlyPaused; + if (currentlyRunning) { + var rawName = TimerService.getActiveTimesheetName(); + activeTitle = (rawName && rawName.trim() !== "") ? rawName.trim() : "Active Timesheet"; + activeTime = TimerService.getElapsedTime(); + globalTimer.visible = true; + } else if (!isSyncing) { + globalTimer.visible = false; + } + } + // Connect to the global backend bridge when available Component.onCompleted: { // Try to find the global backend bridge @@ -55,6 +76,7 @@ Rectangle { break; } } + refreshDisplay(); } // Handle sync events from Python backend @@ -176,6 +198,15 @@ Rectangle { const currentlyPaused = TimerService.isPaused(); const currentTimesheetId = TimerService.getActiveTimesheetId() !== null ? TimerService.getActiveTimesheetId() : -1; + isTimerRunning = currentlyRunning; + isTimerPaused = currentlyPaused; + + if (currentlyRunning) { + var rawName = TimerService.getActiveTimesheetName(); + activeTitle = (rawName && rawName.trim() !== "") ? rawName.trim() : "Active Timesheet"; + activeTime = TimerService.getElapsedTime(); + } + // Update display and visibility if (currentlyRunning || isSyncing) { globalTimer.visible = true; @@ -190,7 +221,7 @@ Rectangle { globalTimer.elapsedDisplay = statusMsg + " (" + progressPercent + "%) - " + syncAccountName; } } else if (currentlyRunning) { - globalTimer.elapsedDisplay = TimerService.getElapsedTime() + " " + TimerService.getActiveTimesheetName(); + globalTimer.elapsedDisplay = activeTime + " " + activeTitle; } } else { globalTimer.visible = false; @@ -224,14 +255,14 @@ Rectangle { height: units.gu(1.4) radius: units.gu(0.7) color: { - if (isSyncing && !TimerService.isRunning()) { + if (isSyncing && !isTimerRunning) { if (syncFailed) return "#ef4444"; // Red for error if (syncSuccessful) return "#22c55e"; // Green for success return "#3b82f6"; // Blue for syncing } - return TimerService.isPaused() ? "#f59e0b" : "#10b981"; // Amber for paused, Green for running + return isTimerPaused ? "#f59e0b" : "#10b981"; // Amber for paused, Green for running } anchors.left: parent.left anchors.leftMargin: units.gu(1.5) @@ -240,12 +271,12 @@ Rectangle { // Pulsing animation SequentialAnimation on opacity { loops: Animation.Infinite - running: globalTimer.visible && !TimerService.isPaused() + running: globalTimer.visible && !isTimerPaused NumberAnimation { from: 0.3 to: 1.0 duration: { - if (isSyncing && !TimerService.isRunning()) { + if (isSyncing && !isTimerRunning) { return syncSuccessful ? 400 : 600; } return 800; @@ -256,7 +287,7 @@ Rectangle { from: 1.0 to: 0.3 duration: { - if (isSyncing && !TimerService.isRunning()) { + if (isSyncing && !isTimerRunning) { return syncSuccessful ? 400 : 600; } return 800; @@ -280,17 +311,9 @@ Rectangle { Label { id: titleLabel width: parent.width - text: { - if (TimerService.isRunning()) { - var name = TimerService.getActiveTimesheetName(); - return (name && name.trim() !== "") ? name.trim() : "Active Timesheet"; - } else if (isSyncing) { - return syncAccountName || "Cloud Sync"; - } - return ""; - } - color: "#f3f4f6" - font.pixelSize: units.gu(1.6) + text: globalTimer.isTimerRunning ? globalTimer.activeTitle : (globalTimer.isSyncing ? (globalTimer.syncAccountName || "Cloud Sync") : "") + color: "#ffffff" + font.pixelSize: units.gu(1.7) font.weight: Font.DemiBold elide: Text.ElideRight maximumLineCount: 1 @@ -305,10 +328,10 @@ Rectangle { // Digital Clock Label { id: timerClock - visible: TimerService.isRunning() - text: TimerService.getElapsedTime() - color: TimerService.isPaused() ? "#f59e0b" : "#38bdf8" - font.pixelSize: units.gu(1.8) + visible: globalTimer.isTimerRunning + text: globalTimer.activeTime + color: globalTimer.isTimerPaused ? "#f59e0b" : "#38bdf8" + font.pixelSize: units.gu(1.9) font.family: "Ubuntu Mono, DejaVu Sans Mono, monospace" font.weight: Font.Bold anchors.verticalCenter: parent.verticalCenter @@ -317,37 +340,37 @@ Rectangle { // Status Badge (RECORDING / PAUSED) Rectangle { id: statusBadge - visible: TimerService.isRunning() + visible: globalTimer.isTimerRunning radius: units.gu(0.4) height: units.gu(1.8) width: statusBadgeText.implicitWidth + units.gu(1) - color: TimerService.isPaused() ? "#451a03" : "#064e3b" - border.color: TimerService.isPaused() ? "#78350f" : "#047857" + color: globalTimer.isTimerPaused ? "#451a03" : "#064e3b" + border.color: globalTimer.isTimerPaused ? "#78350f" : "#047857" border.width: 1 anchors.verticalCenter: parent.verticalCenter Label { id: statusBadgeText anchors.centerIn: parent - text: TimerService.isPaused() ? "PAUSED" : "RECORDING" + text: globalTimer.isTimerPaused ? "PAUSED" : "RECORDING" font.pixelSize: units.gu(1.0) font.weight: Font.Bold - color: TimerService.isPaused() ? "#fbbf24" : "#34d399" + color: globalTimer.isTimerPaused ? "#fbbf24" : "#34d399" } } // Sync Status Subtitle (when syncing without timer) Label { id: syncSubtitle - visible: isSyncing && !TimerService.isRunning() + visible: globalTimer.isSyncing && !globalTimer.isTimerRunning width: parent.width text: { - if (syncFailed) return syncStatusMessage || "Sync Failed"; - if (syncSuccessful) return "✅ All items up to date"; - var progressPercent = Math.round(syncProgress * 100); - return (syncStatusMessage || "Syncing...") + " (" + progressPercent + "%)"; + if (globalTimer.syncFailed) return globalTimer.syncStatusMessage || "Sync Failed"; + if (globalTimer.syncSuccessful) return "✅ All items up to date"; + var progressPercent = Math.round(globalTimer.syncProgress * 100); + return (globalTimer.syncStatusMessage || "Syncing...") + " (" + progressPercent + "%)"; } - color: syncFailed ? "#ef4444" : (syncSuccessful ? "#22c55e" : "#9ca3af") + color: globalTimer.syncFailed ? "#ef4444" : (globalTimer.syncSuccessful ? "#22c55e" : "#9ca3af") font.pixelSize: units.gu(1.3) elide: Text.ElideRight maximumLineCount: 1 @@ -363,30 +386,21 @@ Rectangle { anchors.rightMargin: units.gu(1.2) anchors.verticalCenter: parent.verticalCenter spacing: units.gu(1) - visible: !isSyncing || TimerService.isRunning() + visible: globalTimer.isTimerRunning // Pause/Resume Button - Rectangle { + Image { id: pausebutton - width: units.gu(4.2) - height: units.gu(4.2) - radius: width / 2 - color: TimerService.isPaused() ? "#10b981" : "#f59e0b" - - Image { - id: pauseIcon - anchors.centerIn: parent - width: units.gu(2.4) - height: units.gu(2.4) - source: TimerService.isPaused() ? "../../images/play.png" : "../../images/pause.png" - fillMode: Image.PreserveAspectFit - } + width: units.gu(4.5) + height: units.gu(4.5) + source: globalTimer.isTimerPaused ? "../../images/play.png" : "../../images/pause.png" + fillMode: Image.PreserveAspectFit MouseArea { anchors.fill: parent - onPressed: pausebutton.scale = 0.92 - onReleased: pausebutton.scale = 1.0 - onCanceled: pausebutton.scale = 1.0 + onPressed: pausebutton.opacity = 0.6 + onReleased: pausebutton.opacity = 1.0 + onCanceled: pausebutton.opacity = 1.0 onClicked: { if (TimerService.isPaused()) TimerService.start(TimerService.getActiveTimesheetId()); @@ -397,27 +411,18 @@ Rectangle { } // Stop Button - Rectangle { + Image { id: stopbutton - width: units.gu(4.2) - height: units.gu(4.2) - radius: width / 2 - color: "#ef4444" - - Image { - id: stopIcon - anchors.centerIn: parent - width: units.gu(2.4) - height: units.gu(2.4) - source: "../../images/stop.png" - fillMode: Image.PreserveAspectFit - } + width: units.gu(4.5) + height: units.gu(4.5) + source: "../../images/stop.png" + fillMode: Image.PreserveAspectFit MouseArea { anchors.fill: parent - onPressed: stopbutton.scale = 0.92 - onReleased: stopbutton.scale = 1.0 - onCanceled: stopbutton.scale = 1.0 + onPressed: stopbutton.opacity = 0.6 + onReleased: stopbutton.opacity = 1.0 + onCanceled: stopbutton.opacity = 1.0 onClicked: { var activeTimesheetId = TimerService.getActiveTimesheetId(); var activeTimesheetName = TimerService.getActiveTimesheetName(); From 8289919c8c94f92bcfd42b19c9f12a3c04d51c66 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:18:03 +0530 Subject: [PATCH 006/105] fix: resolve duplicate bottom layer from ModelDownloadTimerWidget --- qml/app/GlobalWidgets.qml | 3 ++- qml/components/system/GlobalTimerWidget.qml | 17 ++++++++--------- .../system/ModelDownloadTimerWidget.qml | 1 + 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/qml/app/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 8eec9c24..410b5d9c 100644 --- a/qml/app/GlobalWidgets.qml +++ b/qml/app/GlobalWidgets.qml @@ -21,7 +21,7 @@ Item { id: globalTimerWidget z: 999 anchors.bottom: parent.bottom - anchors.bottomMargin: Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0 + anchors.bottomMargin: (Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0) + units.gu(1) visible: false showNotification: function (title, message, type) { notifPopup.open(title, message, type); @@ -33,6 +33,7 @@ Item { id: modelDownloadTimerWidget z: 9999 anchors.bottom: parent.bottom + anchors.bottomMargin: (Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0) + units.gu(1) visible: false showNotification: function (title, message, type) { notifPopup.open(title, message, type); diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 0d159e0a..3f30d73a 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -10,16 +10,15 @@ import "../../../models/logger.js" as Logger Rectangle { id: globalTimer width: Math.min(parent ? parent.width - units.gu(4) : units.gu(46), units.gu(46)) - height: units.gu(7.5) + height: units.gu(7.2) color: "#1e222b" border.color: "#333a46" border.width: 1 radius: units.gu(1.6) - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - anchors.margins: units.gu(1) + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined z: 999 + property bool enableTimesheetTimer: true property string elapsedDisplay: "" property string activeTitle: "Active Timesheet" property string activeTime: "00:00:00" @@ -49,8 +48,8 @@ Rectangle { // Public function to immediately sync UI state with TimerService function refreshDisplay() { - const currentlyRunning = TimerService.isRunning(); - const currentlyPaused = TimerService.isPaused(); + const currentlyRunning = enableTimesheetTimer ? TimerService.isRunning() : false; + const currentlyPaused = enableTimesheetTimer ? TimerService.isPaused() : false; isTimerRunning = currentlyRunning; isTimerPaused = currentlyPaused; if (currentlyRunning) { @@ -184,7 +183,7 @@ Rectangle { syncStatusMessage = ""; // Hide if no timer is running either - if (!TimerService.isRunning()) { + if (!enableTimesheetTimer || !TimerService.isRunning()) { globalTimer.visible = false; } } @@ -194,8 +193,8 @@ Rectangle { running: true repeat: true onTriggered: { - const currentlyRunning = TimerService.isRunning(); - const currentlyPaused = TimerService.isPaused(); + const currentlyRunning = enableTimesheetTimer ? TimerService.isRunning() : false; + const currentlyPaused = enableTimesheetTimer ? TimerService.isPaused() : false; const currentTimesheetId = TimerService.getActiveTimesheetId() !== null ? TimerService.getActiveTimesheetId() : -1; isTimerRunning = currentlyRunning; diff --git a/qml/components/system/ModelDownloadTimerWidget.qml b/qml/components/system/ModelDownloadTimerWidget.qml index a570910d..279ccd31 100644 --- a/qml/components/system/ModelDownloadTimerWidget.qml +++ b/qml/components/system/ModelDownloadTimerWidget.qml @@ -3,6 +3,7 @@ import Lomiri.Components 1.3 GlobalTimerWidget { id: downloadWidget + enableTimesheetTimer: false // Override completion logic to connect to our specific download events Component.onCompleted: { From 4b4ab09c2edcd547055da6e42bf30603ee386b37 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:24:54 +0530 Subject: [PATCH 007/105] asset: upgrade play, pause, and stop icons to crisp vector and high-DPI assets --- qml/images/pause.png | Bin 34899 -> 3302 bytes qml/images/pause.svg | 5 +++++ qml/images/play (1).png | Bin 12976 -> 3580 bytes qml/images/play.png | Bin 17633 -> 3580 bytes qml/images/play.svg | 4 ++++ qml/images/stop.png | Bin 28387 -> 3170 bytes qml/images/stop.svg | 4 ++++ 7 files changed, 13 insertions(+) create mode 100644 qml/images/pause.svg create mode 100644 qml/images/play.svg create mode 100644 qml/images/stop.svg diff --git a/qml/images/pause.png b/qml/images/pause.png index 4ff3a606bd1f45b049afa79578a1e3cb8247c5c6..c2e09c219851852848aef4e8f89c25869d7752a5 100644 GIT binary patch literal 3302 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�a>GXJ8yyWVxG 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 diff --git a/qml/images/pause.svg b/qml/images/pause.svg new file mode 100644 index 00000000..1df8c309 --- /dev/null +++ b/qml/images/pause.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/qml/images/play (1).png b/qml/images/play (1).png index 8f4f0cd801771b35b44e66ceaf27e324eb53e6dc..65f6d5c033e267bc7f06a74c9cc6ccec0567d493 100644 GIT binary patch literal 3580 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 bw=eQ^7h%w?y9HPX{8Ca-L+9Qx4g9|VhKPL1 diff --git a/qml/images/play.svg b/qml/images/play.svg new file mode 100644 index 00000000..2a5d692e --- /dev/null +++ b/qml/images/play.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/qml/images/stop.png b/qml/images/stop.png index 62d5b7e5de15f7daf8e2a771411d893658af62af..d69fca97901d4b064652469181814f51337edb36 100644 GIT binary patch literal 3170 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 IM6N<$f(|6W?EnA( literal 28387 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 diff --git a/qml/images/stop.svg b/qml/images/stop.svg new file mode 100644 index 00000000..5bd79d3f --- /dev/null +++ b/qml/images/stop.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 1ad6891cd6e6971210d30971da8643b5f356cb09 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:29:57 +0530 Subject: [PATCH 008/105] style: apply Ubuntu Touch native warm dark palette to GlobalTimerWidget --- qml/components/system/GlobalTimerWidget.qml | 76 +++++++++------------ 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 3f30d73a..0f96f008 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -11,10 +11,10 @@ Rectangle { id: globalTimer width: Math.min(parent ? parent.width - units.gu(4) : units.gu(46), units.gu(46)) height: units.gu(7.2) - color: "#1e222b" - border.color: "#333a46" + color: "#262626" + border.color: "#3d3d3d" border.width: 1 - radius: units.gu(1.6) + radius: units.gu(1.2) anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined z: 999 @@ -250,18 +250,18 @@ Rectangle { // Animated indicator dot Rectangle { id: indicator - width: units.gu(1.4) - height: units.gu(1.4) - radius: units.gu(0.7) + width: units.gu(1.3) + height: units.gu(1.3) + radius: units.gu(0.65) color: { if (isSyncing && !isTimerRunning) { if (syncFailed) - return "#ef4444"; // Red for error + return "#DF382C"; // Ubuntu Red if (syncSuccessful) - return "#22c55e"; // Green for success - return "#3b82f6"; // Blue for syncing + return "#38B44A"; // Ubuntu Green + return "#19B6EE"; // Ubuntu Light Blue } - return isTimerPaused ? "#f59e0b" : "#10b981"; // Amber for paused, Green for running + return isTimerPaused ? "#AEA79F" : "#38B44A"; // Warm gray when paused, Ubuntu green when recording } anchors.left: parent.left anchors.leftMargin: units.gu(1.5) @@ -304,24 +304,24 @@ Rectangle { anchors.right: buttonRow.visible ? buttonRow.left : parent.right anchors.rightMargin: units.gu(1.2) anchors.verticalCenter: parent.verticalCenter - spacing: units.gu(0.3) + spacing: units.gu(0.2) // Line 1: Timesheet / Account Title Label { id: titleLabel width: parent.width text: globalTimer.isTimerRunning ? globalTimer.activeTitle : (globalTimer.isSyncing ? (globalTimer.syncAccountName || "Cloud Sync") : "") - color: "#ffffff" + color: "#FFFFFF" font.pixelSize: units.gu(1.7) font.weight: Font.DemiBold elide: Text.ElideRight maximumLineCount: 1 } - // Line 2: Timer Duration + Status Badge (or Sync Status Message) + // Line 2: Timer Duration + Status (or Sync Status Message) Row { id: subtitleRow - spacing: units.gu(1) + spacing: units.gu(0.8) width: parent.width // Digital Clock @@ -329,33 +329,22 @@ Rectangle { id: timerClock visible: globalTimer.isTimerRunning text: globalTimer.activeTime - color: globalTimer.isTimerPaused ? "#f59e0b" : "#38bdf8" - font.pixelSize: units.gu(1.9) - font.family: "Ubuntu Mono, DejaVu Sans Mono, monospace" - font.weight: Font.Bold + color: "#FFFFFF" + font.pixelSize: units.gu(1.7) + font.family: "Ubuntu, DejaVu Sans, sans-serif" + font.weight: Font.Normal anchors.verticalCenter: parent.verticalCenter } - // Status Badge (RECORDING / PAUSED) - Rectangle { - id: statusBadge + // Clean Status Text (No artificial colored box badges) + Label { + id: statusText visible: globalTimer.isTimerRunning - radius: units.gu(0.4) - height: units.gu(1.8) - width: statusBadgeText.implicitWidth + units.gu(1) - color: globalTimer.isTimerPaused ? "#451a03" : "#064e3b" - border.color: globalTimer.isTimerPaused ? "#78350f" : "#047857" - border.width: 1 + text: "• " + (globalTimer.isTimerPaused ? "Paused" : "Recording") + color: globalTimer.isTimerPaused ? "#E95420" : "#AEA79F" + font.pixelSize: units.gu(1.4) + font.weight: Font.Normal anchors.verticalCenter: parent.verticalCenter - - Label { - id: statusBadgeText - anchors.centerIn: parent - text: globalTimer.isTimerPaused ? "PAUSED" : "RECORDING" - font.pixelSize: units.gu(1.0) - font.weight: Font.Bold - color: globalTimer.isTimerPaused ? "#fbbf24" : "#34d399" - } } // Sync Status Subtitle (when syncing without timer) @@ -369,7 +358,7 @@ Rectangle { var progressPercent = Math.round(globalTimer.syncProgress * 100); return (globalTimer.syncStatusMessage || "Syncing...") + " (" + progressPercent + "%)"; } - color: globalTimer.syncFailed ? "#ef4444" : (globalTimer.syncSuccessful ? "#22c55e" : "#9ca3af") + color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#AEA79F") font.pixelSize: units.gu(1.3) elide: Text.ElideRight maximumLineCount: 1 @@ -390,8 +379,8 @@ Rectangle { // Pause/Resume Button Image { id: pausebutton - width: units.gu(4.5) - height: units.gu(4.5) + width: units.gu(4.4) + height: units.gu(4.4) source: globalTimer.isTimerPaused ? "../../images/play.png" : "../../images/pause.png" fillMode: Image.PreserveAspectFit @@ -412,8 +401,8 @@ Rectangle { // Stop Button Image { id: stopbutton - width: units.gu(4.5) - height: units.gu(4.5) + width: units.gu(4.4) + height: units.gu(4.4) source: "../../images/stop.png" fillMode: Image.PreserveAspectFit @@ -449,7 +438,7 @@ Rectangle { anchors.rightMargin: units.gu(1) height: units.gu(0.4) radius: units.gu(0.2) - color: "#111827" + color: "#1a1a1a" clip: true Rectangle { @@ -459,7 +448,7 @@ Rectangle { anchors.bottom: parent.bottom width: isSyncing ? parent.width * syncProgress : 0 radius: parent.radius - color: syncSuccessful ? "#22c55e" : (syncFailed ? "#ef4444" : "#3b82f6") + color: syncSuccessful ? "#38B44A" : (syncFailed ? "#DF382C" : "#E95420") Behavior on width { NumberAnimation { @@ -496,5 +485,4 @@ Rectangle { onCancelled: { Logger.debug("GlobalTimerWidget", "Description popup cancelled - timer continues running") } - } } \ No newline at end of file From 49998878197e1dd889b6625b6efe00373942a153 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:31:22 +0530 Subject: [PATCH 009/105] fix(qml): restore missing closing brace in GlobalTimerWidget --- qml/components/system/GlobalTimerWidget.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 0f96f008..d062b059 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -485,4 +485,5 @@ Rectangle { onCancelled: { Logger.debug("GlobalTimerWidget", "Description popup cancelled - timer continues running") } + } } \ No newline at end of file From db10955d28fc1ae2b10740a0a854aaa33295980d Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:37:37 +0530 Subject: [PATCH 010/105] feat(ui): enhance Sync UI with rotating sync icon, live percentage badge, and refined progress bar --- qml/components/system/GlobalTimerWidget.qml | 174 +++++++++++++------- 1 file changed, 111 insertions(+), 63 deletions(-) diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index d062b059..ccbed5b1 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -247,61 +247,79 @@ Rectangle { } } - // Animated indicator dot - Rectangle { - id: indicator - width: units.gu(1.3) - height: units.gu(1.3) - radius: units.gu(0.65) - color: { - if (isSyncing && !isTimerRunning) { - if (syncFailed) - return "#DF382C"; // Ubuntu Red - if (syncSuccessful) - return "#38B44A"; // Ubuntu Green - return "#19B6EE"; // Ubuntu Light Blue - } - return isTimerPaused ? "#AEA79F" : "#38B44A"; // Warm gray when paused, Ubuntu green when recording - } + // Left Indicator: Pulsing Dot (for Timer) or Animated Sync Icon (for Sync) + Item { + id: leftIconContainer + width: units.gu(2.4) + height: units.gu(2.4) anchors.left: parent.left anchors.leftMargin: units.gu(1.5) anchors.verticalCenter: parent.verticalCenter - // Pulsing animation - SequentialAnimation on opacity { - loops: Animation.Infinite - running: globalTimer.visible && !isTimerPaused - NumberAnimation { - from: 0.3 - to: 1.0 - duration: { - if (isSyncing && !isTimerRunning) { - return syncSuccessful ? 400 : 600; - } - return 800; - } - easing.type: Easing.InOutQuad + // Pulsing Dot (Visible when timer is running) + Rectangle { + id: timerIndicatorDot + visible: globalTimer.isTimerRunning + anchors.centerIn: parent + width: units.gu(1.3) + height: units.gu(1.3) + radius: units.gu(0.65) + color: globalTimer.isTimerPaused ? "#AEA79F" : "#38B44A" + + SequentialAnimation on opacity { + loops: Animation.Infinite + running: globalTimer.visible && globalTimer.isTimerRunning && !globalTimer.isTimerPaused + 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 } } - NumberAnimation { - from: 1.0 - to: 0.3 - duration: { - if (isSyncing && !isTimerRunning) { - return syncSuccessful ? 400 : 600; - } - return 800; - } - easing.type: Easing.InOutQuad + } + + // Rotating Sync Icon (Visible when syncing without timer) + Image { + id: syncIcon + visible: globalTimer.isSyncing && !globalTimer.isTimerRunning && !globalTimer.syncSuccessful && !globalTimer.syncFailed + anchors.fill: parent + source: "../../images/refresh.svg" + fillMode: Image.PreserveAspectFit + + RotationAnimation on rotation { + loops: Animation.Infinite + running: globalTimer.visible && globalTimer.isSyncing && !globalTimer.isTimerRunning && !globalTimer.syncSuccessful && !globalTimer.syncFailed + from: 0 + to: 360 + duration: 1200 } } + + // Success Icon + Label { + id: syncSuccessIcon + visible: globalTimer.isSyncing && !globalTimer.isTimerRunning && globalTimer.syncSuccessful + anchors.centerIn: parent + text: "✓" + color: "#38B44A" + font.pixelSize: units.gu(2.2) + font.weight: Font.Bold + } + + // Error Icon + Label { + id: syncErrorIcon + visible: globalTimer.isSyncing && !globalTimer.isTimerRunning && globalTimer.syncFailed + anchors.centerIn: parent + text: "!" + color: "#DF382C" + font.pixelSize: units.gu(2.0) + font.weight: Font.Bold + } } // Main Content Section (Two-Line Layout) Column { id: textContent - anchors.left: indicator.right + anchors.left: leftIconContainer.right anchors.leftMargin: units.gu(1.2) - anchors.right: buttonRow.visible ? buttonRow.left : parent.right + anchors.right: (globalTimer.isTimerRunning ? buttonRow.left : (globalTimer.isSyncing ? syncRightBadge.left : parent.right)) anchors.rightMargin: units.gu(1.2) anchors.verticalCenter: parent.verticalCenter spacing: units.gu(0.2) @@ -310,7 +328,14 @@ Rectangle { Label { id: titleLabel width: parent.width - text: globalTimer.isTimerRunning ? globalTimer.activeTitle : (globalTimer.isSyncing ? (globalTimer.syncAccountName || "Cloud Sync") : "") + text: { + if (globalTimer.isTimerRunning) { + return globalTimer.activeTitle; + } else if (globalTimer.isSyncing) { + return globalTimer.syncAccountName ? ("Syncing " + globalTimer.syncAccountName) : "Cloud Sync"; + } + return ""; + } color: "#FFFFFF" font.pixelSize: units.gu(1.7) font.weight: Font.DemiBold @@ -324,7 +349,7 @@ Rectangle { spacing: units.gu(0.8) width: parent.width - // Digital Clock + // Digital Clock (Timer mode) Label { id: timerClock visible: globalTimer.isTimerRunning @@ -336,7 +361,7 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter } - // Clean Status Text (No artificial colored box badges) + // Clean Status Text (Timer mode) Label { id: statusText visible: globalTimer.isTimerRunning @@ -347,18 +372,17 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter } - // Sync Status Subtitle (when syncing without timer) + // Sync Status Subtitle (Sync mode) Label { id: syncSubtitle 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"; - var progressPercent = Math.round(globalTimer.syncProgress * 100); - return (globalTimer.syncStatusMessage || "Syncing...") + " (" + progressPercent + "%)"; + if (globalTimer.syncFailed) return globalTimer.syncStatusMessage || "Sync failed"; + if (globalTimer.syncSuccessful) return "All items up to date"; + return globalTimer.syncStatusMessage || "Synchronizing..."; } - color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#AEA79F") + color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#D0CBC5") font.pixelSize: units.gu(1.3) elide: Text.ElideRight maximumLineCount: 1 @@ -367,7 +391,31 @@ Rectangle { } } - // Action Buttons Container + // Right Side Sync Status / Percentage Badge (Sync mode) + Item { + id: syncRightBadge + visible: globalTimer.isSyncing && !globalTimer.isTimerRunning + anchors.right: parent.right + anchors.rightMargin: units.gu(1.5) + anchors.verticalCenter: parent.verticalCenter + width: syncProgressText.implicitWidth + units.gu(1.5) + height: units.gu(3.2) + + Label { + id: syncProgressText + anchors.centerIn: parent + text: { + if (globalTimer.syncFailed) return "FAILED"; + if (globalTimer.syncSuccessful) return "DONE"; + return Math.round(globalTimer.syncProgress * 100) + "%"; + } + color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#19B6EE") + font.pixelSize: (globalTimer.syncFailed || globalTimer.syncSuccessful) ? units.gu(1.3) : units.gu(1.7) + font.weight: Font.Bold + } + } + + // Action Buttons Container (Timer mode) Row { id: buttonRow anchors.right: parent.right @@ -426,19 +474,19 @@ Rectangle { } } - // Progress Bar Indicator at Bottom + // Integrated Bottom Progress Bar Rectangle { id: progressContainer - visible: isSyncing + visible: globalTimer.isSyncing anchors.bottom: parent.bottom + anchors.bottomMargin: units.gu(0.5) anchors.left: parent.left + anchors.leftMargin: units.gu(1.5) anchors.right: parent.right - anchors.bottomMargin: units.gu(0.2) - anchors.leftMargin: units.gu(1) - anchors.rightMargin: units.gu(1) - height: units.gu(0.4) + anchors.rightMargin: units.gu(1.5) + height: units.gu(0.35) radius: units.gu(0.2) - color: "#1a1a1a" + color: "#1c1c1c" clip: true Rectangle { @@ -446,14 +494,14 @@ Rectangle { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom - width: isSyncing ? parent.width * syncProgress : 0 + width: globalTimer.isSyncing ? (parent.width * Math.max(0.02, Math.min(1.0, globalTimer.syncProgress))) : 0 radius: parent.radius - color: syncSuccessful ? "#38B44A" : (syncFailed ? "#DF382C" : "#E95420") + color: globalTimer.syncSuccessful ? "#38B44A" : (globalTimer.syncFailed ? "#DF382C" : "#19B6EE") Behavior on width { NumberAnimation { - duration: 200 - easing.type: Easing.OutQuad + duration: 250 + easing.type: Easing.OutCubic } } } From 8282de9c434c22085df81fe6dc7fa4afdb7a7430 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 13:47:33 +0530 Subject: [PATCH 011/105] Color fixes for Sync-GlobalTimerWidget --- qml/components/system/GlobalTimerWidget.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index ccbed5b1..89c03137 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -409,7 +409,7 @@ Rectangle { if (globalTimer.syncSuccessful) return "DONE"; return Math.round(globalTimer.syncProgress * 100) + "%"; } - color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#19B6EE") + color: globalTimer.syncFailed ? "#DF382C" : (globalTimer.syncSuccessful ? "#38B44A" : "#E95420") font.pixelSize: (globalTimer.syncFailed || globalTimer.syncSuccessful) ? units.gu(1.3) : units.gu(1.7) font.weight: Font.Bold } @@ -496,7 +496,7 @@ Rectangle { anchors.bottom: parent.bottom width: globalTimer.isSyncing ? (parent.width * Math.max(0.02, Math.min(1.0, globalTimer.syncProgress))) : 0 radius: parent.radius - color: globalTimer.syncSuccessful ? "#38B44A" : (globalTimer.syncFailed ? "#DF382C" : "#19B6EE") + color: globalTimer.syncSuccessful ? "#38B44A" : (globalTimer.syncFailed ? "#DF382C" : "#E95420") Behavior on width { NumberAnimation { From aba70e6a2327672e26de75e66a5dcfa568a052e2 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 14:51:07 +0530 Subject: [PATCH 012/105] feat(scripts): add linting and import validation scripts with pre-commit hook setup --- scripts/check_imports.py | 97 ++++++++++++++ scripts/find_unused_timesheet_functions.sh | 20 --- scripts/lint.sh | 147 +++++++++++++++++++++ scripts/setup_hooks.sh | 51 +++++++ 4 files changed, 295 insertions(+), 20 deletions(-) create mode 100755 scripts/check_imports.py delete mode 100644 scripts/find_unused_timesheet_functions.sh create mode 100755 scripts/lint.sh create mode 100755 scripts/setup_hooks.sh diff --git a/scripts/check_imports.py b/scripts/check_imports.py new file mode 100755 index 00000000..da628a9d --- /dev/null +++ b/scripts/check_imports.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +QML and JavaScript Import & Symbol Validator. +Validates: +1. Required module imports in .qml files (ensures file is not missing QtQuick/Lomiri). +2. Existence of relative imported files and folders (e.g. import "../../models/foo.js"). +3. Usage of common namespace aliases (TimerService, Utils, Logger, etc.) without import. +""" + +import sys +import os +import re + +KNOWN_SERVICES = [ + "TimerService", + "Utils", + "Logger", + "DraftManager", + "MainModel", + "NavigationRoutes" +] + +def validate_file(filepath): + errors = [] + if not os.path.isfile(filepath): + return [f"File not found: {filepath}"] + + try: + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + except Exception as e: + return [f"Could not read file: {e}"] + + file_dir = os.path.dirname(os.path.abspath(filepath)) + + # Check 1: Missing base imports in .qml files + if filepath.endswith(".qml"): + import_lines = re.findall(r"^\s*import\s+.+$", content, re.MULTILINE) + has_root_object = re.search(r"^\s*(?:[A-Z]\w+|Item|Rectangle|Page|Component|QtObject|Column|Row)\s*\{", content, re.MULTILINE) + if not import_lines and has_root_object: + errors.append("File contains QML objects but has NO import statements (missing QtQuick/Lomiri imports).") + + # Check 2: Relative file/folder imports in QML + # Matches: import "path" or import "path" as Alias or import "../path" + qml_imports = re.findall(r"^\s*import\s+[\"']([^\"']+)[\"'](?:\s+as\s+(\w+))?", content, re.MULTILINE) + for rel_path, alias in qml_imports: + target_path = os.path.normpath(os.path.join(file_dir, rel_path)) + if not os.path.exists(target_path): + errors.append(f"Import path not found: \"{rel_path}\" (resolved to: {target_path})") + + # Check 3: Relative .import in JavaScript files (.pragma library) + # Matches: .import "path" as Alias + js_imports = re.findall(r"^\s*\.import\s+[\"']([^\"']+)[\"']\s+as\s+(\w+)", content, re.MULTILINE) + for rel_path, alias in js_imports: + target_path = os.path.normpath(os.path.join(file_dir, rel_path)) + if not os.path.exists(target_path): + errors.append(f"JS pragma import not found: \"{rel_path}\" (resolved to: {target_path})") + + # Check 4: Unimported service aliases + for service in KNOWN_SERVICES: + # Check if service is called (e.g. TimerService.start()) + if re.search(r"\b" + service + r"\.", content): + # Check if imported in QML or JS + qml_alias = re.search(r"import\s+.*?as\s+" + service + r"\b", content) + js_alias = re.search(r"\.import\s+.*?as\s+" + service + r"\b", content) + # Or defined in the file itself (e.g. var TimerService = ...) + local_decl = re.search(r"\b(?:var|let|const|function|property\s+var)\s+" + service + r"\b", content) + if not qml_alias and not js_alias and not local_decl: + # Exclude file if it's the actual implementation file of that service + base_name = os.path.splitext(os.path.basename(filepath))[0] + if base_name.lower() not in service.lower(): + errors.append(f"Identifier \"{service}\" is used, but missing \"import ... as {service}\"") + + return errors + +def main(): + if len(sys.argv) < 2: + print("Usage: check_imports.py [file2...]") + sys.exit(1) + + has_errors = False + for filepath in sys.argv[1:]: + if not filepath.endswith((".qml", ".js")): + continue + errs = validate_file(filepath) + if errs: + has_errors = True + print(f"[FAIL] Import check failed in {filepath}:") + for err in errs: + print(f" - {err}") + + if has_errors: + sys.exit(1) + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/scripts/find_unused_timesheet_functions.sh b/scripts/find_unused_timesheet_functions.sh deleted file mode 100644 index f7a6f682..00000000 --- a/scripts/find_unused_timesheet_functions.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="${1:-.}" -file="$root/models/timesheet.js" - -if [[ ! -f "$file" ]]; then - echo "Missing file: $file" >&2 - exit 1 -fi - -# List function names with line numbers, then report usage outside timesheet.js. -grep -n "^function " "$file" | while IFS=: read -r line rest; do - name=$(echo "$rest" | sed -E 's/^function ([^(]+).*/\1/') - if grep -R --exclude="$(basename "$file")" -n "${name}(" "$root" >/dev/null; then - echo "USED: $name ($file:$line)" - else - echo "UNUSED: $name ($file:$line)" - fi -done diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 00000000..d21864b5 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ============================================================================== +# Local Linting & Syntax Verification Script +# Read-only verification for QML, JavaScript, and Python files. +# Never modifies files or causes merge conflicts. +# ============================================================================== + +set -o pipefail + +# Text formatting +if [ -t 1 ]; then + BOLD="\033[1m" + GREEN="\033[32m" + RED="\033[31m" + YELLOW="\033[33m" + BLUE="\033[34m" + RESET="\033[0m" +else + BOLD="" + GREEN="" + RED="" + YELLOW="" + BLUE="" + RESET="" +fi + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +ERRORS=0 +CHECKED=0 +FAILED_FILES=() + +# Check for qmllint +if ! command -v qmllint &> /dev/null; then + echo -e "${RED}[ERROR] 'qmllint' is not installed or not in PATH.${RESET}" + echo "Install it via your Qt/Ubuntu development packages (e.g. qtdeclarative5-dev-tools or qml-tools)." + exit 1 +fi + +IMPORT_CHECKER="$PROJECT_ROOT/scripts/check_imports.py" + +lint_qml_js() { + local file="$1" + ((CHECKED++)) + local file_failed=0 + + # 1. Grammar & Bracket Syntax check via qmllint + local qml_out + qml_out=$(qmllint -I qml -I models -I qml/components/system "$file" 2>&1) + local qml_status=$? + if [ $qml_status -ne 0 ] || [ -n "$qml_out" ]; then + echo -e "${RED}[FAIL] Syntax Error:${RESET} $file" + if [ -n "$qml_out" ]; then + echo "$qml_out" | sed 's/^/ /' + fi + file_failed=1 + fi + + # 2. Deep Import & Symbol validation via check_imports.py + if [ -x "$IMPORT_CHECKER" ]; then + local imp_out + imp_out=$("$IMPORT_CHECKER" "$file" 2>&1) + local imp_status=$? + if [ $imp_status -ne 0 ]; then + echo -e "${RED}[FAIL] Import Error:${RESET} $file" + if [ -n "$imp_out" ]; then + echo "$imp_out" | sed 's/^/ /' + fi + file_failed=1 + fi + fi + + if [ $file_failed -eq 1 ]; then + ((ERRORS++)) + FAILED_FILES+=("$file") + fi +} + +lint_python() { + local file="$1" + ((CHECKED++)) + local output + output=$(python3 -m py_compile "$file" 2>&1) + local status=$? + if [ $status -ne 0 ]; then + echo -e "${RED}[FAIL] Python Syntax Error:${RESET} $file" + if [ -n "$output" ]; then + echo "$output" | sed 's/^/ /' + fi + ((ERRORS++)) + FAILED_FILES+=("$file") + fi +} + +echo -e "${BOLD}${BLUE}[INFO] Starting codebase linting...${RESET}\n" + +# If specific files were passed as arguments, check only those +if [ "$#" -gt 0 ]; then + for file in "$@"; do + if [ ! -f "$file" ]; then + echo -e "${YELLOW}[WARN] Skipping non-existent file: $file${RESET}" + continue + fi + + case "$file" in + *.qml|*.js) + lint_qml_js "$file" + ;; + *.py) + lint_python "$file" + ;; + *) + # Non-lintable file, skip silently + ;; + esac + done +else + # Full codebase scan (excluding build, .git, .clickable, .agent) + while IFS= read -r -d '' file; do + lint_qml_js "$file" + done < <(find qml models -type f \( -name "*.qml" -o -name "*.js" \) -not -path "*/build/*" -print0) + + while IFS= read -r -d '' file; do + lint_python "$file" + done < <(find . -maxdepth 2 -type f -name "*.py" -not -path "*/build/*" -not -path "*/.git/*" -not -path "*/.clickable/*" -not -path "*/.agent/*" -print0) + + if [ -d "src" ]; then + while IFS= read -r -d '' file; do + lint_python "$file" + done < <(find src -type f -name "*.py" -not -path "*/__pycache__/*" -print0) + fi +fi + +echo "" +echo "------------------------------------------------------------" +if [ $ERRORS -eq 0 ]; then + echo -e "${GREEN}${BOLD}[SUCCESS] All $CHECKED files passed syntax and import validation.${RESET}" + exit 0 +else + echo -e "${RED}${BOLD}[ERROR] Linting failed: $ERRORS error(s) found across $CHECKED checked files.${RESET}" + echo -e "${RED}Failed files:${RESET}" + for failed in "${FAILED_FILES[@]}"; do + echo -e " - $failed" + done + exit 1 +fi diff --git a/scripts/setup_hooks.sh b/scripts/setup_hooks.sh new file mode 100755 index 00000000..689b9efd --- /dev/null +++ b/scripts/setup_hooks.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# ============================================================================== +# Git Pre-commit Hook Setup Script +# Installs local pre-commit hook to lint only staged files before committing. +# ============================================================================== + +set -e + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK_DEST="$PROJECT_ROOT/.git/hooks/pre-commit" + +if [ ! -d "$PROJECT_ROOT/.git" ]; then + echo "[ERROR] Not a git repository." + exit 1 +fi + +cat << 'EOF' > "$HOOK_DEST" +#!/usr/bin/env bash +# ============================================================================== +# Git Pre-commit Hook: Lint Staged Files Only (Read-Only) +# Blocks commit if syntax or import errors are detected in staged files. +# ============================================================================== + +PROJECT_ROOT="$(git rev-parse --show-toplevel)" +LINT_SCRIPT="$PROJECT_ROOT/scripts/lint.sh" + +if [ ! -x "$LINT_SCRIPT" ]; then + echo "[WARN] $LINT_SCRIPT not found or not executable. Skipping pre-commit lint." + exit 0 +fi + +# Get staged QML, JS, and Python files that were added/copied/modified +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(qml|js|py)$' || true) + +if [ -z "$STAGED_FILES" ]; then + # No relevant files staged + exit 0 +fi + +echo "[INFO] Running pre-commit syntax and import checks on staged files..." +if ! "$LINT_SCRIPT" $STAGED_FILES; then + echo "" + echo "[ERROR] Commit aborted: Linting errors detected in staged files." + echo "[HINT] Fix the issues above, stage your changes, and commit again." + echo "[HINT] Emergency bypass if required: git commit --no-verify" + exit 1 +fi +EOF + +chmod +x "$HOOK_DEST" +echo "[SUCCESS] Git pre-commit hook successfully installed at: $HOOK_DEST" From 28e41444c7396bbf8839ec0d2a3c21d339e724e8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 15:02:23 +0530 Subject: [PATCH 013/105] fix(.gitignore): remove unnecessary website build artifacts from ignore list --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index e050961e..bb37b641 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,4 @@ build .agent voice_to_text/lib/ .clickable -website/node_modules -website/.docusaurus -website/build .vscode \ No newline at end of file From f1a0428dad35895fb36da3576201d4152e897939 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 17:55:23 +0530 Subject: [PATCH 014/105] refactor(utils): enhance truncateText function and improve text cleaning across components --- models/utils.js | 17 +++++++++++------ qml/components/cards/ActivityDetailsCard.qml | 5 +---- qml/components/richtext/ReadMorePage.qml | 16 ++++++---------- qml/components/richtext/RichTextPreview.qml | 2 +- qml/components/system/GlobalTimerWidget.qml | 14 ++++++++------ .../tasks/components/TaskDetailsCard.qml | 5 +---- .../components/TimeSheetDetailsCard.qml | 5 ++++- qml/features/timesheets/pages/Timesheet.qml | 10 +++++++--- 8 files changed, 39 insertions(+), 35 deletions(-) diff --git a/models/utils.js b/models/utils.js index 138c02b7..76e8a69c 100644 --- a/models/utils.js +++ b/models/utils.js @@ -412,10 +412,12 @@ function getNextMonthSameDay(baseDate) { } function truncateText(text, maxLength) { - if (text.length > maxLength) { - return text.slice(0, maxLength) + "..."; + if (!text || typeof text !== 'string') return ""; + var cleaned = cleanText(stripHtmlTags(text)); + if (cleaned.length > maxLength) { + return cleaned.slice(0, maxLength).trim() + "..."; } - return text; + return cleaned; } function getFormattedTimestampUTC() { @@ -593,11 +595,14 @@ function cleanText(str) { if (typeof str !== 'string') return ''; return str - // Remove common invisible/control characters (ASCII + Unicode) - .replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028\u2029\u2060\uFEFF]/g, '') + .replace(/ /gi, ' ') + // Remove common invisible/control characters (ASCII + Unicode + non-breaking space) + .replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028\u2029\u2060\uFEFF\u00A0]/g, '') // Normalize to avoid weird composed characters .normalize('NFC') - // Trim extra whitespace + // Collapse multiple spaces into single space + .replace(/[ \t]+/g, ' ') + // Trim extra whitespace from both ends .trim(); } diff --git a/qml/components/cards/ActivityDetailsCard.qml b/qml/components/cards/ActivityDetailsCard.qml index 4ee7b164..997d8a8c 100644 --- a/qml/components/cards/ActivityDetailsCard.qml +++ b/qml/components/cards/ActivityDetailsCard.qml @@ -30,10 +30,7 @@ ListItem { signal dateChanged(int accountid, int recordId, string newDate) function truncateText(text, maxLength) { - if (text.length > maxLength) { - return text.slice(0, maxLength) + '...'; - } - return text; + return Utils.truncateText(text, maxLength); } function isActivityOverdue() { diff --git a/qml/components/richtext/ReadMorePage.qml b/qml/components/richtext/ReadMorePage.qml index fbc13f54..131497a3 100644 --- a/qml/components/richtext/ReadMorePage.qml +++ b/qml/components/richtext/ReadMorePage.qml @@ -401,9 +401,7 @@ Page { if (useRichText && editor) { // Use cached text property which is kept in sync via contentChanged var currentContent = editor.getFormattedText() || editor.text || ""; - if (currentContent) { - Global.description_temporary_holder = currentContent; - } + Global.description_temporary_holder = currentContent; // Save draft when leaving ReadMore page if (parentDraftHandler) { @@ -411,10 +409,10 @@ Page { parentDraftHandler.saveDraft(); } } else if (!useRichText && simpleEditor) { - Global.description_temporary_holder = simpleEditor.text; + Global.description_temporary_holder = simpleEditor.text || ""; // Save draft when leaving ReadMore page if (parentDraftHandler) { - parentDraftHandler.markFieldChanged("description", simpleEditor.text); + parentDraftHandler.markFieldChanged("description", Global.description_temporary_holder); parentDraftHandler.saveDraft(); } } @@ -445,15 +443,13 @@ Page { if (useRichText && editor) { // Use the cached text property which is kept in sync via contentChanged var currentContent = editor.getFormattedText() || editor.text || ""; - if (currentContent) { - Global.description_temporary_holder = currentContent; - } + Global.description_temporary_holder = currentContent; } else if (!useRichText && simpleEditor) { - Global.description_temporary_holder = simpleEditor.text; + Global.description_temporary_holder = simpleEditor.text || ""; } // Save draft one last time before page is destroyed - if (parentDraftHandler && Global.description_temporary_holder) { + if (parentDraftHandler) { parentDraftHandler.markFieldChanged("description", Global.description_temporary_holder); parentDraftHandler.saveDraft(); } diff --git a/qml/components/richtext/RichTextPreview.qml b/qml/components/richtext/RichTextPreview.qml index a005701e..0ddefa1f 100644 --- a/qml/components/richtext/RichTextPreview.qml +++ b/qml/components/richtext/RichTextPreview.qml @@ -417,7 +417,7 @@ Rectangle { currentContent = text; } - if (currentContent && currentContent !== originalHtmlContent) { + if (currentContent !== undefined && currentContent !== null && currentContent !== originalHtmlContent) { // console.log("[RichTextPreview] User typing detected, content length:", currentContent.length); originalHtmlContent = currentContent; root.contentChanged(currentContent); diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 89c03137..6037fb20 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -20,7 +20,7 @@ Rectangle { property bool enableTimesheetTimer: true property string elapsedDisplay: "" - property string activeTitle: "Active Timesheet" + property string activeTitle: "/" property string activeTime: "00:00:00" property bool isTimerRunning: false property bool isTimerPaused: false @@ -54,7 +54,8 @@ Rectangle { isTimerPaused = currentlyPaused; if (currentlyRunning) { var rawName = TimerService.getActiveTimesheetName(); - activeTitle = (rawName && rawName.trim() !== "") ? rawName.trim() : "Active Timesheet"; + var cleanName = rawName ? Utils.cleanText(Utils.stripHtmlTags(rawName)) : ""; + activeTitle = (cleanName && cleanName.trim() !== "") ? cleanName.trim() : "/"; activeTime = TimerService.getElapsedTime(); globalTimer.visible = true; } else if (!isSyncing) { @@ -128,7 +129,7 @@ Rectangle { syncSuccessful = true; syncFailed = false; syncProgress = 1.0; - syncStatusMessage = "✅ Sync Complete!"; + syncStatusMessage = "Sync Complete!"; // Auto-hide after 3 seconds autoHideTimer.interval = 3000; @@ -139,7 +140,7 @@ Rectangle { function failSync(errorMessage) { syncSuccessful = false; syncFailed = true; - syncStatusMessage = "❌ " + (errorMessage || "Sync Failed"); + syncStatusMessage = errorMessage || "Sync Failed"; // Auto-hide after 5 seconds autoHideTimer.interval = 5000; @@ -202,7 +203,8 @@ Rectangle { if (currentlyRunning) { var rawName = TimerService.getActiveTimesheetName(); - activeTitle = (rawName && rawName.trim() !== "") ? rawName.trim() : "Active Timesheet"; + var cleanName = rawName ? Utils.cleanText(Utils.stripHtmlTags(rawName)) : ""; + activeTitle = (cleanName && cleanName.trim() !== "") ? cleanName.trim() : "/"; activeTime = TimerService.getElapsedTime(); } @@ -213,7 +215,7 @@ Rectangle { if (syncFailed) { globalTimer.elapsedDisplay = syncStatusMessage + " - " + syncAccountName; } else if (syncSuccessful) { - globalTimer.elapsedDisplay = "✅ Sync Complete - " + syncAccountName; + globalTimer.elapsedDisplay = "Sync Complete - " + syncAccountName; } else { var progressPercent = Math.round(syncProgress * 100); var statusMsg = syncStatusMessage || "Syncing..."; diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 1a3170f8..81d610e3 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -662,10 +662,7 @@ anchors.right: parent.right } } function truncateText(text, maxLength) { - if (text.length > maxLength) { - return text.slice(0, maxLength) + '...'; - } - return text; + return Utils.truncateText(text, maxLength); } function toDateOnly(datetimeStr) { // Assumes input like "2025-06-06 15:30:00" diff --git a/qml/features/timesheets/components/TimeSheetDetailsCard.qml b/qml/features/timesheets/components/TimeSheetDetailsCard.qml index 4bfbe03e..9b2d118e 100644 --- a/qml/features/timesheets/components/TimeSheetDetailsCard.qml +++ b/qml/features/timesheets/components/TimeSheetDetailsCard.qml @@ -243,7 +243,10 @@ ListItem { Text { - text: ((typeof name === "string" && name.trim() !== "") ? Utils.truncateText(name, 30) : "No Description") + text: { + var t = Utils.truncateText(name, 30); + return t !== "" ? t : "No Description"; + } textFormat: Text.PlainText font.pixelSize: units.gu(AppConst.FontSizes.ListHeading) elide: Text.ElideRight diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 0332390d..1035c9ea 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -149,6 +149,9 @@ Page { } var description = description_text.getFormattedText ? description_text.getFormattedText() : description_text.text; + if (typeof description === "string") { + description = description.trim(); + } var timesheet_data = { 'record_date': date_widget.formattedDate(), @@ -221,6 +224,9 @@ Page { } var description = description_text.getFormattedText ? description_text.getFormattedText() : description_text.text; + if (typeof description === "string") { + description = description.trim(); + } var timesheet_data = { 'record_date': date_widget.formattedDate(), @@ -257,9 +263,7 @@ Page { // Now that project is in DB, retry starting the timer time_sheet_widget.tryStartTimer(); - if (description && description.trim() !== "") { - TimerService.updateActiveTimesheetName(description); - } + TimerService.updateActiveTimesheetName(description || ""); return true; } From cb98116b7bdc645c254d9a87628aaa25373f388c Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 18:00:01 +0530 Subject: [PATCH 015/105] update status logic to reflect timer activity and workflow state --- qml/features/timesheets/pages/Timesheet.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 1035c9ea..7323e1ca 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -165,7 +165,7 @@ Page { 'quadrant': priorityGrid.currentIndex + 1, 'user_id': user, 'timer_type': isTimerActive ? "automatic" : "manual", - 'status': "draft" // WORKFLOW status (not submitted yet), NOT form draft status + 'status': isTimerActive ? "active" : (currentStatus === "ready" || currentStatus === "updated" ? currentStatus : "draft") }; if (recordid && recordid !== 0) { timesheet_data.id = recordid; From f01d8122d90063d48614526ccaa06f8c7db4572a Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 19 Aug 2026 18:08:20 +0530 Subject: [PATCH 016/105] mark previous timesheet as draft when starting a new timer fix(timesheet): revert other active timesheets to draft status when marking one as active --- models/timer_service.js | 6 ++++-- models/timesheet.js | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/models/timer_service.js b/models/timer_service.js index 7ad34040..236deb29 100644 --- a/models/timer_service.js +++ b/models/timer_service.js @@ -44,10 +44,12 @@ function start(timesheetId) { var durationHours = getElapsedDuration(); Logger.debug("Timer_service", "Pausing previous timer before starting new one, durationHours:", durationHours) Model.updateTimesheetWithDuration(activeTimesheetId, durationHours); - // Leave previous in paused state + if (!Model.isTimesheetFinalized(activeTimesheetId)) { + Model.markTimesheetAsDraftById(activeTimesheetId); + } paused = true; pauseStartTime = Date.now(); - Logger.debug("Timer_service", "Previous timesheet paused. Starting new timesheet...") + Logger.debug("Timer_service", "Previous timesheet paused and marked as draft. Starting new timesheet...") } // If already running on the same timesheet and not paused, ignore redundant start else if (!paused && activeTimesheetId === timesheetId) { diff --git a/models/timesheet.js b/models/timesheet.js index 3c99beb4..5cf303c5 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -1484,14 +1484,20 @@ function markTimesheetAsActiveById(timesheetId) { try { db.transaction(function (tx) { + // Revert any other timesheets previously marked 'active' to 'draft' + tx.executeSql( + "UPDATE account_analytic_line_app SET last_modified = ?, status = 'draft' WHERE status = 'active' AND id != ?", + [timestamp, timesheetId] + ); + // Mark target timesheet as active tx.executeSql( "UPDATE account_analytic_line_app SET last_modified = ?, status = ? WHERE id = ?", [timestamp, "active", timesheetId] ); }); - Logger.debug("Timesheet", "Timesheet " + timesheetId + " marked as draft successfully.") + Logger.debug("Timesheet", "Timesheet " + timesheetId + " marked as active successfully.") } catch (e) { - Logger.debug("Timesheet", "markTimesheetAsDraftById failed:", e) + Logger.debug("Timesheet", "markTimesheetAsActiveById failed:", e) } } From 54132e01f7ccbaf941dda6085dafa1b8d24dc2c9 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 20 Aug 2026 12:26:43 +0530 Subject: [PATCH 017/105] chore: update version to 1.3.2 across manifest, constants, release notes, and daemon --- manifest.json.in | 2 +- models/constants.js | 2 +- qml/app/pages/release_notes.txt | 4 ++-- src/daemon.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/manifest.json.in b/manifest.json.in index bab0e267..fa357907 100644 --- a/manifest.json.in +++ b/manifest.json.in @@ -18,7 +18,7 @@ "push-helper": "ubtms-push-helper.json" } }, - "version": "1.3.1", + "version": "1.3.2", "maintainer": "CIT Services ", "framework" : "@CLICK_FRAMEWORK@" } diff --git a/models/constants.js b/models/constants.js index 626c2ddb..0a0d4ab2 100644 --- a/models/constants.js +++ b/models/constants.js @@ -2,7 +2,7 @@ .pragma library -var version="1.3.1" +var version="1.3.2" //fonts var FontSizes = { diff --git a/qml/app/pages/release_notes.txt b/qml/app/pages/release_notes.txt index 8e66d017..972efff5 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.1 • © 2026 CIT Services + Time Management for Ubuntu Touch • Version 1.3.2 • © 2026 CIT Services

diff --git a/src/daemon.py b/src/daemon.py index 60733558..d6f20b97 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.0') + return manifest.get('version', '1.3.2') # 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.0" # Fallback version + return "1.3.2" # Fallback version APP_VERSION = get_app_version() From fe3dc6ca013fcec9c05dfffdb35470c0102447d8 Mon Sep 17 00:00:00 2001 From: Srushti Kulkarni Date: Thu, 20 Aug 2026 22:58:29 +0530 Subject: [PATCH 018/105] fix: support local account activity types --- models/activity.js | 60 +++++++++++++------- models/database.js | 31 ++++++++++ models/dbinit.js | 2 + qml/components/cards/ActivityDetailsCard.qml | 2 +- 4 files changed, 73 insertions(+), 22 deletions(-) diff --git a/models/activity.js b/models/activity.js index 16a8ca05..d11747b9 100644 --- a/models/activity.js +++ b/models/activity.js @@ -496,32 +496,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 +553,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. @@ -1848,7 +1866,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; } @@ -2098,4 +2116,4 @@ function getAllDoneActivitiesPaginated(limit, offset) { } return activityList; -} \ No newline at end of file +} diff --git a/models/database.js b/models/database.js index 9383e53a..be7aef50 100644 --- a/models/database.js +++ b/models/database.js @@ -169,6 +169,37 @@ function ensureDefaultLocalAccountExists() { } } +/** + * 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); + } +} /** * 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..36c76a17 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", diff --git a/qml/components/cards/ActivityDetailsCard.qml b/qml/components/cards/ActivityDetailsCard.qml index 4ee7b164..16094cc9 100644 --- a/qml/components/cards/ActivityDetailsCard.qml +++ b/qml/components/cards/ActivityDetailsCard.qml @@ -348,7 +348,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) From 6c762cbc7ae0a507b512a303cde766d5aa1097f2 Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 21 Aug 2026 01:22:45 +0530 Subject: [PATCH 019/105] fix: resolve the project name error --- qml/features/tasks/components/TaskList.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index e422177a..b7e7c642 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -450,6 +450,10 @@ Item { } var projectName = Project.getProjectName(projectIdToUse, row.account_id); + + if (projectName === "Unknown Project") { + projectName = Project.getProjectDetails(projectIdToUse).name || i18n.dtr("ubtms", "Unknown Project"); + } var item = { id_val: odooId, From d5f23e0652528279736a9acf3573edfd4e569cf9 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 21 Aug 2026 14:05:56 +0530 Subject: [PATCH 020/105] feat: add lint and import validation workflow --- .github/workflows/lint.yml | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..d6262919 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,52 @@ +name: Lint & Import Validation + +on: + push: + branches: + - main + - dev + - 'release/**' + pull_request: + branches: + - main + - dev + - 'release/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-validate: + name: Lint QML, JS & Python + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install Qt5 & qmllint dependencies + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + qtdeclarative5-dev-tools \ + qml-tools + + - name: Verify qmllint installation + run: | + qmllint --version 2>&1 || true + which qmllint + + - name: Ensure scripts are executable + run: | + chmod +x scripts/lint.sh scripts/check_imports.py + + - name: Run Syntax and Import Checks + run: | + ./scripts/lint.sh From 1ead7f81ccbf8a4fc5da0b2d6b2219964ccfdcf6 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 21 Aug 2026 14:08:18 +0530 Subject: [PATCH 021/105] fixes for lint yaml --- .github/workflows/lint.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d6262919..e8d48567 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,13 +35,13 @@ jobs: run: | sudo apt-get update -qq sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - qtdeclarative5-dev-tools \ - qml-tools + qtdeclarative5-dev-tools + echo "/usr/lib/qt5/bin" >> $GITHUB_PATH - name: Verify qmllint installation run: | + which qmllint || true qmllint --version 2>&1 || true - which qmllint - name: Ensure scripts are executable run: | From 3e0973bb8c4f65fd99305ceeafcfd4a87bd2ef99 Mon Sep 17 00:00:00 2001 From: Srushti Kulkarni Date: Fri, 21 Aug 2026 20:34:43 +0530 Subject: [PATCH 022/105] fix: pass account id when resolving activity types --- qml/features/activities/pages/Activity_Page.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/features/activities/pages/Activity_Page.qml b/qml/features/activities/pages/Activity_Page.qml index d4bf5d7e..e39bb5fb 100644 --- a/qml/features/activities/pages/Activity_Page.qml +++ b/qml/features/activities/pages/Activity_Page.qml @@ -337,7 +337,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, @@ -575,7 +575,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; } From d7bd312a978a5824436a0dfc778c4465fb333d01 Mon Sep 17 00:00:00 2001 From: Srushti Kulkarni Date: Sat, 22 Aug 2026 17:34:49 +0530 Subject: [PATCH 023/105] fix: improve date field color contrast --- qml/components/pickers/DateRangeSelector.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index 9880376d..5a30e7b5 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -123,7 +123,7 @@ Item { enabled: !dateRangeSelector.readOnly text: isStartDateValid ? Qt.formatDate(startDateItem.date, "dd-MM-yyyy") : "" placeholderText: isStartDateValid ? "" : "No date set" - color: isStartDateValid ? "black" : "gray" + color: isStartDateValid ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") : "gray" } MouseArea { @@ -170,7 +170,7 @@ Item { enabled: !dateRangeSelector.readOnly text: isEndDateValid ? Qt.formatDate(endDateItem.date, "dd-MM-yyyy") : "" placeholderText: isEndDateValid ? "" : "No date set" - color: isEndDateValid ? "black" : "gray" + color: isEndDateValid ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") : "gray" } MouseArea { From 0a2e67f3e902f2fbd95ad77c28f5d5403627f6b2 Mon Sep 17 00:00:00 2001 From: Aniket Palsodkar Date: Mon, 17 Aug 2026 14:27:27 +0530 Subject: [PATCH 024/105] Add Japanese translation --- po/ja.po | 2151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2151 insertions(+) create mode 100644 po/ja.po 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 "時間管理" From c8e71fab67c62fa6c687beee314c825b703941ae Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 27 Aug 2026 12:28:28 +0530 Subject: [PATCH 025/105] fix: prevent data leakage on account deletion and cleanup orphan attachment files --- models/accounts.js | 33 ++++++++++-- .../settings/pages/Settings_Accounts.qml | 33 +++++++++++- src/backend.py | 53 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/models/accounts.js b/models/accounts.js index cf1d4983..ff59b3ee 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -123,6 +123,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; + } } }); @@ -385,18 +390,40 @@ 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" ]; 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 " + userId); + try { + tx.executeSql(`DELETE FROM ${table} WHERE account_id = ?`, [userId]); + } 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]); + // 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]); + } + } + DBCommon.log(`Account and related data deleted for account_id: ${userId}`); }); diff --git a/qml/features/settings/pages/Settings_Accounts.qml b/qml/features/settings/pages/Settings_Accounts.qml index 89de5c2f..29402071 100644 --- a/qml/features/settings/pages/Settings_Accounts.qml +++ b/qml/features/settings/pages/Settings_Accounts.qml @@ -119,10 +119,41 @@ 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); + } + } + accountToDelete = -1; accountIndexToDelete = -1; } diff --git a/src/backend.py b/src/backend.py index 42f00e65..84527934 100755 --- a/src/backend.py +++ b/src/backend.py @@ -528,6 +528,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. From 62134f2f67c1d7072bab80d10b368ddc2b3e6958 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 27 Aug 2026 13:25:33 +0530 Subject: [PATCH 026/105] fix: enhance attachment handling for local accounts and improve query efficiency --- models/project.js | 12 +-- models/task.js | 12 +-- qml/components/selectors/WorkItemSelector.qml | 6 ++ qml/components/workflow/AttachmentManager.qml | 5 + qml/features/projects/pages/Projects.qml | 11 ++- qml/features/tasks/pages/Tasks.qml | 12 ++- src/backend.py | 91 +++++++++++++++++-- 7 files changed, 120 insertions(+), 29 deletions(-) diff --git a/models/project.js b/models/project.js index af424737..82095bf2 100644 --- a/models/project.js +++ b/models/project.js @@ -522,15 +522,15 @@ 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 = ? + AND (res_id = ? OR (odoo_record_id = ? AND odoo_record_id > 0)) AND account_id = ? ORDER BY name COLLATE NOCASE ASC `; - var result = tx.executeSql(query, [odooRecordId, accountId]); + var result = tx.executeSql(query, [odooRecordId, odooRecordId, accountId]); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -541,9 +541,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: "" }); } }); diff --git a/models/task.js b/models/task.js index c0f70e23..8902b75c 100644 --- a/models/task.js +++ b/models/task.js @@ -283,15 +283,15 @@ 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 = ? + AND (res_id = ? OR (odoo_record_id = ? AND odoo_record_id > 0)) AND account_id = ? ORDER BY name COLLATE NOCASE ASC `; - var result = tx.executeSql(query, [odooRecordId, accountId]); + var result = tx.executeSql(query, [odooRecordId, odooRecordId, accountId]); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -302,9 +302,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: "" }); } }); diff --git a/qml/components/selectors/WorkItemSelector.qml b/qml/components/selectors/WorkItemSelector.qml index 5863b86a..0d830748 100644 --- a/qml/components/selectors/WorkItemSelector.qml +++ b/qml/components/selectors/WorkItemSelector.qml @@ -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); diff --git a/qml/components/workflow/AttachmentManager.qml b/qml/components/workflow/AttachmentManager.qml index b4212e3a..d79997c7 100644 --- a/qml/components/workflow/AttachmentManager.qml +++ b/qml/components/workflow/AttachmentManager.qml @@ -760,6 +760,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/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index 69dd71c9..62ef4f35 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -530,7 +530,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; @@ -997,11 +998,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)); } } } diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 2bc32f18..fe95a6ed 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -895,13 +895,14 @@ Page { 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_id: (currentTask && currentTask.odoo_record_id > 0) ? currentTask.odoo_record_id : ((currentTask && currentTask.id) ? currentTask.id : recordid) + account_id: (currentTask && currentTask.account_id !== undefined) ? currentTask.account_id : 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); + var accId = (currentTask && currentTask.account_id !== undefined) ? currentTask.account_id : 0; + attachments_widget.setAttachments(Task.getAttachmentsForTask(resId, accId)); } onItemClicked: function (rec) { @@ -999,7 +1000,8 @@ 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 diff --git a/src/backend.py b/src/backend.py index 84527934..1bbab318 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"} From 9f0c0fe3d8f0b7505ef5c3b971e210b6fd0aa91b Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 27 Aug 2026 14:32:53 +0530 Subject: [PATCH 027/105] perf: optimize task pagination with SQL search, direct all-filter queries, and batched hours --- models/task.js | 183 ++++++++++++++++++++++++++++++------------------- 1 file changed, 111 insertions(+), 72 deletions(-) diff --git a/models/task.js b/models/task.js index ac91efa5..e9871d57 100644 --- a/models/task.js +++ b/models/task.js @@ -1726,107 +1726,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 = 50; // 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 { From b4caf0bd7e28307f011f61ca2a9b4fa8314a4247 Mon Sep 17 00:00:00 2001 From: Omkarbahirat Date: Mon, 31 Aug 2026 14:12:19 +0530 Subject: [PATCH 028/105] Fix voice model installation and display (#303) * Fix voice model installation and display * Fix deletion of Clickable voice models * Fix duplicate voice model detection * Optimize installed voice model lookup * Harden voice model deletion paths --------- Co-authored-by: Omkar Bahirat Co-authored-by: Suraj Yadav --- src/backend.py | 311 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 212 insertions(+), 99 deletions(-) diff --git a/src/backend.py b/src/backend.py index 1bbab318..9fda42f9 100755 --- a/src/backend.py +++ b/src/backend.py @@ -812,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(): @@ -1269,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"} From ba04638aab717fc8b98f927062eff57d45af085d Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 1 Sep 2026 11:41:17 +0530 Subject: [PATCH 029/105] Patches for local project stages --- models/project.js | 22 ++++++--- qml/components/cards/ProjectDetailsCard.qml | 13 +++-- qml/components/selectors/StageFilterMenu.qml | 50 ++++++++++---------- qml/components/visualization/ProjectList.qml | 9 +++- 4 files changed, 57 insertions(+), 37 deletions(-) diff --git a/models/project.js b/models/project.js index 82095bf2..b2fc473c 100644 --- a/models/project.js +++ b/models/project.js @@ -742,12 +742,22 @@ function getProjectsFilteredPaginated(options) { // 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.stageId === -2) { + // "Open" filter + if (options.accountId === 0) { + // Local account projects have no stages and are always considered open + } else if (options.openStageIds && options.openStageIds.length > 0) { + var placeholders = options.openStageIds.map(function () { return "?"; }).join(","); + if (options.accountId === -1 || options.accountId === undefined) { + // "All Accounts": match open Odoo stages OR local projects / projects without a stage + whereClauses.push("(stage IN (" + placeholders + ") OR account_id = 0 OR stage = 0 OR stage IS NULL)"); + } else { + // Specific Odoo 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) { // Specific stage diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index 75694c49..92cb2534 100644 --- a/qml/components/cards/ProjectDetailsCard.qml +++ b/qml/components/cards/ProjectDetailsCard.qml @@ -317,11 +317,16 @@ ListItem { } 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") + property string stageName: (stage && stage > 0) ? (Project.getProjectStageName(stage) || "") : "" + property bool isDone: { + var lower = stageName.toLowerCase(); + return lower === "completed" || lower === "finished" || lower === "closed" || lower === "verified" || lower === "done"; + } + visible: stageName !== "" + text: stageName + color: isDone ? "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 + font.bold: isDone } Rectangle { diff --git a/qml/components/selectors/StageFilterMenu.qml b/qml/components/selectors/StageFilterMenu.qml index 25dc223a..0f8c9bf3 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); } } } diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index c68099f2..dab05dd9 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -348,7 +348,7 @@ Item { name: row.name || "Untitled", projectName: row.name || "Untitled", accountName: accountName, - recordId: odooId, + recordId: odooId || 0, allocatedHours: row.allocated_hours ? row.allocated_hours : 0, remainingHours: row.remaining_hours ? row.remaining_hours : 0, startDate: row.planned_start_date || "", @@ -356,7 +356,7 @@ 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 @@ -444,6 +444,11 @@ Item { // Special case for "Open" filter (odoo_record_id = -2) if (stageFilter.odoo_record_id === -2) { + // Local projects (account_id === 0 or without a stage) don't have stages and are always considered open + if (project.account_id === 0 || !project.stage || project.stage === 0) { + 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) { From ced44f7a2d3dbe02248276cc6ed83a2c37146ef1 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 1 Sep 2026 12:00:00 +0530 Subject: [PATCH 030/105] Fix local project hierarchy mapping and navigation --- qml/components/cards/ProjectDetailsCard.qml | 9 +++++---- qml/components/visualization/ProjectList.qml | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index 92cb2534..bd102830 100644 --- a/qml/components/cards/ProjectDetailsCard.qml +++ b/qml/components/cards/ProjectDetailsCard.qml @@ -122,7 +122,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 && recordId > 0 text: "Start Timer" onTriggered: { play_pause_workflow(); @@ -130,7 +130,7 @@ ListItem { }, Action { id: startstopaction - visible: recordId > 0 + visible: projectCard.accountId > 0 && recordId > 0 iconSource: "../../images/stop.png" text: i18n.dtr("ubtms", "Stop Timer") onTriggered: { @@ -255,9 +255,10 @@ ListItem { anchors.fill: parent z: 1 // Much lower than star MouseArea onClicked: { - if (hasChildren && recordId > 0) { + if (hasChildren) { // For projects with children, emit navigation signal - navigationRequested(recordId, projectCard.accountId || 0); + var navId = (projectCard.accountId === 0 || recordId <= 0) ? localId : recordId; + navigationRequested(navId, projectCard.accountId || 0); } else { // For leaf projects, show details (same as View-On action) viewRequested(localId); diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index dab05dd9..aba0236d 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -322,11 +322,12 @@ Item { // 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; @@ -341,14 +342,14 @@ Item { } 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 || 0, + recordId: effectiveId, allocatedHours: row.allocated_hours ? row.allocated_hours : 0, remainingHours: row.remaining_hours ? row.remaining_hours : 0, startDate: row.planned_start_date || "", From 68c121d20aa88a477898071c946254ffcf66c26e Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 1 Sep 2026 12:00:14 +0530 Subject: [PATCH 031/105] Fix task page back navigation and local project lookup --- models/task.js | 4 ++-- .../selectors/MultiAssigneeSelector.qml | 5 +---- qml/features/tasks/pages/Task_Page.qml | 15 ++++++++++++++- qml/features/tasks/pages/Tasks.qml | 1 - 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/models/task.js b/models/task.js index 2531a4e9..5c592da2 100644 --- a/models/task.js +++ b/models/task.js @@ -930,8 +930,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) { diff --git a/qml/components/selectors/MultiAssigneeSelector.qml b/qml/components/selectors/MultiAssigneeSelector.qml index b074cea1..e118baad 100644 --- a/qml/components/selectors/MultiAssigneeSelector.qml +++ b/qml/components/selectors/MultiAssigneeSelector.qml @@ -285,12 +285,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/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index e34fb0a7..87d39508 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() } diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index fe95a6ed..f0229a9c 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -886,7 +886,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) From cd8746a0a67a4190cb3efa1fa50317542261da7e Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 1 Sep 2026 12:27:23 +0530 Subject: [PATCH 032/105] Fix project task filtering for local and child projects --- models/task.js | 20 ++++++++++---------- qml/features/projects/pages/Projects.qml | 8 +++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/models/task.js b/models/task.js index 5c592da2..5090db36 100644 --- a/models/task.js +++ b/models/task.js @@ -2162,8 +2162,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 @@ -2309,8 +2309,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 @@ -2948,7 +2948,7 @@ 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 = ""; @@ -2989,7 +2989,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} @@ -3096,8 +3096,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); @@ -3158,8 +3158,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))); } diff --git a/qml/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index 62ef4f35..1e0ea635 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -796,13 +796,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 }); @@ -822,9 +823,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 }); From 1fb73676c931bb805684bda1b577efb8a630a52e Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 1 Sep 2026 16:55:03 +0530 Subject: [PATCH 033/105] Fix local account edge cases across tasks, activities, selectors, and dashboard (fixes #312, #313, #314, #315, #316) --- models/Main.js | 2 +- models/accounts.js | 4 +- models/activity.js | 115 +++++++------------ models/project.js | 4 +- models/task.js | 19 +-- models/utils.js | 12 +- qml/components/selectors/ProjectSelector.qml | 3 +- qml/components/selectors/UserSelector.qml | 5 +- qml/features/projects/pages/Projects.qml | 6 +- qml/features/tasks/components/TaskList.qml | 25 ++-- qml/features/tasks/pages/Tasks.qml | 8 +- 11 files changed, 88 insertions(+), 115 deletions(-) diff --git a/models/Main.js b/models/Main.js index 1bb749d5..2248e576 100644 --- a/models/Main.js +++ b/models/Main.js @@ -91,7 +91,7 @@ function get_projects_spent_hours(account, startDate, endDate) { var query = "SELECT p.name AS entity_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_app p ON (p.odoo_record_id = a.project_id OR (a.account_id = 0 AND p.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 "; if (account !== -1 && account !== undefined && account !== null) { diff --git a/models/accounts.js b/models/accounts.js index ff59b3ee..ae3ddf81 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -602,8 +602,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 d11747b9..c5358e5a 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; } @@ -972,12 +932,12 @@ function getActivitiesForProject(projectOdooRecordId, accountId) { (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 = ? + SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL THEN id ELSE odoo_record_id END) FROM project_task_app + WHERE (project_id = ? OR sub_project_id = ?) AND account_id = ? )) ) ORDER BY a.due_date ASC - `, [accountId, projectOdooRecordId, projectOdooRecordId, accountId]); + `, [accountId, projectOdooRecordId, projectOdooRecordId, projectOdooRecordId, accountId]); for (var i = 0; i < rs.rows.length; i++) { var row = rs.rows.item(i); @@ -1060,13 +1020,13 @@ function getActivitiesForProjectPaginated(projectOdooRecordId, accountId, limit, (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 = ? + SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL THEN id ELSE odoo_record_id END) FROM project_task_app + WHERE (project_id = ? OR sub_project_id = ?) AND account_id = ? )) ) ORDER BY a.due_date ASC LIMIT ? OFFSET ? - `, [accountId, projectOdooRecordId, projectOdooRecordId, accountId, limit, offset]); + `, [accountId, projectOdooRecordId, projectOdooRecordId, projectOdooRecordId, accountId, limit, offset]); for (var i = 0; i < rs.rows.length; i++) { var row = rs.rows.item(i); @@ -1135,24 +1095,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' @@ -1162,6 +1116,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); @@ -1218,8 +1181,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; @@ -1229,7 +1192,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' diff --git a/models/project.js b/models/project.js index b2fc473c..b658ebdc 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) { diff --git a/models/task.js b/models/task.js index 5090db36..b6d9e5df 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) { @@ -347,13 +347,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++) { @@ -3312,19 +3312,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, diff --git a/models/utils.js b/models/utils.js index 76e8a69c..ac58649a 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); 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/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/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index 1e0ea635..cb6c73aa 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -735,7 +735,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, @@ -761,9 +762,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 }); diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index b7e7c642..097284be 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -223,7 +223,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; @@ -268,7 +269,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 +298,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); @@ -439,8 +442,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 +459,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 || "", @@ -477,9 +480,9 @@ Item { 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) diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index f0229a9c..8a0525c5 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -819,7 +819,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,10 +832,11 @@ 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" }); From 56481ce52eee28b3b90c2ecc5c427f91309af9de Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 11:21:56 +0530 Subject: [PATCH 034/105] Add global local account toggle switch to menu and drawer headers --- global-local-account-toggle.md | 65 +++++++++++++++++++ models/accounts.js | 31 +++++++++ qml/app/AppDrawer.qml | 57 ++++++++++------ qml/app/GlobalWidgets.qml | 4 ++ qml/app/navigation/MenuPage.qml | 57 ++++++++++------ .../dialogs/AccountSelectorDialog.qml | 32 +++++++++ 6 files changed, 208 insertions(+), 38 deletions(-) create mode 100644 global-local-account-toggle.md diff --git a/global-local-account-toggle.md b/global-local-account-toggle.md new file mode 100644 index 00000000..a10c39a1 --- /dev/null +++ b/global-local-account-toggle.md @@ -0,0 +1,65 @@ +# Implementation Plan: Global Local Account Toggle + +## 1. Overview +Add a dedicated `Switch` control to the menu header in both desktop (`MenuPage.qml`) and mobile (`AppDrawer.qml`) layouts to enable instant global switching between the Local Account (`accountId = 0`) and the previously active or default remote account (`accountId > 0`). + +--- + +## 2. Requirements & Behavior +1. **Control Type**: Dedicated compact `Switch` component styled cleanly in the header. +2. **Placement**: Placed adjacent to the account selector button in both `qml/app/navigation/MenuPage.qml` and `qml/app/AppDrawer.qml`. +3. **Toggle ON (Checked)**: + - Switches the global application account to `0` ("Local Account"). + - Triggers `rootApp.globalAccountChanged(0, "Local Account")` and `rootApp.accountDataRefreshRequested(0)`. +4. **Toggle OFF (Unchecked)**: + - Restores the previously active remote account (`lastRemoteAccountId`). + - If no previous remote account was used in the session, falls back to the default account selected in settings (`is_default = 1` where `id > 0`), or the first available remote account. +5. **Bidirectional Synchronization**: + - `Switch.checked` reflects `accountPicker.selectedAccountId === 0`. + - When the user selects "Local Account" via `AccountSelectorDialog`, `Switch` turns ON automatically. + - When the user selects a remote account (e.g. "CIT") via `AccountSelectorDialog`, `Switch` turns OFF automatically and records that remote account as `lastRemoteAccountId`. +6. **Account Label Clickability**: + - The account selector label remains clickable at all times. + +--- + +## 3. Architecture & File Changes + +### Task 1: Remote Account Memory Helper +* **File**: `models/accounts.js` +* **Action**: Add `getDefaultRemoteAccountId()` helper to find the default remote account (`id > 0` and `is_default = 1`) or first remote account (`id > 0`). + +### Task 2: State Tracking & Switch Logic in Global Context +* **File**: `qml/components/dialogs/AccountSelectorDialog.qml` and `qml/app/GlobalWidgets.qml` +* **Action**: + - Add `property int lastRemoteAccountId` initialized to `Accounts.getDefaultRemoteAccountId()`. + - In `accountPicker.onAccepted` (or inside dialog): if `id > 0`, update `lastRemoteAccountId = id`. + - Add a helper method `switchToLocalMode(bool enableLocal)` to execute the toggle transition cleanly. + +### Task 3: Header Toggle in Desktop View +* **File**: `qml/app/navigation/MenuPage.qml` +* **Action**: + - Add the `Switch` component in the header `RowLayout` beside the account selector button. + - Bind `checked: typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0`. + - Handle toggle action to switch between local and remote mode. + +### Task 4: Header Toggle in Mobile Drawer View +* **File**: `qml/app/AppDrawer.qml` +* **Action**: + - Mirror the `Switch` component in the drawer header `RowLayout` with identical bindings and behaviors. + +### Task 5: Verification & Regression Testing +* **Action**: + - Test toggling ON switches app to Local Account (tasks, projects, dashboard update). + - Test toggling OFF restores the previous remote account (e.g. "CIT"). + - Test opening `AccountSelectorDialog` and choosing an account updates the switch. + - Run lint and full project checklist (`python3 .agent/scripts/checklist.py .`). + +--- + +## 4. Verification Criteria +- [ ] Toggling ON sets global account to 0 ("Local Account") across all pages. +- [ ] Toggling OFF restores the last active remote account. +- [ ] Selecting an account in the dialog keeps the toggle in sync. +- [ ] Header layout in both desktop and mobile drawer remains visually balanced without overflow or clipping. +- [ ] No regression on remote or local synchronization and data queries. diff --git a/models/accounts.js b/models/accounts.js index ae3ddf81..fddb53a4 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -139,6 +139,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. * diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 7611c20c..5cd8eee6 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -61,29 +61,36 @@ Controls.Drawer { } // Account Selector button with Account label adjacent to icon - RowLayout { + Item { id: accountSelectorItem - spacing: units.gu(0.5) + implicitWidth: accountRow.implicitWidth + implicitHeight: accountRow.implicitHeight Layout.alignment: Qt.AlignVCenter - Icon { - name: "account" - width: units.gu(2.4) - height: units.gu(2.4) - color: "white" - Layout.alignment: Qt.AlignVCenter - } + RowLayout { + id: accountRow + anchors.fill: parent + spacing: units.gu(0.5) + + Icon { + name: "account" + width: units.gu(2.4) + height: units.gu(2.4) + color: "white" + Layout.alignment: Qt.AlignVCenter + } - Label { - id: accountNameLabel - Layout.alignment: Qt.AlignVCenter - text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" - color: "white" - font.pixelSize: units.dp(13) - font.bold: true - elide: Text.ElideRight - maximumLineCount: 1 - Layout.maximumWidth: units.gu(12) + Label { + id: accountNameLabel + Layout.alignment: Qt.AlignVCenter + text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" + color: "white" + font.pixelSize: units.dp(13) + font.bold: true + elide: Text.ElideRight + maximumLineCount: 1 + Layout.maximumWidth: units.gu(10) + } } MouseArea { @@ -98,6 +105,18 @@ Controls.Drawer { } } + // Local Account Toggle Switch + Switch { + id: localToggleSwitch + Layout.alignment: Qt.AlignVCenter + checked: typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0 + onClicked: { + if (typeof accountPicker !== "undefined") { + accountPicker.toggleLocalMode(checked); + } + } + } + // Theme Toggle Button Item { width: units.gu(4) diff --git a/qml/app/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 410b5d9c..7b9fdc62 100644 --- a/qml/app/GlobalWidgets.qml +++ b/qml/app/GlobalWidgets.qml @@ -65,6 +65,10 @@ Item { return; } + if (id > 0) { + accountPicker.lastRemoteAccountId = id; + } + if (rootApp.currentAccountId === id) { return; } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 9cd83f68..348ff14a 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -66,29 +66,36 @@ Page { } // Account Selector button with Account label adjacent to icon - RowLayout { + Item { id: accountBtn - spacing: units.gu(0.5) + implicitWidth: accountRow.implicitWidth + implicitHeight: accountRow.implicitHeight Layout.alignment: Qt.AlignVCenter - Icon { - name: "account" - width: units.gu(2.4) - height: units.gu(2.4) - color: "white" - Layout.alignment: Qt.AlignVCenter - } + RowLayout { + id: accountRow + anchors.fill: parent + spacing: units.gu(0.5) + + Icon { + name: "account" + width: units.gu(2.4) + height: units.gu(2.4) + color: "white" + Layout.alignment: Qt.AlignVCenter + } - Label { - id: accountLabel - Layout.alignment: Qt.AlignVCenter - text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" - color: "white" - font.pixelSize: units.dp(13) - font.bold: true - elide: Text.ElideRight - maximumLineCount: 1 - Layout.maximumWidth: units.gu(12) + Label { + id: accountLabel + Layout.alignment: Qt.AlignVCenter + text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" + color: "white" + font.pixelSize: units.dp(13) + font.bold: true + elide: Text.ElideRight + maximumLineCount: 1 + Layout.maximumWidth: units.gu(10) + } } MouseArea { @@ -102,6 +109,18 @@ Page { } } + // Local Account Toggle Switch + Switch { + id: localToggleSwitch + Layout.alignment: Qt.AlignVCenter + checked: typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0 + onClicked: { + if (typeof accountPicker !== "undefined") { + accountPicker.toggleLocalMode(checked); + } + } + } + // Theme Mode Toggle Item { width: units.gu(4) diff --git a/qml/components/dialogs/AccountSelectorDialog.qml b/qml/components/dialogs/AccountSelectorDialog.qml index 4134c590..3df96a48 100644 --- a/qml/components/dialogs/AccountSelectorDialog.qml +++ b/qml/components/dialogs/AccountSelectorDialog.qml @@ -24,6 +24,15 @@ 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() @@ -37,6 +46,26 @@ Item { PopupUtils.open(dialogComponent) } + /** 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) + } + } else { + var targetId = lastRemoteAccountId > 0 ? lastRemoteAccountId : Accounts.getDefaultRemoteAccountId() + if (targetId > 0 && selectedAccountId !== targetId) { + selectedAccountId = targetId + selectedAccountName = Accounts.getAccountName(targetId) + accepted(targetId, selectedAccountName) + } else if (targetId <= 0) { + open(selectedAccountId) + } + } + } + // ---------- Private ---------- Component { id: dialogComponent @@ -178,6 +207,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) From b3b1cbbe28f80ff6c773cc652a16de0d44d45e3a Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 11:26:31 +0530 Subject: [PATCH 035/105] Keep local toggle switch synchronized with account selector dialog --- qml/app/AppDrawer.qml | 26 ++++++++++++++++++- qml/app/navigation/MenuPage.qml | 26 ++++++++++++++++++- .../dialogs/AccountSelectorDialog.qml | 5 ---- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 5cd8eee6..02e14b92 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -109,12 +109,36 @@ Controls.Drawer { Switch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter - checked: typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0 + checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + onClicked: { if (typeof accountPicker !== "undefined") { accountPicker.toggleLocalMode(checked); } } + + Binding { + target: localToggleSwitch + property: "checked" + value: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + } + + Connections { + target: typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: { + localToggleSwitch.checked = (accountPicker.selectedAccountId === 0); + } + onAccepted: { + localToggleSwitch.checked = (accountId === 0); + } + } + + Connections { + target: typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: { + localToggleSwitch.checked = (accountId === 0); + } + } } // Theme Toggle Button diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 348ff14a..1d059b50 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -113,12 +113,36 @@ Page { Switch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter - checked: typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0 + checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + onClicked: { if (typeof accountPicker !== "undefined") { accountPicker.toggleLocalMode(checked); } } + + Binding { + target: localToggleSwitch + property: "checked" + value: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + } + + Connections { + target: typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: { + localToggleSwitch.checked = (accountPicker.selectedAccountId === 0); + } + onAccepted: { + localToggleSwitch.checked = (accountId === 0); + } + } + + Connections { + target: typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: { + localToggleSwitch.checked = (accountId === 0); + } + } } // Theme Mode Toggle diff --git a/qml/components/dialogs/AccountSelectorDialog.qml b/qml/components/dialogs/AccountSelectorDialog.qml index 3df96a48..70a2879b 100644 --- a/qml/components/dialogs/AccountSelectorDialog.qml +++ b/qml/components/dialogs/AccountSelectorDialog.qml @@ -75,11 +75,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 From 0e436b7b02b5f678d5c253b5aa47184227f88446 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 11:48:27 +0530 Subject: [PATCH 036/105] Fix project updates navigation back button and creation workflow --- models/project.js | 24 +++++++++++------ qml/features/projects/pages/Projects.qml | 29 +++++++++++--------- qml/features/updates/pages/Updates.qml | 1 + qml/features/updates/pages/Updates_Page.qml | 30 ++++++++++++++++----- 4 files changed, 58 insertions(+), 26 deletions(-) diff --git a/models/project.js b/models/project.js index b658ebdc..1ce6695c 100644 --- a/models/project.js +++ b/models/project.js @@ -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); @@ -817,8 +817,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); @@ -1399,10 +1399,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; } diff --git a/qml/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index cb6c73aa..f24acd0a 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -859,18 +859,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 }); } } @@ -888,9 +891,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 }); diff --git a/qml/features/updates/pages/Updates.qml b/qml/features/updates/pages/Updates.qml index 2bee649c..ddb740c1 100644 --- a/qml/features/updates/pages/Updates.qml +++ b/qml/features/updates/pages/Updates.qml @@ -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..8baa4c71 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", @@ -204,8 +222,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); } From de4e3611817d76c049dda2f46f8d97051b3b98f8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 12:01:34 +0530 Subject: [PATCH 037/105] Fix activity page back button and local user assignee filtering --- models/activity.js | 4 +- .../activities/pages/Activity_Page.qml | 54 +++++++++++++++---- qml/features/tasks/components/TaskList.qml | 14 +++-- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/models/activity.js b/models/activity.js index c5358e5a..ef38fa04 100644 --- a/models/activity.js +++ b/models/activity.js @@ -1942,11 +1942,11 @@ 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})) 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++) { diff --git a/qml/features/activities/pages/Activity_Page.qml b/qml/features/activities/pages/Activity_Page.qml index e39bb5fb..452f0b34 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) { diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index 097284be..5caa4484 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -248,11 +248,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; - if (taskUserIds.length > 0 && taskAccountId !== null && selectedUserId !== null && selectedAccountId !== null && taskUserIds.indexOf(selectedUserId) >= 0 && taskAccountId === selectedAccountId) { + 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 (userMatches && accountMatches) { matchesSelectedAssignee = true; break; } From 5f3f7190c4f5e1de005972541fbd98c806449627 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 12:25:03 +0530 Subject: [PATCH 038/105] Fix local account timesheet creation and user resolution --- models/accounts.js | 3 +- models/timesheet.js | 175 ++++++++++-------- qml/features/dashboard/pages/Dashboard.qml | 58 +++--- qml/features/tasks/pages/Tasks.qml | 3 +- qml/features/timesheets/pages/Timesheet.qml | 12 +- .../timesheets/pages/Timesheet_Page.qml | 22 ++- 6 files changed, 156 insertions(+), 117 deletions(-) diff --git a/models/accounts.js b/models/accounts.js index fddb53a4..b3cfdd36 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -472,7 +472,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; diff --git a/models/timesheet.js b/models/timesheet.js index 5cf303c5..7fea3625 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -61,8 +61,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 +70,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 +92,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 +102,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; } @@ -180,16 +180,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 +208,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 +218,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; } @@ -297,27 +297,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; @@ -330,17 +330,17 @@ function fetchTimesheetsByStatusPaginated(status, accountId, limit, offset) { 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]); + 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; } @@ -412,11 +412,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 { @@ -428,17 +428,17 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { 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]); + 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; } @@ -545,8 +545,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 +554,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 +576,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 +586,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 +688,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 +731,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 +741,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; } @@ -1073,7 +1073,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); } @@ -1157,7 +1157,7 @@ function saveTimesheet(data) { has_draft = 0 WHERE id = ?`, [ - data.instance_id || null, + (data.instance_id !== undefined && data.instance_id !== null) ? data.instance_id : null, data.record_date || Utils.getToday(), data.project || null, data.task || null, @@ -1169,7 +1169,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 ? 1 : null), data.id ]); @@ -1188,15 +1188,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 +1213,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 +1225,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 +1265,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 +1276,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 +1284,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 +1305,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 +1349,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 +1363,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 +1371,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 +1389,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, diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index 422983ba..f700b5a9 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; @@ -256,15 +274,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"), { @@ -490,15 +500,7 @@ 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(); } } diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 8a0525c5..552d8e7e 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -842,7 +842,8 @@ Page { }); } 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, diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 7323e1ca..05571b54 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -120,7 +120,10 @@ Page { } 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, cannot save", "error"); @@ -202,7 +205,10 @@ Page { // 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 +492,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); } } diff --git a/qml/features/timesheets/pages/Timesheet_Page.qml b/qml/features/timesheets/pages/Timesheet_Page.qml index 54fb043e..c5053bac 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, @@ -361,8 +368,15 @@ Page { ] 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, From 4afa14063597b375c7a2706ee175eef24488b642 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 12:26:55 +0530 Subject: [PATCH 039/105] Remove task plan file global-local-account-toggle.md --- global-local-account-toggle.md | 65 ---------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 global-local-account-toggle.md diff --git a/global-local-account-toggle.md b/global-local-account-toggle.md deleted file mode 100644 index a10c39a1..00000000 --- a/global-local-account-toggle.md +++ /dev/null @@ -1,65 +0,0 @@ -# Implementation Plan: Global Local Account Toggle - -## 1. Overview -Add a dedicated `Switch` control to the menu header in both desktop (`MenuPage.qml`) and mobile (`AppDrawer.qml`) layouts to enable instant global switching between the Local Account (`accountId = 0`) and the previously active or default remote account (`accountId > 0`). - ---- - -## 2. Requirements & Behavior -1. **Control Type**: Dedicated compact `Switch` component styled cleanly in the header. -2. **Placement**: Placed adjacent to the account selector button in both `qml/app/navigation/MenuPage.qml` and `qml/app/AppDrawer.qml`. -3. **Toggle ON (Checked)**: - - Switches the global application account to `0` ("Local Account"). - - Triggers `rootApp.globalAccountChanged(0, "Local Account")` and `rootApp.accountDataRefreshRequested(0)`. -4. **Toggle OFF (Unchecked)**: - - Restores the previously active remote account (`lastRemoteAccountId`). - - If no previous remote account was used in the session, falls back to the default account selected in settings (`is_default = 1` where `id > 0`), or the first available remote account. -5. **Bidirectional Synchronization**: - - `Switch.checked` reflects `accountPicker.selectedAccountId === 0`. - - When the user selects "Local Account" via `AccountSelectorDialog`, `Switch` turns ON automatically. - - When the user selects a remote account (e.g. "CIT") via `AccountSelectorDialog`, `Switch` turns OFF automatically and records that remote account as `lastRemoteAccountId`. -6. **Account Label Clickability**: - - The account selector label remains clickable at all times. - ---- - -## 3. Architecture & File Changes - -### Task 1: Remote Account Memory Helper -* **File**: `models/accounts.js` -* **Action**: Add `getDefaultRemoteAccountId()` helper to find the default remote account (`id > 0` and `is_default = 1`) or first remote account (`id > 0`). - -### Task 2: State Tracking & Switch Logic in Global Context -* **File**: `qml/components/dialogs/AccountSelectorDialog.qml` and `qml/app/GlobalWidgets.qml` -* **Action**: - - Add `property int lastRemoteAccountId` initialized to `Accounts.getDefaultRemoteAccountId()`. - - In `accountPicker.onAccepted` (or inside dialog): if `id > 0`, update `lastRemoteAccountId = id`. - - Add a helper method `switchToLocalMode(bool enableLocal)` to execute the toggle transition cleanly. - -### Task 3: Header Toggle in Desktop View -* **File**: `qml/app/navigation/MenuPage.qml` -* **Action**: - - Add the `Switch` component in the header `RowLayout` beside the account selector button. - - Bind `checked: typeof accountPicker !== "undefined" && accountPicker.selectedAccountId === 0`. - - Handle toggle action to switch between local and remote mode. - -### Task 4: Header Toggle in Mobile Drawer View -* **File**: `qml/app/AppDrawer.qml` -* **Action**: - - Mirror the `Switch` component in the drawer header `RowLayout` with identical bindings and behaviors. - -### Task 5: Verification & Regression Testing -* **Action**: - - Test toggling ON switches app to Local Account (tasks, projects, dashboard update). - - Test toggling OFF restores the previous remote account (e.g. "CIT"). - - Test opening `AccountSelectorDialog` and choosing an account updates the switch. - - Run lint and full project checklist (`python3 .agent/scripts/checklist.py .`). - ---- - -## 4. Verification Criteria -- [ ] Toggling ON sets global account to 0 ("Local Account") across all pages. -- [ ] Toggling OFF restores the last active remote account. -- [ ] Selecting an account in the dialog keeps the toggle in sync. -- [ ] Header layout in both desktop and mobile drawer remains visually balanced without overflow or clipping. -- [ ] No regression on remote or local synchronization and data queries. From dbe242a718aa06d05d83666b2508827994b4919f Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 13:33:36 +0530 Subject: [PATCH 040/105] Change local account switch accent color to orange --- qml/app/AppDrawer.qml | 6 ++++++ qml/app/navigation/MenuPage.qml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 02e14b92..816b8413 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -2,6 +2,7 @@ import QtQuick 2.6 import QtQuick.Controls 2.2 as Controls import QtQuick.Layouts 1.3 import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 import "../components" import "navigation/NavigationRoutes.js" as NavigationRoutes @@ -110,6 +111,11 @@ Controls.Drawer { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onClicked: { if (typeof accountPicker !== "undefined") { diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 1d059b50..2797d2f4 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -24,6 +24,7 @@ import QtQuick 2.7 import Lomiri.Components 1.3 +import Lomiri.Components.Themes.Ambiance 1.3 import QtCharts 2.0 import QtQuick.Layouts 1.11 import Qt.labs.settings 1.0 @@ -114,6 +115,11 @@ Page { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + style: Component { + SwitchStyle { + checkedBackgroundColor: LomiriColors.orange + } + } onClicked: { if (typeof accountPicker !== "undefined") { From 615297c46d2b9c50a433ba7fa68c0def62e1e621 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 13:44:44 +0530 Subject: [PATCH 041/105] Use lighter orange accent for local account switch --- qml/app/AppDrawer.qml | 2 +- qml/app/navigation/MenuPage.qml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 816b8413..68561ddd 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -113,7 +113,7 @@ Controls.Drawer { checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - checkedBackgroundColor: LomiriColors.orange + checkedBackgroundColor: "#FFA766" } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 2797d2f4..038e4cd2 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -117,7 +117,7 @@ Page { checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - checkedBackgroundColor: LomiriColors.orange + checkedBackgroundColor: "#FFA766" } } From a5c974ac1ba95bb7e0d98371321a7f41f631af16 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 13:47:30 +0530 Subject: [PATCH 042/105] Match switch checked background to menu item highlight color --- qml/app/AppDrawer.qml | 2 +- qml/app/navigation/MenuPage.qml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 68561ddd..46cc9db9 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -113,7 +113,7 @@ Controls.Drawer { checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - checkedBackgroundColor: "#FFA766" + checkedBackgroundColor: "#fff1de" } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 038e4cd2..6d4c70ca 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -117,7 +117,7 @@ Page { checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - checkedBackgroundColor: "#FFA766" + checkedBackgroundColor: "#fff1de" } } From e114ead7e733bb1e24c14b2b756cf2ad655baa69 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 13:48:38 +0530 Subject: [PATCH 043/105] Darken switch checked background to warm peach accent --- qml/app/AppDrawer.qml | 2 +- qml/app/navigation/MenuPage.qml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 46cc9db9..17fc1612 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -113,7 +113,7 @@ Controls.Drawer { checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - checkedBackgroundColor: "#fff1de" + checkedBackgroundColor: "#ffd8a8" } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 6d4c70ca..9f33406b 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -117,7 +117,7 @@ Page { checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - checkedBackgroundColor: "#fff1de" + checkedBackgroundColor: "#ffd8a8" } } From f0932ee00f028c3d75c87a1fac45278c9c8c7a49 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 15:03:58 +0530 Subject: [PATCH 044/105] Fix dashboard charts data aggregation and account sync for local account --- models/Main.js | 48 +++++++++++------ models/project.js | 58 ++++++++++++++------- models/task.js | 10 ++-- qml/features/dashboard/charts/Charts3.qml | 31 +++++++++-- qml/features/dashboard/charts/Charts4.qml | 34 ++++++++++-- qml/features/dashboard/pages/Dashboard.qml | 36 +++++++++++-- qml/features/dashboard/pages/Dashboard2.qml | 35 +++++++++++-- 7 files changed, 196 insertions(+), 56 deletions(-) diff --git a/models/Main.js b/models/Main.js index 2248e576..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 OR (a.account_id = 0 AND p.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/project.js b/models/project.js index 1ce6695c..8ded0571 100644 --- a/models/project.js +++ b/models/project.js @@ -1223,11 +1223,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) { @@ -1239,21 +1248,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 }); } @@ -1267,10 +1277,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) { @@ -1282,20 +1300,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 }); } } @@ -1342,18 +1361,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"; @@ -1363,7 +1383,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, diff --git a/models/task.js b/models/task.js index b6d9e5df..20fce44a 100644 --- a/models/task.js +++ b/models/task.js @@ -2953,7 +2953,7 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) 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(?)"); @@ -2999,19 +2999,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(?)"; 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..77af208a 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, @@ -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 f700b5a9..46e0045f 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -237,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; @@ -438,6 +443,8 @@ Page { onLoaded: { if (item) { item.autoRefreshOnAccountChange = false; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + item.selectedAccountId = accId; } } } @@ -452,6 +459,8 @@ Page { onLoaded: { if (item) { item.autoRefreshOnAccountChange = false; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + item.selectedAccountId = accId; } } } @@ -506,13 +515,30 @@ Page { } 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..600bbc82 100644 --- a/qml/features/dashboard/pages/Dashboard2.qml +++ b/qml/features/dashboard/pages/Dashboard2.qml @@ -91,19 +91,34 @@ Page { 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 +169,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 +193,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); } } } From a364fb685b164427035fdfda1e4a49e06d1b1b89 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 16:34:02 +0530 Subject: [PATCH 045/105] Reduce size of Local Account toggle switch in drawer and menu --- qml/app/AppDrawer.qml | 6 ++++++ qml/app/navigation/MenuPage.qml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 17fc1612..c6ceb6fa 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -110,9 +110,15 @@ Controls.Drawer { Switch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: units.gu(3.6) + Layout.preferredHeight: units.gu(1.8) + width: units.gu(3.6) + height: units.gu(1.8) checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { + implicitWidth: units.gu(3.6) + implicitHeight: units.gu(1.8) checkedBackgroundColor: "#ffd8a8" } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 9f33406b..75173e94 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -114,9 +114,15 @@ Page { Switch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: units.gu(3.6) + Layout.preferredHeight: units.gu(1.8) + width: units.gu(3.6) + height: units.gu(1.8) checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { + implicitWidth: units.gu(3.6) + implicitHeight: units.gu(1.8) checkedBackgroundColor: "#ffd8a8" } } From 2033c4abb15f908354231b73c94e01f48183311d Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 16:48:25 +0530 Subject: [PATCH 046/105] Adjust toggle switch size to 4.2x2.1 and display Local instead of Local Account --- models/accounts.js | 14 +++++++++++++- models/database.js | 10 +++++++--- qml/app/AppDrawer.qml | 17 ++++++++++------- qml/app/navigation/MenuPage.qml | 17 ++++++++++------- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/models/accounts.js b/models/accounts.js index b3cfdd36..d300608b 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); } }); @@ -595,6 +599,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 = ""; @@ -606,6 +614,10 @@ function getAccountName(accountId) { } }); + if (name === "Local Account") { + return "Local"; + } + return name; } catch (e) { Logger.error("Accounts", "getAccountName failed:", e) diff --git a/models/database.js b/models/database.js index be7aef50..59c7ef1d 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 diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index c6ceb6fa..9e9e7755 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -84,7 +84,10 @@ Controls.Drawer { Label { id: accountNameLabel Layout.alignment: Qt.AlignVCenter - text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" + 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 @@ -110,15 +113,15 @@ Controls.Drawer { Switch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: units.gu(3.6) - Layout.preferredHeight: units.gu(1.8) - width: units.gu(3.6) - height: units.gu(1.8) + Layout.preferredWidth: units.gu(4.2) + Layout.preferredHeight: units.gu(2.1) + width: units.gu(4.2) + height: units.gu(2.1) checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - implicitWidth: units.gu(3.6) - implicitHeight: units.gu(1.8) + implicitWidth: units.gu(4.2) + implicitHeight: units.gu(2.1) checkedBackgroundColor: "#ffd8a8" } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 75173e94..0ec8e655 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -89,7 +89,10 @@ Page { Label { id: accountLabel Layout.alignment: Qt.AlignVCenter - text: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountName : "" + 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 @@ -114,15 +117,15 @@ Page { Switch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: units.gu(3.6) - Layout.preferredHeight: units.gu(1.8) - width: units.gu(3.6) - height: units.gu(1.8) + Layout.preferredWidth: units.gu(4.2) + Layout.preferredHeight: units.gu(2.1) + width: units.gu(4.2) + height: units.gu(2.1) checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false style: Component { SwitchStyle { - implicitWidth: units.gu(3.6) - implicitHeight: units.gu(1.8) + implicitWidth: units.gu(4.2) + implicitHeight: units.gu(2.1) checkedBackgroundColor: "#ffd8a8" } } From 5ae3e75afff330804a55d354a7d012e063cac787 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 17:10:22 +0530 Subject: [PATCH 047/105] Apply consistent SwitchStyle accent color to all switches throughout application --- .../settings/components/SettingsToggleItem.qml | 5 +++++ qml/features/settings/pages/Account_Page.qml | 10 ++++++++++ qml/features/settings/pages/Settings_Notifications.qml | 10 ++++++++++ qml/features/settings/pages/Settings_Sync.qml | 5 +++++ qml/features/settings/pages/Settings_VoiceModel.qml | 10 ++++++++++ 5 files changed, 40 insertions(+) diff --git a/qml/features/settings/components/SettingsToggleItem.qml b/qml/features/settings/components/SettingsToggleItem.qml index e2a95b5a..874e13af 100644 --- a/qml/features/settings/components/SettingsToggleItem.qml +++ b/qml/features/settings/components/SettingsToggleItem.qml @@ -134,6 +134,11 @@ Item { anchors.centerIn: parent checked: root.checked enabled: root.enabled + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } 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..88a89b60 100644 --- a/qml/features/settings/pages/Account_Page.qml +++ b/qml/features/settings/pages/Account_Page.qml @@ -601,6 +601,11 @@ Page { checked: useCustomSyncSettings enabled: !isReadOnly anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } onCheckedChanged: { useCustomSyncSettings = checked; } @@ -635,6 +640,11 @@ Page { checked: true enabled: !isReadOnly anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } } } diff --git a/qml/features/settings/pages/Settings_Notifications.qml b/qml/features/settings/pages/Settings_Notifications.qml index 1c08e075..eeeb0352 100644 --- a/qml/features/settings/pages/Settings_Notifications.qml +++ b/qml/features/settings/pages/Settings_Notifications.qml @@ -225,6 +225,11 @@ Page { checked: notificationSettingsPage.notificationsEnabled && !notificationSettingsPage.isSyncUploadOnly enabled: !notificationSettingsPage.isSyncUploadOnly anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } onClicked: { notificationSettingsPage.notificationsEnabled = checked; saveAutoSyncSetting("notifications_enabled", checked ? "true" : "false"); @@ -305,6 +310,11 @@ Page { checked: getAutoSyncSetting("notification_schedule_enabled") === "true" enabled: notificationSettingsPage.notificationsEffectivelyEnabled anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } onClicked: { saveAutoSyncSetting("notification_schedule_enabled", checked ? "true" : "false"); } diff --git a/qml/features/settings/pages/Settings_Sync.qml b/qml/features/settings/pages/Settings_Sync.qml index 53492e6a..4c7665e7 100644 --- a/qml/features/settings/pages/Settings_Sync.qml +++ b/qml/features/settings/pages/Settings_Sync.qml @@ -164,6 +164,11 @@ Page { id: autoSyncSwitch checked: getAutoSyncSetting("autosync_enabled") === "true" anchors.verticalCenter: parent.verticalCenter + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } 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..aab3ba2a 100644 --- a/qml/features/settings/pages/Settings_VoiceModel.qml +++ b/qml/features/settings/pages/Settings_VoiceModel.qml @@ -737,6 +737,11 @@ Page { anchors.rightMargin: units.gu(2) anchors.verticalCenter: parent.verticalCenter checked: isVoiceInputEnabled + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } onCheckedChanged: { if (checked !== isVoiceInputEnabled) { saveVoiceInputEnabledSetting(checked); @@ -765,6 +770,11 @@ Page { anchors.rightMargin: units.gu(2) anchors.verticalCenter: parent.verticalCenter checked: isVoiceLowMemoryMode + style: Component { + SwitchStyle { + checkedBackgroundColor: "#ffd8a8" + } + } onCheckedChanged: { if (checked !== isVoiceLowMemoryMode) { saveVoiceLowMemoryModeSetting(checked); From abfc5793f62b24ba7ebc0fc05106f043faedcfb4 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 17:38:15 +0530 Subject: [PATCH 048/105] Import Lomiri.Components.Themes.Ambiance for SwitchStyle in settings pages --- qml/features/settings/components/SettingsToggleItem.qml | 1 + qml/features/settings/pages/Account_Page.qml | 1 + qml/features/settings/pages/Settings_Notifications.qml | 1 + qml/features/settings/pages/Settings_Sync.qml | 1 + qml/features/settings/pages/Settings_VoiceModel.qml | 1 + 5 files changed, 5 insertions(+) diff --git a/qml/features/settings/components/SettingsToggleItem.qml b/qml/features/settings/components/SettingsToggleItem.qml index 874e13af..cecd7fa3 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. diff --git a/qml/features/settings/pages/Account_Page.qml b/qml/features/settings/pages/Account_Page.qml index 88a89b60..3e4b1759 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 diff --git a/qml/features/settings/pages/Settings_Notifications.qml b/qml/features/settings/pages/Settings_Notifications.qml index eeeb0352..bbd39ab5 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" diff --git a/qml/features/settings/pages/Settings_Sync.qml b/qml/features/settings/pages/Settings_Sync.qml index 4c7665e7..0c0247da 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" diff --git a/qml/features/settings/pages/Settings_VoiceModel.qml b/qml/features/settings/pages/Settings_VoiceModel.qml index aab3ba2a..2ca555b0 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" From 0c4c67e1c373f093918ec79d3b1289d8ee23edb6 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 17:51:48 +0530 Subject: [PATCH 049/105] Align switch active colors with brand orange and contextual header contrast --- qml/app/AppDrawer.qml | 2 +- qml/app/navigation/MenuPage.qml | 2 +- qml/features/settings/components/SettingsToggleItem.qml | 2 +- qml/features/settings/pages/Account_Page.qml | 4 ++-- qml/features/settings/pages/Settings_Notifications.qml | 4 ++-- qml/features/settings/pages/Settings_Sync.qml | 2 +- qml/features/settings/pages/Settings_VoiceModel.qml | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 9e9e7755..f0fface1 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -122,7 +122,7 @@ Controls.Drawer { SwitchStyle { implicitWidth: units.gu(4.2) implicitHeight: units.gu(2.1) - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: Qt.darker(LomiriColors.orange, 1.35) } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 0ec8e655..35dae3de 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -126,7 +126,7 @@ Page { SwitchStyle { implicitWidth: units.gu(4.2) implicitHeight: units.gu(2.1) - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: Qt.darker(LomiriColors.orange, 1.35) } } diff --git a/qml/features/settings/components/SettingsToggleItem.qml b/qml/features/settings/components/SettingsToggleItem.qml index cecd7fa3..51f06694 100644 --- a/qml/features/settings/components/SettingsToggleItem.qml +++ b/qml/features/settings/components/SettingsToggleItem.qml @@ -137,7 +137,7 @@ Item { enabled: root.enabled style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onClicked: { diff --git a/qml/features/settings/pages/Account_Page.qml b/qml/features/settings/pages/Account_Page.qml index 3e4b1759..fd05afcd 100644 --- a/qml/features/settings/pages/Account_Page.qml +++ b/qml/features/settings/pages/Account_Page.qml @@ -604,7 +604,7 @@ Page { anchors.verticalCenter: parent.verticalCenter style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onCheckedChanged: { @@ -643,7 +643,7 @@ Page { anchors.verticalCenter: parent.verticalCenter style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } } diff --git a/qml/features/settings/pages/Settings_Notifications.qml b/qml/features/settings/pages/Settings_Notifications.qml index bbd39ab5..8f8324f4 100644 --- a/qml/features/settings/pages/Settings_Notifications.qml +++ b/qml/features/settings/pages/Settings_Notifications.qml @@ -228,7 +228,7 @@ Page { anchors.verticalCenter: parent.verticalCenter style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onClicked: { @@ -313,7 +313,7 @@ Page { anchors.verticalCenter: parent.verticalCenter style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onClicked: { diff --git a/qml/features/settings/pages/Settings_Sync.qml b/qml/features/settings/pages/Settings_Sync.qml index 0c0247da..ba4e4a70 100644 --- a/qml/features/settings/pages/Settings_Sync.qml +++ b/qml/features/settings/pages/Settings_Sync.qml @@ -167,7 +167,7 @@ Page { anchors.verticalCenter: parent.verticalCenter style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onCheckedChanged: { diff --git a/qml/features/settings/pages/Settings_VoiceModel.qml b/qml/features/settings/pages/Settings_VoiceModel.qml index 2ca555b0..d05cfe3d 100644 --- a/qml/features/settings/pages/Settings_VoiceModel.qml +++ b/qml/features/settings/pages/Settings_VoiceModel.qml @@ -740,7 +740,7 @@ Page { checked: isVoiceInputEnabled style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onCheckedChanged: { @@ -773,7 +773,7 @@ Page { checked: isVoiceLowMemoryMode style: Component { SwitchStyle { - checkedBackgroundColor: "#ffd8a8" + checkedBackgroundColor: LomiriColors.orange } } onCheckedChanged: { From b66292ff95cc4cd4f5436be245f60264f7d467b8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 17:56:33 +0530 Subject: [PATCH 050/105] Update working days selection buttons to use brand orange accent --- qml/features/settings/pages/Settings_Notifications.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/features/settings/pages/Settings_Notifications.qml b/qml/features/settings/pages/Settings_Notifications.qml index 8f8324f4..24837d10 100644 --- a/qml/features/settings/pages/Settings_Notifications.qml +++ b/qml/features/settings/pages/Settings_Notifications.qml @@ -455,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"; } From 856b5bf8f75d3de53a82ea8d80b502b83e4e2945 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 2 Sep 2026 18:02:35 +0530 Subject: [PATCH 051/105] Replace sky blue button colors with brand orange and modernize header save icon --- models/constants.js | 8 ++++---- qml/features/activities/pages/Activities.qml | 2 +- qml/features/projects/pages/Projects.qml | 2 +- qml/features/settings/pages/Account_Page.qml | 2 +- qml/features/tasks/pages/Tasks.qml | 2 +- .../timesheets/components/TimeSheetDetailsCard.qml | 2 +- qml/features/timesheets/pages/Timesheet.qml | 2 +- qml/features/updates/pages/Updates.qml | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/models/constants.js b/models/constants.js index 0a0d4ab2..61f60d98 100644 --- a/models/constants.js +++ b/models/constants.js @@ -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/qml/features/activities/pages/Activities.qml b/qml/features/activities/pages/Activities.qml index 85b312e7..ad7a01b4 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: { diff --git a/qml/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index f24acd0a..373b195b 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -65,7 +65,7 @@ Page { trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" text: i18n.dtr("ubtms", "Save") visible: !isReadOnly onTriggered: { diff --git a/qml/features/settings/pages/Account_Page.qml b/qml/features/settings/pages/Account_Page.qml index fd05afcd..a8b2a7a1 100644 --- a/qml/features/settings/pages/Account_Page.qml +++ b/qml/features/settings/pages/Account_Page.qml @@ -64,7 +64,7 @@ Page { } trailingActionBar.actions: [ Action { - iconSource: "../../../images/save.svg" + iconName: "tick" visible: !isReadOnly text: i18n.dtr("ubtms","Save") diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 552d8e7e..75d0a7d6 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: { diff --git a/qml/features/timesheets/components/TimeSheetDetailsCard.qml b/qml/features/timesheets/components/TimeSheetDetailsCard.qml index 9b2d118e..ee266875 100644 --- a/qml/features/timesheets/components/TimeSheetDetailsCard.qml +++ b/qml/features/timesheets/components/TimeSheetDetailsCard.qml @@ -164,7 +164,7 @@ ListItem { Action { id: readyAction visible: (recordId !== TimerService.getActiveTimesheetId()) //Dont show this for the active running entry - iconSource: "../../../images/save.svg" + 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 05571b54..7b4d4af4 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: { diff --git a/qml/features/updates/pages/Updates.qml b/qml/features/updates/pages/Updates.qml index ddb740c1..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: { From 2a82b7f887523c515fb793c9e6be3cecb7ea745b Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:02:37 +0530 Subject: [PATCH 052/105] Add getProjectTaskCountMap helper to calculate project and subproject task counts --- models/project.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/models/project.js b/models/project.js index 8ded0571..5d8fef53 100644 --- a/models/project.js +++ b/models/project.js @@ -1479,3 +1479,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; +} From 3029984a29eb5e736b5d0f4b6fc9acb3124df0a2 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:02:42 +0530 Subject: [PATCH 053/105] Implement breadcrumb navigation bar and bind project task counts in ProjectList --- qml/components/visualization/ProjectList.qml | 259 ++++++++++++++----- 1 file changed, 194 insertions(+), 65 deletions(-) diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index aba0236d..f2a904e0 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 ".." /* @@ -98,6 +99,7 @@ Item { currentAccountId = id; navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; // Reset to default "Open" filter stageFilter.enabled = true; @@ -113,6 +115,7 @@ Item { property int currentParentId: -1 property int currentAccountId: accountPicker.selectedAccountId + property string currentParentName: "" property ListModel navigationStackModel: ListModel {} property var childrenMap: ({}) property bool childrenMapReady: false @@ -147,7 +150,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 +159,22 @@ 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; + currentAccountId = last.accountId !== undefined ? last.accountId : -1; + currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + } } function selectProject(localId) { @@ -177,6 +192,7 @@ Item { function refresh() { navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; currentAccountId = accountPicker.selectedAccountId; // Reset pagination @@ -202,6 +218,7 @@ Item { if (flatViewMode) { navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; } // Refresh the model @@ -318,6 +335,7 @@ Item { } var tempMap = {}; + var taskCountMap = Project.getProjectTaskCountMap ? Project.getProjectTaskCountMap(currentAccountId) : {}; // First pass: Create project color map for inheritance lookup var projectColorMap = {}; @@ -341,6 +359,8 @@ Item { inheritedColor = projectColorMap[parentOdooId] || 0; } + var taskCount = taskCountMap[effectiveId] || (taskCountMap[row.id] || 0); + var item = { id_val: effectiveId, local_id: row.id, @@ -360,7 +380,8 @@ Item { 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 @@ -766,27 +787,139 @@ Item { } } - // 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 ? units.gu(6) : 0) clip: true + spacing: 0 model: getCurrentModel() footer: LoadMoreFooter { @@ -801,58 +934,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); + } } } } From b19f7e34ffac440e83ec68336db3a1d32038c24d Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:02:48 +0530 Subject: [PATCH 054/105] Redesign ProjectDetailsCard with clean typography, left-aligned star, and balanced spacing --- qml/components/cards/ProjectDetailsCard.qml | 589 ++++++++++++-------- 1 file changed, 352 insertions(+), 237 deletions(-) diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index bd102830..e7ec60fa 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,108 @@ 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) || "") : "" + 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(" • "); + } Connections { target: globalTimerWidget @@ -88,10 +193,8 @@ ListItem { function play_pause_workflow() { if (Timesheet.doesProjectIdMatchSheetInActive(recordId, 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 { @@ -141,272 +244,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) { - // For projects with children, emit navigation signal - var navId = (projectCard.accountId === 0 || recordId <= 0) ? localId : recordId; - navigationRequested(navId, 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 { - property string stageName: (stage && stage > 0) ? (Project.getProjectStageName(stage) || "") : "" - property bool isDone: { - var lower = stageName.toLowerCase(); - return lower === "completed" || lower === "finished" || lower === "closed" || lower === "verified" || lower === "done"; - } - visible: stageName !== "" - text: stageName - color: isDone ? "green" : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#bbb" : "#555") - font.pixelSize: units.gu(1.75) - font.bold: isDone - } - - 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 + } } } From a547c6b137ab7ec9cbd55dc192f03166d2706661 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:47:21 +0530 Subject: [PATCH 055/105] Support local account automated timesheet lifecycle in timesheet model --- models/timesheet.js | 133 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 25 deletions(-) diff --git a/models/timesheet.js b/models/timesheet.js index 7fea3625..6d298a00 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]; @@ -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]; @@ -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]; @@ -328,7 +352,7 @@ 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 = ? OR id = ?) LIMIT 1", [row.task_id, row.task_id]); if (rs_task.rows.length > 0) taskName = rs_task.rows.item(0).name; @@ -347,6 +371,7 @@ function fetchTimesheetsByStatusPaginated(status, accountId, limit, offset) { 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]; @@ -426,7 +459,7 @@ 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 = ? OR id = ?) LIMIT 1", [row.task_id, row.task_id]); if (rs_task.rows.length > 0) taskName = rs_task.rows.item(0).name; @@ -445,6 +478,7 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { 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, @@ -1430,8 +1464,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; @@ -1517,15 +1551,71 @@ 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) { + accountId = rs.rows.item(0).account_id; + } + }); + } 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"; @@ -1556,14 +1646,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 @@ -1577,7 +1660,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) } }); From a9e8fe97abb87a08582a175c114dea805bc512d2 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:47:25 +0530 Subject: [PATCH 056/105] Enable automated timer actions on Project and Task cards for local accounts --- qml/components/cards/ProjectDetailsCard.qml | 29 ++++++++++++++----- .../tasks/components/TaskDetailsCard.qml | 26 ++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index e7ec60fa..a63bb6d7 100644 --- a/qml/components/cards/ProjectDetailsCard.qml +++ b/qml/components/cards/ProjectDetailsCard.qml @@ -167,6 +167,8 @@ ListItem { return parts.join(" • "); } + property int effectiveProjectId: (projectCard.accountId === 0 || recordId <= 0) ? localId : recordId + Connections { target: globalTimerWidget onTimerStopped: { @@ -174,31 +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()) { TimerService.pause(); } else if (TimerService.isPaused()) { 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) { @@ -211,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); + } } } @@ -225,7 +238,7 @@ ListItem { Action { id: playpauseaction iconSource: timer_on ? (timer_paused ? "../../images/play.png" : "../../images/pause.png") : "../../images/play.png" - visible: projectCard.accountId > 0 && recordId > 0 + visible: (projectCard.accountId === 0 && localId > 0) || (projectCard.accountId > 0 && recordId > 0) text: "Start Timer" onTriggered: { play_pause_workflow(); @@ -233,7 +246,7 @@ ListItem { }, Action { id: startstopaction - visible: projectCard.accountId > 0 && recordId > 0 + visible: (projectCard.accountId === 0 && localId > 0) || (projectCard.accountId > 0 && recordId > 0) iconSource: "../../images/stop.png" text: i18n.dtr("ubtms", "Stop Timer") onTriggered: { diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 81d610e3..561a684a 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -60,6 +60,7 @@ ListItem { 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 signal editRequested(int localId) signal deleteRequested(int localId) @@ -102,17 +103,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 +174,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 +183,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 +198,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 +243,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 +252,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: { @@ -674,7 +680,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) { From a666688186fc8ba37eb8351528dc8f0fb5da952f Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:47:28 +0530 Subject: [PATCH 057/105] Decouple local account timesheets from sync in UI components --- .../timesheets/components/TimeRecorderWidget.qml | 9 +++++++-- .../timesheets/components/TimeSheetDetailsCard.qml | 3 ++- qml/features/timesheets/pages/Timesheet.qml | 2 +- qml/features/timesheets/pages/Timesheet_Page.qml | 4 +++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/qml/features/timesheets/components/TimeRecorderWidget.qml b/qml/features/timesheets/components/TimeRecorderWidget.qml index 42a16020..5019d89e 100644 --- a/qml/features/timesheets/components/TimeRecorderWidget.qml +++ b/qml/features/timesheets/components/TimeRecorderWidget.qml @@ -225,9 +225,14 @@ Item { 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"); + } } } } diff --git a/qml/features/timesheets/components/TimeSheetDetailsCard.qml b/qml/features/timesheets/components/TimeSheetDetailsCard.qml index ee266875..cfeacc49 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 @@ -163,7 +164,7 @@ ListItem { }, Action { id: readyAction - visible: (recordId !== TimerService.getActiveTimesheetId()) //Dont show this for the active running entry + 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: { diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 7b4d4af4..9d71f5f3 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -168,7 +168,7 @@ Page { 'quadrant': priorityGrid.currentIndex + 1, 'user_id': user, 'timer_type': isTimerActive ? "automatic" : "manual", - 'status': isTimerActive ? "active" : (currentStatus === "ready" || currentStatus === "updated" ? currentStatus : "draft") + 'status': isTimerActive ? "active" : (currentStatus === "ready" || currentStatus === "updated" || currentStatus === "saved" ? currentStatus : "draft") }; if (recordid && recordid !== 0) { timesheet_data.id = recordid; diff --git a/qml/features/timesheets/pages/Timesheet_Page.qml b/qml/features/timesheets/pages/Timesheet_Page.qml index c5053bac..80c19766 100644 --- a/qml/features/timesheets/pages/Timesheet_Page.qml +++ b/qml/features/timesheets/pages/Timesheet_Page.qml @@ -261,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, @@ -317,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 || "" From 008a792bec9a7d1cab655d7aa39dadfc5eb67760 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 15:58:57 +0530 Subject: [PATCH 058/105] Fix saving, drafting, and finalizing running timesheets for local accounts --- models/timesheet.js | 11 +- qml/components/system/GlobalTimerWidget.qml | 4 + .../components/TimeRecorderWidget.qml | 44 +++++-- .../components/TimeSheetDescriptionPopup.qml | 37 ++++-- .../components/TimeSheetDetailsCard.qml | 15 ++- qml/features/timesheets/pages/Timesheet.qml | 123 +++++++++++++++++- 6 files changed, 197 insertions(+), 37 deletions(-) diff --git a/models/timesheet.js b/models/timesheet.js index 6d298a00..6a609e2e 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -1054,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, @@ -1121,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, @@ -1191,7 +1195,7 @@ function saveTimesheet(data) { has_draft = 0 WHERE id = ?`, [ - (data.instance_id !== undefined && data.instance_id !== null) ? 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, @@ -1203,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 !== "") ? data.user_id : (data.instance_id === 0 ? 1 : 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 ]); @@ -1563,7 +1567,8 @@ function getTimesheetAccountId(timesheetId) { 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) { - accountId = rs.rows.item(0).account_id; + var raw = rs.rows.item(0).account_id; + accountId = (raw !== undefined && raw !== null) ? parseInt(raw) : 0; } }); } catch (e) { diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 6037fb20..79361258 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -518,7 +518,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) { + Model.markTimesheetAsSavedById(targetId); + } } onFinalized: function (success, message) { diff --git a/qml/features/timesheets/components/TimeRecorderWidget.qml b/qml/features/timesheets/components/TimeRecorderWidget.qml index 5019d89e..68a4fc5e 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(); } } @@ -219,6 +245,8 @@ Item { return; } + autoRecorder.beforeStop(); + if (TimerService.isRunning() && TimerService.getActiveTimesheetId() === timesheetId) { TimerService.stop(); } @@ -234,6 +262,8 @@ Item { notifPopup.open("Saved", "Timesheet has been finalised successfully", "success"); } } + + autoRecorder.stopped(); } } } @@ -266,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 cfeacc49..64d066e7 100644 --- a/qml/features/timesheets/components/TimeSheetDetailsCard.qml +++ b/qml/features/timesheets/components/TimeSheetDetailsCard.qml @@ -78,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() { @@ -88,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"); } } diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index 9d71f5f3..f05de34a 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -85,6 +85,14 @@ Page { save_timesheet(); } }, + Action { + iconName: "ok" + visible: !isReadOnly + text: "Finalize" + onTriggered: { + finalize_timesheet(); + } + }, Action { iconName: "edit" visible: isReadOnly && recordid !== 0 @@ -120,8 +128,9 @@ Page { } const ids = workItem.getIds(); + var isLocal = (ids.account_id === 0); var user = Accounts.getCurrentUserOdooId(ids.account_id); - if (ids.account_id === 0 && (!user || user <= 0)) { + if (isLocal && (!user || user <= 0)) { user = 1; } @@ -135,18 +144,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; } @@ -156,6 +166,13 @@ Page { description = description.trim(); } + var determinedStatus = "draft"; + if (isTimerActive) { + determinedStatus = "active"; + } else if (currentStatus === "ready" || currentStatus === "updated" || currentStatus === "saved") { + determinedStatus = currentStatus; + } + var timesheet_data = { 'record_date': date_widget.formattedDate(), 'instance_id': ids.account_id < 0 ? 0 : ids.account_id, @@ -168,7 +185,7 @@ Page { 'quadrant': priorityGrid.currentIndex + 1, 'user_id': user, 'timer_type': isTimerActive ? "automatic" : "manual", - 'status': isTimerActive ? "active" : (currentStatus === "ready" || currentStatus === "updated" || currentStatus === "saved" ? currentStatus : "draft") + 'status': determinedStatus }; if (recordid && recordid !== 0) { timesheet_data.id = recordid; @@ -201,6 +218,90 @@ 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() { @@ -782,6 +883,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: { From 54e1d57cdee545ae1059a0b455db1f8054b49b35 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 16:15:59 +0530 Subject: [PATCH 059/105] Modernize navigation hamburger menu, floating action menu UI, and account chips --- qml/app/AppDrawer.qml | 26 +- qml/app/navigation/MenuPage.qml | 26 +- qml/components/base/TSIconButton.qml | 10 +- qml/components/navigation/DialerMenu.qml | 286 +++++++++++++++--- qml/components/system/GlobalTimerWidget.qml | 3 +- qml/features/dashboard/pages/Dashboard.qml | 9 +- qml/features/tasks/pages/MyTasksPage.qml | 3 +- qml/features/tasks/pages/Task_Page.qml | 3 +- .../timesheets/pages/Timesheet_Page.qml | 3 +- 9 files changed, 300 insertions(+), 69 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index f0fface1..06f72516 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -61,22 +61,30 @@ Controls.Drawer { Layout.fillWidth: true } - // Account Selector button with Account label adjacent to icon - Item { + // Account Selector chip with Account label adjacent to icon + Rectangle { id: accountSelectorItem - implicitWidth: accountRow.implicitWidth - implicitHeight: accountRow.implicitHeight + implicitWidth: accountRow.implicitWidth + units.gu(1.8) + implicitHeight: units.gu(3.6) + 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.fill: parent - spacing: units.gu(0.5) + anchors.centerIn: parent + spacing: units.gu(0.6) Icon { name: "account" - width: units.gu(2.4) - height: units.gu(2.4) + width: units.gu(2.2) + height: units.gu(2.2) color: "white" Layout.alignment: Qt.AlignVCenter } @@ -98,7 +106,9 @@ Controls.Drawer { } MouseArea { + id: accountMouseArea anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { drawerRoot.close(); diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 35dae3de..e980ee40 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -66,22 +66,30 @@ Page { Layout.fillWidth: true } - // Account Selector button with Account label adjacent to icon - Item { + // Account Selector chip with Account label adjacent to icon + Rectangle { id: accountBtn - implicitWidth: accountRow.implicitWidth - implicitHeight: accountRow.implicitHeight + implicitWidth: accountRow.implicitWidth + units.gu(1.8) + implicitHeight: units.gu(3.6) + 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.fill: parent - spacing: units.gu(0.5) + anchors.centerIn: parent + spacing: units.gu(0.6) Icon { name: "account" - width: units.gu(2.4) - height: units.gu(2.4) + width: units.gu(2.2) + height: units.gu(2.2) color: "white" Layout.alignment: Qt.AlignVCenter } @@ -103,7 +111,9 @@ Page { } MouseArea { + id: accountMouseArea anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (typeof accountPicker !== "undefined") { 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/navigation/DialerMenu.qml b/qml/components/navigation/DialerMenu.qml index 36bdf8bb..722256fd 100644 --- a/qml/components/navigation/DialerMenu.qml +++ b/qml/components/navigation/DialerMenu.qml @@ -32,81 +32,277 @@ 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 || opacityAnim.running + 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(1.6) + 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 + overshoot: 1.15 + } + } + } 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 + overshoot: 1.1 + } + } + + 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.centerIn: parent + anchors.leftMargin: units.gu(1.8) + anchors.rightMargin: units.gu(0.8) + 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; overshoot: 1.1 } + } + + 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; overshoot: 1.1 } + } + + 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/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 79361258..b18f30bc 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 ".." @@ -521,7 +522,7 @@ Rectangle { var targetId = descriptionPopup.timesheetId; TimerService.stop(); if (status === "saved" && targetId > 0) { - Model.markTimesheetAsSavedById(targetId); + TimeSheet.markTimesheetAsSavedById(targetId); } } diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index 46e0045f..f567f7dc 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -262,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: { diff --git a/qml/features/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index 59b01c5b..a729eb0a 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -418,7 +418,8 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Create") + label: i18n.dtr("ubtms", "Create Task"), + iconName: "add" } ] diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 87d39508..433d1f5e 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -414,7 +414,8 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Task") + label: i18n.dtr("ubtms", "Create Task"), + iconName: "add" } ] onMenuItemSelected: { diff --git a/qml/features/timesheets/pages/Timesheet_Page.qml b/qml/features/timesheets/pages/Timesheet_Page.qml index 80c19766..7ac58613 100644 --- a/qml/features/timesheets/pages/Timesheet_Page.qml +++ b/qml/features/timesheets/pages/Timesheet_Page.qml @@ -365,7 +365,8 @@ Page { z: 9999 menuModel: [ { - label: i18n.dtr("ubtms", "Create"), + label: i18n.dtr("ubtms", "Create Timesheet"), + iconName: "alarm-clock" }, ] onMenuItemSelected: { From 1bde4750f95c8ae695900fb6b79b8c7cff1c9d45 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 3 Sep 2026 16:18:05 +0530 Subject: [PATCH 060/105] Fix QML NumberAnimation easing property in DialerMenu --- qml/components/navigation/DialerMenu.qml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/qml/components/navigation/DialerMenu.qml b/qml/components/navigation/DialerMenu.qml index 722256fd..4190daeb 100644 --- a/qml/components/navigation/DialerMenu.qml +++ b/qml/components/navigation/DialerMenu.qml @@ -93,7 +93,6 @@ Item { NumberAnimation { duration: 180 + (repeater.count - 1 - index) * 35 easing.type: Easing.OutBack - overshoot: 1.15 } } } @@ -110,7 +109,6 @@ Item { NumberAnimation { duration: 180 + (repeater.count - 1 - index) * 35 easing.type: Easing.OutBack - overshoot: 1.1 } } @@ -250,7 +248,7 @@ Item { NumberAnimation { duration: 180; easing.type: Easing.OutQuad } } Behavior on rotation { - NumberAnimation { duration: 220; easing.type: Easing.OutBack; overshoot: 1.1 } + NumberAnimation { duration: 220; easing.type: Easing.OutBack } } Icon { @@ -277,7 +275,7 @@ Item { NumberAnimation { duration: 180; easing.type: Easing.OutQuad } } Behavior on rotation { - NumberAnimation { duration: 220; easing.type: Easing.OutBack; overshoot: 1.1 } + NumberAnimation { duration: 220; easing.type: Easing.OutBack } } Icon { From 4e7330e4879420818ff6c8b5b417f7caadfdc71b Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 4 Sep 2026 13:43:11 +0530 Subject: [PATCH 061/105] Configure default Project Stages and Global Task Stages for Local Account --- models/accounts.js | 4 + models/database.js | 89 +++++++++++++++++++ models/dbinit.js | 4 + models/project.js | 41 +++++---- models/task.js | 48 +++++----- .../selectors/ProjectStageSelector.qml | 2 +- qml/components/visualization/ProjectList.qml | 16 ++-- qml/features/projects/pages/Projects.qml | 6 +- 8 files changed, 164 insertions(+), 46 deletions(-) diff --git a/models/accounts.js b/models/accounts.js index d300608b..4d60ce3f 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -413,6 +413,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); diff --git a/models/database.js b/models/database.js index 59c7ef1d..2c0d2696 100644 --- a/models/database.js +++ b/models/database.js @@ -168,6 +168,8 @@ function ensureDefaultLocalAccountExists() { } }); + ensureDefaultLocalProjectStages(); + ensureDefaultLocalTaskStages(); } catch (e) { logException(e); } @@ -204,6 +206,93 @@ function ensureDefaultLocalActivityTypes() { 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 36c76a17..9b416963 100644 --- a/models/dbinit.js +++ b/models/dbinit.js @@ -432,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 (\ @@ -470,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 (\ diff --git a/models/project.js b/models/project.js index 5d8fef53..d6c9183c 100644 --- a/models/project.js +++ b/models/project.js @@ -329,9 +329,8 @@ function getProjectUpdateByOdooId(odoo_record_id, accountId) { return update || {}; } -function getProjectStageName(odooRecordId) { - var stageName = null; - +function getProjectStageName(odooRecordId, accountId) { + var stageName = ""; try { var db = Sql.LocalStorage.openDatabaseSync( DBCommon.NAME, @@ -341,14 +340,12 @@ function getProjectStageName(odooRecordId) { ); db.transaction(function (tx) { - var query = ` - SELECT name - FROM project_project_stage_app - WHERE odoo_record_id = ? - LIMIT 1 - `; + var query = (accountId !== undefined && accountId !== null) + ? "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1" + : "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? LIMIT 1"; + var params = (accountId !== undefined && accountId !== null) ? [odooRecordId, accountId] : [odooRecordId]; - var result = tx.executeSql(query, [odooRecordId]); + var result = tx.executeSql(query, params); if (result.rows.length > 0) { stageName = result.rows.item(0).name; @@ -478,18 +475,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] ); }); @@ -1034,6 +1040,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, \ @@ -1041,7 +1052,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"); @@ -1055,7 +1066,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!'; diff --git a/models/task.js b/models/task.js index 20fce44a..19fe7473 100644 --- a/models/task.js +++ b/models/task.js @@ -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 ] @@ -188,8 +193,11 @@ function getTaskStageName(odooRecordId, accountId) { var stageName = "Undefined"; try { - if (odooRecordId === -1) { - return "Undefined"; // special case + if (odooRecordId === -1 && accountId !== 0) { + return "Undefined"; // special case for remote accounts + } + if (!odooRecordId) { + return "Undefined"; } var db = Sql.LocalStorage.openDatabaseSync( @@ -200,14 +208,12 @@ 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 = (accountId !== undefined && accountId !== null) + ? "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1" + : "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? LIMIT 1"; + var params = (accountId !== undefined && accountId !== null) ? [odooRecordId, accountId] : [odooRecordId]; - var result = tx.executeSql(query, [odooRecordId, accountId]); + var result = tx.executeSql(query, params); if (result.rows.length > 0) { stageName = result.rows.item(0).name; @@ -223,11 +229,12 @@ function getTaskStageName(odooRecordId, accountId) { /** * 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 +248,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; @@ -3505,9 +3510,10 @@ function updateTaskStage(taskId, stageOdooRecordId, accountId) { } // 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/qml/components/selectors/ProjectStageSelector.qml b/qml/components/selectors/ProjectStageSelector.qml index e7004e05..24384bd8 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 diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index f2a904e0..daa84e6c 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -996,12 +996,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 @@ -1027,7 +1029,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; @@ -1039,12 +1043,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; @@ -1052,7 +1056,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/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index 373b195b..f26eaafe 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -657,7 +657,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 @@ -665,7 +665,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"; } @@ -696,7 +696,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 }); } From a3807433f2765fee409f350949036664600ed30a Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 4 Sep 2026 14:20:37 +0530 Subject: [PATCH 062/105] Fix stage display and reactivity in task and project list views --- models/project.js | 36 ++++++++++++------- models/task.js | 36 +++++++++++++------ qml/TSApp.qml | 2 ++ qml/components/cards/ProjectDetailsCard.qml | 2 +- qml/components/navigation/DialerMenu.qml | 2 +- .../selectors/ProjectStageSelector.qml | 2 +- qml/components/visualization/ProjectList.qml | 20 ++++++++--- qml/features/dashboard/charts/Charts4.qml | 2 +- qml/features/projects/pages/Projects.qml | 8 +++++ .../tasks/components/TaskDetailsCard.qml | 31 +++++++++++++--- qml/features/tasks/components/TaskList.qml | 11 ++++++ qml/features/tasks/pages/Tasks.qml | 12 +++++++ 12 files changed, 126 insertions(+), 38 deletions(-) diff --git a/models/project.js b/models/project.js index d6c9183c..f0dd8413 100644 --- a/models/project.js +++ b/models/project.js @@ -332,6 +332,9 @@ function getProjectUpdateByOdooId(odoo_record_id, accountId) { function getProjectStageName(odooRecordId, accountId) { var stageName = ""; try { + if (!odooRecordId || odooRecordId === 0) { + return ""; + } var db = Sql.LocalStorage.openDatabaseSync( DBCommon.NAME, DBCommon.VERSION, @@ -340,10 +343,19 @@ function getProjectStageName(odooRecordId, accountId) { ); db.transaction(function (tx) { - var query = (accountId !== undefined && accountId !== null) - ? "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1" - : "SELECT name FROM project_project_stage_app WHERE odoo_record_id = ? LIMIT 1"; - var params = (accountId !== undefined && accountId !== null) ? [odooRecordId, accountId] : [odooRecordId]; + var query = ""; + var params = []; + + 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); @@ -748,29 +760,27 @@ function getProjectsFilteredPaginated(options) { // Stage filter if (options.stageId !== undefined && options.stageId !== null) { - if (options.stageId === -2) { + if (!options.isStage && options.stageId === -2) { // "Open" filter - if (options.accountId === 0) { - // Local account projects have no stages and are always considered open - } else if (options.openStageIds && options.openStageIds.length > 0) { + if (options.openStageIds && options.openStageIds.length > 0) { var placeholders = options.openStageIds.map(function () { return "?"; }).join(","); if (options.accountId === -1 || options.accountId === undefined) { - // "All Accounts": match open Odoo stages OR local projects / projects without a stage - whereClauses.push("(stage IN (" + placeholders + ") OR account_id = 0 OR stage = 0 OR stage IS NULL)"); + // "All Accounts": match open stages OR projects without a stage + whereClauses.push("(stage IN (" + placeholders + ") OR stage = 0 OR stage IS NULL)"); } else { - // Specific Odoo account + // Specific 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 diff --git a/models/task.js b/models/task.js index 19fe7473..1803cbc4 100644 --- a/models/task.js +++ b/models/task.js @@ -190,14 +190,11 @@ function saveOrUpdateTask(data) { function getTaskStageName(odooRecordId, accountId) { - var stageName = "Undefined"; + var stageName = ""; try { - if (odooRecordId === -1 && accountId !== 0) { - return "Undefined"; // special case for remote accounts - } - if (!odooRecordId) { - return "Undefined"; + if (!odooRecordId || odooRecordId === 0) { + return ""; } var db = Sql.LocalStorage.openDatabaseSync( @@ -208,10 +205,19 @@ function getTaskStageName(odooRecordId, accountId) { ); db.transaction(function (tx) { - var query = (accountId !== undefined && accountId !== null) - ? "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? AND account_id = ? LIMIT 1" - : "SELECT name FROM project_task_type_app WHERE odoo_record_id = ? LIMIT 1"; - var params = (accountId !== undefined && accountId !== null) ? [odooRecordId, accountId] : [odooRecordId]; + var query = ""; + var params = []; + + 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); @@ -223,7 +229,7 @@ function getTaskStageName(odooRecordId, accountId) { Logger.error("Task", "getTaskStageName failed:", e) } - return stageName; + return stageName || ""; } /** @@ -3505,6 +3511,14 @@ 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"; } diff --git a/qml/TSApp.qml b/qml/TSApp.qml index 24f879a1..3cbc63d3 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. diff --git a/qml/components/cards/ProjectDetailsCard.qml b/qml/components/cards/ProjectDetailsCard.qml index a63bb6d7..f2e6ae11 100644 --- a/qml/components/cards/ProjectDetailsCard.qml +++ b/qml/components/cards/ProjectDetailsCard.qml @@ -73,7 +73,7 @@ ListItem { signal timesheetRequested(int localId) signal navigationRequested(int projectId, int accountId, string projectName) - property string stageName: (stage && stage > 0) ? (Project.getProjectStageName(stage) || "") : "" + property string stageName: (stage && stage !== 0) ? (Project.getProjectStageName(stage, accountId) || "") : "" property bool isStageDone: { if (!stageName) return false; var lower = stageName.toLowerCase(); diff --git a/qml/components/navigation/DialerMenu.qml b/qml/components/navigation/DialerMenu.qml index 4190daeb..1fcb9ae4 100644 --- a/qml/components/navigation/DialerMenu.qml +++ b/qml/components/navigation/DialerMenu.qml @@ -65,7 +65,7 @@ Item { // Action menu items column Column { id: menuList - visible: dialerMenu.expanded || opacityAnim.running + visible: dialerMenu.expanded spacing: units.gu(1.2) anchors.right: fabContainer.right anchors.bottom: fabContainer.top diff --git a/qml/components/selectors/ProjectStageSelector.qml b/qml/components/selectors/ProjectStageSelector.qml index 24384bd8..0491b4b6 100644 --- a/qml/components/selectors/ProjectStageSelector.qml +++ b/qml/components/selectors/ProjectStageSelector.qml @@ -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/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index daa84e6c..ef9e1260 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -113,6 +113,14 @@ Item { } } + Connections { + target: typeof mainView !== "undefined" ? mainView : null + + onProjectDataChanged: { + populateProjectChildrenMap(); + } + } + property int currentParentId: -1 property int currentAccountId: accountPicker.selectedAccountId property string currentParentName: "" @@ -286,14 +294,17 @@ Item { function _doPaginatedProjectLoad() { // 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 @@ -464,10 +475,9 @@ Item { return true; } - // Special case for "Open" filter (odoo_record_id = -2) - if (stageFilter.odoo_record_id === -2) { - // Local projects (account_id === 0 or without a stage) don't have stages and are always considered open - if (project.account_id === 0 || !project.stage || project.stage === 0) { + // 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; } diff --git a/qml/features/dashboard/charts/Charts4.qml b/qml/features/dashboard/charts/Charts4.qml index 77af208a..c53ef0aa 100644 --- a/qml/features/dashboard/charts/Charts4.qml +++ b/qml/features/dashboard/charts/Charts4.qml @@ -73,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 diff --git a/qml/features/projects/pages/Projects.qml b/qml/features/projects/pages/Projects.qml index f26eaafe..ca971ac8 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -454,6 +454,10 @@ Page { loadProjectData(recordid); } + if (typeof mainView !== "undefined" && mainView && mainView.projectDataChanged) { + mainView.projectDataChanged(); + } + draftHandler.clearDraft(); isReadOnly = true; return true; @@ -1060,6 +1064,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/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 561a684a..678b0776 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -62,6 +62,13 @@ ListItem { property bool hasDraft: false // Indicates if this task has unsaved draft changes property int effectiveTaskId: (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) signal viewRequested(int localId) @@ -587,12 +594,26 @@ ListItem { width: parent.width } - 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: 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 + 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") + } } } } diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index 5caa4484..68f6d498 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -105,6 +105,14 @@ Item { } } + Connections { + target: typeof mainView !== "undefined" ? mainView : null + + onTaskDataChanged: { + refreshWithFilter(); + } + } + // Add the applyFilter method function applyFilter(filterKey) { currentFilter = filterKey; @@ -783,6 +791,9 @@ Item { // Remove the task from the current list display removeTaskFromList(localId); } + onTaskUpdated: localId => { + refreshWithFilter(); + } // MouseArea for task interaction - navigation for parent tasks, view for regular tasks MouseArea { diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 75d0a7d6..06aca660 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -494,6 +494,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 +565,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 +593,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 { From 6d0172deaf0b9dd41adfba6a8c218194a7b386b8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 4 Sep 2026 16:32:39 +0530 Subject: [PATCH 063/105] Add left padding to dialer menu action items for label text --- qml/components/navigation/DialerMenu.qml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qml/components/navigation/DialerMenu.qml b/qml/components/navigation/DialerMenu.qml index 1fcb9ae4..a00abbb7 100644 --- a/qml/components/navigation/DialerMenu.qml +++ b/qml/components/navigation/DialerMenu.qml @@ -77,7 +77,7 @@ Item { delegate: Item { id: itemDelegate - width: cardRow.implicitWidth + units.gu(1.6) + width: cardRow.implicitWidth + units.gu(2.8) height: dialerMenu.itemSize anchors.right: parent ? parent.right : undefined @@ -140,9 +140,9 @@ Item { Row { id: cardRow - anchors.centerIn: parent - anchors.leftMargin: units.gu(1.8) - anchors.rightMargin: units.gu(0.8) + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: units.gu(2.2) spacing: units.gu(1.2) Text { From 5dc84d3f04ea6511e7fae52e7be99a88b72569e3 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 4 Sep 2026 16:35:29 +0530 Subject: [PATCH 064/105] Bump version to 1.3.3 in manifest, constants, release notes, and daemon --- manifest.json.in | 2 +- models/constants.js | 2 +- qml/app/pages/release_notes.txt | 4 ++-- src/daemon.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) 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/constants.js b/models/constants.js index 61f60d98..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 = { 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/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() From d2e66b33b9c907459404f19ee1389fa84e7c62ae Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 7 Sep 2026 10:12:00 +0530 Subject: [PATCH 065/105] Replace desktop theme icon with standard symbolic color icon --- qml/features/settings/pages/Settings_Page.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From e32efbf96cd70f91415c62ee6b7da671333a58b6 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 7 Sep 2026 10:12:03 +0530 Subject: [PATCH 066/105] Remove redundant header finalize button and auto-finalize timesheet on save --- qml/features/timesheets/pages/Timesheet.qml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/qml/features/timesheets/pages/Timesheet.qml b/qml/features/timesheets/pages/Timesheet.qml index f05de34a..f374cebe 100644 --- a/qml/features/timesheets/pages/Timesheet.qml +++ b/qml/features/timesheets/pages/Timesheet.qml @@ -85,14 +85,6 @@ Page { save_timesheet(); } }, - Action { - iconName: "ok" - visible: !isReadOnly - text: "Finalize" - onTriggered: { - finalize_timesheet(); - } - }, Action { iconName: "edit" visible: isReadOnly && recordid !== 0 @@ -171,6 +163,10 @@ Page { 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 = { @@ -196,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 From adb5477e1f6d176bb19a4fca6140120d1f8b19ef Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 7 Sep 2026 11:09:30 +0530 Subject: [PATCH 067/105] Filter empty and inactive records in user and activity type database queries Filter records with null, empty, or whitespace names in getUsers, getActivityTypesForAccount, and getAllActivityAssignees queries. Ensure user queries check for active status and sort results alphabetically. Ref: #329 --- models/accounts.js | 5 ++++- models/activity.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/models/accounts.js b/models/accounts.js index 4d60ce3f..617e79f1 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -199,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] ); diff --git a/models/activity.js b/models/activity.js index ef38fa04..d137662a 100644 --- a/models/activity.js +++ b/models/activity.js @@ -639,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]); @@ -1943,6 +1945,7 @@ function getAllActivityAssignees(accountId) { 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}) OR u.id IN (${placeholders})) + AND u.name IS NOT NULL AND TRIM(u.name) != '' ORDER BY u.name COLLATE NOCASE ASC `; From db5d82364d07b7525f3450f5a7e13276027b7ada Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 7 Sep 2026 11:09:39 +0530 Subject: [PATCH 068/105] Prevent blank rows in option selectors and validate activity type loading - In InlineOptionSelector, validate item names before appending to optionsModel and adjust expandedHeight based on valid item count - Hide delegate items when name is blank and check item count before toggling - Filter empty names in loadAssignees and reloadActivityTypeSelector - Pass scalar default account ID to reloadActivityTypeSelector on new activities Fixes #329 --- .../selectors/InlineOptionSelector.qml | 36 ++++++++++++++----- .../selectors/MultiAssigneeSelector.qml | 15 ++------ qml/components/selectors/WorkItemSelector.qml | 9 +++-- qml/features/activities/pages/Activities.qml | 11 ++++-- .../activities/pages/Activity_Page.qml | 10 +++--- 5 files changed, 48 insertions(+), 33 deletions(-) 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 e118baad..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 diff --git a/qml/components/selectors/WorkItemSelector.qml b/qml/components/selectors/WorkItemSelector.qml index 0d830748..54479bcc 100644 --- a/qml/components/selectors/WorkItemSelector.qml +++ b/qml/components/selectors/WorkItemSelector.qml @@ -687,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/features/activities/pages/Activities.qml b/qml/features/activities/pages/Activities.qml index ad7a01b4..05118233 100644 --- a/qml/features/activities/pages/Activities.qml +++ b/qml/features/activities/pages/Activities.qml @@ -922,8 +922,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 +1057,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 452f0b34..46c6f316 100644 --- a/qml/features/activities/pages/Activity_Page.qml +++ b/qml/features/activities/pages/Activity_Page.qml @@ -435,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 @@ -460,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 From ad52e2c300abaed42899715cb380c1365b89a07b Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 8 Sep 2026 13:56:45 +0530 Subject: [PATCH 069/105] Add left padding to Priority label and align stars in TaskPrioritySelector Fixes #331 --- .../tasks/components/TaskPrioritySelector.qml | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) 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 } } } From e1b336281c3dda05c7e2a0a936152d418b8a56d9 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 8 Sep 2026 14:27:00 +0530 Subject: [PATCH 070/105] Enable initial stage selection and persistence for Local Account tasks Fixes #330 --- .../components/TaskInitialStageSelector.qml | 4 +++- qml/features/tasks/js/taskFormUtils.js | 24 ++++++++++++++----- qml/features/tasks/pages/Tasks.qml | 15 ++++++++---- 3 files changed, 31 insertions(+), 12 deletions(-) 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/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/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 06aca660..1f2e6964 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -310,7 +310,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; @@ -606,7 +606,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; @@ -627,6 +627,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; @@ -714,12 +715,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") { @@ -1035,7 +1040,7 @@ 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); } From c2a0e2886eaeb5474b7fb4ffe0c9ea51d7b539fe Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 9 Sep 2026 17:38:48 +0530 Subject: [PATCH 071/105] Unify project save handling and expose project list alias --- qml/features/projects/pages/Project_Page.qml | 1 + qml/features/projects/pages/Projects.qml | 61 +------------------- 2 files changed, 2 insertions(+), 60 deletions(-) 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 ca971ac8..af94da52 100644 --- a/qml/features/projects/pages/Projects.qml +++ b/qml/features/projects/pages/Projects.qml @@ -69,66 +69,7 @@ Page { 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 { From 614e5a0df678f1c37e72098b648cfbf634ff74ac Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 9 Sep 2026 17:38:54 +0530 Subject: [PATCH 072/105] Maintain active account filter and stage matching in project list on refresh --- models/project.js | 6 +- qml/components/visualization/ProjectList.qml | 122 +++++++++++++++---- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/models/project.js b/models/project.js index f0dd8413..0e1cc80e 100644 --- a/models/project.js +++ b/models/project.js @@ -764,11 +764,11 @@ function getProjectsFilteredPaginated(options) { // "Open" filter if (options.openStageIds && options.openStageIds.length > 0) { var placeholders = options.openStageIds.map(function () { return "?"; }).join(","); - if (options.accountId === -1 || options.accountId === undefined) { - // "All Accounts": match open stages OR projects without a stage + 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 account + // Specific remote account whereClauses.push("stage IN (" + placeholders + ")"); } for (var s = 0; s < options.openStageIds.length; s++) { diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index ef9e1260..3ec93538 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -91,38 +91,85 @@ Item { id: projectList anchors.fill: parent - Connections { - target: accountPicker + 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; + } - onAccepted: function (id, name) { - console.log("Projects getting updated for Account chosen:", id, name); - currentAccountId = id; - navigationStackModel.clear(); - currentParentId = -1; - currentParentName = ""; + function handleAccountChanged(id) { + currentAccountId = id; + navigationStackModel.clear(); + currentParentId = -1; + currentParentName = ""; - // Reset to default "Open" filter - stageFilter.enabled = true; - stageFilter.odoo_record_id = -2; - stageFilter.name = "Open"; + // 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 = ""; + // Clear search + searchQuery = ""; - populateProjectChildrenMap(); + populateProjectChildrenMap(); + } + + Connections { + target: typeof accountPicker !== "undefined" ? accountPicker : null + + onAccepted: function (id, name) { + handleAccountChanged(id); + } + + onSelectedAccountIdChanged: { + var activeId = getSelectedAccountId(); + if (currentParentId === -1 && currentAccountId !== activeId) { + handleAccountChanged(activeId); + } } } Connections { - target: typeof mainView !== "undefined" ? mainView : null + 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: ({}) @@ -180,7 +227,11 @@ Item { 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; + if (currentParentId === -1) { + currentAccountId = getSelectedAccountId(); + } else { + currentAccountId = last.accountId !== undefined ? last.accountId : -1; + } currentParentName = (last.parentName !== undefined) ? last.parentName : ""; } } @@ -201,7 +252,7 @@ Item { navigationStackModel.clear(); currentParentId = -1; currentParentName = ""; - currentAccountId = accountPicker.selectedAccountId; + currentAccountId = getSelectedAccountId(); // Reset pagination currentOffset = 0; @@ -211,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 @@ -292,6 +344,10 @@ 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; @@ -448,11 +504,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); + } } } @@ -481,6 +540,11 @@ Item { 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) { @@ -525,6 +589,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; @@ -572,13 +639,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); + } } } } @@ -590,7 +663,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); } }); From 8a315d72d5689325c92add0dec93c5e00a2307b2 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 9 Sep 2026 18:29:16 +0530 Subject: [PATCH 073/105] Fix overlapping clear icon and polish project search UI - Resolve duplicate clear button collision in project search bar (Fixes #332) - Redesign search bar with tactile capsule styling, proper icon spacing, and dynamic orange focus highlight - Auto-clear active search filter on close or clear button click - Trigger search on return keypress to avoid debounce query spam - Add top breathing room below header and empty state message for zero search results --- qml/components/visualization/ProjectList.qml | 226 +++++++++++++++---- 1 file changed, 179 insertions(+), 47 deletions(-) diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index 3ec93538..1517cf8c 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -290,19 +290,36 @@ Item { // Search functions function toggleSearchVisibility() { showSearchBox = !showSearchBox; + if (!showSearchBox) { + if (searchQuery !== "" || searchField.text !== "") { + clearSearch(); + } + } else { + Qt.callLater(function() { + searchField.forceActiveFocus(); + }); + } } function clearSearch() { - searchField.text = ""; - searchQuery = ""; - customSearch(""); - // Reload from DB without search filter - populateProjectChildrenMap(); + if (searchField.text !== "") { + searchField.text = ""; + } + 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(); } @@ -814,58 +831,137 @@ 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 + Item { + id: searchContainer 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 ? units.gu(6.2) : 0 + visible: showSearchBox + clip: true Rectangle { + id: searchBarBox + anchors.fill: parent + anchors.leftMargin: units.gu(1.5) + anchors.rightMargin: units.gu(1.5) + anchors.topMargin: units.gu(1.2) + anchors.bottomMargin: units.gu(0.4) + radius: units.gu(1.2) + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#1e1e1e" : "#f1f5f9" + border.color: searchField.activeFocus + ? AppConst.Colors.Orange + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d2d2d" : "#e2e8f0") + border.width: searchField.activeFocus ? units.gu(0.18) : units.gu(0.1) + + Behavior on border.color { + ColorAnimation { duration: 150 } + } + + // Tap anywhere in the box to focus input + MouseArea { + anchors.fill: parent + z: 0 + cursorShape: Qt.IBeamCursor + onClicked: { + searchField.forceActiveFocus(); + } + } + + // Search icon with generous spacing + 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 + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#71717a" : "#94a3b8") + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + // Text Input - separated cleanly from the icon + 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: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#f3f4f6" : "#0f172a" + font.pixelSize: units.gu(1.8) + selectByMouse: true + clip: true + inputMethodHints: Qt.ImhNoPredictiveText - 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 + onAccepted: { + performSearch(text); + } + + onTextChanged: { + if (text === "" && searchQuery !== "") { + clearSearch(); + } + } + } - Button { + // Custom placeholder text + Text { + anchors.fill: searchField + verticalAlignment: Text.AlignVCenter + text: i18n.dtr("ubtms", "Search projects...") + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#71717a" : "#94a3b8" + font.pixelSize: searchField.font.pixelSize + visible: !searchField.text && !searchField.activeFocus + elide: Text.ElideRight + } + + // Circular clear button + Item { id: clearSearchButton - z: 10 visible: searchField.text.length > 0 anchors.right: parent.right + anchors.rightMargin: units.gu(0.8) anchors.verticalCenter: parent.verticalCenter - anchors.rightMargin: units.gu(0.5) - width: units.gu(3) - height: units.gu(3) - text: "×" - onClicked: { - clearSearch(); + 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 + ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#444444" : "#cbd5e1") + : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2a2a2a" : "#e2e8f0") + + Icon { + name: "close" + width: units.gu(1.3) + height: units.gu(1.3) + anchors.centerIn: parent + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#a1a1aa" : "#64748b" + } + } + + MouseArea { + id: clearMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + clearSearch(); + } } } } @@ -1001,11 +1097,47 @@ Item { LomiriListView { id: projectListView width: parent.width - height: parent.height - (breadcrumbBar.visible ? breadcrumbBar.height + units.gu(1) : 0) - (showSearchBox ? units.gu(6) : 0) + height: parent.height - (breadcrumbBar.visible ? breadcrumbBar.height + units.gu(1) : 0) - (showSearchBox ? searchContainer.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 From 4c6c0457881dc19750bf3bf2eea3ce4779dac103 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 9 Sep 2026 18:59:36 +0530 Subject: [PATCH 074/105] Add common TSSearchBar component and use in ListHeader and ProjectList --- qml/components/base/TSSearchBar.qml | 182 +++++++++++++++++++ qml/components/navigation/ListHeader.qml | 83 +++------ qml/components/qmldir | 1 + qml/components/visualization/ProjectList.qml | 146 ++------------- 4 files changed, 220 insertions(+), 192 deletions(-) create mode 100644 qml/components/base/TSSearchBar.qml 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/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/qmldir b/qml/components/qmldir index 776e98df..f16a4036 100644 --- a/qml/components/qmldir +++ b/qml/components/qmldir @@ -49,6 +49,7 @@ 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 UbuntuShape 1.0 base/UbuntuShape.qml TasksForDayWidget 1.0 cards/TasksForDayWidget.qml TimePickerPopup 1.0 dialogs/TimePickerPopup.qml diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index 1517cf8c..662060ac 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -291,19 +291,17 @@ Item { function toggleSearchVisibility() { showSearchBox = !showSearchBox; if (!showSearchBox) { - if (searchQuery !== "" || searchField.text !== "") { + if (searchQuery !== "" || searchBar.text !== "") { clearSearch(); } } else { - Qt.callLater(function() { - searchField.forceActiveFocus(); - }); + searchBar.forceActiveFocus(); } } function clearSearch() { - if (searchField.text !== "") { - searchField.text = ""; + if (searchBar.text !== "") { + searchBar.clear(); } if (searchQuery !== "") { searchQuery = ""; @@ -836,133 +834,21 @@ Item { spacing: units.gu(1) // Search field container - Item { - id: searchContainer + Components.TSSearchBar { + id: searchBar width: parent.width - height: showSearchBox ? units.gu(6.2) : 0 + height: showSearchBox ? implicitHeight : 0 visible: showSearchBox - clip: true - - Rectangle { - id: searchBarBox - anchors.fill: parent - anchors.leftMargin: units.gu(1.5) - anchors.rightMargin: units.gu(1.5) - anchors.topMargin: units.gu(1.2) - anchors.bottomMargin: units.gu(0.4) - radius: units.gu(1.2) - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#1e1e1e" : "#f1f5f9" - border.color: searchField.activeFocus - ? AppConst.Colors.Orange - : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2d2d2d" : "#e2e8f0") - border.width: searchField.activeFocus ? units.gu(0.18) : units.gu(0.1) - - Behavior on border.color { - ColorAnimation { duration: 150 } - } - - // Tap anywhere in the box to focus input - MouseArea { - anchors.fill: parent - z: 0 - cursorShape: Qt.IBeamCursor - onClicked: { - searchField.forceActiveFocus(); - } - } - - // Search icon with generous spacing - 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 - : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#71717a" : "#94a3b8") - - Behavior on color { - ColorAnimation { duration: 150 } - } - } - - // Text Input - separated cleanly from the icon - 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: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#f3f4f6" : "#0f172a" - font.pixelSize: units.gu(1.8) - selectByMouse: true - clip: true - inputMethodHints: Qt.ImhNoPredictiveText - - onAccepted: { - performSearch(text); - } - - onTextChanged: { - if (text === "" && searchQuery !== "") { - clearSearch(); - } - } - } - - // Custom placeholder text - Text { - anchors.fill: searchField - verticalAlignment: Text.AlignVCenter - text: i18n.dtr("ubtms", "Search projects...") - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#71717a" : "#94a3b8" - font.pixelSize: searchField.font.pixelSize - visible: !searchField.text && !searchField.activeFocus - elide: Text.ElideRight - } - - // Circular clear button - 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 + placeholderText: i18n.dtr("ubtms", "Search projects...") + bottomPadding: units.gu(0.4) - Rectangle { - anchors.centerIn: parent - width: units.gu(2.4) - height: units.gu(2.4) - radius: width / 2 - color: clearMouseArea.pressed - ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#444444" : "#cbd5e1") - : (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#2a2a2a" : "#e2e8f0") - - Icon { - name: "close" - width: units.gu(1.3) - height: units.gu(1.3) - anchors.centerIn: parent - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#a1a1aa" : "#64748b" - } - } + onAccepted: { + performSearch(query); + } - MouseArea { - id: clearMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - clearSearch(); - } - } + onCleared: { + if (searchQuery !== "") { + clearSearch(); } } } @@ -1097,7 +983,7 @@ Item { LomiriListView { id: projectListView width: parent.width - height: parent.height - (breadcrumbBar.visible ? breadcrumbBar.height + units.gu(1) : 0) - (showSearchBox ? searchContainer.height + units.gu(1) : 0) + height: parent.height - (breadcrumbBar.visible ? breadcrumbBar.height + units.gu(1) : 0) - (showSearchBox ? searchBar.height + units.gu(1) : 0) clip: true spacing: 0 model: getCurrentModel() From acc828cd7c7e973adbced49a620a5f6e7fc00d83 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 9 Sep 2026 18:59:47 +0530 Subject: [PATCH 075/105] Preserve active search query across filter tab switches --- qml/features/activities/pages/Activity_Page.qml | 4 ++++ qml/features/tasks/components/TaskList.qml | 1 - qml/features/tasks/pages/MyTasksPage.qml | 5 ++++- qml/features/tasks/pages/Task_Page.qml | 6 ++++++ qml/features/updates/pages/Updates_Page.qml | 4 ++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/qml/features/activities/pages/Activity_Page.qml b/qml/features/activities/pages/Activity_Page.qml index 46c6f316..5b82488b 100644 --- a/qml/features/activities/pages/Activity_Page.qml +++ b/qml/features/activities/pages/Activity_Page.qml @@ -670,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/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index 68f6d498..eb22f9f3 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -142,7 +142,6 @@ Item { projectOdooRecordId = projectOdooId; projectAccountId = accountId; currentFilter = timeFilter; - currentSearchQuery = ""; refreshWithFilter(); } diff --git a/qml/features/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index a729eb0a..c07ea003 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -305,7 +305,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) { diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 433d1f5e..7efc6ff7 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -298,6 +298,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); diff --git a/qml/features/updates/pages/Updates_Page.qml b/qml/features/updates/pages/Updates_Page.qml index 8baa4c71..5f37fb0e 100644 --- a/qml/features/updates/pages/Updates_Page.qml +++ b/qml/features/updates/pages/Updates_Page.qml @@ -303,6 +303,10 @@ Page { onFilterSelected: { updates.currentStatusFilter = filterKey; + var activeSearch = (updatesListHeader.searchText !== undefined && updatesListHeader.searchText !== null) + ? updatesListHeader.searchText + : (updates.currentSearchQuery || ""); + updates.currentSearchQuery = activeSearch; fetchupdates(); } From 0607a676ecb117f4920969002f3611df16fe4008 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 11:28:49 +0530 Subject: [PATCH 076/105] Allow Tomorrow option in DaySelector for activities while preserving Yesterday for timesheets - Add configurable showTomorrow property to DaySelector (Fixes #299, closes #300) - Buffer custom date selection until picker closes to prevent premature draft updates - Enable showTomorrow on DaySelector in Activities.qml while keeping Yesterday for Timesheet.qml - Add localization to date labels and validate date comparisons --- qml/components/pickers/DaySelector.qml | 112 +++++++++++-------- qml/features/activities/pages/Activities.qml | 1 + 2 files changed, 68 insertions(+), 45 deletions(-) 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/features/activities/pages/Activities.qml b/qml/features/activities/pages/Activities.qml index 05118233..cb884e69 100644 --- a/qml/features/activities/pages/Activities.qml +++ b/qml/features/activities/pages/Activities.qml @@ -820,6 +820,7 @@ Page { DaySelector { id: date_widget + showTomorrow: true readOnly: isReadOnly width: flickable.width - units.gu(2) onDateChanged: function(selectedDate) { From fc278754704b9889d7a99c8c2a0816254d67e80a Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 11:37:55 +0530 Subject: [PATCH 077/105] Refresh personal task list on taskDataChanged signal - Listen for taskDataChanged in MyTasksPage to update personal tasks immediately on save - Resolves list refresh propagation without prop-drilling (Fixes #275, closes #305) --- qml/features/tasks/pages/MyTasksPage.qml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qml/features/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index c07ea003..cc230703 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -458,6 +458,12 @@ Page { handleAccountChange(accountId); } } + + function onTaskDataChanged() { + if (myTasksPage.visible) { + refreshData(); + } + } } onVisibleChanged: { From 37d94771bb4cacf9ebdaf96a453d7e3512d4b3a5 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 12:04:35 +0530 Subject: [PATCH 078/105] Implement collapsible navigation sidebar with desktop and mobile support --- qml/app/AppLayout.qml | 19 ++++-- qml/app/navigation/MenuPage.qml | 60 ++++++++++++++++--- .../navigation/NavigationMenuList.qml | 2 + .../settings/components/SettingsListItem.qml | 24 +++++--- 4 files changed, 84 insertions(+), 21 deletions(-) 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/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index e980ee40..58fcd07a 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -34,7 +34,8 @@ 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") @@ -51,26 +52,67 @@ Page { contents: RowLayout { anchors.fill: parent - anchors.leftMargin: units.gu(2) - anchors.rightMargin: units.gu(1) - spacing: units.gu(1) + anchors.leftMargin: listpage.menuCollapsed ? units.gu(0.4) : units.gu(1.5) + anchors.rightMargin: listpage.menuCollapsed ? units.gu(0.4) : units.gu(1) + spacing: listpage.menuCollapsed ? units.gu(0.4) : units.gu(1) + + // 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(); + } + } + } + } Label { text: i18n.dtr("ubtms", "Menu") + visible: !listpage.menuCollapsed color: "white" fontSize: "large" font.bold: true } Item { + visible: !listpage.menuCollapsed Layout.fillWidth: true } - // Account Selector chip with Account label adjacent to icon + // Account Selector chip (pill in expanded mode, compact icon in collapsed mode) Rectangle { id: accountBtn - implicitWidth: accountRow.implicitWidth + units.gu(1.8) + implicitWidth: listpage.menuCollapsed ? units.gu(3.6) : (accountRow.implicitWidth + units.gu(1.8)) 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" @@ -84,7 +126,7 @@ Page { RowLayout { id: accountRow anchors.centerIn: parent - spacing: units.gu(0.6) + spacing: listpage.menuCollapsed ? 0 : units.gu(0.6) Icon { name: "account" @@ -96,6 +138,7 @@ Page { Label { id: accountLabel + visible: !listpage.menuCollapsed Layout.alignment: Qt.AlignVCenter text: { if (typeof accountPicker === "undefined" || !accountPicker.selectedAccountName) return ""; @@ -126,6 +169,7 @@ Page { // Local Account Toggle Switch Switch { id: localToggleSwitch + visible: !listpage.menuCollapsed Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: units.gu(4.2) Layout.preferredHeight: units.gu(2.1) @@ -172,6 +216,7 @@ Page { // Theme Mode Toggle Item { + visible: !listpage.menuCollapsed width: units.gu(4) height: units.gu(4) Layout.alignment: Qt.AlignVCenter @@ -225,6 +270,7 @@ Page { NavigationMenuList { width: parent.width + collapsed: listpage.menuCollapsed menuItems: NavigationRoutes.menuItems() selectedPageUrl: apLayout && apLayout.currentMenuPageUrl ? apLayout.currentMenuPageUrl : "" 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/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 } } } From 867e5a1035f935c274d57145c40622f88eb101aa Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 12:34:04 +0530 Subject: [PATCH 079/105] Display account, local toggle, and theme actions in collapsed navigation sidebar --- qml/app/navigation/MenuPage.qml | 184 ++++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 9 deletions(-) diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 58fcd07a..b1b19b5a 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -28,6 +28,7 @@ import Lomiri.Components.Themes.Ambiance 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 "../../components" import "NavigationRoutes.js" as NavigationRoutes @@ -52,19 +53,19 @@ Page { contents: RowLayout { anchors.fill: parent - anchors.leftMargin: listpage.menuCollapsed ? units.gu(0.4) : units.gu(1.5) - anchors.rightMargin: listpage.menuCollapsed ? units.gu(0.4) : units.gu(1) - spacing: listpage.menuCollapsed ? units.gu(0.4) : units.gu(1) + anchors.leftMargin: listpage.menuCollapsed ? 0 : units.gu(1.5) + anchors.rightMargin: listpage.menuCollapsed ? 0 : units.gu(1) + spacing: listpage.menuCollapsed ? 0 : units.gu(1) // Collapse / expand sidebar button (multi-column only) Rectangle { id: collapseToggleBtn visible: listpage.isMultiColumn - implicitWidth: units.gu(3.6) + implicitWidth: listpage.menuCollapsed ? units.gu(8) : units.gu(3.6) implicitHeight: units.gu(3.6) Layout.preferredWidth: implicitWidth Layout.preferredHeight: implicitHeight - radius: height / 2 + radius: listpage.menuCollapsed ? 0 : height / 2 color: collapseMouseArea.pressed ? "#40ffffff" : (collapseMouseArea.containsMouse ? "#30ffffff" : "transparent") Layout.alignment: Qt.AlignVCenter @@ -90,6 +91,10 @@ Page { apLayout.toggleMenuCollapsed(); } } + + Controls.ToolTip.visible: listpage.menuCollapsed && collapseMouseArea.containsMouse + Controls.ToolTip.text: i18n.dtr("ubtms", "Expand menu") + Controls.ToolTip.delay: 400 } } @@ -106,10 +111,11 @@ Page { Layout.fillWidth: true } - // Account Selector chip (pill in expanded mode, compact icon in collapsed mode) + // Account Selector chip (visible when expanded) Rectangle { id: accountBtn - implicitWidth: listpage.menuCollapsed ? units.gu(3.6) : (accountRow.implicitWidth + units.gu(1.8)) + visible: !listpage.menuCollapsed + implicitWidth: accountRow.implicitWidth + units.gu(1.8) implicitHeight: units.gu(3.6) Layout.preferredWidth: implicitWidth Layout.preferredHeight: implicitHeight @@ -126,7 +132,7 @@ Page { RowLayout { id: accountRow anchors.centerIn: parent - spacing: listpage.menuCollapsed ? 0 : units.gu(0.6) + spacing: units.gu(0.6) Icon { name: "account" @@ -249,8 +255,168 @@ 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.implicitHeight + units.gu(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) + + // 1. Account Action + Rectangle { + id: collapsedAccountBtn + width: parent.width + height: units.gu(6) + 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 + } + } + + // 2. Local Account Toggle + Rectangle { + id: collapsedLocalBtn + width: parent.width + height: units.gu(6) + color: collapsedLocalArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedLocalArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") + + Switch { + id: collapsedLocalSwitch + anchors.centerIn: parent + width: units.gu(4.2) + height: units.gu(2.1) + enabled: false + checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + style: Component { + SwitchStyle { + implicitWidth: units.gu(4.2) + implicitHeight: units.gu(2.1) + checkedBackgroundColor: Qt.darker(LomiriColors.orange, 1.35) + } + } + + Binding { + target: collapsedLocalSwitch + property: "checked" + value: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false + } + + Connections { + target: typeof accountPicker !== "undefined" ? accountPicker : null + onSelectedAccountIdChanged: { + collapsedLocalSwitch.checked = (accountPicker.selectedAccountId === 0); + } + onAccepted: { + collapsedLocalSwitch.checked = (accountId === 0); + } + } + + Connections { + target: typeof rootApp !== "undefined" ? rootApp : null + onGlobalAccountChanged: { + collapsedLocalSwitch.checked = (accountId === 0); + } + } + } + + 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 + } + } + + // 3. Theme Toggle + Rectangle { + id: collapsedThemeBtn + width: parent.width + height: units.gu(6) + color: collapsedThemeArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedThemeArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") + + Image { + anchors.centerIn: parent + width: units.gu(2.4) + height: units.gu(2.4) + source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "../../images/daymode.png" : "../../images/darkmode.png" + fillMode: Image.PreserveAspectFit + } + + 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 From 6e22b93cd3a3c65e90a3e85680cfa5165dbd827e Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 12:38:49 +0530 Subject: [PATCH 080/105] Fix collapsed hamburger centering and ensure theme toggle visibility --- qml/app/navigation/MenuPage.qml | 67 ++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index b1b19b5a..6641dca7 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -51,21 +51,63 @@ Page { dividerColor: LomiriColors.slate } + // Overlay for collapsed mode to guarantee exact horizontal & vertical centering across 8 GU + Item { + parent: header + anchors.fill: parent + visible: listpage.menuCollapsed + z: 100 + + Rectangle { + anchors.fill: parent + color: collapseOverlayMouseArea.pressed ? "#40ffffff" : (collapseOverlayMouseArea.containsMouse ? "#30ffffff" : "transparent") + + Behavior on color { + ColorAnimation { duration: 100 } + } + + Icon { + 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: RowLayout { + visible: !listpage.menuCollapsed anchors.fill: parent - anchors.leftMargin: listpage.menuCollapsed ? 0 : units.gu(1.5) - anchors.rightMargin: listpage.menuCollapsed ? 0 : units.gu(1) - spacing: listpage.menuCollapsed ? 0 : units.gu(1) + anchors.leftMargin: units.gu(1.5) + anchors.rightMargin: units.gu(1) + spacing: units.gu(1) // Collapse / expand sidebar button (multi-column only) Rectangle { id: collapseToggleBtn visible: listpage.isMultiColumn - implicitWidth: listpage.menuCollapsed ? units.gu(8) : units.gu(3.6) + implicitWidth: units.gu(3.6) implicitHeight: units.gu(3.6) Layout.preferredWidth: implicitWidth Layout.preferredHeight: implicitHeight - radius: listpage.menuCollapsed ? 0 : height / 2 + radius: height / 2 color: collapseMouseArea.pressed ? "#40ffffff" : (collapseMouseArea.containsMouse ? "#30ffffff" : "transparent") Layout.alignment: Qt.AlignVCenter @@ -92,22 +134,20 @@ Page { } } - Controls.ToolTip.visible: listpage.menuCollapsed && collapseMouseArea.containsMouse - Controls.ToolTip.text: i18n.dtr("ubtms", "Expand menu") + Controls.ToolTip.visible: collapseMouseArea.containsMouse + Controls.ToolTip.text: i18n.dtr("ubtms", "Collapse menu") Controls.ToolTip.delay: 400 } } Label { text: i18n.dtr("ubtms", "Menu") - visible: !listpage.menuCollapsed color: "white" fontSize: "large" font.bold: true } Item { - visible: !listpage.menuCollapsed Layout.fillWidth: true } @@ -262,7 +302,7 @@ Page { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - height: listpage.menuCollapsed ? (bottomActionsColumn.implicitHeight + units.gu(1)) : 0 + height: listpage.menuCollapsed ? (units.gu(16.5) + units.dp(1)) : 0 color: isDark ? "#1e1e1e" : "#ffffff" // Divider above bottom actions @@ -280,12 +320,13 @@ Page { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: units.dp(1) + spacing: units.gu(0.5) // 1. Account Action Rectangle { id: collapsedAccountBtn width: parent.width - height: units.gu(6) + height: units.gu(5) color: collapsedAccountArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedAccountArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") Icon { @@ -321,7 +362,7 @@ Page { Rectangle { id: collapsedLocalBtn width: parent.width - height: units.gu(6) + height: units.gu(5) color: collapsedLocalArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedLocalArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") Switch { @@ -384,7 +425,7 @@ Page { Rectangle { id: collapsedThemeBtn width: parent.width - height: units.gu(6) + height: units.gu(5) color: collapsedThemeArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedThemeArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") Image { From ba082f15bf669978fab3406e7e3be89edd69baa8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 12:44:35 +0530 Subject: [PATCH 081/105] Add ColorOverlay to collapsed theme toggle icon for light mode contrast --- qml/app/navigation/MenuPage.qml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 6641dca7..33ad6efd 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -29,6 +29,7 @@ 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 @@ -428,12 +429,24 @@ Page { height: units.gu(5) color: collapsedThemeArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedThemeArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") - Image { + Item { anchors.centerIn: parent width: units.gu(2.4) height: units.gu(2.4) - source: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "../../images/daymode.png" : "../../images/darkmode.png" - fillMode: Image.PreserveAspectFit + + 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 { From a4f87e1bf5d42758abe45eaa7ba39c12f54d6b25 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 13:17:44 +0530 Subject: [PATCH 082/105] Sanitize account connection inputs and expand Odoo server URL validation --- models/utils.js | 29 ++++++++++++++++++-- qml/features/settings/pages/Account_Page.qml | 18 ++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/models/utils.js b/models/utils.js index ac58649a..be651e75 100644 --- a/models/utils.js +++ b/models/utils.js @@ -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/qml/features/settings/pages/Account_Page.qml b/qml/features/settings/pages/Account_Page.qml index a8b2a7a1..9c7379bf 100644 --- a/qml/features/settings/pages/Account_Page.qml +++ b/qml/features/settings/pages/Account_Page.qml @@ -105,17 +105,29 @@ Page { 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; + accountNameInput.text = accountNameInput.text.trim(); + 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; } From 53207b059715382a8433ba1fa6e5651a72c90bb9 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 13:28:30 +0530 Subject: [PATCH 083/105] Harden account name validation and null safety in account settings --- qml/features/settings/pages/Account_Page.qml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qml/features/settings/pages/Account_Page.qml b/qml/features/settings/pages/Account_Page.qml index 9c7379bf..3d7eb5f5 100644 --- a/qml/features/settings/pages/Account_Page.qml +++ b/qml/features/settings/pages/Account_Page.qml @@ -88,6 +88,7 @@ Page { } function handleAccountSave() { + accountNameInput.text = (accountNameInput.text || "").trim(); if (!accountNameInput.text) { notifPopup.open("Error", "Account name cannot be empty", "error"); return; @@ -95,12 +96,12 @@ 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; } @@ -112,9 +113,8 @@ Page { } linkInput.text = urlResult.cleanedUrl; - accountNameInput.text = accountNameInput.text.trim(); - usernameInput.text = usernameInput.text.trim(); - passwordInput.text = passwordInput.text.trim(); + usernameInput.text = (usernameInput.text || "").trim(); + passwordInput.text = (passwordInput.text || "").trim(); dbname = dbname.trim(); if (!usernameInput.text) { From 4080d15e833394e13e25a1cd30db93b34e9622be Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 13:49:22 +0530 Subject: [PATCH 084/105] Add dividers between action icons in collapsed navigation sidebar --- qml/app/navigation/MenuPage.qml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 33ad6efd..730dc7b5 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -303,7 +303,7 @@ Page { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - height: listpage.menuCollapsed ? (units.gu(16.5) + units.dp(1)) : 0 + height: listpage.menuCollapsed ? (bottomActionsColumn.height + units.dp(1)) : 0 color: isDark ? "#1e1e1e" : "#ffffff" // Divider above bottom actions @@ -321,13 +321,13 @@ Page { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: units.dp(1) - spacing: units.gu(0.5) + spacing: 0 // 1. Account Action Rectangle { id: collapsedAccountBtn width: parent.width - height: units.gu(5) + height: units.gu(5.5) color: collapsedAccountArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedAccountArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") Icon { @@ -359,11 +359,19 @@ Page { } } + // 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) + height: units.gu(5.5) color: collapsedLocalArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedLocalArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") Switch { @@ -422,11 +430,19 @@ Page { } } + // 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) + height: units.gu(5.5) color: collapsedThemeArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedThemeArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") Item { From 277748bb830dd78017087daf7e37ebc164884b5a Mon Sep 17 00:00:00 2001 From: Anmol Garg Date: Thu, 10 Sep 2026 17:07:00 +0530 Subject: [PATCH 085/105] Decouple sync and download snackbars with dynamic stacking and static download icon - Stack download snackbar above sync snackbar when both are active - Isolate sync and download backend event handling between widgets - Add customizable icon source and rotation property, keeping download icon static - Provide dedicated titles and subtitles for voice model downloads - Add smooth transition on snackbar margin changes --- qml/app/GlobalWidgets.qml | 6 ++- qml/components/system/GlobalTimerWidget.qml | 48 ++++++++++++------- .../system/ModelDownloadTimerWidget.qml | 10 +++- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/qml/app/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 7b9fdc62..522da04a 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 { diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index b18f30bc..1d8f8ea9 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -44,6 +44,16 @@ Rectangle { property bool syncFailed: false property string syncStatusMessage: "" + // Customization properties for derived widgets (e.g. ModelDownloadTimerWidget) + property bool isDownloadWidget: false + 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 @@ -73,7 +83,9 @@ Rectangle { if (root.backend_bridge) { backendBridge = root.backend_bridge; Logger.debug("GlobalTimerWidget", "GlobalTimer: Connected to backend bridge") - backendBridge.messageReceived.connect(handleSyncEvent); + if (!isDownloadWidget) { + backendBridge.messageReceived.connect(handleSyncEvent); + } break; } } @@ -97,10 +109,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; } } @@ -113,15 +125,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; } } @@ -130,7 +142,7 @@ Rectangle { syncSuccessful = true; syncFailed = false; syncProgress = 1.0; - syncStatusMessage = "Sync Complete!"; + syncStatusMessage = syncSuccessSubtitle; // Auto-hide after 3 seconds autoHideTimer.interval = 3000; @@ -141,7 +153,7 @@ Rectangle { function failSync(errorMessage) { syncSuccessful = false; syncFailed = true; - syncStatusMessage = errorMessage || "Sync Failed"; + syncStatusMessage = errorMessage || defaultSyncFailedText; // Auto-hide after 5 seconds autoHideTimer.interval = 5000; @@ -163,12 +175,12 @@ Rectangle { // Function to start sync indication with BackendBridge integration function startSync(accountId, accountName) { syncAccountId = accountId; - syncAccountName = accountName || "Account " + accountId; + syncAccountName = accountName || (isDownloadWidget ? "" : ("Account " + accountId)); isSyncing = true; syncSuccessful = false; syncFailed = false; syncProgress = 0.0; - syncStatusMessage = "Starting sync..."; + syncStatusMessage = isDownloadWidget ? i18n.dtr("ubtms", "Starting download...") : i18n.dtr("ubtms", "Starting sync..."); globalTimer.visible = true; } @@ -282,12 +294,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 @@ -335,7 +347,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 ""; } @@ -381,9 +393,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) diff --git a/qml/components/system/ModelDownloadTimerWidget.qml b/qml/components/system/ModelDownloadTimerWidget.qml index 279ccd31..59f1df1a 100644 --- a/qml/components/system/ModelDownloadTimerWidget.qml +++ b/qml/components/system/ModelDownloadTimerWidget.qml @@ -4,7 +4,15 @@ import Lomiri.Components 1.3 GlobalTimerWidget { id: downloadWidget enableTimesheetTimer: false - + isDownloadWidget: true + 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/download.svg" + rotateIcon: false + // Override completion logic to connect to our specific download events Component.onCompleted: { var root = downloadWidget; From a9b3c0d55e1d5db8296c9fe095f24269a15ff24b Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 10 Sep 2026 17:24:29 +0530 Subject: [PATCH 086/105] Fix toggle switch state reset when changing app theme Replace Lomiri Switch component in MenuPage and AppDrawer with a custom TSSwitch component. Lomiri's styled switch destroyed and re-instantiated its style instance on theme changes, triggering null reference errors in SwitchStyle and resetting visual state. TSSwitch uses declarative bindings and animations independent of Lomiri's theme style engine. Fixes #342. --- qml/app/AppDrawer.qml | 37 +------------- qml/app/navigation/MenuPage.qml | 73 +++------------------------ qml/components/base/TSSwitch.qml | 87 ++++++++++++++++++++++++++++++++ qml/components/qmldir | 1 + 4 files changed, 96 insertions(+), 102 deletions(-) create mode 100644 qml/components/base/TSSwitch.qml diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 06f72516..910bb75a 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -2,7 +2,6 @@ import QtQuick 2.6 import QtQuick.Controls 2.2 as Controls import QtQuick.Layouts 1.3 import Lomiri.Components 1.3 -import Lomiri.Components.Themes.Ambiance 1.3 import "../components" import "navigation/NavigationRoutes.js" as NavigationRoutes @@ -120,48 +119,16 @@ Controls.Drawer { } // Local Account Toggle Switch - Switch { + TSSwitch { id: localToggleSwitch Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: units.gu(4.2) Layout.preferredHeight: units.gu(2.1) - width: units.gu(4.2) - height: units.gu(2.1) checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - style: Component { - SwitchStyle { - implicitWidth: units.gu(4.2) - implicitHeight: units.gu(2.1) - checkedBackgroundColor: Qt.darker(LomiriColors.orange, 1.35) - } - } onClicked: { if (typeof accountPicker !== "undefined") { - accountPicker.toggleLocalMode(checked); - } - } - - Binding { - target: localToggleSwitch - property: "checked" - value: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - } - - Connections { - target: typeof accountPicker !== "undefined" ? accountPicker : null - onSelectedAccountIdChanged: { - localToggleSwitch.checked = (accountPicker.selectedAccountId === 0); - } - onAccepted: { - localToggleSwitch.checked = (accountId === 0); - } - } - - Connections { - target: typeof rootApp !== "undefined" ? rootApp : null - onGlobalAccountChanged: { - localToggleSwitch.checked = (accountId === 0); + accountPicker.toggleLocalMode(!checked); } } } diff --git a/qml/app/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index 730dc7b5..a1931d61 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -24,7 +24,6 @@ import QtQuick 2.7 import Lomiri.Components 1.3 -import Lomiri.Components.Themes.Ambiance 1.3 import QtCharts 2.0 import QtQuick.Layouts 1.11 import Qt.labs.settings 1.0 @@ -214,49 +213,17 @@ Page { } // Local Account Toggle Switch - Switch { + TSSwitch { id: localToggleSwitch visible: !listpage.menuCollapsed Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: units.gu(4.2) Layout.preferredHeight: units.gu(2.1) - width: units.gu(4.2) - height: units.gu(2.1) checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - style: Component { - SwitchStyle { - implicitWidth: units.gu(4.2) - implicitHeight: units.gu(2.1) - checkedBackgroundColor: Qt.darker(LomiriColors.orange, 1.35) - } - } onClicked: { if (typeof accountPicker !== "undefined") { - accountPicker.toggleLocalMode(checked); - } - } - - Binding { - target: localToggleSwitch - property: "checked" - value: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - } - - Connections { - target: typeof accountPicker !== "undefined" ? accountPicker : null - onSelectedAccountIdChanged: { - localToggleSwitch.checked = (accountPicker.selectedAccountId === 0); - } - onAccepted: { - localToggleSwitch.checked = (accountId === 0); - } - } - - Connections { - target: typeof rootApp !== "undefined" ? rootApp : null - onGlobalAccountChanged: { - localToggleSwitch.checked = (accountId === 0); + accountPicker.toggleLocalMode(!checked); } } } @@ -374,43 +341,15 @@ Page { height: units.gu(5.5) color: collapsedLocalArea.pressed ? (isDark ? "#2a2a2a" : "#f0f0f0") : (collapsedLocalArea.containsMouse ? (isDark ? "#252525" : "#f7f7f7") : "transparent") - Switch { + TSSwitch { id: collapsedLocalSwitch anchors.centerIn: parent width: units.gu(4.2) height: units.gu(2.1) - enabled: false + interactive: false checked: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - style: Component { - SwitchStyle { - implicitWidth: units.gu(4.2) - implicitHeight: units.gu(2.1) - checkedBackgroundColor: Qt.darker(LomiriColors.orange, 1.35) - } - } - - Binding { - target: collapsedLocalSwitch - property: "checked" - value: typeof accountPicker !== "undefined" ? (accountPicker.selectedAccountId === 0) : false - } - - Connections { - target: typeof accountPicker !== "undefined" ? accountPicker : null - onSelectedAccountIdChanged: { - collapsedLocalSwitch.checked = (accountPicker.selectedAccountId === 0); - } - onAccepted: { - collapsedLocalSwitch.checked = (accountId === 0); - } - } - - Connections { - target: typeof rootApp !== "undefined" ? rootApp : null - onGlobalAccountChanged: { - collapsedLocalSwitch.checked = (accountId === 0); - } - } + uncheckedColor: isDark ? "#444444" : "#cccccc" + uncheckedBorderColor: isDark ? "#555555" : "#bbbbbb" } MouseArea { 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/qmldir b/qml/components/qmldir index f16a4036..74b41510 100644 --- a/qml/components/qmldir +++ b/qml/components/qmldir @@ -50,6 +50,7 @@ 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 From 47e2a67656e849ff79bf6dabcdd0187ce6e51204 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Thu, 10 Sep 2026 17:57:48 +0530 Subject: [PATCH 087/105] swipe actions have been hidden for the Local Account --- qml/features/settings/pages/Settings_Accounts.qml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/qml/features/settings/pages/Settings_Accounts.qml b/qml/features/settings/pages/Settings_Accounts.qml index 29402071..4e20c92f 100644 --- a/qml/features/settings/pages/Settings_Accounts.qml +++ b/qml/features/settings/pages/Settings_Accounts.qml @@ -313,13 +313,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, @@ -329,14 +330,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 @@ -346,7 +347,6 @@ Page { Action { iconName: "delete" text: i18n.dtr("ubtms", "Delete") - enabled: model.id !== 0 onTriggered: { accountToDelete = model.id; accountIndexToDelete = index; From 755e7d4b6927b9baf2a907782b4cbf209ad88cdd Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 11 Sep 2026 11:36:31 +0530 Subject: [PATCH 088/105] Fix duplicate attachment uploads and data loss on reopen (#336) --- models/project.js | 4 +- models/task.js | 52 ++++++++++++++++++- .../dialogs/ContentPickerDialog.qml | 11 ++-- qml/components/selectors/WorkItemSelector.qml | 1 + qml/components/workflow/AttachmentManager.qml | 29 +++++++++-- qml/features/tasks/pages/Tasks.qml | 29 +++++++++-- 6 files changed, 111 insertions(+), 15 deletions(-) diff --git a/models/project.js b/models/project.js index 0e1cc80e..567e48e0 100644 --- a/models/project.js +++ b/models/project.js @@ -543,12 +543,12 @@ function getAttachmentsForProject(odooRecordId, accountId) { 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 = ? OR (odoo_record_id = ? AND odoo_record_id > 0)) + AND res_id = ? AND account_id = ? ORDER BY name COLLATE NOCASE ASC `; - var result = tx.executeSql(query, [odooRecordId, odooRecordId, accountId]); + var result = tx.executeSql(query, [odooRecordId, accountId]); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); diff --git a/models/task.js b/models/task.js index 1803cbc4..f4923dd2 100644 --- a/models/task.js +++ b/models/task.js @@ -297,12 +297,12 @@ function getAttachmentsForTask(odooRecordId, accountId) { 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 = ? OR (odoo_record_id = ? AND odoo_record_id > 0)) + AND res_id = ? AND account_id = ? ORDER BY name COLLATE NOCASE ASC `; - var result = tx.executeSql(query, [odooRecordId, odooRecordId, accountId]); + var result = tx.executeSql(query, [odooRecordId, accountId]); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -326,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; + } +} + /** 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/selectors/WorkItemSelector.qml b/qml/components/selectors/WorkItemSelector.qml index 54479bcc..0a5b0997 100644 --- a/qml/components/selectors/WorkItemSelector.qml +++ b/qml/components/selectors/WorkItemSelector.qml @@ -24,6 +24,7 @@ Rectangle { property bool readOnly: false property bool restrictAccountToLocalOnly: false // When true, only show local account for new project creation + readonly property alias selectedAccountId: account_component.selectedId // Add flag to prevent auto-loading when deferred loading is planned property bool deferredLoadingPlanned: false diff --git a/qml/components/workflow/AttachmentManager.qml b/qml/components/workflow/AttachmentManager.qml index d79997c7..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)); + } } } diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index 1f2e6964..72963771 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -96,6 +96,7 @@ 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 @@ -370,6 +371,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 +485,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. @@ -913,14 +923,22 @@ 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 > 0) ? currentTask.odoo_record_id : ((currentTask && currentTask.id) ? currentTask.id : recordid) - account_id: (currentTask && currentTask.account_id !== undefined) ? 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: { - var resId = (currentTask && currentTask.odoo_record_id > 0) ? currentTask.odoo_record_id : ((currentTask && currentTask.id) ? currentTask.id : recordid); - var accId = (currentTask && currentTask.account_id !== undefined) ? currentTask.account_id : 0; + 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)); } @@ -1024,6 +1042,7 @@ Page { }); } else { // We are creating a new task + attachments_widget.clearAttachments(); workItem.loadAccounts(); taskScheduleFields.deadlineText = "Not set"; From e4dc01ed334efe5c0d0a95826878b90e6fc6e1fc Mon Sep 17 00:00:00 2001 From: Anmol Garg Date: Fri, 11 Sep 2026 12:12:37 +0530 Subject: [PATCH 089/105] Decouple base widget from derived flags and add white download icon - Remove isDownloadWidget from GlobalTimerWidget to keep base class generic - Restore original startSync and onCompleted behavior in GlobalTimerWidget - Override handleSyncEvent in ModelDownloadTimerWidget to isolate sync events - Add crisp white download vector icon for high contrast on dark snackbars --- qml/components/system/GlobalTimerWidget.qml | 11 ++++------- qml/components/system/ModelDownloadTimerWidget.qml | 5 +++-- qml/images/downloadWhite.svg | 5 +++++ 3 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 qml/images/downloadWhite.svg diff --git a/qml/components/system/GlobalTimerWidget.qml b/qml/components/system/GlobalTimerWidget.qml index 1d8f8ea9..80021eb0 100644 --- a/qml/components/system/GlobalTimerWidget.qml +++ b/qml/components/system/GlobalTimerWidget.qml @@ -44,8 +44,7 @@ Rectangle { property bool syncFailed: false property string syncStatusMessage: "" - // Customization properties for derived widgets (e.g. ModelDownloadTimerWidget) - property bool isDownloadWidget: false + // 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...") @@ -83,9 +82,7 @@ Rectangle { if (root.backend_bridge) { backendBridge = root.backend_bridge; Logger.debug("GlobalTimerWidget", "GlobalTimer: Connected to backend bridge") - if (!isDownloadWidget) { - backendBridge.messageReceived.connect(handleSyncEvent); - } + backendBridge.messageReceived.connect(handleSyncEvent); break; } } @@ -175,12 +172,12 @@ Rectangle { // Function to start sync indication with BackendBridge integration function startSync(accountId, accountName) { syncAccountId = accountId; - syncAccountName = accountName || (isDownloadWidget ? "" : ("Account " + accountId)); + syncAccountName = accountName || "Account " + accountId; isSyncing = true; syncSuccessful = false; syncFailed = false; syncProgress = 0.0; - syncStatusMessage = isDownloadWidget ? i18n.dtr("ubtms", "Starting download...") : i18n.dtr("ubtms", "Starting sync..."); + syncStatusMessage = "Starting sync..."; globalTimer.visible = true; } diff --git a/qml/components/system/ModelDownloadTimerWidget.qml b/qml/components/system/ModelDownloadTimerWidget.qml index 59f1df1a..7f5ac264 100644 --- a/qml/components/system/ModelDownloadTimerWidget.qml +++ b/qml/components/system/ModelDownloadTimerWidget.qml @@ -4,13 +4,14 @@ import Lomiri.Components 1.3 GlobalTimerWidget { id: downloadWidget enableTimesheetTimer: false - isDownloadWidget: true + 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/download.svg" + syncIconSource: "../../images/downloadWhite.svg" rotateIcon: false // Override completion logic to connect to our specific download events 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 @@ + + + + + From e8574ffd7ee73de86da2bc35aa5533dd32be31fe Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 11 Sep 2026 12:16:53 +0530 Subject: [PATCH 090/105] Fix task activities displaying in project activity views (#345) --- models/activity.js | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/models/activity.js b/models/activity.js index d137662a..448b4fdd 100644 --- a/models/activity.js +++ b/models/activity.js @@ -924,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 (CASE WHEN account_id = 0 OR odoo_record_id IS NULL THEN id ELSE odoo_record_id END) FROM project_task_app - WHERE (project_id = ? OR sub_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, projectOdooRecordId, accountId]); + `, [accountId, projectOdooRecordId]); for (var i = 0; i < rs.rows.length; i++) { var row = rs.rows.item(i); @@ -1013,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 (CASE WHEN account_id = 0 OR odoo_record_id IS NULL THEN id ELSE odoo_record_id END) FROM project_task_app - WHERE (project_id = ? OR sub_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, projectOdooRecordId, accountId, limit, offset]); + `, [accountId, projectOdooRecordId, limit, offset]); for (var i = 0; i < rs.rows.length; i++) { var row = rs.rows.item(i); From 7b08d53d61905b49a6815f063058e0861f1826dd Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 11 Sep 2026 13:21:35 +0530 Subject: [PATCH 091/105] Fix duplicate selectedAccountId property declaration in WorkItemSelector --- qml/components/selectors/WorkItemSelector.qml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qml/components/selectors/WorkItemSelector.qml b/qml/components/selectors/WorkItemSelector.qml index 0a5b0997..b12ec991 100644 --- a/qml/components/selectors/WorkItemSelector.qml +++ b/qml/components/selectors/WorkItemSelector.qml @@ -24,7 +24,6 @@ Rectangle { property bool readOnly: false property bool restrictAccountToLocalOnly: false // When true, only show local account for new project creation - readonly property alias selectedAccountId: account_component.selectedId // Add flag to prevent auto-loading when deferred loading is planned property bool deferredLoadingPlanned: false @@ -133,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 From 32f68add6e5a5ecedd8edcbcb26286073bd80aa9 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 11 Sep 2026 18:07:24 +0530 Subject: [PATCH 092/105] Fix date input field color contrast in light and dark modes (#279) --- qml/components/pickers/DateRangeSelector.qml | 41 ++++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index 5a30e7b5..b3d0d3e2 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -2,6 +2,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 +19,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 +109,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 { @@ -118,12 +120,24 @@ Item { Layout.preferredHeight: units.gu(5) TextField { + 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 ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") : "gray" + 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) + + 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" + } } MouseArea { @@ -153,9 +167,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 { @@ -165,12 +178,24 @@ Item { Layout.preferredHeight: units.gu(5) TextField { + 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 ? (theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "black") : "gray" + 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) + + 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" + } } MouseArea { From 13b5204b7d958b90885387bdd19e699edf869137 Mon Sep 17 00:00:00 2001 From: Anmol Garg <128157063+AnmollGarg@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:47:20 +0530 Subject: [PATCH 093/105] Warn user and prevent switching when no remote account is logged in (#338) * Warn user and prevent switching when no remote account is logged in * Fix multiple overlapping pop-ups on repeated account toggle (#334) - Make NotificationPopup modal and guard open() with activeDialog reference - Guard AccountSelectorDialog open() against duplicate active dialogs - Reset activeDialog on dialog destruction and close --- .../dialogs/AccountSelectorDialog.qml | 22 ++++++++++++++++--- qml/components/feedback/NotificationPopup.qml | 20 +++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/qml/components/dialogs/AccountSelectorDialog.qml b/qml/components/dialogs/AccountSelectorDialog.qml index 70a2879b..66f72059 100644 --- a/qml/components/dialogs/AccountSelectorDialog.qml +++ b/qml/components/dialogs/AccountSelectorDialog.qml @@ -39,11 +39,15 @@ Item { // 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 */ @@ -54,15 +58,23 @@ Item { selectedAccountName = Accounts.getAccountName(0) accepted(0, selectedAccountName) } + return true } else { - var targetId = lastRemoteAccountId > 0 ? lastRemoteAccountId : Accounts.getDefaultRemoteAccountId() + 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) { - open(selectedAccountId) + 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 } } @@ -285,6 +297,10 @@ Item { loadAccounts() } } + + Component.onDestruction: { + root.activeDialog = null + } } } } diff --git a/qml/components/feedback/NotificationPopup.qml b/qml/components/feedback/NotificationPopup.qml index 16b4d70f..74fa94f4 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,6 +47,7 @@ Item { Dialog { id: popupDialog title: popupWrapper.titleText + modal: true // Dark mode friendly styling StyleHints { @@ -68,7 +71,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 +84,10 @@ Item { backgroundColor: LomiriColors.orange } } + + Component.onDestruction: { + popupWrapper.activeDialog = null; + } } } @@ -87,6 +98,11 @@ Item { messageText = messageArg; if (typeArg) type = typeArg; - PopupUtils.open(dialogComponent); + + if (activeDialog) + return activeDialog; + + activeDialog = PopupUtils.open(dialogComponent); + return activeDialog; } } From b17cba903232fb7c09b7422f579050e2a6a161e8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 13:45:03 +0530 Subject: [PATCH 094/105] Fix TextField namespace conflict in DateRangeSelector --- qml/components/pickers/DateRangeSelector.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index b3d0d3e2..ad50a4c4 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -1,5 +1,5 @@ import QtQuick 2.12 -import QtQuick.Controls 2.12 +import QtQuick.Controls 2.12 as Controls import QtQuick.Layouts 1.3 import QtQuick.Dialogs 1.2 import Lomiri.Components 1.3 @@ -119,7 +119,7 @@ Item { Layout.fillWidth: true Layout.preferredHeight: units.gu(5) - TextField { + Controls.TextField { id: startDateField anchors.fill: parent readOnly: true @@ -177,7 +177,7 @@ Item { Layout.fillWidth: true Layout.preferredHeight: units.gu(5) - TextField { + Controls.TextField { id: endDateField anchors.fill: parent readOnly: true From bf2e488aa3dd576e2aa9711350678438776231ea Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 13:45:14 +0530 Subject: [PATCH 095/105] Scope task child queries and subtasks retrieval by account ID --- models/task.js | 123 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 86 insertions(+), 37 deletions(-) diff --git a/models/task.js b/models/task.js index f4923dd2..1bf798ce 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,72 @@ 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) { + // 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 (id = ? OR odoo_record_id = ?)"; + var parentParams = [parentId, parentId]; + if (accountId !== undefined && accountId !== null && accountId >= 0) { + parentQuery += " AND account_id = ?"; + parentParams.push(accountId); + } + parentQuery += " LIMIT 1"; + + var parentRes = tx.executeSql(parentQuery, parentParams); + var localPid = parentId; + var odooPid = 0; + var accId = accountId; + + 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"; + + var result = tx.executeSql(query, params); + for (var i = 0; i < result.rows.length; i++) { + subtaskList.push(DBCommon.rowToObject(result.rows.item(i))); + } + }); + } 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. From e5506c83d5a02e18230ad1da1bc3c2f15259fe33 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 13:45:31 +0530 Subject: [PATCH 096/105] Implement task hierarchy navigation and subtask drilldown in TaskList --- .../tasks/components/TaskDetailsCard.qml | 121 +++++-- qml/features/tasks/components/TaskList.qml | 338 +++++++++++++++--- qml/features/tasks/pages/MyTasksPage.qml | 8 +- qml/features/tasks/pages/Task_Page.qml | 8 +- qml/features/tasks/pages/Tasks.qml | 19 + 5 files changed, 421 insertions(+), 73 deletions(-) diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 678b0776..8dc0eba5 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 || 0, 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 || 0, taskName); + } + } } } } diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index eb22f9f3..48a90ffa 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 @@ -48,8 +51,23 @@ Item { onCurrentParentIdChanged: { currentOffset = 0; - hasMoreItems = true; - _doPopulateTaskChildrenMap(); + hasMoreItems = (currentParentId === -1); + + if (currentParentId === -1) { + currentParentName = ""; + if (childrenMapReady && childrenMap[-1] !== undefined) { + taskListView.model = getCurrentModel(); + } else { + refreshWithFilter(); + } + return; + } + + if (childrenMapReady && childrenMap[currentParentId] !== undefined && childrenMap[currentParentId].count > 0) { + taskListView.model = getCurrentModel(); + } else { + _loadSubtasksForCurrentParent(); + } } // Optional delegate for external data loading (function(limit, offset)) @@ -392,6 +410,88 @@ 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 || "" + }); + currentParentId = taskId; + currentAccountId = (accountId !== undefined && accountId !== null) ? accountId : -1; + currentParentName = taskName || ""; + } + + 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; + currentAccountId = last.accountId !== undefined ? last.accountId : -1; + currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + } + } + + function _loadSubtasksForCurrentParent() { + if (currentParentId === -1) { + return; + } + + hasMoreItems = false; + + var acc = (currentAccountId !== undefined && currentAccountId >= 0) + ? currentAccountId + : (filterByAccount && selectedAccountId >= 0 ? selectedAccountId : -1); + + var subtasks = Task.getSubtasksForParent(currentParentId, acc); + + var model = Qt.createQmlObject('import QtQuick 2.0; ListModel {}', taskNavigator); + childrenMap[currentParentId] = model; + + 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; @@ -399,6 +499,7 @@ Item { if (flatViewMode) { navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; } // Refresh the model @@ -487,6 +588,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 +623,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); } } @@ -538,8 +641,14 @@ Item { 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; + if (children && children.length > 0) { + child.hasChildren = true; + child.childCount = children.length; + } else { + var childCheck = Task.checkTaskHasChildren(child.local_id); + child.hasChildren = childCheck.hasChildren; + child.childCount = childCheck.childCount; + } }); } @@ -567,6 +676,7 @@ Item { isLoading = true; navigationStackModel.clear(); currentParentId = -1; + currentParentName = ""; // Reset pagination currentOffset = 0; @@ -598,6 +708,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 +732,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 @@ -712,17 +826,146 @@ Item { 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: { + 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 + } + + // 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 +973,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 +996,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 +1011,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 +1028,11 @@ Item { onViewRequested: d => { taskSelected(local_id); } + onNavigationRequested: (taskId, accountId, taskName) => { + if (!flatViewMode) { + navigateToTask(taskId, accountId, taskName); + } + } onTimesheetRequested: localId => { taskTimesheetRequested(localId); } @@ -793,30 +1043,34 @@ 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 } } } diff --git a/qml/features/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index cc230703..11ed4d9f 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -72,10 +72,14 @@ 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; + } + 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..6955ea5b 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -89,10 +89,14 @@ 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; + } + 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..c1cf8a1c 100644 --- a/qml/features/tasks/pages/Tasks.qml +++ b/qml/features/tasks/pages/Tasks.qml @@ -1063,6 +1063,25 @@ 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 = 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); + } + } } } From 63ed0e75f2e65436e4daebe975cc528bc9787886 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 13:59:44 +0530 Subject: [PATCH 097/105] Fix subtask styling and prevent filter refresh corruption in TaskList --- models/task.js | 33 +++++++++++++- qml/features/tasks/components/TaskList.qml | 50 ++++++++++++++-------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/models/task.js b/models/task.js index 1bf798ce..54eb85cb 100644 --- a/models/task.js +++ b/models/task.js @@ -1582,9 +1582,40 @@ function getSubtasksForParent(parentId, accountId, 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++) { - subtaskList.push(DBCommon.rowToObject(result.rows.item(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 timeQuery = "SELECT SUM(unit_amount) as total_hours FROM account_analytic_line_app WHERE (status IS NULL OR status != 'deleted') AND (task_id = ? OR (task_id = ? AND ? > 0) OR sub_task_id = ? OR (sub_task_id = ? AND ? > 0)) AND account_id = ?"; + var timeParams = [task.id, task.odoo_record_id || 0, task.odoo_record_id || 0, task.id, task.odoo_record_id || 0, task.odoo_record_id || 0, 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) { diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index 48a90ffa..9db23b02 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -48,6 +48,7 @@ Item { property int currentOffset: 0 property bool hasMoreItems: true property bool isLoadingMore: false + property var fallbackEmptyModel: ListModel {} onCurrentParentIdChanged: { currentOffset = 0; @@ -55,19 +56,12 @@ Item { if (currentParentId === -1) { currentParentName = ""; - if (childrenMapReady && childrenMap[-1] !== undefined) { - taskListView.model = getCurrentModel(); - } else { - refreshWithFilter(); - } + currentAccountId = -1; + refreshWithFilter(); return; } - if (childrenMapReady && childrenMap[currentParentId] !== undefined && childrenMap[currentParentId].count > 0) { - taskListView.model = getCurrentModel(); - } else { - _loadSubtasksForCurrentParent(); - } + _loadSubtasksForCurrentParent(); } // Optional delegate for external data loading (function(limit, offset)) @@ -131,20 +125,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; @@ -156,6 +160,7 @@ Item { // Add combined project and time filter method function applyProjectAndTimeFilter(projectOdooId, accountId, timeFilter) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = accountId; @@ -166,6 +171,7 @@ Item { // Add combined project and search filter method function applyProjectAndSearchFilter(projectOdooId, accountId, searchQuery) { + _resetHierarchyNavigation(); filterByProject = true; projectOdooRecordId = projectOdooId; projectAccountId = accountId; @@ -350,6 +356,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 @@ -403,6 +413,7 @@ Item { } function applyAccountFilter(accountId) { + _resetHierarchyNavigation(); filterByAccount = (accountId >= 0); selectedAccountId = accountId; filterByProject = false; @@ -448,8 +459,13 @@ Item { var subtasks = Task.getSubtasksForParent(currentParentId, acc); - var model = Qt.createQmlObject('import QtQuick 2.0; ListModel {}', taskNavigator); - childrenMap[currentParentId] = model; + 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; @@ -497,9 +513,7 @@ Item { // Reset navigation when switching modes if (flatViewMode) { - navigationStackModel.clear(); - currentParentId = -1; - currentParentName = ""; + _resetHierarchyNavigation(); } // Refresh the model @@ -819,7 +833,7 @@ 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 { @@ -904,9 +918,7 @@ Item { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - navigationStackModel.clear(); - currentParentId = -1; - currentParentName = ""; + _resetHierarchyNavigation(); } } } From 33c26f49249af627362383de41a0b6db79e1e99c Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 14:49:50 +0530 Subject: [PATCH 098/105] Clean up orphan project updates and sync account changes on instance removal --- models/accounts.js | 22 +++++++--- models/dbinit.js | 44 +++++++++++++++++++ models/project.js | 10 +++-- qml/app/GlobalWidgets.qml | 10 ++--- .../settings/pages/Settings_Accounts.qml | 5 +++ qml/features/updates/pages/Updates_Page.qml | 38 +++++++++++++--- 6 files changed, 110 insertions(+), 19 deletions(-) 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..3be6b390 100644 --- a/models/dbinit.js +++ b/models/dbinit.js @@ -649,6 +649,7 @@ function initializeDatabase() { purgeCache(); syncDraftFlags(); + cleanupOrphanAccountData(); Logger.debug("Dbinit", "Database initialization complete") } @@ -802,3 +803,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/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/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/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(); } From c4aa4dace9580a279d3925ba6878808117592548 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 14:55:40 +0530 Subject: [PATCH 099/105] Replace Controls.TextField with styled Rectangle and Label in DateRangeSelector --- qml/components/pickers/DateRangeSelector.qml | 59 ++++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index ad50a4c4..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 as Controls 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) - Controls.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) - Controls.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 } } From b1e23e095874d55e87b6933f0dc1080f8d970ef4 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 15:07:28 +0530 Subject: [PATCH 100/105] Fix QML layout anchor conflicts in TaskList, TimeRecorderWidget, and Timesheet --- qml/features/tasks/components/TaskList.qml | 46 +++++++++---------- .../components/TimeRecorderWidget.qml | 2 +- qml/features/timesheets/pages/Timesheet.qml | 6 +-- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/qml/features/tasks/components/TaskList.qml b/qml/features/tasks/components/TaskList.qml index 9db23b02..4b117ac6 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -1058,32 +1058,32 @@ Item { } } } + } - // 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) + // 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) - Icon { - name: "info" - width: units.gu(3) - height: units.gu(3) - anchors.horizontalCenter: parent.horizontalCenter - color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#6b7280" : "#9ca3af" - } + 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 - } + 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 } } } 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: { From f027f329370b95a717b98bade3ac00ea35648b16 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 15:14:11 +0530 Subject: [PATCH 101/105] Optimize startup performance with deferred DB maintenance and lazy query loading --- models/dbinit.js | 8 ++++-- qml/TSApp.qml | 11 ++++++++ qml/app/StartupManager.qml | 26 +++++++++---------- qml/features/dashboard/pages/Dashboard2.qml | 11 ++++++++ qml/features/tasks/pages/MyTasksPage.qml | 2 +- qml/features/tasks/pages/Task_Page.qml | 10 ++++--- .../timesheets/pages/Timesheet_Page.qml | 4 ++- qml/features/updates/pages/Updates_Page.qml | 20 ++++++++++---- 8 files changed, 66 insertions(+), 26 deletions(-) diff --git a/models/dbinit.js b/models/dbinit.js index 3be6b390..0d72fe82 100644 --- a/models/dbinit.js +++ b/models/dbinit.js @@ -647,11 +647,15 @@ function initializeDatabase() { } + Logger.debug("Dbinit", "Database initialization complete") +} + +function performPostStartupMaintenance() { + Logger.debug("Dbinit", "Performing post-startup database maintenance...") purgeCache(); syncDraftFlags(); cleanupOrphanAccountData(); - - Logger.debug("Dbinit", "Database initialization complete") + Logger.debug("Dbinit", "Post-startup database maintenance complete") } /** 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/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/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/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index 11ed4d9f..8fabc8a2 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -488,7 +488,7 @@ Page { updateCurrentUser(); - if (currentUserOdooId > 0) { + if (visible && currentUserOdooId > 0) { loadPersonalStages(); if (personalStages.length > 0 && currentPersonalStageId !== undefined) { loadTasksWithIndicator(function() { diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 6955ea5b..7735e502 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -575,10 +575,12 @@ Page { // Apply default assignee filter (current logged-in user) applyDefaultAssigneeFilter(); - if (filterByProject) { - tasklist.applyProjectAndTimeFilter(projectOdooRecordId, projectAccountId, currentFilter); - } else { - tasklist.applyFilter(currentFilter); + if (visible) { + if (filterByProject) { + tasklist.applyProjectAndTimeFilter(projectOdooRecordId, projectAccountId, currentFilter); + } else { + tasklist.applyFilter(currentFilter); + } } } diff --git a/qml/features/timesheets/pages/Timesheet_Page.qml b/qml/features/timesheets/pages/Timesheet_Page.qml index 7ac58613..5469abc9 100644 --- a/qml/features/timesheets/pages/Timesheet_Page.qml +++ b/qml/features/timesheets/pages/Timesheet_Page.qml @@ -407,7 +407,9 @@ Page { // do NOT re-assign it imperatively — that would break the binding and // cause the filter to lose sync when the user changes accounts. currentFilter = "all"; - fetch_timesheets_list(); + if (visible) { + fetch_timesheets_list(); + } } // Loading indicator overlay diff --git a/qml/features/updates/pages/Updates_Page.qml b/qml/features/updates/pages/Updates_Page.qml index 1411c8ae..cad53efd 100644 --- a/qml/features/updates/pages/Updates_Page.qml +++ b/qml/features/updates/pages/Updates_Page.qml @@ -159,7 +159,9 @@ Page { onAccepted: function (id, name) { if (!filterByProject) { selectedAccountId = id; - fetchupdates(); + if (visible) { + fetchupdates(); + } } } @@ -168,7 +170,9 @@ Page { var activeId = accountPicker.selectedAccountId; if (selectedAccountId !== activeId) { selectedAccountId = activeId; - fetchupdates(); + if (visible) { + fetchupdates(); + } } } } @@ -191,7 +195,9 @@ Page { if (!filterByProject) { selectedAccountId = acctNum; } - fetchupdates(); + if (visible) { + fetchupdates(); + } } onGlobalAccountChanged: function (accountId, accountName) { var acctNum = -1; @@ -208,7 +214,9 @@ Page { if (!filterByProject) { selectedAccountId = acctNum; } - fetchupdates(); + if (visible) { + fetchupdates(); + } } } @@ -412,7 +420,9 @@ Page { if (!filterByProject && typeof accountPicker !== "undefined" && accountPicker) { selectedAccountId = accountPicker.selectedAccountId; } - fetchupdates(); + if (visible) { + fetchupdates(); + } } // Loading indicator overlay From dc55d302f73bfc74e5e88ec4a6b7f56e37f333e1 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 15:36:05 +0530 Subject: [PATCH 102/105] Fix device build launch, daemon path handling, and QML view initialization --- clickable.yaml | 2 +- qml-notify-module/NotificationHelper.cpp | 36 +++++++++++++------ qml/components/feedback/NotificationPopup.qml | 6 ---- qml/components/selectors/StageFilterMenu.qml | 2 +- qml/features/tasks/pages/MyTasksPage.qml | 2 +- qml/features/tasks/pages/Task_Page.qml | 10 +++--- .../timesheets/pages/Timesheet_Page.qml | 4 +-- qml/features/updates/pages/Updates_Page.qml | 20 +++-------- src/daemon_bootstrap.py | 32 +++++++++++++---- 9 files changed, 65 insertions(+), 49 deletions(-) diff --git a/clickable.yaml b/clickable.yaml index e121c029..5da79662 100644 --- a/clickable.yaml +++ b/clickable.yaml @@ -1,6 +1,6 @@ clickable_minimum_required: 8.0.0 builder: cmake -kill: qmlscene +launch: "bash -c 'pkill -9 -x qmlscene 2>/dev/null || true; sleep 0.2; env DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/32011/bus XDG_RUNTIME_DIR=/run/user/32011 lomiri-app-launch ubtms_ubtms_1.3.3 || true'" skip_review: true dependencies_target: - libatomic1 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/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/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/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index 8fabc8a2..11ed4d9f 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -488,7 +488,7 @@ Page { updateCurrentUser(); - if (visible && currentUserOdooId > 0) { + if (currentUserOdooId > 0) { loadPersonalStages(); if (personalStages.length > 0 && currentPersonalStageId !== undefined) { loadTasksWithIndicator(function() { diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 7735e502..6955ea5b 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -575,12 +575,10 @@ Page { // Apply default assignee filter (current logged-in user) applyDefaultAssigneeFilter(); - if (visible) { - if (filterByProject) { - tasklist.applyProjectAndTimeFilter(projectOdooRecordId, projectAccountId, currentFilter); - } else { - tasklist.applyFilter(currentFilter); - } + if (filterByProject) { + tasklist.applyProjectAndTimeFilter(projectOdooRecordId, projectAccountId, currentFilter); + } else { + tasklist.applyFilter(currentFilter); } } diff --git a/qml/features/timesheets/pages/Timesheet_Page.qml b/qml/features/timesheets/pages/Timesheet_Page.qml index 5469abc9..7ac58613 100644 --- a/qml/features/timesheets/pages/Timesheet_Page.qml +++ b/qml/features/timesheets/pages/Timesheet_Page.qml @@ -407,9 +407,7 @@ Page { // do NOT re-assign it imperatively — that would break the binding and // cause the filter to lose sync when the user changes accounts. currentFilter = "all"; - if (visible) { - fetch_timesheets_list(); - } + fetch_timesheets_list(); } // Loading indicator overlay diff --git a/qml/features/updates/pages/Updates_Page.qml b/qml/features/updates/pages/Updates_Page.qml index cad53efd..1411c8ae 100644 --- a/qml/features/updates/pages/Updates_Page.qml +++ b/qml/features/updates/pages/Updates_Page.qml @@ -159,9 +159,7 @@ Page { onAccepted: function (id, name) { if (!filterByProject) { selectedAccountId = id; - if (visible) { - fetchupdates(); - } + fetchupdates(); } } @@ -170,9 +168,7 @@ Page { var activeId = accountPicker.selectedAccountId; if (selectedAccountId !== activeId) { selectedAccountId = activeId; - if (visible) { - fetchupdates(); - } + fetchupdates(); } } } @@ -195,9 +191,7 @@ Page { if (!filterByProject) { selectedAccountId = acctNum; } - if (visible) { - fetchupdates(); - } + fetchupdates(); } onGlobalAccountChanged: function (accountId, accountName) { var acctNum = -1; @@ -214,9 +208,7 @@ Page { if (!filterByProject) { selectedAccountId = acctNum; } - if (visible) { - fetchupdates(); - } + fetchupdates(); } } @@ -420,9 +412,7 @@ Page { if (!filterByProject && typeof accountPicker !== "undefined" && accountPicker) { selectedAccountId = accountPicker.selectedAccountId; } - if (visible) { - fetchupdates(); - } + fetchupdates(); } // Loading indicator overlay 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__": From 1d5d459e136c49f18096e514b3702ab46f5a72cb Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 16:20:18 +0530 Subject: [PATCH 103/105] Fix subtask drilldown navigation, child count calculation, and revert clickable launch --- clickable.yaml | 2 +- models/task.js | 21 ++++++++++++---- .../tasks/components/TaskDetailsCard.qml | 4 ++-- qml/features/tasks/components/TaskList.qml | 24 +++++++++---------- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/clickable.yaml b/clickable.yaml index 5da79662..e121c029 100644 --- a/clickable.yaml +++ b/clickable.yaml @@ -1,6 +1,6 @@ clickable_minimum_required: 8.0.0 builder: cmake -launch: "bash -c 'pkill -9 -x qmlscene 2>/dev/null || true; sleep 0.2; env DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/32011/bus XDG_RUNTIME_DIR=/run/user/32011 lomiri-app-launch ubtms_ubtms_1.3.3 || true'" +kill: qmlscene skip_review: true dependencies_target: - libatomic1 diff --git a/models/task.js b/models/task.js index 54eb85cb..1f76c366 100644 --- a/models/task.js +++ b/models/task.js @@ -1544,19 +1544,30 @@ function getSubtasksForParent(parentId, accountId, projectOdooRecordId) { 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 (parent_id = ? OR 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 parent_id = ? THEN 0 ELSE 1 END LIMIT 1", + [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 (id = ? OR odoo_record_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 (accountId !== undefined && accountId !== null && accountId >= 0) { + if (accId !== undefined && accId !== null && accId >= 0) { parentQuery += " AND account_id = ?"; - parentParams.push(accountId); + parentParams.push(accId); } - parentQuery += " LIMIT 1"; + 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; - var accId = accountId; if (parentRes.rows.length > 0) { var pRow = parentRes.rows.item(0); diff --git a/qml/features/tasks/components/TaskDetailsCard.qml b/qml/features/tasks/components/TaskDetailsCard.qml index 8dc0eba5..d9085a9b 100644 --- a/qml/features/tasks/components/TaskDetailsCard.qml +++ b/qml/features/tasks/components/TaskDetailsCard.qml @@ -330,7 +330,7 @@ ListItem { enabled: !starInteractionActive onClicked: { if (hasChildren && !flatViewMode) { - taskCard.navigationRequested(taskCard.effectiveTaskId, taskCard.accountId || 0, taskName); + taskCard.navigationRequested(taskCard.effectiveTaskId, (taskCard.accountId !== undefined && taskCard.accountId !== null) ? taskCard.accountId : -1, taskName); } else { viewRequested(localId); } @@ -677,7 +677,7 @@ ListItem { preventStealing: true onClicked: { mouse.accepted = true; - taskCard.navigationRequested(taskCard.effectiveTaskId, taskCard.accountId || 0, taskName); + 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 4b117ac6..cc09229b 100644 --- a/qml/features/tasks/components/TaskList.qml +++ b/qml/features/tasks/components/TaskList.qml @@ -64,6 +64,12 @@ Item { _loadSubtasksForCurrentParent(); } + onCurrentAccountIdChanged: { + if (currentParentId !== -1) { + _loadSubtasksForCurrentParent(); + } + } + // Optional delegate for external data loading (function(limit, offset)) property var loadDelegate: null @@ -431,18 +437,18 @@ Item { accountId: currentAccountId !== undefined ? currentAccountId : -1, parentName: currentParentName || "" }); - currentParentId = taskId; 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); - currentParentId = last.parentId !== undefined ? last.parentId : -1; currentAccountId = last.accountId !== undefined ? last.accountId : -1; currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + currentParentId = last.parentId !== undefined ? last.parentId : -1; } } @@ -455,7 +461,7 @@ Item { var acc = (currentAccountId !== undefined && currentAccountId >= 0) ? currentAccountId - : (filterByAccount && selectedAccountId >= 0 ? selectedAccountId : -1); + : (filterByAccount && selectedAccountId >= 0 ? selectedAccountId : (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId >= 0 ? accountPicker.selectedAccountId : -1)); var subtasks = Task.getSubtasksForParent(currentParentId, acc); @@ -654,15 +660,9 @@ Item { // Mark children for (var parent in tempMap) { tempMap[parent].forEach(function (child) { - var children = tempMap[child.id_val]; - if (children && children.length > 0) { - child.hasChildren = true; - child.childCount = children.length; - } else { - var childCheck = Task.checkTaskHasChildren(child.local_id); - child.hasChildren = childCheck.hasChildren; - child.childCount = childCheck.childCount; - } + var childCheck = Task.checkTaskHasChildren(child.local_id); + child.hasChildren = childCheck.hasChildren; + child.childCount = childCheck.childCount; }); } From 443deed3778c5aa267a49b671d9d9a634feb688c Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 16:41:42 +0530 Subject: [PATCH 104/105] Scope subtask timesheet calculation and parent resolution by account ID --- models/task.js | 9 ++++---- qml/features/tasks/pages/MyTasksPage.qml | 3 +++ qml/features/tasks/pages/Task_Page.qml | 3 +++ qml/features/tasks/pages/Tasks.qml | 26 +++++++++++++++++++++--- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/models/task.js b/models/task.js index 1f76c366..2915680f 100644 --- a/models/task.js +++ b/models/task.js @@ -1547,8 +1547,8 @@ function getSubtasksForParent(parentId, accountId, projectOdooRecordId) { var accId = accountId; if (accId === undefined || accId === null || accId < 0) { var detectAccRes = tx.executeSql( - "SELECT account_id FROM project_task_app WHERE (parent_id = ? OR 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 parent_id = ? THEN 0 ELSE 1 END LIMIT 1", - [parentId, parentId, parentId, parentId] + "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; @@ -1617,8 +1617,9 @@ function getSubtasksForParent(parentId, accountId, projectOdooRecordId) { task.color_pallet = inheritedColor; // Calculate total hours spent from timesheet entries - 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 (task_id = ? AND ? > 0) OR sub_task_id = ? OR (sub_task_id = ? AND ? > 0)) AND account_id = ?"; - var timeParams = [task.id, task.odoo_record_id || 0, task.odoo_record_id || 0, task.id, task.odoo_record_id || 0, task.odoo_record_id || 0, task.account_id]; + 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; diff --git a/qml/features/tasks/pages/MyTasksPage.qml b/qml/features/tasks/pages/MyTasksPage.qml index 11ed4d9f..53332c64 100644 --- a/qml/features/tasks/pages/MyTasksPage.qml +++ b/qml/features/tasks/pages/MyTasksPage.qml @@ -78,6 +78,9 @@ Page { }; 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); } diff --git a/qml/features/tasks/pages/Task_Page.qml b/qml/features/tasks/pages/Task_Page.qml index 6955ea5b..0be7c344 100644 --- a/qml/features/tasks/pages/Task_Page.qml +++ b/qml/features/tasks/pages/Task_Page.qml @@ -95,6 +95,9 @@ Page { }; 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); } diff --git a/qml/features/tasks/pages/Tasks.qml b/qml/features/tasks/pages/Tasks.qml index c1cf8a1c..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 @@ -1065,9 +1066,28 @@ Page { } } else if (selectedparentId > 0) { // Prefill parent task when creating subtask while drilled down - var parentTaskDetails = Task.getTaskDetails(selectedparentId); - if (!parentTaskDetails || !parentTaskDetails.id) { - parentTaskDetails = Task.getTaskDetailsByOdooId(selectedparentId); + 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; From bacd944b03d0243b2c42c380e1b0048af15ec8f8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 14 Sep 2026 17:42:53 +0530 Subject: [PATCH 105/105] fixed for responsive MEnu --- qml/app/AppDrawer.qml | 222 ++++++++++++++---------- qml/app/navigation/MenuPage.qml | 290 ++++++++++++++++++-------------- 2 files changed, 298 insertions(+), 214 deletions(-) diff --git a/qml/app/AppDrawer.qml b/qml/app/AppDrawer.qml index 910bb75a..5c1f6982 100644 --- a/qml/app/AppDrawer.qml +++ b/qml/app/AppDrawer.qml @@ -39,119 +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) + + // Left section: Menu title + RowLayout { + id: drawerLeftSection + anchors.left: parent.left + anchors.right: drawerRightSection.left + anchors.rightMargin: units.gu(0.5) + anchors.verticalCenter: parent.verticalCenter + + Label { + text: i18n.dtr("ubtms", "Menu") + color: "white" + fontSize: "large" + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + } } - // Account Selector chip with Account label adjacent to icon - Rectangle { - id: accountSelectorItem - implicitWidth: accountRow.implicitWidth + units.gu(1.8) - implicitHeight: units.gu(3.6) - 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 } - } + // 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.6) + 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 + } - 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))) + } } - Label { - id: accountNameLabel - Layout.alignment: Qt.AlignVCenter - text: { - if (typeof accountPicker === "undefined" || !accountPicker.selectedAccountName) return ""; - return (accountPicker.selectedAccountId === 0 || accountPicker.selectedAccountName === "Local Account") ? "Local" : accountPicker.selectedAccountName; + 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; } - color: "white" - font.pixelSize: units.dp(13) - font.bold: true - elide: Text.ElideRight - maximumLineCount: 1 - Layout.maximumWidth: units.gu(10) + Controls.ToolTip.delay: 400 } } - MouseArea { - id: accountMouseArea - anchors.fill: parent - hoverEnabled: true - 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); } } } - } - // Local Account Toggle Switch - TSSwitch { - id: localToggleSwitch - 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: { - if (typeof accountPicker !== "undefined") { - accountPicker.toggleLocalMode(!checked); + // 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 + + Behavior on color { + ColorAnimation { duration: 100 } } - } - } - // 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 - } + 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 { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - Theme.name = theme.name === "Ubuntu.Components.Themes.SuruDark" ? "Ubuntu.Components.Themes.Ambiance" : "Ubuntu.Components.Themes.SuruDark"; + 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/navigation/MenuPage.qml b/qml/app/navigation/MenuPage.qml index a1931d61..b2904464 100644 --- a/qml/app/navigation/MenuPage.qml +++ b/qml/app/navigation/MenuPage.qml @@ -92,162 +92,202 @@ Page { } } - contents: RowLayout { + contents: Item { + id: headerContents visible: !listpage.menuCollapsed anchors.fill: parent - anchors.leftMargin: units.gu(1.5) - anchors.rightMargin: units.gu(1) - spacing: units.gu(1) + 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) - // 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 + // 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) - Behavior on color { - ColorAnimation { duration: 100 } - } + // 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" - } + 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(); + 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 + Controls.ToolTip.visible: collapseMouseArea.containsMouse + Controls.ToolTip.text: i18n.dtr("ubtms", "Collapse menu") + Controls.ToolTip.delay: 400 + } } - } - - Label { - text: i18n.dtr("ubtms", "Menu") - color: "white" - fontSize: "large" - font.bold: true - } - Item { - Layout.fillWidth: true + Label { + text: i18n.dtr("ubtms", "Menu") + color: "white" + fontSize: "large" + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } } - // Account Selector chip (visible when expanded) - Rectangle { - id: accountBtn - visible: !listpage.menuCollapsed - implicitWidth: accountRow.implicitWidth + units.gu(1.8) - 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 + // 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) - Behavior on color { - ColorAnimation { duration: 100 } - } + // 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.6) + 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 + } - 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))) + } } - Label { - id: accountLabel - visible: !listpage.menuCollapsed - Layout.alignment: Qt.AlignVCenter - text: { - if (typeof accountPicker === "undefined" || !accountPicker.selectedAccountName) return ""; - return (accountPicker.selectedAccountId === 0 || accountPicker.selectedAccountName === "Local Account") ? "Local" : accountPicker.selectedAccountName; + MouseArea { + id: accountMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (typeof accountPicker !== "undefined") { + accountPicker.open(accountPicker.selectedAccountId); + } } - color: "white" - font.pixelSize: units.dp(13) - font.bold: true - elide: Text.ElideRight - maximumLineCount: 1 - Layout.maximumWidth: units.gu(10) + + 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 { - id: accountMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor + // 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 + onClicked: { if (typeof accountPicker !== "undefined") { - accountPicker.open(accountPicker.selectedAccountId); + accountPicker.toggleLocalMode(!checked); } } } - } - // Local Account Toggle Switch - TSSwitch { - id: localToggleSwitch - visible: !listpage.menuCollapsed - 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: { - if (typeof accountPicker !== "undefined") { - accountPicker.toggleLocalMode(!checked); + // 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 + + Behavior on color { + ColorAnimation { duration: 100 } } - } - } - // Theme Mode Toggle - Item { - visible: !listpage.menuCollapsed - 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" ? "../../images/daymode.png" : "../../images/darkmode.png" + fillMode: Image.PreserveAspectFit + } - 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"; + } - MouseArea { - anchors.fill: parent - 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 } } }