ph4 #53

Merged
mo merged 19 commits from ph4 into prod 2025-06-04 17:51:17 +02:00
10 changed files with 1499 additions and 312 deletions
Showing only changes of commit 82205f6158 - Show all commits

View File

@ -270,5 +270,292 @@
"removeMemberSuccess": "DE: Member removed successfully",
"removeMemberFailed": "DE: Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

View File

@ -270,5 +270,292 @@
"removeMemberSuccess": "Member removed successfully",
"removeMemberFailed": "Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

View File

@ -270,5 +270,292 @@
"removeMemberSuccess": "ES: Member removed successfully",
"removeMemberFailed": "ES: Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

View File

@ -270,5 +270,292 @@
"removeMemberSuccess": "FR: Member removed successfully",
"removeMemberFailed": "FR: Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

View File

@ -1,32 +1,32 @@
<template>
<main class="container page-padding">
<VHeading level="1" text="Account Settings" class="mb-3" />
<VHeading level="1" :text="$t('accountPage.title')" class="mb-3" />
<div v-if="loading" class="text-center">
<VSpinner label="Loading profile..." />
<VSpinner :label="$t('accountPage.loadingProfile')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchProfile">Retry</VButton>
<VButton variant="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</VButton>
</template>
</VAlert>
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<VCard class="mb-3">
<template #header><VHeading level="3">Profile Information</VHeading></template>
<template #header><VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading></template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField label="Name" class="flex-grow">
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required />
</VFormField>
<VFormField label="Email" class="flex-grow">
<VFormField :label="$t('accountPage.profileSection.emailLabel')" class="flex-grow">
<VInput type="email" id="profileEmail" v-model="profile.email" required readonly />
</VFormField>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="saving">
<VSpinner v-if="saving" size="sm" /> Save Changes
<VSpinner v-if="saving" size="sm" /> {{ $t('accountPage.profileSection.saveButton') }}
</VButton>
</template>
</VCard>
@ -35,18 +35,18 @@
<!-- Password Section -->
<form @submit.prevent="onChangePassword">
<VCard class="mb-3">
<template #header><VHeading level="3">Change Password</VHeading></template>
<template #header><VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading></template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField label="Current Password" class="flex-grow">
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required />
</VFormField>
<VFormField label="New Password" class="flex-grow">
<VFormField :label="$t('accountPage.passwordSection.newPasswordLabel')" class="flex-grow">
<VInput type="password" id="newPassword" v-model="password.newPassword" required />
</VFormField>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="changingPassword">
<VSpinner v-if="changingPassword" size="sm" /> Change Password
<VSpinner v-if="changingPassword" size="sm" /> {{ $t('accountPage.passwordSection.changeButton') }}
</VButton>
</template>
</VCard>
@ -54,28 +54,28 @@
<!-- Notifications Section -->
<VCard>
<template #header><VHeading level="3">Notification Preferences</VHeading></template>
<template #header><VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading></template>
<VList class="preference-list">
<VListItem class="preference-item">
<div class="preference-label">
<span>Email Notifications</span>
<small>Receive email notifications for important updates</small>
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" label="Email Notifications" id="emailNotificationsToggle" />
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>List Updates</span>
<small>Get notified when lists are updated</small>
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" label="List Updates" id="listUpdatesToggle"/>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle"/>
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>Group Activities</span>
<small>Receive notifications for group activities</small>
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" label="Group Activities" id="groupActivitiesToggle"/>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle"/>
</VListItem>
</VList>
</VCard>
@ -84,6 +84,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue';
@ -97,6 +98,8 @@ import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
const { t } = useI18n();
interface Profile {
name: string;
email: string;
@ -136,10 +139,11 @@ const fetchProfile = async () => {
// Assume preferences are also fetched or part of profile
// preferences.value = response.data.preferences || preferences.value;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load profile';
error.value = message;
const apiMessage = err instanceof Error ? err.message : t('accountPage.notifications.profileLoadFailed');
error.value = apiMessage; // Show translated or API error message in the VAlert
console.error('Failed to fetch profile:', err);
notificationStore.addNotification({ message, type: 'error' });
// For the notification pop-up, always use the translated generic message
notificationStore.addNotification({ message: t('accountPage.notifications.profileLoadFailed'), type: 'error' });
} finally {
loading.value = false;
}
@ -149,11 +153,11 @@ const onSubmitProfile = async () => {
saving.value = true;
try {
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value);
notificationStore.addNotification({ message: 'Profile updated successfully', type: 'success' });
notificationStore.addNotification({ message: t('accountPage.notifications.profileUpdateSuccess'), type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
const message = err instanceof Error ? err.message : t('accountPage.notifications.profileUpdateFailed');
console.error('Failed to update profile:', err);
notificationStore.addNotification({ message, type: 'error' });
notificationStore.addNotification({ message: t('accountPage.notifications.profileUpdateFailed'), type: 'error' });
} finally {
saving.value = false;
}
@ -161,11 +165,11 @@ const onSubmitProfile = async () => {
const onChangePassword = async () => {
if (!password.value.current || !password.value.newPassword) {
notificationStore.addNotification({ message: 'Please fill in both current and new password fields.', type: 'warning' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordFieldsRequired'), type: 'warning' });
return;
}
if (password.value.newPassword.length < 8) {
notificationStore.addNotification({ message: 'New password must be at least 8 characters long.', type: 'warning' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordTooShort'), type: 'warning' });
return;
}
@ -177,11 +181,11 @@ const onChangePassword = async () => {
new: password.value.newPassword
});
password.value = { current: '', newPassword: '' };
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordChangeSuccess'), type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to change password';
const message = err instanceof Error ? err.message : t('accountPage.notifications.passwordChangeFailed');
console.error('Failed to change password:', err);
notificationStore.addNotification({ message, type: 'error' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordChangeFailed'), type: 'error' });
} finally {
changingPassword.value = false;
}
@ -192,11 +196,11 @@ const onPreferenceChange = async () => {
// Consider debouncing or providing a "Save Preferences" button if API calls are expensive.
try {
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value);
notificationStore.addNotification({ message: 'Preferences updated successfully', type: 'success' });
notificationStore.addNotification({ message: t('accountPage.notifications.preferencesUpdateSuccess'), type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update preferences';
const message = err instanceof Error ? err.message : t('accountPage.notifications.preferencesUpdateFailed');
console.error('Failed to update preferences:', err);
notificationStore.addNotification({ message, type: 'error' });
notificationStore.addNotification({ message: t('accountPage.notifications.preferencesUpdateFailed'), type: 'error' });
// Optionally revert the toggle if the API call fails
// await fetchProfile(); // Or manage state more granularly
}

View File

@ -1,12 +1,12 @@
<template>
<main class="container page-padding text-center">
<h1>Welcome to Valerie UI App</h1>
<p class="mb-3">This is the main index page.</p>
<h1>{{ $t('indexPage.welcomeMessage') }}</h1>
<p class="mb-3">{{ $t('indexPage.mainPageInfo') }}</p>
<!-- The ExampleComponent is not provided, so this section is a placeholder -->
<div v-if="todos.length" class="card">
<div class="card-header">
<h3>Sample Todos (from IndexPage data)</h3>
<h3>{{ $t('indexPage.sampleTodosHeader') }}</h3>
</div>
<div class="card-body">
<ul class="item-list">
@ -16,18 +16,21 @@
</div>
</li>
</ul>
<p class="mt-2">Total count from meta: {{ meta.totalCount }}</p>
<p class="mt-2">{{ $t('indexPage.totalCountLabel') }} {{ meta.totalCount }}</p>
</div>
</div>
<p v-else>No todos to display.</p>
<p v-else>{{ $t('indexPage.noTodos') }}</p>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Todo, Meta } from '@/components/models'; // Adjusted path if models.ts is in the same directory
// import ExampleComponent from 'components/ExampleComponent.vue'; // This component is not provided for conversion
const { t } = useI18n(); // Added for consistency, though not strictly needed if only $t in template
const todos = ref<Todo[]>([
{ id: 1, content: 'ct1' },
{ id: 2, content: 'ct2' },

View File

@ -1,12 +1,12 @@
<template>
<main class="neo-container page-padding">
<div v-if="pageInitialLoad && !list && !error" class="text-center py-10">
<VSpinner label="Loading list..." size="lg" />
<VSpinner :label="$t('listDetailPage.loading.list')" size="lg" />
</div>
<VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4">
<template #actions>
<VButton @click="fetchListDetails">Retry</VButton>
<VButton @click="fetchListDetails">{{ $t('listDetailPage.retryButton') }}</VButton>
</template>
</VAlert>
@ -15,10 +15,10 @@
<div class="neo-list-header">
<VHeading :level="1" :text="list.name" class="mb-3 neo-title" />
<div class="neo-header-actions">
<VButton @click="showCostSummaryDialog = true" :disabled="!isOnline" icon-left="clipboard">Cost Summary
<VButton @click="showCostSummaryDialog = true" :disabled="!isOnline" icon-left="clipboard">{{ $t('listDetailPage.buttons.costSummary') }}
</VButton>
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">Add via OCR</VButton>
<VBadge :text="list.group_id ? 'Group List' : 'Personal List'" :variant="list.group_id ? 'accent' : 'settled'"
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">{{ $t('listDetailPage.buttons.addViaOcr') }}</VButton>
<VBadge :text="list.group_id ? $t('listDetailPage.badges.groupList') : $t('listDetailPage.badges.personalList')" :variant="list.group_id ? 'accent' : 'settled'"
class="neo-status" />
</div>
</div>
@ -26,10 +26,10 @@
<!-- Items List Section -->
<VCard v-if="itemsAreLoading" class="py-10 text-center mt-4">
<VSpinner label="Loading items..." size="lg" />
<VSpinner :label="$t('listDetailPage.loading.items')" size="lg" />
</VCard>
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
empty-title="No Items Yet!" empty-message="Add some items using the form below." class="mt-4" />
:empty-title="$t('listDetailPage.items.emptyState.title')" :empty-message="$t('listDetailPage.items.emptyState.message')" class="mt-4" />
<div v-else class="neo-item-list-container mt-4">
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-list-item"
@ -42,18 +42,18 @@
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" :aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<VIcon name="edit" />
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<VIcon name="trash" />
</button>
</div>
</div>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="item.priceInput = $event"
placeholder="Price" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</li>
@ -63,16 +63,16 @@
<!-- Add New Item Form -->
<form @submit.prevent="onAddItem" class="add-item-form mt-4 p-4 border rounded-lg shadow flex items-center gap-2">
<VIcon name="plus-circle" class="text-gray-400 shrink-0" />
<VFormField class="flex-grow" label="New item name" :label-sr-only="true">
<VInput v-model="newItem.name" placeholder="Add a new item" required ref="itemNameInputRef" />
<VFormField class="flex-grow" :label="$t('listDetailPage.items.addItemForm.itemNameSrLabel')" :label-sr-only="true">
<VInput v-model="newItem.name" :placeholder="$t('listDetailPage.items.addItemForm.placeholder')" required ref="itemNameInputRef" />
</VFormField>
<VFormField label="Quantity" :label-sr-only="true" class="w-24 shrink-0">
<VFormField :label="$t('listDetailPage.items.addItemForm.quantitySrLabel')" :label-sr-only="true" class="w-24 shrink-0">
<VInput type="number" :model-value="newItem.quantity || ''" @update:modelValue="newItem.quantity = $event"
placeholder="Qty" min="1" />
:placeholder="$t('listDetailPage.items.addItemForm.quantityPlaceholder')" min="1" />
</VFormField>
<VButton type="submit" :disabled="addingItem" class="shrink-0">
<VSpinner v-if="addingItem" size="sm" />
<span v-else>Add</span>
<span v-else>{{ $t('listDetailPage.buttons.addItem') }}</span>
</VButton>
</form>
</template>
@ -80,24 +80,24 @@
<!-- Expenses Section (Original Content - Part 3 will refactor this) -->
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
<div class="neo-expenses-header">
<h2 class="neo-expenses-title">Expenses</h2>
<h2 class="neo-expenses-title">{{ $t('listDetailPage.expensesSection.title') }}</h2>
<button class="neo-action-button" @click="showCreateExpenseForm = true">
<svg class="icon">
<use xlink:href="#icon-plus" />
</svg>
Add Expense
{{ $t('listDetailPage.expensesSection.addExpenseButton') }}
</button>
</div>
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading expenses...</p>
<p>{{ $t('listDetailPage.expensesSection.loading') }}</p>
</div>
<div v-else-if="listDetailStore.error" class="neo-error-state">
<p>{{ listDetailStore.error }}</p>
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">Retry</button>
<p>{{ listDetailStore.error }}</p> <!-- Assuming listDetailStore.error is a backend message or already translated if generic -->
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">{{ $t('listDetailPage.expensesSection.retryButton') }}</button>
</div>
<div v-else-if="!expenses || expenses.length === 0" class="neo-empty-state">
<p>No expenses recorded for this list yet.</p>
<p>{{ $t('listDetailPage.expensesSection.emptyState') }}</p>
</div>
<div v-else>
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-card">
@ -108,34 +108,34 @@
</span>
</div>
<div class="neo-expense-details">
Paid by: <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
{{ $t('listDetailPage.expensesSection.paidBy') }} <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
${expense.paid_by_user_id}` }}</strong>
on {{ new Date(expense.expense_date).toLocaleDateString() }}
{{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(expense.expense_date).toLocaleDateString() }}
</div>
<div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="neo-split-details">
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> owes {{
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> {{ $t('listDetailPage.expensesSection.owes') }} {{
formatCurrency(split.owed_amount) }}
<span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }}
</span>
</div>
<div class="neo-split-details">
Paid: {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span>
{{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(split.paid_at).toLocaleDateString() }}</span>
</div>
<button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="neo-button neo-button-primary" @click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading">
Settle My Share
{{ $t('listDetailPage.expensesSection.settleShareButton') }}
</button>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id">
Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} on {{ new Date(activity.paid_at).toLocaleDateString() }}
{{ $t('listDetailPage.expensesSection.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ $t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }}
</li>
</ul>
</div>
@ -149,10 +149,10 @@
@close="showCreateExpenseForm = false" @created="handleExpenseCreated" />
<!-- OCR Dialog -->
<VModal v-model="showOcrDialogState" title="Add Items via OCR" @update:modelValue="!$event && closeOcrDialog()">
<VModal v-model="showOcrDialogState" :title="$t('listDetailPage.modals.ocr.title')" @update:modelValue="!$event && closeOcrDialog()">
<template #default>
<div v-if="ocrLoading" class="text-center">
<VSpinner label="Processing image..." />
<VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
</div>
<VList v-else-if="ocrItems.length > 0">
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
@ -163,22 +163,22 @@
</div>
</VListItem>
</VList>
<VFormField v-else label="Upload Image" :error-message="ocrError || undefined">
<VFormField v-else :label="$t('listDetailPage.modals.ocr.uploadLabel')" :error-message="ocrError || undefined">
<VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
:model-value="''" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="closeOcrDialog">Cancel</VButton>
<VButton variant="neutral" @click="closeOcrDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="addOcrItems"
:disabled="addingOcrItems">
<VSpinner v-if="addingOcrItems" size="sm" /> Add Items
<VSpinner v-if="addingOcrItems" size="sm" :label="$t('listDetailPage.loading.addingOcrItems')" /> {{ $t('listDetailPage.buttons.addItems') }}
</VButton>
</template>
</VModal>
<!-- Confirmation Dialog -->
<VModal v-model="showConfirmDialogState" title="Confirmation" @update:modelValue="!$event && cancelConfirmation()"
<VModal v-model="showConfirmDialogState" :title="$t('listDetailPage.modals.confirmation.title')" @update:modelValue="!$event && cancelConfirmation()"
size="sm">
<template #default>
<div class="text-center">
@ -187,34 +187,34 @@
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="cancelConfirmation">Cancel</VButton>
<VButton variant="primary" @click="handleConfirmedAction">Confirm</VButton>
<VButton variant="neutral" @click="cancelConfirmation">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmedAction">{{ $t('listDetailPage.buttons.confirm') }}</VButton>
</template>
</VModal>
<!-- Cost Summary Dialog -->
<VModal v-model="showCostSummaryDialog" title="List Cost Summary" @update:modelValue="showCostSummaryDialog = false"
<VModal v-model="showCostSummaryDialog" :title="$t('listDetailPage.modals.costSummary.title')" @update:modelValue="showCostSummaryDialog = false"
size="lg">
<template #default>
<div v-if="costSummaryLoading" class="text-center">
<VSpinner label="Loading summary..." />
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div>
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{ listCostSummary.num_participating_users }}</p>
</div>
<h4>User Balances</h4>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>User</th>
<th class="text-right">Items Added Value</th>
<th class="text-right">Amount Due</th>
<th class="text-right">Balance</th>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
</tr>
</thead>
<tbody>
@ -231,60 +231,60 @@
</table>
</div>
</div>
<p v-else>No cost summary available.</p>
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">Close</VButton>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}</VButton>
</template>
</VModal>
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" title="Settle Share" @update:modelValue="!$event && closeSettleShareModal()"
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')" @update:modelValue="!$event && closeSettleShareModal()"
size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
<VSpinner label="Processing settlement..." />
<VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>Settle amount for {{ selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email ||
`User ID: ${selectedSplitForSettlement?.user_id}` }}:</p>
<VFormField label="Amount" :error-message="settleAmountError || undefined">
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` }) }}</p>
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')" :error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">Cancel</VButton>
<VButton variant="primary" @click="handleConfirmSettle">Confirm</VButton>
<VButton variant="neutral" @click="closeSettleShareModal">{{ $t('listDetailPage.settleShareModal.cancelButton') }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') }}</VButton>
</template>
</VModal>
<!-- Edit Item Dialog -->
<VModal v-model="showEditDialog" title="Edit Item" @update:modelValue="!$event && closeEditDialog()">
<VModal v-model="showEditDialog" :title="$t('listDetailPage.modals.editItem.title')" @update:modelValue="!$event && closeEditDialog()">
<template #default>
<VFormField v-if="editingItem" label="Item Name" class="mb-4">
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.nameLabel')" class="mb-4">
<VInput type="text" id="editItemName" v-model="editingItem.name" required />
</VFormField>
<VFormField v-if="editingItem" label="Quantity">
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.quantityLabel')">
<VInput type="number" id="editItemQuantity" :model-value="editingItem.quantity || ''"
@update:modelValue="editingItem.quantity = $event" min="1" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="closeEditDialog">Cancel</VButton>
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">Save Changes
<VButton variant="neutral" @click="closeEditDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">{{ $t('listDetailPage.buttons.saveChanges') }}
</VButton>
</template>
</VModal>
<VAlert v-if="!list && !pageInitialLoad" type="info" message="Group not found or an error occurred." />
<VAlert v-if="!list && !pageInitialLoad" type="info" :message="$t('listDetailPage.errors.genericLoadFailure')" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed
import { useNotificationStore } from '@/stores/notifications';
@ -313,6 +313,7 @@ import VListItem from '@/components/valerie/VListItem.vue';
import VCheckbox from '@/components/valerie/VCheckbox.vue';
// VTextarea and VSelect are not used in this part of the refactor for ListDetailPage
const { t } = useI18n();
// UI-specific properties that we add to items
interface ItemWithUI extends Item {
@ -433,31 +434,23 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
};
const fetchListDetails = async () => {
// If pageInitialLoad is still true here, it means no shell was loaded.
// The main spinner might be showing. We're about to fetch details, so turn off main spinner.
if (pageInitialLoad.value) {
pageInitialLoad.value = false;
}
itemsAreLoading.value = true;
// Check for pre-fetched full data first
const routeId = String(route.params.id);
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
try {
let response;
if (cachedFullData) {
// Use cached data
response = { data: JSON.parse(cachedFullData) };
// Clear the cache after using it
sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else {
// Fetch fresh data
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
}
const rawList = response.data as ListWithExpenses;
// Map API response to local List type
const localList: List = {
id: rawList.id,
name: rawList.name,
@ -477,17 +470,15 @@ const fetchListDetails = async () => {
await fetchListCostSummary();
}
} catch (err: unknown) {
const errorMessage = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
if (!list.value) { // If there was no shell AND this fetch failed
error.value = errorMessage; // This error is for the whole page
const apiErrorMessage = err instanceof Error ? err.message : String(err);
const fallbackErrorMessage = t('listDetailPage.errors.fetchFailed');
if (!list.value) {
error.value = apiErrorMessage || fallbackErrorMessage;
} else {
// We have a shell, but items failed to load.
// Show a notification for item loading failure. list.items will remain as per shell (empty).
notificationStore.addNotification({ message: `Failed to load items: ${errorMessage}`, type: 'error' });
notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage: apiErrorMessage }), type: 'error' });
}
} finally {
itemsAreLoading.value = false;
// If list is still null and no error was set (e.g. silent failure), ensure pageInitialLoad is false.
if (!list.value && !error.value) {
pageInitialLoad.value = false;
}
@ -532,7 +523,7 @@ const isItemPendingSync = (item: Item) => {
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' });
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
@ -541,7 +532,7 @@ const onAddItem = async () => {
addingItem.value = true;
if (!isOnline.value) {
const offlinePayload: any = {
const offlinePayload: any = { // Define explicit type later if needed
name: newItem.value.name
};
if (typeof newItem.value.quantity !== 'undefined') {
@ -555,12 +546,12 @@ const onAddItem = async () => {
}
});
const optimisticItem: ItemWithUI = {
id: Date.now(),
id: Date.now(), // Temporary ID for offline
name: newItem.value.name,
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
is_complete: false,
price: null,
version: 1,
version: 1, // Assuming initial version
updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
list_id: list.value.id,
@ -575,6 +566,7 @@ const onAddItem = async () => {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
addingItem.value = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); // Optimistic UI
return;
}
@ -592,8 +584,9 @@ const onAddItem = async () => {
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add item.', type: 'error' });
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addItemFailed'), type: 'error' });
} finally {
addingItem.value = false;
}
@ -618,6 +611,7 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return;
}
@ -627,9 +621,10 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.is_complete = originalCompleteStatus;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
item.is_complete = originalCompleteStatus; // Revert optimistic update
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'), type: 'error' });
} finally {
item.updating = false;
}
@ -638,11 +633,12 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
const updateItemPrice = async (item: ItemWithUI) => {
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice?.toString()) return;
if (item.price === newPrice?.toString()) return; // No change
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null;
item.price = newPrice?.toString() || null; // Optimistic update
if (!isOnline.value) {
offlineStore.addAction({
type: 'update_list_item',
@ -650,13 +646,14 @@ const updateItemPrice = async (item: ItemWithUI) => {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice ?? null,
completed: item.is_complete
price: newPrice ?? null, // Ensure null is sent if cleared
completed: item.is_complete // Keep completion status
},
version: item.version
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return;
}
@ -666,10 +663,11 @@ const updateItemPrice = async (item: ItemWithUI) => {
{ price: newPrice?.toString(), completed: item.is_complete, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.price = originalPrice;
item.price = originalPrice; // Revert optimistic update
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemPriceFailed'), type: 'error' });
} finally {
item.updating = false;
}
@ -678,6 +676,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return;
item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert
if (!isOnline.value) {
offlineStore.addAction({
@ -687,29 +686,35 @@ const deleteItem = async (item: ItemWithUI) => {
itemId: String(item.id)
}
});
list.value.items = list.value.items.filter(i => i.id !== item.id);
list.value.items = list.value.items.filter(i => i.id !== item.id); // Optimistic UI
item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
list.value.items = originalItems; // Revert optimistic UI
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.deleteItemFailed'), type: 'error' });
} finally {
item.deleting = false;
}
};
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => {
confirmDialogMessage.value = `Mark "${item.name}" as ${newCompleteStatus ? 'complete' : 'incomplete'}?`;
confirmDialogMessage.value = t('listDetailPage.confirmations.updateMessage', {
itemName: item.name,
status: newCompleteStatus ? t('listDetailPage.confirmations.statusComplete') : t('listDetailPage.confirmations.statusIncomplete')
});
pendingAction.value = () => updateItem(item, newCompleteStatus);
showConfirmDialogState.value = true;
};
const confirmDeleteItem = (item: ItemWithUI) => {
confirmDialogMessage.value = `Delete "${item.name}"? This cannot be undone.`;
confirmDialogMessage.value = t('listDetailPage.confirmations.deleteMessage', { itemName: item.name });
pendingAction.value = () => deleteItem(item);
showConfirmDialogState.value = true;
};
@ -723,20 +728,19 @@ const handleConfirmedAction = async () => {
const cancelConfirmation = () => {
showConfirmDialogState.value = false;
pendingAction.value = null;
confirmDialogMessage.value = ''; // Clear message
};
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog();
resetOcrFileDialog(); // From useFileDialog
showOcrDialogState.value = true;
nextTick(() => {
// For VInput type file, direct .value = '' might not work or be needed.
// VInput should handle its own reset if necessary, or this ref might target the native input inside.
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) { // Assuming VInput exposes $el
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Fallback if ref is native input
} else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = '';
}
});
@ -774,17 +778,18 @@ const handleOcrUpload = async (file: File) => {
});
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image.";
ocrError.value = t('listDetailPage.errors.ocrNoItems');
}
} catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
ocrError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.ocrFailed');
} finally {
ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) {
(ocrFileInputRef.value as any).value = '';
} else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = '';
}
}
};
@ -798,16 +803,18 @@ const addOcrItems = async () => {
if (!item.name.trim()) continue;
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Assuming default quantity 1 for OCR items
{ name: item.name, quantity: "1" } // Default quantity 1
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
successCount++;
}
if (successCount > 0) notificationStore.addNotification({ message: `${successCount} item(s) added successfully from OCR.`, type: 'success' });
if (successCount > 0) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemsAddedSuccessOcr', { count: successCount }), type: 'success' });
}
closeOcrDialog();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add OCR items.', type: 'error' });
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addOcrItemsFailed'), type: 'error' });
} finally {
addingOcrItems.value = false;
}
@ -821,7 +828,7 @@ const fetchListCostSummary = async () => {
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
listCostSummary.value = response.data;
} catch (err) {
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load cost summary.';
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.loadCostSummaryFailed');
listCostSummary.value = null;
} finally {
costSummaryLoading.value = false;
@ -844,19 +851,19 @@ const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) {
case ExpenseSplitStatusEnum.PAID: return 'Paid';
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return 'Partially Paid';
case ExpenseSplitStatusEnum.UNPAID: return 'Unpaid';
default: return status;
case ExpenseSplitStatusEnum.PAID: return t('listDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return t('listDetailPage.status.unpaid');
default: return t('listDetailPage.status.unknown');
}
};
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) {
case ExpenseOverallStatusEnum.PAID: return 'Settled';
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return 'Partially Settled';
case ExpenseOverallStatusEnum.UNPAID: return 'Unsettled';
default: return status;
case ExpenseOverallStatusEnum.PAID: return t('listDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return t('listDetailPage.status.unsettled');
default: return t('listDetailPage.status.unknown');
}
};
@ -872,76 +879,74 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return;
return; // Don't interfere with typing
}
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
// Check if any modal is open, if so, don't trigger
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value || showEditDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
return;
}
event.preventDefault();
if (itemNameInputRef.value?.$el) {
if (itemNameInputRef.value?.$el) { // Focus the add item input
(itemNameInputRef.value.$el as HTMLElement).focus();
}
}
});
let touchStartX = 0;
const SWIPE_THRESHOLD = 50;
const SWIPE_THRESHOLD = 50; // Pixels
const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX;
};
const handleTouchMove = () => {
const handleTouchMove = (event: TouchEvent, item: ItemWithUI) => {
// This function might be used for swipe-to-reveal actions in the future
// For now, it's a placeholder or can be removed if not used.
};
const handleTouchEnd = () => {
const handleTouchEnd = (event: TouchEvent, item: ItemWithUI) => {
// This function might be used for swipe-to-reveal actions in the future
// For now, it's a placeholder or can be removed if not used.
};
onMounted(() => {
pageInitialLoad.value = true;
itemsAreLoading.value = false;
error.value = null; // Clear stale errors on mount
error.value = null;
if (!route.params.id) {
error.value = 'No list ID provided';
pageInitialLoad.value = false; // Stop initial load phase, show error
listDetailStore.setError('No list ID provided for expenses.'); // Set error in expense store
error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID
pageInitialLoad.value = false;
listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return;
}
// Attempt to load shell data from sessionStorage
const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id);
if (listShellJSON) {
const shellData = JSON.parse(listShellJSON);
// Ensure the shell data is for the current list
if (shellData.id === parseInt(routeId, 10)) {
list.value = {
id: shellData.id,
name: shellData.name,
description: shellData.description,
is_complete: false, // Assume not complete until full data loaded
items: [], // Start with no items, they will be fetched by fetchListDetails
version: 0, // Placeholder, will be updated
updated_at: new Date().toISOString(), // Placeholder
is_complete: false,
items: [],
version: 0,
updated_at: new Date().toISOString(),
group_id: shellData.group_id,
};
pageInitialLoad.value = false; // Shell loaded, main page spinner can go
// Optionally, clear the sessionStorage item after use
// sessionStorage.removeItem('listDetailShell');
pageInitialLoad.value = false;
} else {
// Shell data is for a different list, clear it
sessionStorage.removeItem('listDetailShell');
// pageInitialLoad remains true, will be set to false by fetchListDetails
}
}
fetchListDetails().then(() => { // Fetches items
fetchListDetails().then(() => {
startPolling();
});
// Fetch expenses using the store when component is mounted
const routeParamsId = route.params.id;
listDetailStore.fetchListWithExpenses(String(routeParamsId));
});
@ -951,7 +956,7 @@ onUnmounted(() => {
});
const editItem = (item: Item) => {
editingItem.value = { ...item };
editingItem.value = { ...item }; // Clone item for editing
showEditDialog.value = true;
};
@ -963,25 +968,22 @@ const closeEditDialog = () => {
const handleConfirmEdit = async () => {
if (!editingItem.value || !list.value) return;
const item = editingItem.value;
const originalItem = list.value.items.find(i => i.id === item.id);
if (!originalItem) return;
const itemToUpdate = editingItem.value; // Already a clone
try {
const response = await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(itemToUpdate.id)),
{
name: item.name,
quantity: item.quantity?.toString(),
version: item.version
name: itemToUpdate.name,
quantity: itemToUpdate.quantity?.toString(), // Ensure quantity is string or null
version: itemToUpdate.version
}
);
// Update the item in the list
const updatedItem = response.data as Item;
const index = list.value.items.findIndex(i => i.id === item.id);
const updatedItemFromApi = response.data as Item;
const index = list.value.items.findIndex(i => i.id === updatedItemFromApi.id);
if (index !== -1) {
list.value.items[index] = processListItems([updatedItem])[0];
list.value.items[index] = processListItems([updatedItemFromApi])[0];
}
notificationStore.addNotification({
@ -991,7 +993,7 @@ const handleConfirmEdit = async () => {
closeEditDialog();
} catch (err) {
notificationStore.addNotification({
message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item',
message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'),
type: 'error'
});
}
@ -999,7 +1001,7 @@ const handleConfirmEdit = async () => {
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) {
notificationStore.addNotification({ message: "You can only settle your own shares.", type: 'warning' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.cannotSettleOthersShares'), type: 'warning' });
return;
}
selectedSplitForSettlement.value = split;
@ -1023,24 +1025,24 @@ const closeSettleShareModal = () => {
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = 'Please enter an amount.';
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = 'Please enter a positive amount.';
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
return false;
}
if (selectedSplitForSettlement.value) {
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { // Epsilon for float issues
settleAmountError.value = `Amount cannot exceed remaining: ${formatCurrency(remaining.toFixed(2))}.`;
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = 'Error: No split selected.'; // Should not happen
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
return false;
}
return true;
@ -1050,13 +1052,13 @@ const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id |
const handleConfirmSettle = async () => {
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) {
notificationStore.addNotification({ message: 'Cannot process settlement: missing data.', type: 'error' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.settlementDataMissing'), type: 'error' });
return;
}
// Use settleAmount.value which is the confirmed amount (remaining amount for MVP)
const activityData: SettlementActivityCreate = {
expense_split_id: selectedSplitForSettlement.value.id,
paid_by_user_id: Number(authStore.user.id), // Convert to number
paid_by_user_id: Number(authStore.user.id),
amount_paid: new Decimal(settleAmount.value).toString(),
paid_at: new Date().toISOString(),
};
@ -1068,15 +1070,14 @@ const handleConfirmSettle = async () => {
});
if (success) {
notificationStore.addNotification({ message: 'Share settled successfully!', type: 'success' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' });
closeSettleShareModal();
} else {
notificationStore.addNotification({ message: listDetailStore.error || 'Failed to settle share.', type: 'error' });
notificationStore.addNotification({ message: listDetailStore.error || t('listDetailPage.notifications.settleShareFailed'), type: 'error' });
}
};
const handleExpenseCreated = (expense: any) => {
// Refresh the expenses list
if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(list.value.id));
}

View File

@ -1,12 +1,12 @@
<template>
<main class="container page-padding">
<div class="page-header">
<h1 class="mb-3">My Assigned Chores</h1>
<h1 class="mb-3">{{ $t('myChoresPage.title') }}</h1>
<div class="header-controls">
<label class="toggle-switch">
<input type="checkbox" v-model="showCompleted" @change="loadAssignments">
<span class="toggle-slider"></span>
Show Completed
{{ $t('myChoresPage.showCompletedToggle') }}
</label>
</div>
</div>
@ -17,7 +17,7 @@
<div v-if="assignmentsByTimeline.overdue.length > 0" class="timeline-section overdue">
<div class="timeline-header">
<div class="timeline-dot overdue"></div>
<h2 class="timeline-title">Overdue</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.overdue') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.overdue.length }}</span>
</div>
<div class="timeline-items">
@ -29,7 +29,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -39,7 +39,7 @@
<div class="assignment-meta">
<div class="assignment-due-date overdue">
<span class="material-icons">schedule</span>
Due {{ formatDate(assignment.due_date) }}
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -49,7 +49,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -61,7 +61,7 @@
<div v-if="assignmentsByTimeline.today.length > 0" class="timeline-section today">
<div class="timeline-header">
<div class="timeline-dot today"></div>
<h2 class="timeline-title">Due Today</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.today') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.today.length }}</span>
</div>
<div class="timeline-items">
@ -73,7 +73,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -83,7 +83,7 @@
<div class="assignment-meta">
<div class="assignment-due-date today">
<span class="material-icons">today</span>
Due Today
{{ $t('myChoresPage.choreCard.dueToday') }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -93,7 +93,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -105,7 +105,7 @@
<div v-if="assignmentsByTimeline.thisWeek.length > 0" class="timeline-section this-week">
<div class="timeline-header">
<div class="timeline-dot this-week"></div>
<h2 class="timeline-title">This Week</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.thisWeek') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.thisWeek.length }}</span>
</div>
<div class="timeline-items">
@ -117,7 +117,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -127,7 +127,7 @@
<div class="assignment-meta">
<div class="assignment-due-date this-week">
<span class="material-icons">date_range</span>
Due {{ formatDate(assignment.due_date) }}
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -137,7 +137,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -149,7 +149,7 @@
<div v-if="assignmentsByTimeline.later.length > 0" class="timeline-section later">
<div class="timeline-header">
<div class="timeline-dot later"></div>
<h2 class="timeline-title">Later</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.later') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.later.length }}</span>
</div>
<div class="timeline-items">
@ -161,7 +161,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -171,7 +171,7 @@
<div class="assignment-meta">
<div class="assignment-due-date later">
<span class="material-icons">schedule</span>
Due {{ formatDate(assignment.due_date) }}
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -181,7 +181,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -193,7 +193,7 @@
<div v-if="showCompleted && assignmentsByTimeline.completed.length > 0" class="timeline-section completed">
<div class="timeline-header">
<div class="timeline-dot completed"></div>
<h2 class="timeline-title">Completed</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.completed') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.completed.length }}</span>
</div>
<div class="timeline-items">
@ -205,7 +205,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -215,7 +215,7 @@
<div class="assignment-meta">
<div class="assignment-due-date completed">
<span class="material-icons">check_circle</span>
Completed {{ formatDate(assignment.completed_at || assignment.updated_at) }}
{{ $t('myChoresPage.choreCard.completedPrefix') }} {{ formatDate(assignment.completed_at || assignment.updated_at) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -231,14 +231,14 @@
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Assignments Yet!</h3>
<p v-if="showCompleted">You have no chore assignments (completed or pending).</p>
<p v-else>You have no pending chore assignments.</p>
<h3>{{ $t('myChoresPage.emptyState.title') }}</h3>
<p v-if="showCompleted">{{ $t('myChoresPage.emptyState.noAssignmentsAll') }}</p>
<p v-else>{{ $t('myChoresPage.emptyState.noAssignmentsPending') }}</p>
<router-link to="/chores" class="btn btn-primary mt-2">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-eye" />
</svg>
View All Chores
{{ $t('myChoresPage.emptyState.viewAllChoresButton') }}
</router-link>
</div>
</main>
@ -246,11 +246,13 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { ChoreAssignment, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
@ -310,13 +312,14 @@ const assignmentsByTimeline = computed(() => {
return timeline
})
const frequencyOptions = [
{ label: 'One Time', value: 'one_time' as ChoreFrequency },
{ label: 'Daily', value: 'daily' as ChoreFrequency },
{ label: 'Weekly', value: 'weekly' as ChoreFrequency },
{ label: 'Monthly', value: 'monthly' as ChoreFrequency },
{ label: 'Custom', value: 'custom' as ChoreFrequency }
]
// frequencyOptions is not directly used for display labels anymore, but can be kept for logic if needed elsewhere.
// const frequencyOptions = [
// { label: 'One Time', value: 'one_time' as ChoreFrequency },
// { label: 'Daily', value: 'daily' as ChoreFrequency },
// { label: 'Weekly', value: 'weekly' as ChoreFrequency },
// { label: 'Monthly', value: 'monthly' as ChoreFrequency },
// { label: 'Custom', value: 'custom' as ChoreFrequency }
// ]
// Methods
const loadAssignments = async () => {
@ -325,7 +328,7 @@ const loadAssignments = async () => {
} catch (error) {
console.error('Failed to load assignments:', error)
notificationStore.addNotification({
message: 'Failed to load assignments',
message: t('myChoresPage.notifications.loadFailed'),
type: 'error'
})
}
@ -338,7 +341,7 @@ const completeAssignment = async (assignment: ChoreAssignment) => {
try {
await choreService.completeAssignment(assignment.id)
notificationStore.addNotification({
message: `Marked "${assignment.chore?.name}" as complete!`,
message: t('myChoresPage.notifications.markedComplete', { choreName: assignment.chore?.name || '' }),
type: 'success'
})
// Reload assignments to show updated state
@ -346,7 +349,7 @@ const completeAssignment = async (assignment: ChoreAssignment) => {
} catch (error) {
console.error('Failed to complete assignment:', error)
notificationStore.addNotification({
message: 'Failed to mark assignment as complete',
message: t('myChoresPage.notifications.markCompleteFailed'),
type: 'error'
})
} finally {
@ -355,23 +358,34 @@ const completeAssignment = async (assignment: ChoreAssignment) => {
}
const formatDate = (date: string | undefined) => {
if (!date) return 'Unknown'
if (!date) return t('myChoresPage.dates.unknownDate');
if (date.includes('T')) {
return format(new Date(date), 'MMM d, yyyy')
} else {
const parts = date.split('-')
if (parts.length === 3) {
return format(new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])), 'MMM d, yyyy')
// Attempt to parse and format; date-fns handles various ISO and other formats.
try {
const parsedDate = new Date(date);
// Check if parsedDate is valid
if (isNaN(parsedDate.getTime())) {
// Handle cases like "YYYY-MM-DD" which might be parsed as UTC midnight
// and then potentially displayed incorrectly depending on timezone.
// If the input is just a date string without time, ensure it's treated as local.
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('myChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
// Catch any error during parsing (though Date constructor is quite forgiving)
return t('myChoresPage.dates.invalidDate');
}
return 'Invalid Date'
}
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return 'Unknown'
const option = frequencyOptions.find(opt => opt.value === frequency)
return option ? option.label : frequency
if (!frequency) return t('myChoresPage.frequencies.unknown');
// Assuming keys like myChoresPage.frequencies.one_time, myChoresPage.frequencies.daily
// The ChoreFrequency enum values ('one_time', 'daily', etc.) match the last part of the key.
return t(`myChoresPage.frequencies.${frequency}`);
}
// Lifecycle

View File

@ -1,10 +1,10 @@
<template>
<main class="container page-padding">
<div class="row q-mb-md items-center justify-between">
<h1 class="mb-3">Personal Chores</h1>
<h1 class="mb-3">{{ $t('personalChoresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
<span class="material-icons">add</span>
New Chore
{{ $t('personalChoresPage.newChoreButton') }}
</button>
</div>
@ -22,7 +22,7 @@
<div class="neo-card-body">
<div class="neo-chore-info">
<div class="neo-chore-due">
Due: {{ formatDate(chore.next_due_date) }}
{{ $t('personalChoresPage.dates.duePrefix') }}: {{ formatDate(chore.next_due_date) }}
</div>
<div v-if="chore.description" class="neo-chore-description">
{{ chore.description }}
@ -31,11 +31,11 @@
<div class="neo-card-actions">
<button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)">
<span class="material-icons">edit</span>
Edit
{{ $t('personalChoresPage.editButton') }}
</button>
<button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)">
<span class="material-icons">delete</span>
Delete
{{ $t('personalChoresPage.deleteButton') }}
</button>
</div>
</div>
@ -46,7 +46,7 @@
<div v-if="showChoreModal" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>{{ isEditing ? 'Edit Chore' : 'New Chore' }}</h3>
<h3>{{ isEditing ? $t('personalChoresPage.modals.editChoreTitle') : $t('personalChoresPage.modals.newChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showChoreModal = false">
<span class="material-icons">close</span>
</button>
@ -54,7 +54,7 @@
<div class="neo-modal-body">
<form @submit.prevent="onSubmit" class="neo-form">
<div class="neo-form-group">
<label for="name">Name</label>
<label for="name">{{ $t('personalChoresPage.form.nameLabel') }}</label>
<input
id="name"
v-model="choreForm.name"
@ -65,7 +65,7 @@
</div>
<div class="neo-form-group">
<label for="description">Description</label>
<label for="description">{{ $t('personalChoresPage.form.descriptionLabel') }}</label>
<textarea
id="description"
v-model="choreForm.description"
@ -75,7 +75,7 @@
</div>
<div class="neo-form-group">
<label for="frequency">Frequency</label>
<label for="frequency">{{ $t('personalChoresPage.form.frequencyLabel') }}</label>
<select
id="frequency"
v-model="choreForm.frequency"
@ -89,7 +89,7 @@
</div>
<div v-if="choreForm.frequency === 'custom'" class="neo-form-group">
<label for="interval">Interval (days)</label>
<label for="interval">{{ $t('personalChoresPage.form.intervalLabel') }}</label>
<input
id="interval"
v-model.number="choreForm.custom_interval_days"
@ -101,7 +101,7 @@
</div>
<div class="neo-form-group">
<label for="dueDate">Next Due Date</label>
<label for="dueDate">{{ $t('personalChoresPage.form.dueDateLabel') }}</label>
<input
id="dueDate"
v-model="choreForm.next_due_date"
@ -113,8 +113,8 @@
</form>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showChoreModal = false">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Save</button>
<button class="btn btn-neutral" @click="showChoreModal = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-primary" @click="onSubmit">{{ $t('personalChoresPage.saveButton') }}</button>
</div>
</div>
</div>
@ -123,17 +123,17 @@
<div v-if="showDeleteDialog" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>Delete Chore</h3>
<h3>{{ $t('personalChoresPage.modals.deleteChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showDeleteDialog = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="neo-modal-body">
<p>Are you sure you want to delete this chore?</p>
<p>{{ $t('personalChoresPage.deleteDialog.confirmationText') }}</p>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">Cancel</button>
<button class="btn btn-danger" @click="deleteChore">Delete</button>
<button class="btn btn-neutral" @click="showDeleteDialog = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">{{ $t('personalChoresPage.deleteButton') }}</button>
</div>
</div>
</div>
@ -141,12 +141,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
@ -165,13 +167,13 @@ const choreForm = ref<ChoreCreate>({
type: 'personal'
})
const frequencyOptions = [
{ label: 'One Time', value: 'one_time' as ChoreFrequency },
{ label: 'Daily', value: 'daily' as ChoreFrequency },
{ label: 'Weekly', value: 'weekly' as ChoreFrequency },
{ label: 'Monthly', value: 'monthly' as ChoreFrequency },
{ label: 'Custom', value: 'custom' as ChoreFrequency }
]
const frequencyOptions = computed(() => [
{ label: t('personalChoresPage.frequencies.one_time'), value: 'one_time' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.daily'), value: 'daily' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.custom'), value: 'custom' as ChoreFrequency }
])
// Methods
const loadChores = async () => {
@ -180,7 +182,7 @@ const loadChores = async () => {
} catch (error) {
console.error('Failed to load personal chores:', error)
notificationStore.addNotification({
message: 'Failed to load personal chores',
message: t('personalChoresPage.notifications.loadFailed'),
type: 'error'
})
}
@ -216,13 +218,13 @@ const onSubmit = async () => {
if (isEditing.value && selectedChore.value) {
await choreService.updatePersonalChore(selectedChore.value.id, payload as ChoreUpdate)
notificationStore.addNotification({
message: 'Personal chore updated successfully',
message: t('personalChoresPage.notifications.updateSuccess'),
type: 'success'
})
} else {
await choreService.createPersonalChore(payload as ChoreCreate)
notificationStore.addNotification({
message: 'Personal chore created successfully',
message: t('personalChoresPage.notifications.createSuccess'),
type: 'success'
})
}
@ -231,7 +233,7 @@ const onSubmit = async () => {
} catch (error) {
console.error('Failed to save personal chore:', error)
notificationStore.addNotification({
message: `Failed to ${isEditing.value ? 'update' : 'create'} personal chore`,
message: t('personalChoresPage.notifications.saveFailed'), // Generic message
type: 'error'
})
}
@ -249,34 +251,43 @@ const deleteChore = async () => {
await choreService.deletePersonalChore(selectedChore.value.id)
showDeleteDialog.value = false
notificationStore.addNotification({
message: 'Personal chore deleted successfully',
message: t('personalChoresPage.notifications.deleteSuccess'),
type: 'success'
})
loadChores()
} catch (error) {
console.error('Failed to delete personal chore:', error)
notificationStore.addNotification({
message: 'Failed to delete personal chore',
message: t('personalChoresPage.notifications.deleteFailed'),
type: 'error'
})
}
}
const formatDate = (date: string) => {
if (date && date.includes('T')) {
return format(new Date(date), 'MMM d, yyyy');
} else if (date) {
const parts = date.split('-');
if (parts.length === 3) {
return format(new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])), 'MMM d, yyyy');
const formatDate = (date: string | undefined) => {
if (!date) return ''; // Or perhaps a specific 'Unknown Date' string if desired: t('personalChoresPage.dates.unknownDate')
try {
// Handles both 'YYYY-MM-DD' and full ISO with 'T'
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
// Explicitly handle 'YYYY-MM-DD' if new Date() struggles with it directly as local time
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('personalChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
return t('personalChoresPage.dates.invalidDate');
}
}
return 'Invalid Date';
}
const formatFrequency = (frequency: ChoreFrequency) => {
const option = frequencyOptions.find(opt => opt.value === frequency)
return option ? option.label : frequency
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return t('personalChoresPage.frequencies.unknown');
// Use the value from frequencyOptions which is now translated
const option = frequencyOptions.value.find(opt => opt.value === frequency);
return option ? option.label : t(`personalChoresPage.frequencies.${frequency}`); // Fallback if somehow not in options
}
// Lifecycle

View File

@ -2,29 +2,29 @@
<main class="flex items-center justify-center page-container">
<div class="card signup-card">
<div class="card-header">
<h3>Sign Up</h3>
<h3>{{ $t('signupPage.header') }}</h3>
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="name" class="form-label">Full Name</label>
<label for="name" class="form-label">{{ $t('signupPage.fullNameLabel') }}</label>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div>
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<label for="email" class="form-label">{{ $t('signupPage.emailLabel') }}</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<div class="form-group mb-2">
<label for="password" class="form-label">Password</label>
<label for="password" class="form-label">{{ $t('signupPage.passwordLabel') }}</label>
<div class="input-with-icon-append">
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="new-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
aria-label="Toggle password visibility">
:aria-label="$t('signupPage.togglePasswordVisibility')">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
@ -34,7 +34,7 @@
</div>
<div class="form-group mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<label for="confirmPassword" class="form-label">{{ $t('signupPage.confirmPasswordLabel') }}</label>
<input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
class="form-input" required autocomplete="new-password" />
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
@ -44,11 +44,11 @@
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Sign Up
{{ $t('signupPage.submitButton') }}
</button>
<div class="text-center mt-2">
<router-link to="auth/login" class="link-styled">Already have an account? Login</router-link>
<router-link to="auth/login" class="link-styled">{{ $t('signupPage.loginLink') }}</router-link>
</div>
</form>
</div>
@ -59,9 +59,11 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
import { useNotificationStore } from '@/stores/notifications';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
@ -82,22 +84,22 @@ const isValidEmail = (val: string): boolean => {
const validateForm = (): boolean => {
formErrors.value = {};
if (!name.value.trim()) {
formErrors.value.name = 'Name is required';
formErrors.value.name = t('signupPage.validation.nameRequired');
}
if (!email.value.trim()) {
formErrors.value.email = 'Email is required';
formErrors.value.email = t('signupPage.validation.emailRequired');
} else if (!isValidEmail(email.value)) {
formErrors.value.email = 'Invalid email format';
formErrors.value.email = t('signupPage.validation.emailInvalid');
}
if (!password.value) {
formErrors.value.password = 'Password is required';
formErrors.value.password = t('signupPage.validation.passwordRequired');
} else if (password.value.length < 8) {
formErrors.value.password = 'Password must be at least 8 characters';
formErrors.value.password = t('signupPage.validation.passwordLength');
}
if (!confirmPassword.value) {
formErrors.value.confirmPassword = 'Please confirm your password';
formErrors.value.confirmPassword = t('signupPage.validation.confirmPasswordRequired');
} else if (password.value !== confirmPassword.value) {
formErrors.value.confirmPassword = 'Passwords do not match';
formErrors.value.confirmPassword = t('signupPage.validation.passwordsNoMatch');
}
return Object.keys(formErrors.value).length === 0;
};
@ -114,13 +116,17 @@ const onSubmit = async () => {
email: email.value,
password: password.value,
});
notificationStore.addNotification({ message: 'Account created successfully. Please login.', type: 'success' });
notificationStore.addNotification({ message: t('signupPage.notifications.signupSuccess'), type: 'success' });
router.push('auth/login');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Signup failed. Please try again.';
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });
// Prefer API error message if available, otherwise use generic translated message for the form
const errorMessageForForm = error instanceof Error ? error.message : t('signupPage.notifications.signupFailed');
formErrors.value.general = errorMessageForForm;
// For the notification pop-up, always use the generic translated message if API message is not specific enough or not an Error
const notificationMessage = error instanceof Error && error.message ? error.message : t('signupPage.notifications.signupFailed');
console.error("Signup error:", error); // Keep detailed log for developers
notificationStore.addNotification({ message: notificationMessage, type: 'error' });
} finally {
loading.value = false;
}