Merge remote-tracking branch 'origin/develop' into 0814-fixes
# Conflicts: # public/locales/en/labels.json # src/views/Chores/MyChores.jsx # src/views/Chores/components/ChoreToolbarPrototype.jsx # src/views/Chores/components/MultiSelectToolbar.jsx # src/views/Chores/hooks/useChoreActions.js
This commit is contained in:
@@ -72,12 +72,338 @@
|
||||
"paused": "Timer Paused",
|
||||
"reset": "Reset Timer",
|
||||
"delete": "Delete Session"
|
||||
}
|
||||
},
|
||||
"unableUndo": "Failed to undo",
|
||||
"offlineComplete": "You're offline — completion will sync when back online",
|
||||
"unableComplete": "Unable to complete task",
|
||||
"offlineSkip": "You're offline — skip will sync when back online",
|
||||
"unableSkip": "Unable to skip task",
|
||||
"offlineStart": "You're offline — start will sync when back online",
|
||||
"unableStart": "Unable to start task",
|
||||
"offlinePause": "You're offline — pause will sync when back online",
|
||||
"unablePause": "Unable to pause task",
|
||||
"offlineRestore": "You're offline — restore will sync when back online",
|
||||
"restoreFailed": "Failed to restore",
|
||||
"unableRestore": "Unable to restore task"
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultProject": "Default Project"
|
||||
"defaultProject": "Default Project",
|
||||
"everyone": "Everyone",
|
||||
"mine": "Mine",
|
||||
"availableToMe": "Available to me",
|
||||
"others": "Others",
|
||||
"cards": "Cards",
|
||||
"compact": "Compact",
|
||||
"calendar": "Calendar",
|
||||
"filters": "Filters",
|
||||
"viewGroup": "View & Group",
|
||||
"filterNamePlaceholder": "Filter name…",
|
||||
"clearAll": "Clear all",
|
||||
"saveFilter": "Save Filter",
|
||||
"saveAsNew": "Save as New Filter",
|
||||
"groupBy": "Group by",
|
||||
"showTasksFor": "Show tasks for",
|
||||
"type": {
|
||||
"assignee": "Assignee"
|
||||
},
|
||||
"display": "Display",
|
||||
"displayOptions": "Display options"
|
||||
},
|
||||
"sort": {
|
||||
"assignedToMe": "Assigned to me"
|
||||
"assignedToMe": "Assigned to me",
|
||||
"smart": "Smart",
|
||||
"createdDate": "Created Date",
|
||||
"updatedDate": "Updated Date",
|
||||
"sortAndGroup": "Sort and Group (Ctrl+G)",
|
||||
"groupBy": "Group By",
|
||||
"quickFilters": "Quick Filters",
|
||||
"assignedTo": "Assigned to:",
|
||||
"availableForMe": "Available for me",
|
||||
"assignedToOthers": "Assigned to others",
|
||||
"createFilter": "Create Filter",
|
||||
"createFilterHint": "Build advanced filter rules"
|
||||
},
|
||||
"thing": {
|
||||
"triggerHint": "Trigger a task when a thing state changes to a desired state",
|
||||
"selectThing": "Select a thing",
|
||||
"enterText": "Enter the text to trigger the task"
|
||||
},
|
||||
"activity": {
|
||||
"showMore": "Show more",
|
||||
"title": "Recent Activities",
|
||||
"loading": "Loading activities...",
|
||||
"status": {
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"overdue": "Overdue",
|
||||
"dueToday": "Due Today",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"dueThisWeek": "Due This Week",
|
||||
"noDueDate": "No Due Date",
|
||||
"dueDate": "Due Date",
|
||||
"dueLater": "Due Later"
|
||||
},
|
||||
"insights": {
|
||||
"highPriority": "High Priority"
|
||||
},
|
||||
"labels": {
|
||||
"label": "Labels"
|
||||
},
|
||||
"actionMenu": {
|
||||
"view": "View",
|
||||
"archive": "Archive",
|
||||
"writeNFC": "Write to NFC",
|
||||
"completeWithNote": "Complete with note",
|
||||
"completeInPast": "Complete in past",
|
||||
"skipToNext": "Skip to next due date",
|
||||
"sendNudge": "Send nudge",
|
||||
"history": "History",
|
||||
"clone": "Clone",
|
||||
"moveToProject": "Move to project",
|
||||
"removeDueDate": "Remove due date"
|
||||
},
|
||||
"actions": {
|
||||
"rescheduledTitle": "Task Rescheduled",
|
||||
"rescheduledMessage": "The task due date has been updated successfully.",
|
||||
"unplannedTitle": "Task Unplanned",
|
||||
"unplannedMessage": "The task is now unplanned and has no due date.",
|
||||
"archivedTitle": "Task Archived",
|
||||
"startedTitle": "Task Started",
|
||||
"startedMessage": "The task has been marked as started.",
|
||||
"pausedTitle": "Task Paused",
|
||||
"pausedMessage": "The task has been paused.",
|
||||
"deletedMessage": "The task has been deleted.",
|
||||
"undoable": {
|
||||
"completed": "Task completed"
|
||||
},
|
||||
"completionPending": "Task completion pending",
|
||||
"failCompleteTitle": "Failed to complete",
|
||||
"failStartTitle": "Failed to start",
|
||||
"failPauseTitle": "Failed to pause",
|
||||
"failApproveTitle": "Failed to approve",
|
||||
"failRejectTitle": "Failed to reject",
|
||||
"deletedMessageLong": "The task has been deleted successfully.",
|
||||
"failDeleteTitle": "Failed to delete",
|
||||
"failArchiveTitle": "Failed to archive",
|
||||
"failSkipTitle": "Failed to skip",
|
||||
"movedTitle": "Task Moved",
|
||||
"failMoveTitle": "Failed to move task",
|
||||
"failRescheduleTitle": "Failed to reschedule",
|
||||
"nudgeSentTitle": "Nudge Sent!",
|
||||
"nudgeFailed": "Failed to send nudge",
|
||||
"failNudgeTitle": "Failed to Send Nudge",
|
||||
"bulk": {
|
||||
"completeTitle": "Complete Tasks",
|
||||
"completedTitle": "✅ Tasks Completed",
|
||||
"completeFailedTitle": "Bulk Complete Failed",
|
||||
"archiveTitle": "Archive Tasks",
|
||||
"archivedTitle": "📦 Tasks Archived",
|
||||
"archiveFailedTitle": "Bulk Archive Failed",
|
||||
"deleteTitle": "Delete Tasks",
|
||||
"skipTitle": "Skip Tasks",
|
||||
"skippedTitle": "⏭️ Tasks Skipped",
|
||||
"skipFailedTitle": "Bulk Skip Failed"
|
||||
}
|
||||
},
|
||||
"archived": {
|
||||
"restoredTitle": "Task Restored",
|
||||
"restoredMsg": "The task has been restored and is now active.",
|
||||
"deletedTitle": "Task Deleted",
|
||||
"delete": "Delete",
|
||||
"someFailedTitle": "Some Tasks Failed",
|
||||
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||
"deletedBulkTitle": "🗑️ Tasks Deleted",
|
||||
"bulkDeleteFailTitle": "Bulk Delete Failed",
|
||||
"archivedDate": "Archived Date",
|
||||
"loadFailTitle": "Failed to load archived tasks",
|
||||
"loadFailMsg": "Please try again later.",
|
||||
"deletedMsg": "The archived task has been permanently deleted.",
|
||||
"restoreTasksTitle": "Restore Tasks",
|
||||
"restore": "Restore",
|
||||
"restoreConfirm_one": "Restore {{count}} task to active list?",
|
||||
"restoreConfirm_other": "Restore {{count}} tasks to active list?",
|
||||
"restoredBulkTitle": "📤 Tasks Restored",
|
||||
"restoredCount_one": "Restored {{count}} task{{offline}}.",
|
||||
"restoredCount_other": "Restored {{count}} tasks{{offline}}.",
|
||||
"restoreFailCount_one": "{{count}} task could not be restored.",
|
||||
"restoreFailCount_other": "{{count}} tasks could not be restored.",
|
||||
"bulkRestoreFailTitle": "Bulk Restore Failed",
|
||||
"deleteTasksTitle": "Delete Archived Tasks",
|
||||
"deleteConfirm_one": "Permanently delete {{count}} archived task?\n\nThis action cannot be undone.",
|
||||
"deleteConfirm_other": "Permanently delete {{count}} archived tasks?\n\nThis action cannot be undone.",
|
||||
"deletedCount_one": "Successfully deleted {{count}} task.",
|
||||
"deletedCount_other": "Successfully deleted {{count}} tasks.",
|
||||
"deleteFailCount_one": "{{count}} task could not be deleted.",
|
||||
"deleteFailCount_other": "{{count}} tasks could not be deleted.",
|
||||
"title": "Archived Tasks",
|
||||
"subtitle": "View and manage tasks that have been archived or completed.",
|
||||
"heading": "Archived Tasks",
|
||||
"searchPlaceholder": "Search archived tasks",
|
||||
"switchToCompact": "Switch to Compact View",
|
||||
"switchToCard": "Switch to Card View",
|
||||
"exitMultiSelect": "Exit Multi-select Mode (Ctrl+S)",
|
||||
"enableMultiSelect": "Enable Multi-select Mode (Ctrl+S)",
|
||||
"selected_one": "{{count}} task selected",
|
||||
"selected_other": "{{count}} tasks selected",
|
||||
"selectAllTitle": "Select all visible tasks (Ctrl+A)",
|
||||
"all": "All",
|
||||
"closeMultiSelect": "Close multi-select (Esc)",
|
||||
"clearMultiSelect": "Clear multi-select (Esc)",
|
||||
"close": "Close",
|
||||
"clear": "Clear",
|
||||
"restoreSelectedTitle": "Restore selected tasks (R)",
|
||||
"deleteSelectedTitle": "Delete selected tasks (E)",
|
||||
"clearSearch": "Clear search",
|
||||
"clearFilters": "Clear filters",
|
||||
"count_one": "{{count}} archived task",
|
||||
"count_other": "{{count}} archived tasks",
|
||||
"matching": " matching \"{{term}}\""
|
||||
},
|
||||
"edit": {
|
||||
"deleteConfirm": "Are you sure you want to delete this chore?"
|
||||
},
|
||||
"list": {
|
||||
"complete": "Complete",
|
||||
"filterSaved": "Filter Saved",
|
||||
"filterSavedMsg": "\"{{name}}\" has been saved",
|
||||
"nothingScheduled": "Nothing scheduled",
|
||||
"createChore": "Create new chore (Cmd+C)",
|
||||
"filterUpdated": "Filter Updated",
|
||||
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
|
||||
"advancedFilterCreated": "Advanced Filter Created",
|
||||
"advancedFilterCreatedMsg": "\"{{name}}\" has been created successfully"
|
||||
},
|
||||
"multiToolbar": {
|
||||
"skip": "Skip",
|
||||
"completeTitle": "Complete selected tasks (Enter)",
|
||||
"complete": "Complete",
|
||||
"skipTitle": "Skip selected tasks (/)",
|
||||
"archiveTitle": "Archive selected tasks (X)",
|
||||
"archive": "Archive",
|
||||
"deleteTitle": "Delete selected tasks (Shift+X)"
|
||||
},
|
||||
"remind": {
|
||||
"title": "Reminders"
|
||||
},
|
||||
"assignee": {
|
||||
"anyone": "Anyone"
|
||||
},
|
||||
"filterChip": {
|
||||
"cancelAll": "Cancel All Filters",
|
||||
"unpin": "Unpin filter",
|
||||
"pin": "Pin filter",
|
||||
"edit": "Edit filter",
|
||||
"delete": "Delete filter"
|
||||
},
|
||||
"multiSelect": {
|
||||
"showShortcuts": "Show keyboard shortcuts",
|
||||
"title": "Multi-select Mode",
|
||||
"selection": "Selection",
|
||||
"selectAll": "Select all visible tasks",
|
||||
"clearOrExit": "Clear selection or exit multi-select mode",
|
||||
"actions": "Actions",
|
||||
"markCompleted": "Mark selected tasks as completed",
|
||||
"deleteSelected": "Delete selected tasks",
|
||||
"interface": "Interface",
|
||||
"quickAdd": "Quick add new task"
|
||||
},
|
||||
"notifications": {
|
||||
"needTitle": "Need Notification?",
|
||||
"needBody": "You need to enable permission to receive notifications, do you want to enable it?",
|
||||
"keepDisabled": "No, Keep it Disabled"
|
||||
},
|
||||
"assigneeCard": {
|
||||
"loading": "Loading tasks by assignee...",
|
||||
"title": "Tasks by Assignee",
|
||||
"scheduled": "Scheduled",
|
||||
"pendingReview": "Pending Review"
|
||||
},
|
||||
"filter": {
|
||||
"status": {
|
||||
"inProgress": "In Progress"
|
||||
}
|
||||
},
|
||||
"impersonate": {
|
||||
"viewAs": "View tasks as",
|
||||
"switchTitle": "Switch to user view",
|
||||
"switchHint": "Tasks will be filtered to show only assignments for selected user",
|
||||
"chooseUser": "Choose User",
|
||||
"changeUser": "Change User"
|
||||
},
|
||||
"modals": {
|
||||
"changeDueDate": "Change due date",
|
||||
"completePast": "Save Chore that you completed in the past",
|
||||
"delegate": "Delegate to someone else",
|
||||
"selectPerformer": "Select a performer",
|
||||
"addNote": "Add note to attach to this completion:",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"shortcuts": {
|
||||
"allSelectedTitle": "✅ All Tasks Selected",
|
||||
"someSelectedTitle": "🎯 Tasks Selected",
|
||||
"allSelectedAltTitle": "🎯 All Tasks Selected"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Chores Overviews",
|
||||
"search": "Search",
|
||||
"newChore": "New Chore",
|
||||
"unassigned": "Unassigned",
|
||||
"changeDueDate": "Change due date"
|
||||
},
|
||||
"dataError": {
|
||||
"attachmentsFailed": "Failed to fetch attachments"
|
||||
},
|
||||
"nudge": {
|
||||
"title": "Send Nudge",
|
||||
"customMessage": "Custom Message (optional)",
|
||||
"messagePlaceholder": "Add a personal message with your nudge...",
|
||||
"notifyAll": "Notify All Assignees",
|
||||
"notifyAllHint": "If enabled, all members who can see this task will be notified. Otherwise, only the assigned person will receive the nudge."
|
||||
},
|
||||
"nfc": {
|
||||
"errWrite": "Error writing to NFC tag. Please try again.",
|
||||
"errUnsupported": "NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.",
|
||||
"titleSuccess": "Tag written!",
|
||||
"titleError": "Something went wrong",
|
||||
"titleWaiting": "Hold near NFC tag",
|
||||
"subSuccess": "Your NFC tag is ready to use.",
|
||||
"subWaiting": "Keep your device near the tag until complete.",
|
||||
"subIdle": "Encode this task link onto any NFC tag.",
|
||||
"tagUrl": "Tag URL",
|
||||
"copyUrl": "Copy URL",
|
||||
"autoComplete": "Auto-complete on scan",
|
||||
"autoCompleteHint": "Mark task done when tag is tapped"
|
||||
},
|
||||
"duePicker": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"weekend": "Weekend",
|
||||
"nextWeek": "Next week"
|
||||
},
|
||||
"photoTask": {
|
||||
"title": "Scan photo to create task",
|
||||
"cameraUnavailable": "Camera not available",
|
||||
"uploadPhoto": "Upload Photo",
|
||||
"scanDocument": "Scan Document",
|
||||
"capture": "Capture",
|
||||
"processNatively": "Process Natively",
|
||||
"processImage": "Process Image",
|
||||
"createTask": "Create Task",
|
||||
"failedScan": "Failed scan"
|
||||
},
|
||||
"descriptionPlaceholder": "Enter description...",
|
||||
"notifTemplate": {
|
||||
"onDueDate": "On due date",
|
||||
"beforeDue": "{{count}} {{unit}} before due",
|
||||
"afterDue": "{{count}} {{unit}} after due",
|
||||
"errDuplicate": "This notification setting already exists. Please use a different timing.",
|
||||
"errOneDue": "Only one \"Due Alert\" notification is allowed.",
|
||||
"errAllConfigured": "All common {{type}} times are already configured.",
|
||||
"reminder": "Reminder",
|
||||
"dueAlert": "Due Alert",
|
||||
"followUp": "Follow-up",
|
||||
"rememberFuture": "Remember for Future Tasks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,14 @@
|
||||
"tooLargeTitle": "File Too Large",
|
||||
"tooLargeMessage": "The file you are trying to upload is too large.",
|
||||
"deniedTitle": "Permission Denied",
|
||||
"deniedMessage": "You do not have permission to upload files."
|
||||
"deniedMessage": "You do not have permission to upload files.",
|
||||
"plusFeatureTitle": "Plus Feature",
|
||||
"plusFeatureMessage": "Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.",
|
||||
"upgradeTitle": "Upgrade Required",
|
||||
"upgradeMessage": "Image uploads are only available for Plus accounts.",
|
||||
"failedTitle": "Upload Failed",
|
||||
"failedMessage": "Failed to upload image.",
|
||||
"processingMessage": "An error occurred while processing the image."
|
||||
},
|
||||
"getStarted": "Get Started!",
|
||||
"imageLoadFailed": "Failed to load image.",
|
||||
@@ -163,5 +170,44 @@
|
||||
"showDetails": "Show error details",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copied": "Error details copied to clipboard"
|
||||
}
|
||||
},
|
||||
"userMenu": {
|
||||
"actAsAnother": "Act as another user",
|
||||
"stopImpersonating": "Stop Impersonating",
|
||||
"returnToAccount": "Return to your account",
|
||||
"accountPrefs": "Account & preferences",
|
||||
"invitePeople": "Invite People",
|
||||
"addMembers": "Add members to your circle",
|
||||
"sidePanelSettings": "Side Panel Settings"
|
||||
},
|
||||
"realtime": {
|
||||
"newTaskTitle": "New Task Created",
|
||||
"connected": "You are now receiving real-time as they happen.",
|
||||
"errorTitle": "Real-time Error",
|
||||
"parseTitle": "Message Error",
|
||||
"parseMessage": "Failed to parse server message",
|
||||
"disabledTitle": "Connection Temporarily Disabled",
|
||||
"failedTitle": "Connection Failed",
|
||||
"stateConnectionError": "Connection error occurred",
|
||||
"stateAuthExpired": "Authentication expired - refreshing token...",
|
||||
"stateTokenRefreshed": "Token refreshed - reconnecting...",
|
||||
"stateRefreshInProgress": "Token refresh in progress - reconnecting soon...",
|
||||
"stateSessionExpired": "Session expired - please log in again",
|
||||
"stateAuthFailed": "Authentication failed - please log in again",
|
||||
"stateAuthError": "Authentication error - please log in again",
|
||||
"stateTimeout": "Connection timeout - reconnecting...",
|
||||
"connectErrorTitle": "Connection Error",
|
||||
"connectErrorMessage": "Failed to establish real-time connection. Please try again."
|
||||
},
|
||||
"removeAll": "Remove all",
|
||||
"apply": "Apply",
|
||||
"cancelAll": "Cancel all",
|
||||
"sync": {
|
||||
"aria": "Open sync and network status",
|
||||
"allSynced": "All changes synced",
|
||||
"willSync": "Will sync when back online",
|
||||
"cancelAll": "Cancel All"
|
||||
},
|
||||
"attachments": "Attachments",
|
||||
"fileNumbered": "File {{index}}"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,11 @@
|
||||
"nameExists": "A filter with this name already exists",
|
||||
"tasks_one": "{{count}} task",
|
||||
"tasks_other": "{{count}} tasks",
|
||||
"overdue": "{{count}} overdue"
|
||||
"overdue": "{{count}} overdue",
|
||||
"delete": {
|
||||
"title": "Delete Filter"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No saved filters yet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
"hasNotes": "Has Notes",
|
||||
"hasPoints": "Has Points",
|
||||
"timePeriod": "Time Period",
|
||||
"user": "User"
|
||||
"user": "User",
|
||||
"completedBy": "Completed By",
|
||||
"completedAt": "Completed At"
|
||||
},
|
||||
"period": {
|
||||
"days_one": "{{count}} Day",
|
||||
@@ -85,5 +87,26 @@
|
||||
},
|
||||
"activities": {
|
||||
"title": "Activities"
|
||||
},
|
||||
"title": {
|
||||
"summary": "Task Summary",
|
||||
"activity": "Task Activity"
|
||||
},
|
||||
"noResults": {
|
||||
"clear": "Clear filters"
|
||||
},
|
||||
"toast": {
|
||||
"updateQueued": {
|
||||
"title": "History Update Queued"
|
||||
},
|
||||
"updated": {
|
||||
"title": "History Updated"
|
||||
},
|
||||
"deleteQueued": {
|
||||
"title": "History Delete Queued"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "History Deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +35,13 @@
|
||||
"notFoundDescription": "This label may have been deleted or is no longer shared with you.",
|
||||
"backToLabels": "Back to labels"
|
||||
},
|
||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
|
||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task.",
|
||||
"modal": {
|
||||
"errorEmptyName": "Name cannot be empty",
|
||||
"errorDuplicate": "Label with this name already exists",
|
||||
"errorNoColor": "Please select a color",
|
||||
"saveFailedTitle": "Failed to save label",
|
||||
"saveFailedMessage": "Unable to save label. Please try again.",
|
||||
"name": "Name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,12 @@
|
||||
"createNewDescription": "Add a custom project workspace",
|
||||
"manage": "Manage Projects",
|
||||
"manageDescription": "View, edit, and organize all projects"
|
||||
},
|
||||
"modal": {
|
||||
"errorNameRequired": "Project name is required",
|
||||
"errorUpdate": "Failed to update project",
|
||||
"errorCreate": "Failed to create project",
|
||||
"namePlaceholder": "Enter project name...",
|
||||
"descriptionPlaceholder": "Optional project description..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,8 @@
|
||||
"backupCodesModal": {
|
||||
"title": "New Backup Codes",
|
||||
"warning": "Your previous backup codes are now invalid. Save these new codes in a safe place. Each code can only be used once."
|
||||
}
|
||||
},
|
||||
"generateCodes": "Generate New Codes"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
@@ -576,5 +577,8 @@
|
||||
"selectOwner": "Select new owner",
|
||||
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
||||
"typeDelete": "DELETE"
|
||||
},
|
||||
"realtime": {
|
||||
"titleSse": "Real-time Updates (SSE)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,16 @@
|
||||
"sessionDeletedMessage": "Timer session has been deleted successfully.",
|
||||
"sessionDeleteErrorTitle": "Error deleting session",
|
||||
"deleteConfirmTitle": "Delete Timer Session",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this timer session?"
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this timer session?",
|
||||
"startedTitle": "Timer Started",
|
||||
"startedMessage": "Work session has been started successfully.",
|
||||
"startQueuedTitle": "Start queued",
|
||||
"startFailTitle": "Failed to start timer",
|
||||
"pausedTitle": "Timer Paused",
|
||||
"pausedMessage": "Work session has been paused.",
|
||||
"pauseQueuedTitle": "Pause queued",
|
||||
"pauseFailTitle": "Failed to pause timer",
|
||||
"deleteSessionTitle": "Delete Session"
|
||||
},
|
||||
"loading": "Loading timer data...",
|
||||
"noData": "No timer data found for this chore.",
|
||||
@@ -25,5 +34,6 @@
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"leaveEmpty": "Leave empty if session is ongoing",
|
||||
"noSessionForChore": "No timer session found for this chore."
|
||||
"noSessionForChore": "No timer session found for this chore.",
|
||||
"saveChanges": "Save Changes"
|
||||
}
|
||||
|
||||
@@ -13,26 +13,29 @@ import Option from '@mui/joy/Option'
|
||||
import Select from '@mui/joy/Select'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||
|
||||
const timeUnits = TIME_UNITS
|
||||
|
||||
const timingOptions = [
|
||||
{ label: 'Before', value: 'before' },
|
||||
{ label: 'Due', value: 'ondue' },
|
||||
{ label: 'After', value: 'after' },
|
||||
{ value: 'before' },
|
||||
{ value: 'ondue' },
|
||||
{ value: 'after' },
|
||||
]
|
||||
|
||||
function getRelativeLabel(notification) {
|
||||
function getRelativeLabel(notification, t) {
|
||||
const { value, unit } = notification
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) {
|
||||
return 'On due date'
|
||||
return t('notifTemplate.onDueDate')
|
||||
}
|
||||
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
|
||||
const unitName = t(`notifTemplate.unitName.${unit}`)
|
||||
const absValue = Math.abs(numericValue)
|
||||
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
|
||||
return numericValue < 0
|
||||
? t('notifTemplate.beforeDue', { count: absValue, unit: unitName })
|
||||
: t('notifTemplate.afterDue', { count: absValue, unit: unitName })
|
||||
}
|
||||
|
||||
// Helper functions to convert between internal value and UI representation
|
||||
@@ -71,6 +74,7 @@ const NotificationTemplate = ({
|
||||
value,
|
||||
showTimeline = true,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [notifications, setNotifications] = useState(
|
||||
value?.templates ||
|
||||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
|
||||
@@ -218,9 +222,7 @@ const NotificationTemplate = ({
|
||||
if (!currentNotification) return
|
||||
|
||||
if (isDuplicate(currentNotification, idx, currentList)) {
|
||||
setError(
|
||||
'This notification setting already exists. Please use a different timing.',
|
||||
)
|
||||
setError(t('notifTemplate.errDuplicate'))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -232,14 +234,14 @@ const NotificationTemplate = ({
|
||||
|
||||
if (type === 'due') {
|
||||
if (notificationsRef.current.some(n => Number(n.value) === 0)) {
|
||||
setError('Only one "Due Alert" notification is allowed.')
|
||||
setError(t('notifTemplate.errOneDue'))
|
||||
return
|
||||
}
|
||||
newNotification = { value: 0, unit: 'm' }
|
||||
} else {
|
||||
newNotification = getSmartSuggestion(type)
|
||||
if (!newNotification) {
|
||||
setError(`All common ${type} times are already configured.`)
|
||||
setError(t('notifTemplate.errAllConfigured', { type }))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -364,7 +366,7 @@ const NotificationTemplate = ({
|
||||
fontSize: '0.6rem',
|
||||
}}
|
||||
>
|
||||
Due Date
|
||||
{t('group.dueDate')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -398,7 +400,7 @@ const NotificationTemplate = ({
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
title={getRelativeLabel(n)}
|
||||
title={getRelativeLabel(n, t)}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={
|
||||
@@ -550,7 +552,7 @@ const NotificationTemplate = ({
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{getRelativeLabel(n)}
|
||||
{getRelativeLabel(n, t)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -575,7 +577,7 @@ const NotificationTemplate = ({
|
||||
value={opt.value}
|
||||
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
|
||||
>
|
||||
{opt.label}
|
||||
{t(`notifTemplate.timing.${opt.value}`)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -638,7 +640,7 @@ const NotificationTemplate = ({
|
||||
>
|
||||
{timeUnits.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(`notifTemplate.unitShort.${opt.value}`)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -688,7 +690,7 @@ const NotificationTemplate = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Reminder
|
||||
{t('notifTemplate.reminder')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('due')}
|
||||
@@ -710,7 +712,7 @@ const NotificationTemplate = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Due Alert
|
||||
{t('notifTemplate.dueAlert')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('followup')}
|
||||
@@ -729,7 +731,7 @@ const NotificationTemplate = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Follow-up
|
||||
{t('notifTemplate.followUp')}
|
||||
</Button>
|
||||
</Box>
|
||||
{showSaveDefault && (
|
||||
@@ -762,7 +764,7 @@ const NotificationTemplate = ({
|
||||
setShowSaveDefault(false)
|
||||
}}
|
||||
>
|
||||
Remember for Future Tasks
|
||||
{t('notifTemplate.rememberFuture')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -13,8 +13,10 @@ import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SSESettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
@@ -75,7 +77,7 @@ const SSESettings = () => {
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates (SSE)
|
||||
{t('realtime.titleSse')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
|
||||
@@ -35,8 +35,10 @@ import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const UserProfileAvatar = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const navigate = useNavigate()
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -297,7 +299,7 @@ const UserProfileAvatar = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Act as another user
|
||||
{t('userMenu.actAsAnother')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -319,13 +321,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Stop Impersonating
|
||||
{t('userMenu.stopImpersonating')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Return to your account
|
||||
{t('userMenu.returnToAccount')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -349,13 +351,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Settings
|
||||
{t('settings')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Account & preferences
|
||||
{t('userMenu.accountPrefs')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -374,13 +376,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Invite People
|
||||
{t('userMenu.invitePeople')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Add members to your circle
|
||||
{t('userMenu.addMembers')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -401,7 +403,7 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Side Panel Settings
|
||||
{t('userMenu.sidePanelSettings')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAlerts } from '../service/AlertsProvider'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { useAuth } from './useAuth.jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SSE_STATES = {
|
||||
CONNECTING: 0,
|
||||
@@ -19,6 +20,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
|
||||
|
||||
export const useSSE = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
// Only fetch user profile if authenticated - prevents unnecessary API calls on landing page
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -97,7 +99,7 @@ export const useSSE = () => {
|
||||
case 'chore.created':
|
||||
showNotification({
|
||||
type: 'info',
|
||||
title: 'New Task Created',
|
||||
title: t('realtime.newTaskTitle'),
|
||||
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
|
||||
duration: 5000,
|
||||
})
|
||||
@@ -242,14 +244,14 @@ export const useSSE = () => {
|
||||
showAlert({
|
||||
type: 'success',
|
||||
color: 'success',
|
||||
message: 'You are now receiving real-time as they happen.',
|
||||
message: t('realtime.connected'),
|
||||
})
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('SSE error event:', eventData.data)
|
||||
showError({
|
||||
title: 'Real-time Error',
|
||||
title: t('realtime.errorTitle'),
|
||||
message:
|
||||
eventData.data.message ||
|
||||
'An error occurred with real-time updates',
|
||||
@@ -262,8 +264,8 @@ export const useSSE = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err)
|
||||
showError({
|
||||
title: 'Message Error',
|
||||
message: 'Failed to parse server message',
|
||||
title: t('realtime.parseTitle'),
|
||||
message: t('realtime.parseMessage'),
|
||||
})
|
||||
return // Stop processing if JSON parsing fails
|
||||
}
|
||||
@@ -315,7 +317,7 @@ export const useSSE = () => {
|
||||
if (isCircuitBreakerOpen) {
|
||||
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||
showError({
|
||||
title: 'Connection Temporarily Disabled',
|
||||
title: t('realtime.disabledTitle'),
|
||||
message:
|
||||
'Connection blocked due to repeated failures. Please try again later.',
|
||||
})
|
||||
@@ -328,7 +330,7 @@ export const useSSE = () => {
|
||||
)
|
||||
setIsCircuitBreakerOpen(true)
|
||||
showError({
|
||||
title: 'Connection Failed',
|
||||
title: t('realtime.failedTitle'),
|
||||
message:
|
||||
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
|
||||
})
|
||||
@@ -378,7 +380,7 @@ export const useSSE = () => {
|
||||
const ticket = await fetchSSETicket()
|
||||
if (!ticket) {
|
||||
console.error('SSE: Failed to obtain connection ticket')
|
||||
setError('Connection error occurred')
|
||||
setError(t('realtime.stateConnectionError'))
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
scheduleReconnect(
|
||||
RECONNECT_INTERVALS[
|
||||
@@ -511,7 +513,7 @@ export const useSSE = () => {
|
||||
|
||||
if (is401Error) {
|
||||
console.log('SSE 401 error detected, attempting token refresh...')
|
||||
setError('Authentication expired - refreshing token...')
|
||||
setError(t('realtime.stateAuthExpired'))
|
||||
|
||||
try {
|
||||
const refreshResult = await apiClient.refreshToken()
|
||||
@@ -520,7 +522,7 @@ export const useSSE = () => {
|
||||
console.log(
|
||||
'Token refreshed successfully, retrying SSE connection...',
|
||||
)
|
||||
setError('Token refreshed - reconnecting...')
|
||||
setError(t('realtime.stateTokenRefreshed'))
|
||||
|
||||
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
|
||||
console.log(
|
||||
@@ -553,7 +555,7 @@ export const useSSE = () => {
|
||||
console.log(
|
||||
'SSE: Token refresh in progress by another request, waiting...',
|
||||
)
|
||||
setError('Token refresh in progress - reconnecting soon...')
|
||||
setError(t('realtime.stateRefreshInProgress'))
|
||||
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
@@ -572,23 +574,23 @@ export const useSSE = () => {
|
||||
return
|
||||
} else if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
setError(t('realtime.stateSessionExpired'))
|
||||
return
|
||||
} else {
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
setError(t('realtime.stateAuthFailed'))
|
||||
return
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh error:', refreshError)
|
||||
setError('Authentication error - please log in again')
|
||||
setError(t('realtime.stateAuthError'))
|
||||
return
|
||||
}
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
setError(t('realtime.stateTimeout'))
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
setError(t('realtime.stateConnectionError'))
|
||||
}
|
||||
|
||||
// Schedule reconnect for non-401 errors
|
||||
@@ -618,8 +620,8 @@ export const useSSE = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to create SSE connection:', err)
|
||||
showError({
|
||||
title: 'Connection Error',
|
||||
message: 'Failed to establish real-time connection. Please try again.',
|
||||
title: t('realtime.connectErrorTitle'),
|
||||
message: t('realtime.connectErrorMessage'),
|
||||
})
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ const ChoreView = () => {
|
||||
message: t('choreView.taskCompletionUndone'),
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
throw new Error(t('choreView.unableUndo'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
@@ -331,7 +331,7 @@ const ChoreView = () => {
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — completion will sync when back online",
|
||||
message: t('choreView.offlineComplete'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -340,7 +340,7 @@ const ChoreView = () => {
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to complete task',
|
||||
message: error?.message || t('choreView.unableComplete'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,7 @@ const ChoreView = () => {
|
||||
message: t('choreView.taskSkipUndone'),
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
throw new Error(t('choreView.unableUndo'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
@@ -389,7 +389,7 @@ const ChoreView = () => {
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — skip will sync when back online",
|
||||
message: t('choreView.offlineSkip'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -398,7 +398,7 @@ const ChoreView = () => {
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to skip task',
|
||||
message: error?.message || t('choreView.unableSkip'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -424,7 +424,7 @@ const ChoreView = () => {
|
||||
setChore(startedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — start will sync when back online",
|
||||
message: t('choreView.offlineStart'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -436,7 +436,7 @@ const ChoreView = () => {
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to start task',
|
||||
message: error?.message || t('choreView.unableStart'),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -463,7 +463,7 @@ const ChoreView = () => {
|
||||
setChore(pausedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — pause will sync when back online",
|
||||
message: t('choreView.offlinePause'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -475,7 +475,7 @@ const ChoreView = () => {
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to pause task',
|
||||
message: error?.message || t('choreView.unablePause'),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -579,7 +579,7 @@ const ChoreView = () => {
|
||||
setChore({ ...chore, isActive: true })
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — restore will sync when back online",
|
||||
message: t('choreView.offlineRestore'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await offlineDB.saveChores([{ ...chore, isActive: false }])
|
||||
@@ -589,8 +589,8 @@ const ChoreView = () => {
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
message: error.message || 'Unable to restore task',
|
||||
title: t('choreView.restoreFailed'),
|
||||
message: error.message || t('choreView.unableRestore'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
const isValidTrigger = (thing, condition, triggerState) => {
|
||||
const newErrors = {}
|
||||
if (!thing || !triggerState) {
|
||||
@@ -54,6 +55,7 @@ const ThingTriggerSection = ({
|
||||
selected,
|
||||
isAttepmtingToSave,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [selectedThing, setSelectedThing] = useState(null)
|
||||
const [condition, setCondition] = useState(null)
|
||||
const [triggerState, setTriggerState] = useState(null)
|
||||
@@ -85,7 +87,7 @@ const ThingTriggerSection = ({
|
||||
return (
|
||||
<Card sx={{ mt: 1 }}>
|
||||
<Typography level='h5'>
|
||||
Trigger a task when a thing state changes to a desired state
|
||||
{t('thing.triggerHint')}
|
||||
</Typography>
|
||||
{things?.length === 0 && (
|
||||
<Typography level='body-sm'>
|
||||
@@ -135,7 +137,7 @@ const ThingTriggerSection = ({
|
||||
</Box>
|
||||
)}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label='Select a thing' />
|
||||
<TextField {...params} label={t('thing.selectThing')} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -215,7 +217,7 @@ const ThingTriggerSection = ({
|
||||
<Input
|
||||
value={triggerState}
|
||||
onChange={e => setTriggerState(e.target.value)}
|
||||
label='Enter the text to trigger the task'
|
||||
label={t('thing.enterText')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -31,8 +31,10 @@ import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||
const { t } = useTranslation('chores')
|
||||
// Find the member who completed the activity
|
||||
const completedByMember = members?.find(
|
||||
member => member.userId === activity.completedBy,
|
||||
@@ -243,7 +245,7 @@ const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Show more
|
||||
{t('activity.showMore')}
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
@@ -272,7 +274,7 @@ const groupActivitiesByDate = activities => {
|
||||
return groups
|
||||
}
|
||||
|
||||
const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
const ActivitiesCard = ({ title = t('activity.title') }) => {
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
|
||||
// Use hooks to fetch data
|
||||
@@ -332,7 +334,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Loading activities...
|
||||
{t('activity.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Sheet>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
@@ -95,6 +96,7 @@ const applyPendingArchivedState = async chores => {
|
||||
}
|
||||
|
||||
const ArchivedTasks = () => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
@@ -135,7 +137,7 @@ const ArchivedTasks = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'assignee',
|
||||
label: 'Assignee',
|
||||
label: t('toolbar.type.assignee'),
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
@@ -147,7 +149,7 @@ const ArchivedTasks = () => {
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
label: t('priority'),
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
@@ -162,7 +164,7 @@ const ArchivedTasks = () => {
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
label: t('labels.label'),
|
||||
type: 'multi-select',
|
||||
icon: <Label />,
|
||||
options: availableLabels.map(l => ({
|
||||
@@ -189,7 +191,7 @@ const ArchivedTasks = () => {
|
||||
: []),
|
||||
{
|
||||
id: 'archivedAt',
|
||||
label: 'Archived Date',
|
||||
label: t('archived.archivedDate'),
|
||||
type: 'date-range',
|
||||
icon: <Archive />,
|
||||
filterFn: (item, value) => {
|
||||
@@ -200,7 +202,7 @@ const ArchivedTasks = () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers, availableLabels],
|
||||
[performers, availableLabels, t],
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -274,8 +276,8 @@ const ArchivedTasks = () => {
|
||||
setFilteredChores(sortedChores)
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Failed to load archived tasks',
|
||||
message: 'Please try again later.',
|
||||
title: t('archived.loadFailTitle'),
|
||||
message: t('archived.loadFailMsg'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
@@ -427,8 +429,8 @@ const ArchivedTasks = () => {
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
showSuccess({
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
title: t('archived.restoredTitle'),
|
||||
message: t('archived.restoredMsg'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -444,8 +446,8 @@ const ArchivedTasks = () => {
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The archived task has been permanently deleted.',
|
||||
title: t('archived.deletedTitle'),
|
||||
message: t('archived.deletedMsg'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -506,10 +508,10 @@ const ArchivedTasks = () => {
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Restore Tasks',
|
||||
confirmText: 'Restore',
|
||||
cancelText: 'Cancel',
|
||||
message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`,
|
||||
title: t('archived.restoreTasksTitle'),
|
||||
confirmText: t('archived.restore'),
|
||||
cancelText: t('common:cancel'),
|
||||
message: t('archived.restoreConfirm', { count: selectedData.length }),
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
@@ -574,23 +576,28 @@ const ArchivedTasks = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
}
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Restored ${allRestored.length} task${allRestored.length > 1 ? 's' : ''}${offlineNote}.`,
|
||||
title: t('archived.restoredBulkTitle'),
|
||||
message: t('archived.restoredCount', {
|
||||
count: allRestored.length,
|
||||
offline: offlineNote,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`,
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: t('archived.restoreFailCount', {
|
||||
count: failedTasks.length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Restore Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: t('archived.bulkRestoreFailTitle'),
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -605,10 +612,10 @@ const ArchivedTasks = () => {
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Archived Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Permanently delete ${selectedData.length} archived task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
|
||||
title: t('archived.deleteTasksTitle'),
|
||||
confirmText: t('archived.delete'),
|
||||
cancelText: t('common:cancel'),
|
||||
message: t('archived.deleteConfirm', { count: selectedData.length }),
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
@@ -626,8 +633,10 @@ const ArchivedTasks = () => {
|
||||
|
||||
if (deletedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '🗑️ Tasks Deleted',
|
||||
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
|
||||
title: t('archived.deletedBulkTitle'),
|
||||
message: t('archived.deletedCount', {
|
||||
count: deletedTasks.length,
|
||||
}),
|
||||
})
|
||||
|
||||
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
||||
@@ -643,16 +652,18 @@ const ArchivedTasks = () => {
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: t('archived.deleteFailCount', {
|
||||
count: failedTasks.length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Delete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: t('archived.bulkDeleteFailTitle'),
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -694,10 +705,10 @@ const ArchivedTasks = () => {
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Archived Tasks
|
||||
{t('archived.title')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
View and manage tasks that have been archived or completed.
|
||||
{t('archived.subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -716,7 +727,7 @@ const ArchivedTasks = () => {
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Archive />
|
||||
Archived Tasks
|
||||
{t('archived.heading')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -742,7 +753,7 @@ const ArchivedTasks = () => {
|
||||
>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder='Search archived tasks'
|
||||
placeholder={t('archived.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
fullWidth
|
||||
sx={{
|
||||
@@ -824,8 +835,8 @@ const ArchivedTasks = () => {
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: 'Switch to Card View'
|
||||
? t('archived.switchToCompact')
|
||||
: t('archived.switchToCard')
|
||||
}
|
||||
>
|
||||
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
|
||||
@@ -845,8 +856,8 @@ const ArchivedTasks = () => {
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode (Ctrl+S)'
|
||||
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||
? t('archived.exitMultiSelect')
|
||||
: t('archived.enableMultiSelect')
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
@@ -930,8 +941,7 @@ const ArchivedTasks = () => {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckBox sx={{ color: 'primary.500' }} />
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{selectedChores.size} task
|
||||
{selectedChores.size !== 1 ? 's' : ''} selected
|
||||
{t('archived.selected', { count: selectedChores.size })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -954,9 +964,9 @@ const ArchivedTasks = () => {
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Select all visible tasks (Ctrl+A)'
|
||||
title={t('archived.selectAllTitle')}
|
||||
>
|
||||
All
|
||||
{t('archived.all')}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='A'
|
||||
@@ -985,9 +995,15 @@ const ArchivedTasks = () => {
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
|
||||
title={
|
||||
selectedChores.size === 0
|
||||
? t('archived.closeMultiSelect')
|
||||
: t('archived.clearMultiSelect')
|
||||
}
|
||||
>
|
||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||
{selectedChores.size === 0
|
||||
? t('archived.close')
|
||||
: t('archived.clear')}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
withCtrl={false}
|
||||
@@ -1031,9 +1047,9 @@ const ArchivedTasks = () => {
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Restore selected tasks (R)'
|
||||
title={t('archived.restoreSelectedTitle')}
|
||||
>
|
||||
Restore
|
||||
{t('archived.restore')}
|
||||
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='R'
|
||||
@@ -1058,9 +1074,9 @@ const ArchivedTasks = () => {
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Delete selected tasks (E)'
|
||||
title={t('archived.deleteSelectedTitle')}
|
||||
>
|
||||
Delete
|
||||
{t('archived.delete')}
|
||||
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='E'
|
||||
@@ -1093,12 +1109,12 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
primaryAction={
|
||||
searchTerm
|
||||
? { label: 'Clear search', onClick: handleSearchClose }
|
||||
: { label: 'Clear filters', onClick: clearAll }
|
||||
? { label: t('archived.clearSearch'), onClick: handleSearchClose }
|
||||
: { label: t('archived.clearFilters'), onClick: clearAll }
|
||||
}
|
||||
secondaryAction={
|
||||
searchTerm && hasActiveFilters
|
||||
? { label: 'Clear filters', onClick: clearAll }
|
||||
? { label: t('archived.clearFilters'), onClick: clearAll }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1114,9 +1130,8 @@ const ArchivedTasks = () => {
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{finalChores.length} archived task
|
||||
{finalChores.length !== 1 ? 's' : ''}
|
||||
{searchTerm && ` matching "${searchTerm}"`}
|
||||
{t('archived.count', { count: finalChores.length })}
|
||||
{searchTerm && t('archived.matching', { term: searchTerm })}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ThumbDown,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLongPress } from '../../hooks/useLongPress'
|
||||
import ChoreCard from './ChoreCard'
|
||||
@@ -89,6 +90,7 @@ const ChoreListView = ({
|
||||
showActions = true,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation('chores')
|
||||
const renderChoreCard = (chore, key) => {
|
||||
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
|
||||
return (
|
||||
@@ -202,7 +204,7 @@ const ChoreListView = ({
|
||||
<Check sx={{ fontSize: 20 }} />
|
||||
)}
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
{chore.status !== 1 ? 'Start' : 'Complete'}
|
||||
{chore.status !== 1 ? t('choreView.start') : t('list.complete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Webhook,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
@@ -42,6 +43,7 @@ const CompactChoreCard = ({
|
||||
onlyClickable = false,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation('chores')
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { timeFormat } = useLocalization()
|
||||
@@ -96,7 +98,7 @@ const CompactChoreCard = ({
|
||||
if (assignee) parts.push(assignee)
|
||||
}
|
||||
if (chore.assignedTo === null) {
|
||||
parts.push('Anyone')
|
||||
parts.push(t('assignee.anyone'))
|
||||
}
|
||||
|
||||
// Points
|
||||
@@ -404,7 +406,7 @@ const CompactChoreCard = ({
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
||||
{getDueDateChipText(chore.nextDueDate, chore, timeFormat, t)}
|
||||
</Chip>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
@@ -15,6 +16,7 @@ const IconButtonWithMenu = ({
|
||||
useChips,
|
||||
title,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
const menuOptions = Array.isArray(options) ? options : []
|
||||
@@ -120,7 +122,7 @@ const IconButtonWithMenu = ({
|
||||
setSelectedItem?.setSelectedItem('All')
|
||||
}}
|
||||
>
|
||||
Cancel All Filters
|
||||
{t('filterChip.cancelAll')}
|
||||
</MenuItem>
|
||||
)} */}
|
||||
</Menu>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Box, Card, IconButton, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false)
|
||||
@@ -29,8 +31,8 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
aria-label='Show keyboard shortcuts'
|
||||
title='Show keyboard shortcuts'
|
||||
aria-label={t('multiSelect.showShortcuts')}
|
||||
title={t('multiSelect.showShortcuts')}
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
@@ -39,7 +41,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
<ResponsiveModal
|
||||
open={isHelpOpen}
|
||||
onClose={() => setIsHelpOpen(false)}
|
||||
title='Multi-select Mode'
|
||||
title={t('multiSelect.title')}
|
||||
description='Use these keyboard shortcuts to work more efficiently.'
|
||||
footer={
|
||||
<ModalActions
|
||||
@@ -51,16 +53,16 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
{/* Selection shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}>
|
||||
Selection
|
||||
{t('multiSelect.selection')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Ctrl', 'A']}
|
||||
description='Select all visible tasks'
|
||||
description={t('multiSelect.selectAll')}
|
||||
/>
|
||||
<ShortcutItem
|
||||
keys={['Esc']}
|
||||
description='Clear selection or exit multi-select mode'
|
||||
description={t('multiSelect.clearOrExit')}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
@@ -68,16 +70,16 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
{/* Action shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}>
|
||||
Actions
|
||||
{t('multiSelect.actions')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Enter']}
|
||||
description='Mark selected tasks as completed'
|
||||
description={t('multiSelect.markCompleted')}
|
||||
/>
|
||||
<ShortcutItem
|
||||
keys={['Del', '⌫']}
|
||||
description='Delete selected tasks'
|
||||
description={t('multiSelect.deleteSelected')}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
@@ -85,12 +87,12 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
{/* Interface shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}>
|
||||
Interface
|
||||
{t('multiSelect.interface')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Ctrl', 'K']}
|
||||
description='Quick add new task'
|
||||
description={t('multiSelect.quickAdd')}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Mirrors the assignee options in the toolbar, phrased to drop into a
|
||||
// sentence ("none of them are assigned to you").
|
||||
@@ -87,6 +88,7 @@ const ASSIGNEE_FILTER_LABELS = {
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { t } = useTranslation('chores')
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||
const { showError, showSuccess, showUndo, showWarning } = useNotification()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -203,16 +205,16 @@ const MyChores = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Due Date',
|
||||
label: t('group.dueDate'),
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
options: [
|
||||
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'Due today', label: 'Due Today', color: 'warning' },
|
||||
{ value: 'Due in week', label: 'Due This Week' },
|
||||
{ value: 'Due Later', label: 'Due Later' },
|
||||
{ value: 'No Due Date', label: 'No Due Date' },
|
||||
{ value: 'Pending Approval', label: 'Pending Approval' },
|
||||
{ value: 'Overdue', label: t('group.overdue'), color: 'danger' },
|
||||
{ value: 'Due today', label: t('group.dueToday'), color: 'warning' },
|
||||
{ value: 'Due in week', label: t('group.dueThisWeek') },
|
||||
{ value: 'Due Later', label: t('group.dueLater') },
|
||||
{ value: 'No Due Date', label: t('group.noDueDate') },
|
||||
{ value: 'Pending Approval', label: t('group.pendingApproval') },
|
||||
],
|
||||
filterFn: (item, value) => {
|
||||
const now = new Date()
|
||||
@@ -243,7 +245,7 @@ const MyChores = () => {
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
label: t('priority'),
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
@@ -258,7 +260,7 @@ const MyChores = () => {
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
label: t('labels.label'),
|
||||
type: 'multi-select',
|
||||
icon: <Style />,
|
||||
options: userLabels.map(l => ({
|
||||
@@ -1030,8 +1032,8 @@ const MyChores = () => {
|
||||
updateFilter={updateFilter}
|
||||
onFilterSaved={name =>
|
||||
showSuccess({
|
||||
title: 'Filter Saved',
|
||||
message: `"${name}" has been saved`,
|
||||
title: t('list.filterSaved'),
|
||||
message: t('list.filterSavedMsg', { name }),
|
||||
})
|
||||
}
|
||||
onClearAllFilters={() => {
|
||||
@@ -1401,7 +1403,7 @@ const MyChores = () => {
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<EditCalendar />}
|
||||
title='Nothing scheduled'
|
||||
title={t('list.nothingScheduled')}
|
||||
description='This day is free. Add a task if you want something to land here.'
|
||||
primaryAction={{
|
||||
label: 'Add task',
|
||||
@@ -1543,7 +1545,7 @@ const MyChores = () => {
|
||||
onClick={() => {
|
||||
Navigate(`/chores/create`)
|
||||
}}
|
||||
title='Create new chore'
|
||||
title={t('list.createChore')}
|
||||
>
|
||||
<Add />
|
||||
<KeyboardShortcutHint
|
||||
@@ -1657,15 +1659,15 @@ const MyChores = () => {
|
||||
operator: filter.operator,
|
||||
})
|
||||
showSuccess({
|
||||
title: 'Filter Updated',
|
||||
message: `"${filter.name}" has been updated successfully`,
|
||||
title: t('list.filterUpdated'),
|
||||
message: t('list.filterUpdatedMsg', { name: filter.name }),
|
||||
})
|
||||
} else {
|
||||
// Create new filter
|
||||
saveFilter(filter)
|
||||
showSuccess({
|
||||
title: 'Advanced Filter Created',
|
||||
message: `"${filter.name}" has been created successfully`,
|
||||
title: t('list.advancedFilterCreated'),
|
||||
message: t('list.advancedFilterCreatedMsg', { name: filter.name }),
|
||||
})
|
||||
}
|
||||
setShowAdvancedFilterBuilder(false)
|
||||
|
||||
@@ -3,9 +3,11 @@ import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { registerPushNotifications } from '../../CapacitorListener'
|
||||
|
||||
const NotificationAccessSnackbar = () => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Define the function outside of useEffect
|
||||
@@ -55,10 +57,9 @@ const NotificationAccessSnackbar = () => {
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Typography level='title-lg'>Need Notification?</Typography>
|
||||
<Typography level='title-lg'>{t('notifications.needTitle')}</Typography>
|
||||
<Typography sx={{ mt: 1, mb: 2 }}>
|
||||
You need to enable permission to receive notifications, do you want to
|
||||
enable it?
|
||||
{t('notifications.needBody')}
|
||||
</Typography>
|
||||
<Stack direction='row' spacing={1}>
|
||||
<Button
|
||||
@@ -84,7 +85,7 @@ const NotificationAccessSnackbar = () => {
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
{t('common:yes')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -98,7 +99,7 @@ const NotificationAccessSnackbar = () => {
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
No, Keep it Disabled
|
||||
{t('notifications.keepDisabled')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Box, Button, Chip, Sheet, Typography } from '@mui/joy'
|
||||
import { useMemo } from 'react'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Static insight filter definitions – used for URL restoration
|
||||
export const INSIGHT_FILTER_DEFS = {
|
||||
@@ -62,6 +63,7 @@ const SmartInsightsCard = ({
|
||||
clearTempFilter,
|
||||
tempFilter,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
// Detect all possible insights from chores
|
||||
const insights = useMemo(() => {
|
||||
if (!chores || chores.length === 0) return []
|
||||
@@ -84,7 +86,7 @@ const SmartInsightsCard = ({
|
||||
id: 'overdue',
|
||||
priority: 1,
|
||||
count: overdueTasks.length,
|
||||
title: 'Overdue',
|
||||
title: t('group.overdue'),
|
||||
description: `${overdueTasks.length} ${overdueTasks.length === 1 ? 'task is' : 'tasks are'} overdue`,
|
||||
color: 'danger',
|
||||
bgColor: TASK_COLOR.OVERDUE,
|
||||
@@ -113,7 +115,7 @@ const SmartInsightsCard = ({
|
||||
id: 'due-today',
|
||||
priority: 2,
|
||||
count: dueTodayTasks.length,
|
||||
title: 'Due Today',
|
||||
title: t('group.dueToday'),
|
||||
description: `${dueTodayTasks.length} ${dueTodayTasks.length === 1 ? 'task' : 'tasks'} due by end of day`,
|
||||
color: 'warning',
|
||||
bgColor: '#FFA500',
|
||||
@@ -138,7 +140,7 @@ const SmartInsightsCard = ({
|
||||
id: 'pending-approval',
|
||||
priority: 3,
|
||||
count: pendingApprovalTasks.length,
|
||||
title: 'Pending Approval',
|
||||
title: t('group.pendingApproval'),
|
||||
description: `${pendingApprovalTasks.length} ${pendingApprovalTasks.length === 1 ? 'task awaits' : 'tasks await'} approval`,
|
||||
color: 'neutral',
|
||||
bgColor: TASK_COLOR.PENDING_REVIEW,
|
||||
@@ -167,7 +169,7 @@ const SmartInsightsCard = ({
|
||||
id: 'due-this-week',
|
||||
priority: 4,
|
||||
count: dueThisWeekTasks.length,
|
||||
title: 'Due This Week',
|
||||
title: t('group.dueThisWeek'),
|
||||
description: `${dueThisWeekTasks.length} ${dueThisWeekTasks.length === 1 ? 'task' : 'tasks'} due in the next 7 days`,
|
||||
color: 'primary',
|
||||
bgColor: TASK_COLOR.IN_PROGRESS,
|
||||
@@ -194,7 +196,7 @@ const SmartInsightsCard = ({
|
||||
id: 'high-priority',
|
||||
priority: 5,
|
||||
count: highPriorityTasks.length,
|
||||
title: 'High Priority',
|
||||
title: t('insights.highPriority'),
|
||||
description: `${highPriorityTasks.length} ${highPriorityTasks.length === 1 ? 'task requires' : 'tasks require'} immediate attention`,
|
||||
color: 'warning',
|
||||
bgColor: '#FF6B6B',
|
||||
@@ -221,7 +223,7 @@ const SmartInsightsCard = ({
|
||||
id: 'no-due-date',
|
||||
priority: 6,
|
||||
count: noDueDateTasks.length,
|
||||
title: 'No Due Date',
|
||||
title: t('group.noDueDate'),
|
||||
description: `${noDueDateTasks.length} ${noDueDateTasks.length === 1 ? 'task needs' : 'tasks need'} a deadline`,
|
||||
color: 'neutral',
|
||||
bgColor: '#9E9E9E',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
|
||||
const SortAndGrouping = ({
|
||||
@@ -28,6 +29,7 @@ const SortAndGrouping = ({
|
||||
title,
|
||||
onCreateNewFilter,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
|
||||
@@ -79,12 +81,12 @@ const SortAndGrouping = ({
|
||||
if (!anchorEl) return
|
||||
|
||||
const groupByItems = [
|
||||
{ name: 'Smart', value: 'default' },
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
{ name: 'Created Date', value: 'created_date' },
|
||||
{ name: 'Updated Date', value: 'updated_date' },
|
||||
{ name: t('sort.smart'), value: 'default' },
|
||||
{ name: t('group.dueDate'), value: 'due_date' },
|
||||
{ name: t('priority'), value: 'priority' },
|
||||
{ name: t('labels.label'), value: 'labels' },
|
||||
{ name: t('sort.createdDate'), value: 'created_date' },
|
||||
{ name: t('sort.updatedDate'), value: 'updated_date' },
|
||||
]
|
||||
|
||||
const filterItems = [
|
||||
@@ -249,7 +251,7 @@ const SortAndGrouping = ({
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
title='Sort and Group (Ctrl+G)'
|
||||
title={t('sort.sortAndGroup')}
|
||||
>
|
||||
{icon}
|
||||
{label ? label : null}
|
||||
@@ -279,7 +281,7 @@ const SortAndGrouping = ({
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
title='Sort and Group (Ctrl+G)'
|
||||
title={t('sort.sortAndGroup')}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
@@ -322,7 +324,7 @@ const SortAndGrouping = ({
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{title || 'Group By'}
|
||||
{title || t('sort.groupBy')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -330,12 +332,12 @@ const SortAndGrouping = ({
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{[
|
||||
{ name: 'Smart', value: 'default' },
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
{ name: 'Created Date', value: 'created_date' },
|
||||
{ name: 'Updated Date', value: 'updated_date' },
|
||||
{ name: t('sort.smart'), value: 'default' },
|
||||
{ name: t('group.dueDate'), value: 'due_date' },
|
||||
{ name: t('priority'), value: 'priority' },
|
||||
{ name: t('labels.label'), value: 'labels' },
|
||||
{ name: t('sort.createdDate'), value: 'created_date' },
|
||||
{ name: t('sort.updatedDate'), value: 'updated_date' },
|
||||
].map((item, index) => (
|
||||
<MenuItem
|
||||
key={`${k}-${item?.value}`}
|
||||
@@ -406,7 +408,7 @@ const SortAndGrouping = ({
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
Quick Filters
|
||||
{t('sort.quickFilters')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -422,7 +424,7 @@ const SortAndGrouping = ({
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='body-xs' sx={{ fontWeight: 600 }}>
|
||||
Assigned to:
|
||||
{t('sort.assignedTo')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -431,28 +433,28 @@ const SortAndGrouping = ({
|
||||
key={`${k}-assignee-anyone`}
|
||||
index={4}
|
||||
filterKey='anyone'
|
||||
label='Anyone'
|
||||
label={t('assignee.anyone')}
|
||||
/>
|
||||
|
||||
<MenuItem_QuickFilter
|
||||
key={`${k}-assignee-assigned-to-me`}
|
||||
index={5}
|
||||
filterKey='assigned_to_me'
|
||||
label='Assigned to me'
|
||||
label={t('sort.assignedToMe')}
|
||||
/>
|
||||
|
||||
<MenuItem_QuickFilter
|
||||
key={`${k}-assignee-available-for-me`}
|
||||
index={6}
|
||||
filterKey='available_for_me'
|
||||
label='Available for me'
|
||||
label={t('sort.availableForMe')}
|
||||
/>
|
||||
|
||||
<MenuItem_QuickFilter
|
||||
key={`${k}-assignee-assigned-to-others`}
|
||||
index={7}
|
||||
filterKey='assigned_to_others'
|
||||
label='Assigned to others'
|
||||
label={t('sort.assignedToOthers')}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
@@ -485,13 +487,13 @@ const SortAndGrouping = ({
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Create Filter
|
||||
{t('sort.createFilter')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Build advanced filter rules
|
||||
{t('sort.createFilterHint')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
@@ -5,8 +5,10 @@ import EmptyState from '../../components/common/EmptyState'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [assigneeData, setAssigneeData] = useState([])
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
@@ -105,7 +107,7 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Loading tasks by assignee...
|
||||
{t('assigneeCard.loading')}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)
|
||||
@@ -164,7 +166,7 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
}}
|
||||
>
|
||||
<BarChart color='' />
|
||||
<Typography level='title-md'>Tasks by Assignee</Typography>
|
||||
<Typography level='title-md'>{t('assigneeCard.title')}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -181,22 +183,22 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
{[
|
||||
{
|
||||
key: 'inProgress',
|
||||
label: 'In Progress',
|
||||
label: t('filter.status.inProgress'),
|
||||
color: getStatusColor('inProgress'),
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'Overdue',
|
||||
label: t('group.overdue'),
|
||||
color: getStatusColor('overdue'),
|
||||
},
|
||||
{
|
||||
key: 'scheduled',
|
||||
label: 'Scheduled',
|
||||
label: t('assigneeCard.scheduled'),
|
||||
color: getStatusColor('scheduled'),
|
||||
},
|
||||
{
|
||||
key: 'pendingReview',
|
||||
label: 'Pending Review',
|
||||
label: t('assigneeCard.pendingReview'),
|
||||
color: getStatusColor('pendingReview'),
|
||||
},
|
||||
].map(status => (
|
||||
|
||||
@@ -2,11 +2,13 @@ import { SupervisorAccount } from '@mui/icons-material'
|
||||
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import UserModal from '../Modals/Inputs/UserModal'
|
||||
const UserSwitcher = () => {
|
||||
const {
|
||||
const { t } = useTranslation('chores')
|
||||
const {
|
||||
impersonatedUser,
|
||||
isImpersonating,
|
||||
startImpersonation,
|
||||
@@ -52,15 +54,15 @@ const UserSwitcher = () => {
|
||||
}}
|
||||
>
|
||||
<SupervisorAccount color='' />
|
||||
<Typography level='title-md'>View tasks as</Typography>
|
||||
<Typography level='title-md'>{t('impersonate.viewAs')}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 0.5 }}>
|
||||
Switch to user view
|
||||
{t('impersonate.switchTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1, color: 'text.secondary' }}>
|
||||
Tasks will be filtered to show only assignments for selected user
|
||||
{t('impersonate.switchHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -69,7 +71,7 @@ const UserSwitcher = () => {
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
size='sm'
|
||||
>
|
||||
Choose User
|
||||
{t('impersonate.chooseUser')}
|
||||
</Button>
|
||||
<UserModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -120,7 +122,7 @@ const UserSwitcher = () => {
|
||||
}}
|
||||
>
|
||||
<SupervisorAccount color='' />
|
||||
<Typography level='title-md'>View tasks as</Typography>
|
||||
<Typography level='title-md'>{t('impersonate.viewAs')}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -153,7 +155,7 @@ const UserSwitcher = () => {
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Change User
|
||||
{t('impersonate.changeUser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
@@ -164,7 +166,7 @@ const UserSwitcher = () => {
|
||||
stopImpersonation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import DueDatePickerModal, {
|
||||
combineDueDate,
|
||||
@@ -25,13 +26,14 @@ const ChoreModals = ({
|
||||
onNudge,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
return (
|
||||
<>
|
||||
{activeModal === 'changeDueDate' && modalChore && (
|
||||
<DueDatePickerModal
|
||||
open={true}
|
||||
key={'changeDueDate' + modalChore.id}
|
||||
title='Change due date'
|
||||
title={t('modals.changeDueDate')}
|
||||
{...splitDueDate(modalChore.nextDueDate)}
|
||||
onClose={onClose}
|
||||
onApply={parts =>
|
||||
@@ -46,7 +48,7 @@ const ChoreModals = ({
|
||||
isOpen={true}
|
||||
key={'completedInPast' + modalChore.id}
|
||||
current={modalChore.nextDueDate}
|
||||
title='Save Chore that you completed in the past'
|
||||
title={t('modals.completePast')}
|
||||
onClose={onClose}
|
||||
onSave={onCompleteWithPastDate}
|
||||
/>
|
||||
@@ -57,8 +59,8 @@ const ChoreModals = ({
|
||||
isOpen={true}
|
||||
options={membersData?.res || []}
|
||||
displayKey='displayName'
|
||||
title='Delegate to someone else'
|
||||
placeholder='Select a performer'
|
||||
title={t('modals.delegate')}
|
||||
placeholder={t('modals.selectPerformer')}
|
||||
onClose={onClose}
|
||||
onSave={selected => onAssigneeChange(selected.id)}
|
||||
/>
|
||||
@@ -67,9 +69,9 @@ const ChoreModals = ({
|
||||
{activeModal === 'completeWithNote' && modalChore && (
|
||||
<TextModal
|
||||
isOpen={true}
|
||||
title='Add note to attach to this completion:'
|
||||
title={t('modals.addNote')}
|
||||
onClose={onClose}
|
||||
okText='Complete'
|
||||
okText={t('modals.complete')}
|
||||
onSave={onCompleteWithNote}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -63,6 +63,7 @@ import FilterBuilderContent, {
|
||||
import SearchBar from './SearchBar'
|
||||
import ProjectSelector from '../../components/ProjectSelector'
|
||||
import CustomFilterChips from './CustomFilterChips'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
@@ -225,6 +226,7 @@ const ChoreToolbar = ({
|
||||
searchInputRef,
|
||||
showKeyboardShortcuts,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
||||
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
@@ -253,7 +255,7 @@ const ChoreToolbar = ({
|
||||
createdBy: 'Created By',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
label: 'Labels',
|
||||
label: t('labels.label'),
|
||||
project: 'Project',
|
||||
dueDate: 'Due Date',
|
||||
points: 'Points',
|
||||
@@ -513,33 +515,33 @@ const ChoreToolbar = ({
|
||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||
|
||||
const groupByOptions = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
{ value: 'due_date', label: 'Due Date' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'labels', label: 'Labels' },
|
||||
{ value: 'default', label: t('sort.smart') },
|
||||
{ value: 'due_date', label: t('dueDate') },
|
||||
{ value: 'priority', label: t('priority') },
|
||||
{ value: 'labels', label: t('labels.label') },
|
||||
]
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: 'anyone', label: 'Everyone' },
|
||||
{ value: 'assigned_to_me', label: 'Mine' },
|
||||
{ value: 'available_for_me', label: 'Available to me' },
|
||||
{ value: 'assigned_to_others', label: 'Others' },
|
||||
{ value: 'anyone', label: t('toolbar.everyone') },
|
||||
{ value: 'assigned_to_me', label: t('toolbar.mine') },
|
||||
{ value: 'available_for_me', label: t('toolbar.availableToMe') },
|
||||
{ value: 'assigned_to_others', label: t('toolbar.others') },
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Cards',
|
||||
label: t('toolbar.cards'),
|
||||
icon: <ViewAgenda sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Compact',
|
||||
label: t('toolbar.compact'),
|
||||
icon: <ViewComfy sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: 'Calendar',
|
||||
label: t('toolbar.calendar'),
|
||||
icon: <CalendarMonth sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
]
|
||||
@@ -577,8 +579,8 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
aria-label='Filters'
|
||||
title='Filters'
|
||||
aria-label={t('toolbar.filters')}
|
||||
title={t('toolbar.filters')}
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
@@ -603,8 +605,8 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
aria-label='Display options'
|
||||
title='Display'
|
||||
aria-label={t('toolbar.displayOptions')}
|
||||
title={t('toolbar.display')}
|
||||
>
|
||||
<DisplaySettings />
|
||||
</IconButton>
|
||||
@@ -704,7 +706,7 @@ const ChoreToolbar = ({
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
placeholder='Filter name…'
|
||||
placeholder={t('toolbar.filterNamePlaceholder')}
|
||||
value={saveFilterName}
|
||||
onChange={e => setSaveFilterName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
|
||||
@@ -716,7 +718,7 @@ const ChoreToolbar = ({
|
||||
onClick={handleSaveFilter}
|
||||
disabled={!saveFilterName.trim()}
|
||||
>
|
||||
Save
|
||||
{t('common:save')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -724,7 +726,7 @@ const ChoreToolbar = ({
|
||||
color='neutral'
|
||||
onClick={() => setSavingFilter(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('choreView.cancel')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -748,7 +750,7 @@ const ChoreToolbar = ({
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
{t('toolbar.clearAll')}
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
@@ -780,7 +782,7 @@ const ChoreToolbar = ({
|
||||
disabled={!editingSavedFilter}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save Filter
|
||||
{t('toolbar.saveFilter')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
@@ -794,7 +796,7 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save as New Filter
|
||||
{t('toolbar.saveAsNew')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
@@ -808,7 +810,7 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
{t('activity.status.done')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -889,13 +891,13 @@ const ChoreToolbar = ({
|
||||
onClick={() => setDisplaySheetOpen(false)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
{t('activity.status.done')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* View section */}
|
||||
<SectionHeader label='View' />
|
||||
<SectionHeader label={t('actionMenu.view')} />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{viewOptions.map(opt => (
|
||||
<Chip
|
||||
@@ -928,7 +930,7 @@ const ChoreToolbar = ({
|
||||
{/* Group by section */}
|
||||
<SectionHeader
|
||||
icon={<Sort />}
|
||||
label='Group by'
|
||||
label={t('toolbar.groupBy')}
|
||||
badge={
|
||||
selectedGroupBy !== 'default'
|
||||
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
|
||||
@@ -959,7 +961,7 @@ const ChoreToolbar = ({
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<SectionHeader
|
||||
icon={<FilterList />}
|
||||
label='Show tasks for'
|
||||
label={t('toolbar.showTasksFor')}
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Star,
|
||||
StarBorder,
|
||||
} from '@mui/icons-material'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
@@ -28,6 +29,7 @@ const CustomFilterChips = ({
|
||||
onFilterPin,
|
||||
onFilterEdit,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const navigate = useNavigate()
|
||||
const [menuAnchor, setMenuAnchor] = useState(null)
|
||||
const [selectedFilter, setSelectedFilter] = useState(null)
|
||||
@@ -237,24 +239,24 @@ const CustomFilterChips = ({
|
||||
{selectedFilter.isPinned ? (
|
||||
<>
|
||||
<StarBorder sx={{ mr: 1 }} />
|
||||
Unpin filter
|
||||
{t('filterChip.unpin')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Star sx={{ mr: 1 }} />
|
||||
Pin filter
|
||||
{t('filterChip.pin')}
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
{onFilterEdit && (
|
||||
<MenuItem onClick={handleEdit}>
|
||||
<Edit sx={{ mr: 1 }} />
|
||||
Edit filter
|
||||
{t('filterChip.edit')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleDelete} color='danger'>
|
||||
<Delete sx={{ mr: 1 }} />
|
||||
Delete filter
|
||||
{t('filterChip.delete')}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import LABEL_COLORS, {
|
||||
@@ -154,6 +155,7 @@ const MultiSelectToolbar = ({
|
||||
showKeyboardShortcuts,
|
||||
selectAllDisabled,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false)
|
||||
const [dueMenuAnchor, setDueMenuAnchor] = useState(null)
|
||||
@@ -251,7 +253,7 @@ const MultiSelectToolbar = ({
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckBox sx={{ color: 'primary.500' }} />
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{selectedCount} task{selectedCount !== 1 ? 's' : ''} selected
|
||||
{t('archived.selected', { count: selectedCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -274,9 +276,9 @@ const MultiSelectToolbar = ({
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Select all visible tasks (Ctrl+A)'
|
||||
title={t('archived.selectAllTitle')}
|
||||
>
|
||||
All
|
||||
{t('archived.all')}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='A'
|
||||
@@ -301,9 +303,13 @@ const MultiSelectToolbar = ({
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={`${selectedCount === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? t('archived.closeMultiSelect')
|
||||
: t('archived.clearMultiSelect')
|
||||
}
|
||||
>
|
||||
{selectedCount === 0 ? 'Close' : 'Clear'}
|
||||
{selectedCount === 0 ? t('archived.close') : t('archived.clear')}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
withCtrl={false}
|
||||
@@ -349,9 +355,9 @@ const MultiSelectToolbar = ({
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Complete selected tasks (Enter)'
|
||||
title={t('multiToolbar.completeTitle')}
|
||||
>
|
||||
Complete
|
||||
{t('multiToolbar.complete')}
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Enter'
|
||||
@@ -460,9 +466,9 @@ const MultiSelectToolbar = ({
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Skip selected tasks (/)'
|
||||
title={t('multiToolbar.skipTitle')}
|
||||
>
|
||||
Skip
|
||||
{t('multiToolbar.skip')}
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='/'
|
||||
@@ -548,9 +554,9 @@ const MultiSelectToolbar = ({
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Archive selected tasks (X)'
|
||||
title={t('multiToolbar.archiveTitle')}
|
||||
>
|
||||
Archive
|
||||
{t('multiToolbar.archive')}
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='X'
|
||||
@@ -575,9 +581,9 @@ const MultiSelectToolbar = ({
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Delete selected tasks (Shift+X)'
|
||||
title={t('multiToolbar.deleteTitle')}
|
||||
>
|
||||
Delete
|
||||
{t('archived.delete')}
|
||||
{showKeyboardShortcuts && selectedCount > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='E'
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../../../utils/Fetcher'
|
||||
import { offlineDB } from '../../../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Effectively "can this action be queued offline?" — requires the offline
|
||||
// feature, otherwise there is no command queue to replay it later.
|
||||
@@ -71,6 +72,7 @@ export const useChoreActions = ({
|
||||
getSelectedChoresData,
|
||||
clearSelection,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const queryClient = useQueryClient()
|
||||
const archiveChore = useArchiveChore()
|
||||
const unarchiveChore = useUnArchiveChore()
|
||||
@@ -126,16 +128,16 @@ export const useChoreActions = ({
|
||||
skipped: 'Task skip has been undone.',
|
||||
}
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: undoMessages[event],
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
throw new Error(t('choreView.unableUndo'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -146,39 +148,39 @@ export const useChoreActions = ({
|
||||
const notifications = {
|
||||
rescheduled: {
|
||||
type: 'success',
|
||||
title: 'Task Rescheduled',
|
||||
message: 'The task due date has been updated successfully.',
|
||||
title: t('actions.rescheduledTitle'),
|
||||
message: t('actions.rescheduledMessage'),
|
||||
},
|
||||
'due-date-removed': {
|
||||
type: 'success',
|
||||
title: 'Task Unplanned',
|
||||
message: 'The task is now unplanned and has no due date.',
|
||||
title: t('actions.unplannedTitle'),
|
||||
message: t('actions.unplannedMessage'),
|
||||
},
|
||||
unarchive: {
|
||||
type: 'success',
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
title: t('archived.restoredTitle'),
|
||||
message: t('archived.restoredMsg'),
|
||||
},
|
||||
archive: {
|
||||
type: 'success',
|
||||
title: 'Task Archived',
|
||||
title: t('actions.archivedTitle'),
|
||||
message:
|
||||
'The task has been archived and hidden from the active list.',
|
||||
},
|
||||
started: {
|
||||
type: 'success',
|
||||
title: 'Task Started',
|
||||
message: 'The task has been marked as started.',
|
||||
title: t('actions.startedTitle'),
|
||||
message: t('actions.startedMessage'),
|
||||
},
|
||||
paused: {
|
||||
type: 'warning',
|
||||
title: 'Task Paused',
|
||||
message: 'The task has been paused.',
|
||||
title: t('actions.pausedTitle'),
|
||||
message: t('actions.pausedMessage'),
|
||||
},
|
||||
deleted: {
|
||||
type: 'success',
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted.',
|
||||
title: t('archived.deletedTitle'),
|
||||
message: t('actions.deletedMessage'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -227,21 +229,21 @@ export const useChoreActions = ({
|
||||
}
|
||||
})
|
||||
showSuccess({
|
||||
message: 'Task completed',
|
||||
message: t('actions.undoable.completed'),
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(chore.id)
|
||||
if (undoResponse.ok) {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: 'Task completion has been undone.',
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: t('choreView.taskCompletionUndone'),
|
||||
})
|
||||
} else throw new Error('Failed to undo')
|
||||
} else throw new Error(t('choreView.unableUndo'))
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -278,7 +280,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
title: 'Task completion pending',
|
||||
title: t('actions.completionPending'),
|
||||
message:
|
||||
"You're offline — completion will sync when back online",
|
||||
undoAction: async () => {
|
||||
@@ -290,7 +292,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to complete',
|
||||
title: t('actions.failCompleteTitle'),
|
||||
message: error?.message || 'Unable to complete chore',
|
||||
})
|
||||
}
|
||||
@@ -342,7 +344,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to start',
|
||||
title: t('actions.failStartTitle'),
|
||||
message: error?.message || 'Unable to start chore',
|
||||
})
|
||||
}
|
||||
@@ -395,7 +397,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to pause',
|
||||
title: t('actions.failPauseTitle'),
|
||||
message: error?.message || 'Unable to pause chore',
|
||||
})
|
||||
}
|
||||
@@ -412,7 +414,7 @@ export const useChoreActions = ({
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to approve',
|
||||
title: t('actions.failApproveTitle'),
|
||||
message: error.message || 'Unable to approve chore',
|
||||
})
|
||||
}
|
||||
@@ -427,7 +429,7 @@ export const useChoreActions = ({
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to reject',
|
||||
title: t('actions.failRejectTitle'),
|
||||
message: error.message || 'Unable to reject chore',
|
||||
})
|
||||
}
|
||||
@@ -436,10 +438,10 @@ export const useChoreActions = ({
|
||||
case 'delete':
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Chore',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: 'Are you sure you want to delete this chore?',
|
||||
title: t('deleteChore'),
|
||||
confirmText: t('archived.delete'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
message: t('edit.deleteConfirm'),
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
@@ -453,8 +455,8 @@ export const useChoreActions = ({
|
||||
setFilteredChores(newFilteredChores)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
title: t('archived.deletedTitle'),
|
||||
message: t('actions.deletedMessageLong'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -485,7 +487,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
title: t('actions.failDeleteTitle'),
|
||||
message: error?.message || 'Unable to delete chore',
|
||||
})
|
||||
}
|
||||
@@ -539,7 +541,7 @@ export const useChoreActions = ({
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
title: t('actions.failArchiveTitle'),
|
||||
message: error.message || 'Unable to archive chore',
|
||||
})
|
||||
reject(error)
|
||||
@@ -587,7 +589,7 @@ export const useChoreActions = ({
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
title: t('choreView.restoreFailed'),
|
||||
message: error.message || 'Unable to restore chore',
|
||||
})
|
||||
reject(error)
|
||||
@@ -628,7 +630,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to skip',
|
||||
title: t('actions.failSkipTitle'),
|
||||
message: error?.message || 'Unable to skip chore',
|
||||
})
|
||||
}
|
||||
@@ -703,13 +705,13 @@ export const useChoreActions = ({
|
||||
if (response.ok) {
|
||||
updateChoreInState(updatedChore, 'moved-to-project')
|
||||
showSuccess({
|
||||
title: 'Task Moved',
|
||||
title: t('actions.movedTitle'),
|
||||
message: `Task moved to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to move task',
|
||||
title: t('actions.failMoveTitle'),
|
||||
message: error?.message || 'Unable to move task to project',
|
||||
})
|
||||
}
|
||||
@@ -787,7 +789,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to reschedule',
|
||||
title: t('actions.failRescheduleTitle'),
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
@@ -873,15 +875,15 @@ export const useChoreActions = ({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
showSuccess({
|
||||
title: 'Nudge Sent!',
|
||||
title: t('actions.nudgeSentTitle'),
|
||||
message: data.message || 'Nudge sent successfully',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to send nudge')
|
||||
throw new Error(t('actions.nudgeFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to Send Nudge',
|
||||
title: t('actions.failNudgeTitle'),
|
||||
message: error.message || 'Unable to send nudge at this time',
|
||||
})
|
||||
} finally {
|
||||
@@ -935,6 +937,8 @@ export const useChoreActions = ({
|
||||
// just used is itself the confirmation.
|
||||
confirm,
|
||||
// "completed", "rescheduled", … — reads as `2 tasks could not be ${verb}.`
|
||||
// t() key output for the "the whole batch blew up" toast title.
|
||||
failedTitle,
|
||||
failureVerb,
|
||||
onSucceeded,
|
||||
perChore,
|
||||
@@ -969,7 +973,7 @@ export const useChoreActions = ({
|
||||
|
||||
if (failed.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: `${taskCount(failed.length)} could not be ${failureVerb}.`,
|
||||
})
|
||||
}
|
||||
@@ -983,8 +987,8 @@ export const useChoreActions = ({
|
||||
await execute()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: `Bulk ${failureVerb} failed`,
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: failedTitle || `Bulk ${failureVerb} failed`,
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -992,7 +996,7 @@ export const useChoreActions = ({
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
cancelText: 'Cancel',
|
||||
cancelText: t('choreView.cancel'),
|
||||
...confirm,
|
||||
onClose: async isConfirmed => {
|
||||
setConfirmModelConfig({})
|
||||
@@ -1001,8 +1005,8 @@ export const useChoreActions = ({
|
||||
await execute()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: `Bulk ${failureVerb} failed`,
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: failedTitle || `Bulk ${failureVerb} failed`,
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -1022,8 +1026,8 @@ export const useChoreActions = ({
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Complete Tasks',
|
||||
confirmText: 'Complete',
|
||||
title: t('actions.bulk.completeTitle'),
|
||||
confirmText: t('list.complete'),
|
||||
message: `Mark ${taskCount(targets.length)} as completed?`,
|
||||
},
|
||||
perChore: chore =>
|
||||
@@ -1035,9 +1039,10 @@ export const useChoreActions = ({
|
||||
null,
|
||||
),
|
||||
),
|
||||
successTitle: '✅ Tasks Completed',
|
||||
successTitle: t('actions.bulk.completedTitle'),
|
||||
successVerb: 'Completed',
|
||||
failureVerb: 'completed',
|
||||
failedTitle: t('actions.bulk.completeFailedTitle'),
|
||||
})
|
||||
}, [getSelectedChoresData, chores, impersonatedUser, runBulk])
|
||||
|
||||
@@ -1046,14 +1051,15 @@ export const useChoreActions = ({
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Skip Tasks',
|
||||
confirmText: 'Skip',
|
||||
title: t('actions.bulk.skipTitle'),
|
||||
confirmText: t('multiToolbar.skip'),
|
||||
message: `Skip ${taskCount(targets.length)} to next due date?`,
|
||||
},
|
||||
perChore: chore => expectOk(SkipChore(chore.id)),
|
||||
successTitle: '⏭️ Tasks Skipped',
|
||||
successTitle: t('actions.bulk.skippedTitle'),
|
||||
successVerb: 'Skipped',
|
||||
failureVerb: 'skipped',
|
||||
failedTitle: t('actions.bulk.skipFailedTitle'),
|
||||
buildUndo: succeeded => async () => {
|
||||
try {
|
||||
for (const chore of succeeded) {
|
||||
@@ -1061,13 +1067,13 @@ export const useChoreActions = ({
|
||||
}
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: `Undo skip for ${taskCount(succeeded.length)}.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -1079,8 +1085,8 @@ export const useChoreActions = ({
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Archive Tasks',
|
||||
confirmText: 'Archive',
|
||||
title: t('actions.bulk.archiveTitle'),
|
||||
confirmText: t('actionMenu.archive'),
|
||||
message: `Archive ${taskCount(targets.length)}?`,
|
||||
},
|
||||
perChore: chore =>
|
||||
@@ -1090,9 +1096,10 @@ export const useChoreActions = ({
|
||||
onError: reject,
|
||||
})
|
||||
}),
|
||||
successTitle: '📦 Tasks Archived',
|
||||
successTitle: t('actions.bulk.archivedTitle'),
|
||||
successVerb: 'Archived',
|
||||
failureVerb: 'archived',
|
||||
failedTitle: t('actions.bulk.archiveFailedTitle'),
|
||||
onSucceeded: succeeded => removeLocalChores(succeeded.map(c => c.id)),
|
||||
})
|
||||
}, [getSelectedChoresData, chores, runBulk, archiveChore, removeLocalChores])
|
||||
@@ -1102,14 +1109,15 @@ export const useChoreActions = ({
|
||||
runBulk({
|
||||
targets,
|
||||
confirm: {
|
||||
title: 'Delete Tasks',
|
||||
confirmText: 'Delete',
|
||||
title: t('actions.bulk.deleteTitle'),
|
||||
confirmText: t('archived.delete'),
|
||||
message: `Delete ${taskCount(targets.length)}?\n\nThis action cannot be undone.`,
|
||||
},
|
||||
perChore: chore => expectOk(DeleteChore(chore.id)),
|
||||
successTitle: '🗑️ Tasks Deleted',
|
||||
successTitle: t('archived.deletedBulkTitle'),
|
||||
successVerb: 'Deleted',
|
||||
failureVerb: 'deleted',
|
||||
failedTitle: t('archived.bulkDeleteFailTitle'),
|
||||
onSucceeded: succeeded => removeLocalChores(succeeded.map(c => c.id)),
|
||||
})
|
||||
}, [getSelectedChoresData, chores, runBulk, removeLocalChores])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const useKeyboardShortcuts = ({
|
||||
addTaskModalOpen,
|
||||
@@ -11,6 +12,7 @@ export const useKeyboardShortcuts = ({
|
||||
searchTerm,
|
||||
selectedChores,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,13 +65,13 @@ export const useKeyboardShortcuts = ({
|
||||
|
||||
if (allVisibleSelected) {
|
||||
handlers.onShowMessage({
|
||||
title: '✅ All Tasks Selected',
|
||||
title: t('shortcuts.allSelectedTitle'),
|
||||
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
|
||||
})
|
||||
} else {
|
||||
handlers.onSelectAll()
|
||||
handlers.onShowMessage({
|
||||
title: '🎯 Tasks Selected',
|
||||
title: t('shortcuts.someSelectedTitle'),
|
||||
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
@@ -91,20 +93,20 @@ export const useKeyboardShortcuts = ({
|
||||
|
||||
if (allChoresSelected) {
|
||||
handlers.onShowMessage({
|
||||
title: '✅ All Tasks Selected',
|
||||
title: t('shortcuts.allSelectedTitle'),
|
||||
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
|
||||
})
|
||||
} else if (allExpandedSelected) {
|
||||
handlers.onSelectAll()
|
||||
const collapsedCount = allChores.length - expandedChores.length
|
||||
handlers.onShowMessage({
|
||||
title: '🎯 All Tasks Selected',
|
||||
title: t('shortcuts.allSelectedAltTitle'),
|
||||
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
|
||||
})
|
||||
} else {
|
||||
handlers.onSelectAll()
|
||||
handlers.onShowMessage({
|
||||
title: '🎯 Tasks Selected',
|
||||
title: t('shortcuts.someSelectedTitle'),
|
||||
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
|
||||
})
|
||||
}
|
||||
@@ -184,7 +186,7 @@ export const useKeyboardShortcuts = ({
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen])
|
||||
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen, t])
|
||||
|
||||
return { showKeyboardShortcuts }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SearchRounded,
|
||||
Warning,
|
||||
} from '@mui/icons-material'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -32,6 +33,7 @@ import DateModal from './Modals/Inputs/DateModal'
|
||||
|
||||
// enum for chore status:
|
||||
const CHORE_STATUS = {
|
||||
const { t } = useTranslation('chores')
|
||||
NO_DUE_DATE: 'No due date',
|
||||
DUE_SOON: 'Soon',
|
||||
DUE_NOW: 'Due',
|
||||
@@ -119,7 +121,7 @@ const ChoresOverview = () => {
|
||||
return (
|
||||
<Container>
|
||||
<Typography level='h4' mb={1.5}>
|
||||
Chores Overviews
|
||||
{t('overview.title')}
|
||||
</Typography>
|
||||
{/* <SummaryCard /> */}
|
||||
<Grid container>
|
||||
@@ -132,7 +134,7 @@ const ChoresOverview = () => {
|
||||
gap={2}
|
||||
>
|
||||
<Input
|
||||
placeholder='Search'
|
||||
placeholder={t('overview.search')}
|
||||
value={search}
|
||||
onChange={e => {
|
||||
if (e.target.value === '') {
|
||||
@@ -169,7 +171,7 @@ const ChoresOverview = () => {
|
||||
Navigate(`/chores/create`)
|
||||
}}
|
||||
>
|
||||
New Chore
|
||||
{t('overview.newChore')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -228,7 +230,7 @@ const ChoresOverview = () => {
|
||||
color='warning'
|
||||
startDecorator={<Avatar color='primary'>?</Avatar>}
|
||||
>
|
||||
Unassigned
|
||||
{t('overview.unassigned')}
|
||||
</Chip>
|
||||
)}
|
||||
</td>
|
||||
@@ -315,7 +317,7 @@ const ChoresOverview = () => {
|
||||
<DateModal
|
||||
isOpen={isDateModalOpen}
|
||||
key={choreId}
|
||||
title={`Change due date`}
|
||||
title={t('overview.changeDueDate')}
|
||||
onClose={() => {
|
||||
setIsDateModalOpen(false)
|
||||
}}
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
useToggleFilterPin,
|
||||
useUpdateFilter,
|
||||
} from './FilterQueries'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const FilterCardContent = ({
|
||||
filter,
|
||||
@@ -251,6 +252,7 @@ const FilterCardContent = ({
|
||||
}
|
||||
|
||||
const FilterView = () => {
|
||||
const { t } = useTranslation('filters')
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -432,11 +434,11 @@ const FilterView = () => {
|
||||
const filter = savedFilters.find(f => f.id === id)
|
||||
setConfirmationModel({
|
||||
isOpen: true,
|
||||
title: 'Delete Filter',
|
||||
title: t('delete.title'),
|
||||
message: `Are you sure you want to delete "${filter?.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
confirmText: t('common:delete'),
|
||||
color: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
cancelText: t('common:cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed === true) {
|
||||
handleDeleteFilter(id)
|
||||
@@ -585,7 +587,7 @@ const FilterView = () => {
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<FilterAlt />}
|
||||
title='No saved filters yet'
|
||||
title={t('empty.title')}
|
||||
description='Save a set of conditions once, like "overdue and assigned to me", and jump straight back to it from anywhere.'
|
||||
primaryAction={{
|
||||
label: 'Create a filter',
|
||||
@@ -673,7 +675,7 @@ const FilterView = () => {
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -698,7 +700,7 @@ const FilterView = () => {
|
||||
sx={{ mt: 0.5 }}
|
||||
color='danger'
|
||||
>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
|
||||
@@ -52,8 +52,10 @@ import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ChoreHistory = () => {
|
||||
const { t } = useTranslation('history')
|
||||
const [userHistory, setUserHistory] = useState([])
|
||||
const [historyInfo, setHistoryInfo] = useState([])
|
||||
const { choreId } = useParams()
|
||||
@@ -96,43 +98,43 @@ const ChoreHistory = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
label: t('charts.status.title'),
|
||||
type: 'multi-select',
|
||||
icon: <FilterList />,
|
||||
options: [
|
||||
{
|
||||
value: ChoreHistoryStatus.COMPLETED,
|
||||
label: 'Completed',
|
||||
label: t('status.completed'),
|
||||
color: 'success',
|
||||
icon: <Check sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.SKIPPED,
|
||||
label: 'Skipped',
|
||||
label: t('status.skipped'),
|
||||
color: 'warning',
|
||||
icon: <Redo sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.PENDING_APPROVAL,
|
||||
label: 'Pending',
|
||||
label: t('filter.pending'),
|
||||
color: 'neutral',
|
||||
icon: <HourglassEmpty sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.REJECTED,
|
||||
label: 'Rejected',
|
||||
label: t('status.rejected'),
|
||||
color: 'danger',
|
||||
icon: <ThumbDown sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: 'Missed',
|
||||
label: t('status.missed'),
|
||||
color: 'danger',
|
||||
icon: <RunningWithErrors sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: 'Rescheduled',
|
||||
label: t('status.rescheduled'),
|
||||
color: 'warning',
|
||||
icon: <Schedule sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
@@ -141,14 +143,14 @@ const ChoreHistory = () => {
|
||||
},
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
label: t('filter.hasNotes'),
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'Completed By',
|
||||
label: t('filter.completedBy'),
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
@@ -160,7 +162,7 @@ const ChoreHistory = () => {
|
||||
},
|
||||
{
|
||||
id: 'dateRange',
|
||||
label: 'Completed At',
|
||||
label: t('filter.completedAt'),
|
||||
type: 'date-range',
|
||||
icon: <CalendarMonth />,
|
||||
filterFn: (item, value) => {
|
||||
@@ -328,7 +330,7 @@ const ChoreHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Task Summary
|
||||
{t('title.summary')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={0.5} sx={{ mb: 2 }}>
|
||||
@@ -405,7 +407,7 @@ const ChoreHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Task Activity
|
||||
{t('title.activity')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -425,7 +427,7 @@ const ChoreHistory = () => {
|
||||
icon={<FilterList />}
|
||||
title='No history matches these filters'
|
||||
description='There is history here, but none of it fits the filters that are currently on.'
|
||||
primaryAction={{ label: 'Clear filters', onClick: clearAll }}
|
||||
primaryAction={{ label: t('noResults.clear'), onClick: clearAll }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -467,7 +469,7 @@ const ChoreHistory = () => {
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -486,7 +488,7 @@ const ChoreHistory = () => {
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -560,13 +562,13 @@ const ChoreHistory = () => {
|
||||
setEditHistory(null)
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Update Queued',
|
||||
title: t('toast.updateQueued.title'),
|
||||
message:
|
||||
'You are offline. The history update will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Updated',
|
||||
title: t('toast.updated.title'),
|
||||
message: `The history record has been updated successfully.`,
|
||||
})
|
||||
}
|
||||
@@ -590,13 +592,13 @@ const ChoreHistory = () => {
|
||||
setEditHistory(null)
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Delete Queued',
|
||||
title: t('toast.deleteQueued.title'),
|
||||
message:
|
||||
'You are offline. The history delete will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Deleted',
|
||||
title: t('toast.deleted.title'),
|
||||
message: `The history record has been deleted successfully.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
@@ -33,13 +34,13 @@ const stripHtmlTags = html => {
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
0: { labelKey: 'status.inProgress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { labelKey: 'status.completed', color: 'success', icon: <Check /> },
|
||||
2: { labelKey: 'status.skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { labelKey: 'status.pendingApproval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { labelKey: 'status.rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { labelKey: 'status.missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { labelKey: 'status.rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const HistoryCard = ({
|
||||
@@ -52,45 +53,54 @@ const HistoryCard = ({
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { t } = useTranslation('history')
|
||||
const { fmt } = useLocalization()
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
|
||||
const displayLabel =
|
||||
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
|
||||
historyEntry.status === 6 && !historyEntry.dueDate
|
||||
? t('status.scheduled')
|
||||
: t(config.labelKey)
|
||||
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
||||
|
||||
const getTimingLine = () => {
|
||||
const { status, performedAt, dueDate } = historyEntry
|
||||
if (!dueDate) return null
|
||||
|
||||
if (status === 6) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
if (status === 5) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
if (status === 6 || status === 5) {
|
||||
return t('card.wasDue', { date: fmt.date(dueDate) })
|
||||
}
|
||||
if ((status === 1 || status === 2 || status === 0) && performedAt) {
|
||||
const diffHours = moment(performedAt).diff(dueDate, 'hours')
|
||||
const abs = Math.abs(diffHours)
|
||||
if (abs <= 6) return null // chip already says "On Time"
|
||||
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
|
||||
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
|
||||
const early = diffHours < 0
|
||||
if (abs >= 48) {
|
||||
const days = Math.floor(abs / 24)
|
||||
return early
|
||||
? t('card.daysBeforeDue', { count: days })
|
||||
: t('card.daysAfterDue', { count: days })
|
||||
}
|
||||
return early
|
||||
? t('card.hoursBeforeDue', { count: abs })
|
||||
: t('card.hoursAfterDue', { count: abs })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const timingLine = getTimingLine()
|
||||
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
|
||||
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
|
||||
|
||||
const metaTextParts = [
|
||||
fmt.dateTime(actionDate),
|
||||
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
||||
? `Assigned to ${assignedTo.displayName}`
|
||||
? t('card.assignedTo', { name: assignedTo.displayName })
|
||||
: null,
|
||||
historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null,
|
||||
historyEntry?.points > 0 ? `★ ${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
|
||||
historyEntry?.points > 0
|
||||
? t('card.points', { count: historyEntry.points })
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||
import { cacheChoreImages, getImageSrc } from '../../../utils/ImageCache'
|
||||
import AttachmentViewerModal from './AttachmentViewerModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
@@ -35,6 +36,7 @@ const downloadFile = (url, fileName) => {
|
||||
}
|
||||
|
||||
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -45,7 +47,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
setIsLoading(true)
|
||||
GetChoreAttachments(choreId)
|
||||
.then(async res => {
|
||||
if (!res.ok) throw new Error('Failed to fetch attachments')
|
||||
if (!res.ok) throw new Error(t('chores:dataError.attachmentsFailed'))
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
@@ -56,7 +58,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
})
|
||||
.catch(() => setAttachments([]))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [isOpen, choreId])
|
||||
}, [isOpen, choreId, t])
|
||||
|
||||
const handleClose = () => {
|
||||
setAttachments([])
|
||||
@@ -86,7 +88,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title='Attachments'
|
||||
title={t('attachments')}
|
||||
footer={
|
||||
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||
}
|
||||
@@ -126,7 +128,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-sm' noWrap>
|
||||
{attachment.file_name || `File ${index + 1}`}
|
||||
{attachment.file_name || t('fileNumbered', { index: index + 1 })}
|
||||
</Typography>
|
||||
{attachment.size_bytes > 0 && (
|
||||
<Typography
|
||||
|
||||
@@ -8,8 +8,10 @@ import { useNotification } from '../../../service/NotificationProvider.jsx'
|
||||
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
|
||||
import { useLabels } from '../../Labels/LabelQueries'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function LabelModal({ isOpen, onClose, label }) {
|
||||
const { t } = useTranslation('labels')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [labelName, setLabelName] = useState('')
|
||||
@@ -34,7 +36,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
// Validation logic
|
||||
const validateLabel = () => {
|
||||
if (!labelName.trim()) {
|
||||
setError('Name cannot be empty')
|
||||
setError(t('modal.errorEmptyName'))
|
||||
return false
|
||||
}
|
||||
if (
|
||||
@@ -42,11 +44,11 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
userLabel => userLabel.name === labelName && userLabel.id !== label?.id,
|
||||
)
|
||||
) {
|
||||
setError('Label with this name already exists')
|
||||
setError(t('modal.errorDuplicate'))
|
||||
return false
|
||||
}
|
||||
if (!color) {
|
||||
setError('Please select a color')
|
||||
setError(t('modal.errorNoColor'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -71,13 +73,13 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
.catch(err => {
|
||||
if (err.queued) {
|
||||
showError({
|
||||
title: 'Failed to save label',
|
||||
message: 'Unable to save label. Please try again.',
|
||||
title: t('modal.saveFailedTitle'),
|
||||
message: t('modal.saveFailedMessage'),
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to save label',
|
||||
message: 'Unable to save label. Please try again.',
|
||||
title: t('modal.saveFailedTitle'),
|
||||
message: t('modal.saveFailedMessage'),
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -92,7 +94,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
title={label ? 'Edit Label' : 'Add Label'}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
secondary={{ label: t('common:cancel'), onClick: onClose }}
|
||||
primary={{
|
||||
label: label ? 'Save Changes' : 'Add Label',
|
||||
onClick: handleSave,
|
||||
@@ -103,7 +105,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
<Box>
|
||||
<FormControl>
|
||||
<Typography gutterBottom level='body-sm' alignSelf='start'>
|
||||
Name
|
||||
{t('modal.name')}
|
||||
</Typography>
|
||||
<Input
|
||||
fullWidth
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
|
||||
|
||||
function NudgeModal({ config }) {
|
||||
const { t } = useTranslation('chores')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -107,19 +109,19 @@ function NudgeModal({ config }) {
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
title='Send Nudge'
|
||||
title={t('nudge.title')}
|
||||
description='Send a gentle reminder to the people assigned to this task.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('choreView.cancel'),
|
||||
onClick: () => handleAction(false),
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='X' />
|
||||
) : undefined,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Send Nudge',
|
||||
label: t('nudge.title'),
|
||||
onClick: () => handleAction(true),
|
||||
disabled: !isOfficialInstance,
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
@@ -144,9 +146,9 @@ function NudgeModal({ config }) {
|
||||
)}
|
||||
|
||||
<FormControl mb={2}>
|
||||
<FormLabel>Custom Message (optional)</FormLabel>
|
||||
<FormLabel>{t('nudge.customMessage')}</FormLabel>
|
||||
<Textarea
|
||||
placeholder='Add a personal message with your nudge...'
|
||||
placeholder={t('nudge.messagePlaceholder')}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
minRows={3}
|
||||
@@ -156,10 +158,9 @@ function NudgeModal({ config }) {
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ mb: 3 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<FormLabel>Notify All Assignees</FormLabel>
|
||||
<FormLabel>{t('nudge.notifyAll')}</FormLabel>
|
||||
<Typography level='body-sm' color='text.secondary'>
|
||||
If enabled, all members who can see this task will be notified.
|
||||
Otherwise, only the assigned person will receive the nudge.
|
||||
{t('nudge.notifyAllHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
useUpdateProject,
|
||||
} from '../../Projects/ProjectQueries'
|
||||
import IconPickerModal from './IconPickerModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
const { t } = useTranslation('projects')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [projectDescription, setProjectDescription] = useState('')
|
||||
@@ -58,7 +60,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!projectName.trim()) {
|
||||
setError('Project name is required')
|
||||
setError(t('modal.errorNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,7 +84,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error updating project:', error)
|
||||
setError('Failed to update project')
|
||||
setError(t('modal.errorUpdate'))
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -95,7 +97,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error creating project:', error)
|
||||
setError('Failed to create project')
|
||||
setError(t('modal.errorCreate'))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -130,7 +132,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('common:cancel'),
|
||||
onClick: handleClose,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
@@ -152,7 +154,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
<Input
|
||||
value={projectName}
|
||||
onChange={e => setProjectName(e.target.value)}
|
||||
placeholder='Enter project name...'
|
||||
placeholder={t('modal.namePlaceholder')}
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -164,7 +166,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
<Textarea
|
||||
value={projectDescription}
|
||||
onChange={e => setProjectDescription(e.target.value)}
|
||||
placeholder='Optional project description...'
|
||||
placeholder={t('modal.descriptionPlaceholder')}
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRef, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const pulseKeyframes = `
|
||||
@keyframes nfc-pulse {
|
||||
@@ -105,6 +106,7 @@ function NFCIcon({ status }) {
|
||||
}
|
||||
|
||||
function WriteNFCModal({ config }) {
|
||||
const { t } = useTranslation('chores')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [nfcStatus, setNfcStatus] = useState('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
@@ -171,13 +173,11 @@ function WriteNFCModal({ config }) {
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
setErrorMessage(t('nfc.errWrite'))
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
setErrorMessage(t('nfc.errUnsupported'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,20 +187,20 @@ function WriteNFCModal({ config }) {
|
||||
const isError = nfcStatus === 'error'
|
||||
|
||||
const title = isSuccess
|
||||
? 'Tag written!'
|
||||
? t('nfc.titleSuccess')
|
||||
: isError
|
||||
? 'Something went wrong'
|
||||
? t('nfc.titleError')
|
||||
: isWaiting
|
||||
? 'Hold near NFC tag'
|
||||
: 'Write to NFC'
|
||||
? t('nfc.titleWaiting')
|
||||
: t('actionMenu.writeNFC')
|
||||
|
||||
const subtitle = isSuccess
|
||||
? 'Your NFC tag is ready to use.'
|
||||
? t('nfc.subSuccess')
|
||||
: isError
|
||||
? errorMessage
|
||||
: isWaiting
|
||||
? 'Keep your device near the tag until complete.'
|
||||
: 'Encode this task link onto any NFC tag.'
|
||||
? t('nfc.subWaiting')
|
||||
: t('nfc.subIdle')
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -214,14 +214,14 @@ function WriteNFCModal({ config }) {
|
||||
closeOnEscape={!isWaiting}
|
||||
footer={
|
||||
isSuccess ? (
|
||||
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||
<ModalActions primary={{ label: t('activity.status.done'), onClick: handleClose }} />
|
||||
) : isWaiting ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: handleCancel }}
|
||||
secondary={{ label: t('choreView.cancel'), onClick: handleCancel }}
|
||||
/>
|
||||
) : (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: handleClose }}
|
||||
secondary={{ label: t('choreView.cancel'), onClick: handleClose }}
|
||||
primary={{
|
||||
label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag',
|
||||
onClick: writeToNFC,
|
||||
@@ -250,7 +250,7 @@ function WriteNFCModal({ config }) {
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
Tag URL
|
||||
{t('nfc.tagUrl')}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
@@ -269,7 +269,7 @@ function WriteNFCModal({ config }) {
|
||||
variant='plain'
|
||||
color={copied ? 'success' : 'neutral'}
|
||||
onClick={handleCopy}
|
||||
title='Copy URL'
|
||||
title={t('nfc.copyUrl')}
|
||||
>
|
||||
<ContentCopy sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
@@ -280,7 +280,7 @@ function WriteNFCModal({ config }) {
|
||||
level='body-xs'
|
||||
sx={{ color: 'success.500', mt: 0.5, textAlign: 'right' }}
|
||||
>
|
||||
Copied!
|
||||
{t('common:copied')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -299,10 +299,10 @@ function WriteNFCModal({ config }) {
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' fontWeight={500}>
|
||||
Auto-complete on scan
|
||||
{t('nfc.autoComplete')}
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Mark task done when tag is tapped
|
||||
{t('nfc.autoCompleteHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
|
||||
@@ -249,7 +249,7 @@ const MFASettings = () => {
|
||||
size='sm'
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
>
|
||||
Generate New Codes
|
||||
{t('mfa.generateCodes')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
@@ -53,6 +53,7 @@ import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getSafeBottom } from '../../utils/SafeAreaUtils'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Effectively "can this action be queued offline?" — requires the offline
|
||||
// feature, otherwise there is no command queue to replay it later.
|
||||
@@ -62,6 +63,7 @@ const isNetworkError = err =>
|
||||
err.message === 'Failed to fetch'
|
||||
|
||||
const TimerDetails = () => {
|
||||
const { t } = useTranslation('timer')
|
||||
const { choreId } = useParams()
|
||||
const { fmt } = useLocalization()
|
||||
const [timerData, setTimerData] = useState(null)
|
||||
@@ -231,23 +233,23 @@ const TimerDetails = () => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Session updated',
|
||||
message: 'Timer session has been updated successfully.',
|
||||
title: t('toast.sessionUpdatedTitle'),
|
||||
message: t('toast.sessionUpdatedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
cancelEditingSession(sessionId)
|
||||
},
|
||||
onError: () => {
|
||||
showError({
|
||||
title: 'Failed to update session',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.sessionUpdateFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Error updating session',
|
||||
title: t('toast.sessionUpdateErrorTitle'),
|
||||
message: error.message,
|
||||
})
|
||||
} finally {
|
||||
@@ -261,8 +263,8 @@ const TimerDetails = () => {
|
||||
startChore.mutate(choreId, {
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Timer Started',
|
||||
message: 'Work session has been started successfully.',
|
||||
title: t('toast.startedTitle'),
|
||||
message: t('toast.startedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
@@ -274,7 +276,7 @@ const TimerDetails = () => {
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Start queued',
|
||||
title: t('toast.startQueuedTitle'),
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
@@ -284,8 +286,8 @@ const TimerDetails = () => {
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to start timer',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.startFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -299,8 +301,8 @@ const TimerDetails = () => {
|
||||
pauseChore.mutate(choreId, {
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Timer Paused',
|
||||
message: 'Work session has been paused.',
|
||||
title: t('toast.pausedTitle'),
|
||||
message: t('toast.pausedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
@@ -312,7 +314,7 @@ const TimerDetails = () => {
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Pause queued',
|
||||
title: t('toast.pauseQueuedTitle'),
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
@@ -322,8 +324,8 @@ const TimerDetails = () => {
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to pause timer',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.pauseFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -387,7 +389,7 @@ const TimerDetails = () => {
|
||||
const handleDeleteSession = sessionIndex => {
|
||||
// For now, just show an alert since we'd need to implement session deletion API
|
||||
showError({
|
||||
title: 'Delete Session',
|
||||
title: t('toast.deleteSessionTitle'),
|
||||
message: `Session #${sessionIndex + 1} deletion would be implemented here`,
|
||||
})
|
||||
}
|
||||
@@ -402,13 +404,13 @@ const TimerDetails = () => {
|
||||
|
||||
{loading && (
|
||||
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||
Loading timer data...
|
||||
{t('loading')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !timerData && (
|
||||
<Alert color='warning' sx={{ mb: 2 }}>
|
||||
No timer data found for this chore.
|
||||
{t('noData')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -514,7 +516,7 @@ const TimerDetails = () => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Break Time
|
||||
{t('breakTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -624,7 +626,7 @@ const TimerDetails = () => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Total Time
|
||||
{t('totalTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -824,7 +826,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary' }}
|
||||
>
|
||||
Break Time
|
||||
{t('breakTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isTimerRunning() && (
|
||||
@@ -924,7 +926,7 @@ const TimerDetails = () => {
|
||||
onClick={() => startEditingSession()}
|
||||
size='sm'
|
||||
>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Button>
|
||||
)}
|
||||
{editingSessions[timerData.id] && (
|
||||
@@ -934,7 +936,7 @@ const TimerDetails = () => {
|
||||
onClick={() => cancelEditingSession(timerData.id)}
|
||||
size='sm'
|
||||
>
|
||||
Cancel
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
@@ -943,7 +945,7 @@ const TimerDetails = () => {
|
||||
loading={loading}
|
||||
size='sm'
|
||||
>
|
||||
Save Changes
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -1019,7 +1021,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -1045,7 +1047,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -1242,7 +1244,7 @@ const TimerDetails = () => {
|
||||
startDecorator={<Add />}
|
||||
onClick={() => addPauseLogEntry(timerData.id)}
|
||||
>
|
||||
Add Session
|
||||
{t('addSession')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -1292,7 +1294,7 @@ const TimerDetails = () => {
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||
>
|
||||
Start Time
|
||||
{t('startTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
@@ -1315,7 +1317,7 @@ const TimerDetails = () => {
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||
>
|
||||
End Time
|
||||
{t('endTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import LABEL_COLORS, {
|
||||
@@ -81,6 +82,7 @@ const ChoreActionMenu = ({
|
||||
sx = {},
|
||||
variant = 'soft',
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
|
||||
const [showProjectPicker, setShowProjectPicker] = useState(false)
|
||||
@@ -278,7 +280,7 @@ const ChoreActionMenu = ({
|
||||
{
|
||||
key: 'completeNote',
|
||||
icon: <NoteAdd />,
|
||||
label: 'Complete with note',
|
||||
label: t('actionMenu.completeWithNote'),
|
||||
onClick: () => {
|
||||
onCompleteWithNote?.()
|
||||
handleMenuClose()
|
||||
@@ -287,7 +289,7 @@ const ChoreActionMenu = ({
|
||||
{
|
||||
key: 'completePast',
|
||||
icon: <Update />,
|
||||
label: 'Complete in past',
|
||||
label: t('actionMenu.completeInPast'),
|
||||
onClick: () => {
|
||||
onCompleteWithPastDate?.()
|
||||
handleMenuClose()
|
||||
@@ -296,13 +298,13 @@ const ChoreActionMenu = ({
|
||||
{
|
||||
key: 'skip',
|
||||
icon: <SwitchAccessShortcut />,
|
||||
label: 'Skip to next due date',
|
||||
label: t('actionMenu.skipToNext'),
|
||||
onClick: handleSkip,
|
||||
},
|
||||
{
|
||||
key: 'delegate',
|
||||
icon: <RecordVoiceOver />,
|
||||
label: 'Delegate to someone else',
|
||||
label: t('modals.delegate'),
|
||||
onClick: () => {
|
||||
onChangeAssignee?.()
|
||||
handleMenuClose()
|
||||
@@ -311,7 +313,7 @@ const ChoreActionMenu = ({
|
||||
isOfficialInstance && {
|
||||
key: 'nudge',
|
||||
icon: <Notifications />,
|
||||
label: 'Send nudge',
|
||||
label: t('actionMenu.sendNudge'),
|
||||
onClick: () => {
|
||||
onNudge?.()
|
||||
handleMenuClose()
|
||||
@@ -321,7 +323,7 @@ const ChoreActionMenu = ({
|
||||
{
|
||||
key: 'history',
|
||||
icon: <ManageSearch />,
|
||||
label: 'History',
|
||||
label: t('actionMenu.history'),
|
||||
onClick: handleHistory,
|
||||
},
|
||||
{ key: 'divider-2', type: 'divider' },
|
||||
@@ -330,7 +332,7 @@ const ChoreActionMenu = ({
|
||||
{
|
||||
key: 'changeDueDate',
|
||||
icon: <MoreTime />,
|
||||
label: 'Change due date',
|
||||
label: t('modals.changeDueDate'),
|
||||
onClick: () => {
|
||||
onChangeDueDate?.()
|
||||
handleMenuClose()
|
||||
@@ -354,15 +356,15 @@ const ChoreActionMenu = ({
|
||||
{
|
||||
key: 'writeNfc',
|
||||
icon: <Nfc />,
|
||||
label: 'Write to NFC',
|
||||
label: t('actionMenu.writeNFC'),
|
||||
onClick: () => {
|
||||
onWriteNFC?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{ key: 'edit', icon: <Edit />, label: 'Edit', onClick: handleEdit },
|
||||
{ key: 'clone', icon: <CopyAll />, label: 'Clone', onClick: handleClone },
|
||||
{ key: 'view', icon: <ViewCarousel />, label: 'View', onClick: handleView },
|
||||
{ key: 'edit', icon: <Edit />, label: t('choreView.edit'), onClick: handleEdit },
|
||||
{ key: 'clone', icon: <CopyAll />, label: t('actionMenu.clone'), onClick: handleClone },
|
||||
{ key: 'view', icon: <ViewCarousel />, label: t('actionMenu.view'), onClick: handleView },
|
||||
{
|
||||
key: 'archive',
|
||||
icon: chore.isActive ? <Archive /> : <Unarchive />,
|
||||
@@ -373,14 +375,14 @@ const ChoreActionMenu = ({
|
||||
projects.length > 0 && {
|
||||
key: 'moveToProject',
|
||||
icon: <DriveFileMove />,
|
||||
label: 'Move to project',
|
||||
label: t('actionMenu.moveToProject'),
|
||||
onClick: () => setShowProjectPicker(true),
|
||||
},
|
||||
{ key: 'divider-4', type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <Delete />,
|
||||
label: 'Delete',
|
||||
label: t('archived.delete'),
|
||||
onClick: handleDelete,
|
||||
color: 'danger',
|
||||
},
|
||||
@@ -392,7 +394,7 @@ const ChoreActionMenu = ({
|
||||
|
||||
const quickScheduleButtons = (
|
||||
<>
|
||||
<Tooltip title='Today' placement='top'>
|
||||
<Tooltip title={t('duePicker.today')} placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -403,7 +405,7 @@ const ChoreActionMenu = ({
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<Tooltip title={t('duePicker.tomorrow')} placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -414,7 +416,7 @@ const ChoreActionMenu = ({
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<Tooltip title={t('duePicker.weekend')} placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -425,7 +427,7 @@ const ChoreActionMenu = ({
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<Tooltip title={t('duePicker.nextWeek')} placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -436,7 +438,7 @@ const ChoreActionMenu = ({
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<Tooltip title={t('actionMenu.removeDueDate')} placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='neutral'
|
||||
@@ -650,7 +652,7 @@ const ChoreActionMenu = ({
|
||||
sx={{ mx: 2, mb: 1 }}
|
||||
>
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Back
|
||||
{t('common:back')}
|
||||
</Typography>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getDisplayLabel = templates => {
|
||||
if (!templates || templates.length === 0) return 'Remind'
|
||||
@@ -27,6 +28,7 @@ const NotificationPickerField = ({
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const latestTemplatesRef = useRef(value?.templates || [])
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
@@ -52,7 +54,7 @@ const NotificationPickerField = ({
|
||||
tertiary={
|
||||
hasNotifications
|
||||
? {
|
||||
label: 'Remove all',
|
||||
label: t('common:removeAll'),
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
@@ -61,8 +63,8 @@ const NotificationPickerField = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
secondary={{ label: t('choreView.cancel'), onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: t('common:apply'), onClick: handleSave }}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -130,7 +132,7 @@ const NotificationPickerField = ({
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Reminders'
|
||||
title={t('remind.title')}
|
||||
footer={footer}
|
||||
>
|
||||
<NotificationTemplate
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LABELS = {
|
||||
complete_chore: 'Complete pending',
|
||||
@@ -39,6 +40,7 @@ const formatCommandLabel = commandType => {
|
||||
}
|
||||
|
||||
function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const queryClient = useQueryClient()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -145,9 +147,9 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
title='Pending actions'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
secondary={{ label: t('close'), onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Cancel all',
|
||||
label: t('cancelAll'),
|
||||
color: 'danger',
|
||||
onClick: handleCancelAll,
|
||||
loading: isCancelingAll,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Replay,
|
||||
TextSnippet,
|
||||
} from '@mui/icons-material'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -121,6 +122,7 @@ async function extractTaskFromOCR(ocrText) {
|
||||
}
|
||||
|
||||
const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const videoRef = useRef(null)
|
||||
@@ -302,7 +304,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
onClose={handleClose}
|
||||
size='md'
|
||||
fullWidth
|
||||
title='Scan photo to create task'
|
||||
title={t('photoTask.title')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{(phase === 'capture' || phase === 'preview') && (
|
||||
@@ -340,7 +342,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
<Box sx={{ textAlign: 'center', p: 3 }}>
|
||||
<CameraAlt sx={{ fontSize: 48, opacity: 0.5, mb: 1 }} />
|
||||
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
|
||||
Camera not available
|
||||
{t('photoTask.cameraUnavailable')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -487,7 +489,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
startDecorator={<PhotoCamera />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload Photo
|
||||
{t('photoTask.uploadPhoto')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -503,7 +505,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
startDecorator={<DocumentScanner />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
{t('photoTask.scanDocument')}
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
@@ -513,7 +515,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
startDecorator={<CameraAlt />}
|
||||
onClick={handleCapture}
|
||||
>
|
||||
Capture
|
||||
{t('photoTask.capture')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -538,7 +540,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
color='primary'
|
||||
onClick={() => handleProcess('native')}
|
||||
>
|
||||
Process Natively
|
||||
{t('photoTask.processNatively')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -546,7 +548,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
color='primary'
|
||||
onClick={() => handleProcess('tesseract')}
|
||||
>
|
||||
Process Image
|
||||
{t('photoTask.processImage')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -559,7 +561,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
startDecorator={<ArrowBack />}
|
||||
onClick={handleBackToPreview}
|
||||
>
|
||||
Back
|
||||
{t('common:back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -580,7 +582,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
startDecorator={<ArrowBack />}
|
||||
onClick={handleBackToPreview}
|
||||
>
|
||||
Back
|
||||
{t('common:back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -591,7 +593,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
Retake
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleConfirm}>
|
||||
Create Task
|
||||
{t('photoTask.createTask')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { apiClient } from '../../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { patchDescriptionHtml } from '../../utils/ImageCache'
|
||||
import './RichTextEditor.css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RichTextEditor = forwardRef(
|
||||
(
|
||||
@@ -53,7 +54,7 @@ const RichTextEditor = forwardRef(
|
||||
value = '',
|
||||
onChange,
|
||||
isEditable = true,
|
||||
placeholder = 'Enter description...',
|
||||
placeholder = null,
|
||||
variant = 'outlined',
|
||||
entityId,
|
||||
entityType,
|
||||
@@ -61,6 +62,7 @@ const RichTextEditor = forwardRef(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
// Display-only HTML with expired image srcs swapped for cached/re-signed ones
|
||||
@@ -92,9 +94,9 @@ const RichTextEditor = forwardRef(
|
||||
// Check if user has plus account
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
title: t('common:upload.plusFeatureTitle'),
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
t('common:upload.plusFeatureMessage'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -155,33 +157,33 @@ const RichTextEditor = forwardRef(
|
||||
|
||||
if (response.status === 507) {
|
||||
showError({
|
||||
title: 'Storage Quota Exceeded',
|
||||
message: 'You have exceeded your quota for uploading files.',
|
||||
title: t('common:upload.quotaTitle'),
|
||||
message: t('common:upload.quotaMessage'),
|
||||
})
|
||||
return
|
||||
} else if (response.status === 413) {
|
||||
showError({
|
||||
title: 'File Too Large',
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
title: t('common:upload.tooLargeTitle'),
|
||||
message: t('common:upload.tooLargeMessage'),
|
||||
})
|
||||
return
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
title: t('common:upload.upgradeTitle'),
|
||||
message:
|
||||
'Image uploads are only available for Plus accounts. Please ',
|
||||
t('common:upload.upgradeMessage'),
|
||||
})
|
||||
return
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Permission Denied',
|
||||
message: 'You do not have permission to upload files.',
|
||||
title: t('common:upload.deniedTitle'),
|
||||
message: t('common:upload.deniedMessage'),
|
||||
})
|
||||
return
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
title: t('common:upload.failedTitle'),
|
||||
message: t('common:upload.failedMessage'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -199,8 +201,8 @@ const RichTextEditor = forwardRef(
|
||||
} catch (error) {
|
||||
console.error('Error during image processing or upload:', error)
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
title: t('common:upload.failedTitle'),
|
||||
message: t('common:upload.processingMessage'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -226,7 +228,7 @@ const RichTextEditor = forwardRef(
|
||||
},
|
||||
},
|
||||
},
|
||||
placeholder: placeholder,
|
||||
placeholder: placeholder ?? t('descriptionPlaceholder'),
|
||||
})
|
||||
new QuillMarkdown(editorRef.current, {})
|
||||
editorRef.current.root.innerHTML = value
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Replay,
|
||||
WarningAmber,
|
||||
} from '@mui/icons-material'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -37,6 +38,7 @@ const ScanPanel = ({
|
||||
onTaskExtracted,
|
||||
open,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const {
|
||||
activate,
|
||||
cameraAvailable,
|
||||
@@ -109,18 +111,18 @@ const ScanPanel = ({
|
||||
if (phase === 'capture') {
|
||||
if (isNativeScanner) {
|
||||
return {
|
||||
label: 'Scan Document',
|
||||
label: t('photoTask.scanDocument'),
|
||||
icon: <DocumentScanner />,
|
||||
onClick: handleNativeScan,
|
||||
}
|
||||
}
|
||||
if (cameraAvailable) {
|
||||
return { label: 'Capture', icon: <CameraAlt />, onClick: capture }
|
||||
return { label: t('photoTask.capture'), icon: <CameraAlt />, onClick: capture }
|
||||
}
|
||||
// No camera on this device — Upload is the only way forward, so it
|
||||
// graduates from the inline secondary to the footer's primary
|
||||
return {
|
||||
label: 'Upload Photo',
|
||||
label: t('photoTask.uploadPhoto'),
|
||||
icon: <PhotoCamera />,
|
||||
onClick: openFilePicker,
|
||||
}
|
||||
@@ -322,7 +324,7 @@ const ScanPanel = ({
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Failed scan'
|
||||
alt={t('photoTask.failedScan')}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const COMMAND_LABELS = {
|
||||
create_chore: 'Create chore',
|
||||
@@ -62,6 +63,7 @@ const formatCommandLabel = commandType => {
|
||||
}
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const { t } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
const [failedCommands, setFailedCommands] = useState([])
|
||||
@@ -239,7 +241,7 @@ function SyncStatusIndicator() {
|
||||
return (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
aria-label='Open sync and network status'
|
||||
aria-label={t('sync.aria')}
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 0.5,
|
||||
@@ -483,7 +485,7 @@ function SyncStatusIndicator() {
|
||||
level='body-sm'
|
||||
sx={{ color: 'var(--joy-palette-text-secondary)' }}
|
||||
>
|
||||
All changes synced
|
||||
{t('sync.allSynced')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -517,7 +519,7 @@ function SyncStatusIndicator() {
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Will sync when back online
|
||||
{t('sync.willSync')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -538,7 +540,7 @@ function SyncStatusIndicator() {
|
||||
<ClearAll sx={{ fontSize: 18 }} />
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Cancel All
|
||||
{t('sync.cancelAll')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user