From 7ffeae1476d214d9c7d643c654b77d2432cd58a6 Mon Sep 17 00:00:00 2001 From: mohamad Date: Mon, 9 Jun 2025 22:55:37 +0200 Subject: [PATCH 1/2] feat: Add new components for cost summary, expenses, and item management This commit introduces several new components to enhance the list detail functionality: - **CostSummaryDialog.vue**: A modal for displaying cost summaries, including total costs, user balances, and a detailed breakdown of expenses. - **ExpenseSection.vue**: A section for managing and displaying expenses, featuring loading states, error handling, and collapsible item details. - **ItemsList.vue**: A component for rendering and managing a list of items with drag-and-drop functionality, including a new item input field. - **ListItem.vue**: A detailed item component that supports editing, deleting, and displaying item statuses. - **OcrDialog.vue**: A modal for handling OCR file uploads and displaying extracted items. - **SettleShareModal.vue**: A modal for settling shares among users, allowing input of settlement amounts. - **Error handling utility**: A new utility function for extracting user-friendly error messages from API responses. These additions aim to improve user interaction and streamline the management of costs and expenses within the application. --- .../list-detail/CostSummaryDialog.vue | 142 ++ .../components/list-detail/ExpenseSection.vue | 384 +++++ fe/src/components/list-detail/ItemsList.vue | 253 ++++ fe/src/components/list-detail/ListItem.vue | 444 ++++++ fe/src/components/list-detail/OcrDialog.vue | 114 ++ .../list-detail/SettleShareModal.vue | 73 + fe/src/pages/ListDetailPage.vue | 1348 ++--------------- fe/src/utils/errors.ts | 31 + 8 files changed, 1528 insertions(+), 1261 deletions(-) create mode 100644 fe/src/components/list-detail/CostSummaryDialog.vue create mode 100644 fe/src/components/list-detail/ExpenseSection.vue create mode 100644 fe/src/components/list-detail/ItemsList.vue create mode 100644 fe/src/components/list-detail/ListItem.vue create mode 100644 fe/src/components/list-detail/OcrDialog.vue create mode 100644 fe/src/components/list-detail/SettleShareModal.vue create mode 100644 fe/src/utils/errors.ts diff --git a/fe/src/components/list-detail/CostSummaryDialog.vue b/fe/src/components/list-detail/CostSummaryDialog.vue new file mode 100644 index 0000000..1dfc313 --- /dev/null +++ b/fe/src/components/list-detail/CostSummaryDialog.vue @@ -0,0 +1,142 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/list-detail/ExpenseSection.vue b/fe/src/components/list-detail/ExpenseSection.vue new file mode 100644 index 0000000..5430ef2 --- /dev/null +++ b/fe/src/components/list-detail/ExpenseSection.vue @@ -0,0 +1,384 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/list-detail/ItemsList.vue b/fe/src/components/list-detail/ItemsList.vue new file mode 100644 index 0000000..872a4b5 --- /dev/null +++ b/fe/src/components/list-detail/ItemsList.vue @@ -0,0 +1,253 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/list-detail/ListItem.vue b/fe/src/components/list-detail/ListItem.vue new file mode 100644 index 0000000..4e2f2a5 --- /dev/null +++ b/fe/src/components/list-detail/ListItem.vue @@ -0,0 +1,444 @@ + + + + + \ No newline at end of file diff --git a/fe/src/components/list-detail/OcrDialog.vue b/fe/src/components/list-detail/OcrDialog.vue new file mode 100644 index 0000000..6631c90 --- /dev/null +++ b/fe/src/components/list-detail/OcrDialog.vue @@ -0,0 +1,114 @@ + + + \ No newline at end of file diff --git a/fe/src/components/list-detail/SettleShareModal.vue b/fe/src/components/list-detail/SettleShareModal.vue new file mode 100644 index 0000000..312023b --- /dev/null +++ b/fe/src/components/list-detail/SettleShareModal.vue @@ -0,0 +1,73 @@ + + + \ No newline at end of file diff --git a/fe/src/pages/ListDetailPage.vue b/fe/src/pages/ListDetailPage.vue index 234cd49..1aa4772 100644 --- a/fe/src/pages/ListDetailPage.vue +++ b/fe/src/pages/ListDetailPage.vue @@ -18,7 +18,7 @@ -
+
@@ -58,233 +58,19 @@
-
-

{{ group.categoryName }}

- - - -
- - -
  • - -
  • + -
    - - - - -

    {{ listDetailStore.error }}

    - -
    - - -
    -
    -
    -
    -
    - - - - -
    -
    -
    - {{ expense.description }} -
    -
    - {{ formatCurrency(expense.total_amount) }} — - {{ $t('listDetailPage.expensesSection.paidBy') }} {{ expense.paid_by_user?.name || - expense.paid_by_user?.email }} -
    -
    -
    -
    - - {{ getOverallExpenseStatusText(expense.overall_settlement_status) }} - -
    - - - -
    -
    -
    - - -
    -
    -
    -
    - {{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }} -
    -
    - {{ $t('listDetailPage.expensesSection.owes') }} {{ - formatCurrency(split.owed_amount) }} -
    -
    - - {{ getSplitStatusText(split.status) }} - -
    -
    - -
    -
    - -
    -
      -
    • - {{ $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() }} -
    • -
    -
    -
    -
    -
    -
    -
    +
    @@ -292,116 +78,15 @@ @close="showCreateExpenseForm = false" @created="handleExpenseCreated" /> - - - - + - - - - + - - - - + @@ -419,68 +104,28 @@ import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline'; import { useListDetailStore } from '@/stores/listDetailStore'; import type { ListWithExpenses } from '@/types/list'; import type { Expense, ExpenseSplit } from '@/types/expense'; -import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense'; import { useAuthStore } from '@/stores/auth'; import { Decimal } from 'decimal.js'; import type { SettlementActivityCreate } from '@/types/expense'; -import SettleShareModal from '@/components/SettleShareModal.vue'; +import SettleShareModal from '@/components/list-detail/SettleShareModal.vue'; import CreateExpenseForm from '@/components/CreateExpenseForm.vue'; import type { Item } from '@/types/item'; import VHeading from '@/components/valerie/VHeading.vue'; import VSpinner from '@/components/valerie/VSpinner.vue'; import VAlert from '@/components/valerie/VAlert.vue'; import VButton from '@/components/valerie/VButton.vue'; -import VBadge from '@/components/valerie/VBadge.vue'; -import VIcon from '@/components/valerie/VIcon.vue'; -import VModal from '@/components/valerie/VModal.vue'; -import VFormField from '@/components/valerie/VFormField.vue'; -import VInput from '@/components/valerie/VInput.vue'; -import VList from '@/components/valerie/VList.vue'; -import VListItem from '@/components/valerie/VListItem.vue'; -import VCheckbox from '@/components/valerie/VCheckbox.vue'; import VProgressBar from '@/components/valerie/VProgressBar.vue'; import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue'; -import draggable from 'vuedraggable'; import { useCategoryStore } from '@/stores/categoryStore'; import { storeToRefs } from 'pinia'; +import ItemsList from '@/components/list-detail/ItemsList.vue'; +import ExpenseSection from '@/components/list-detail/ExpenseSection.vue'; +import OcrDialog from '@/components/list-detail/OcrDialog.vue'; +import CostSummaryDialog from '@/components/list-detail/CostSummaryDialog.vue'; +import { getApiErrorMessage } from '@/utils/errors'; const { t } = useI18n(); -// Helper to extract user-friendly error messages from API responses -const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => { - if (err && typeof err === 'object') { - // Check for FastAPI/DRF-style error response - if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) { - const errorData = err.response.data as any; // Type assertion for easier access - if (typeof errorData.detail === 'string') { - return errorData.detail; - } - if (typeof errorData.message === 'string') { // Common alternative - return errorData.message; - } - // FastAPI validation errors often come as an array of objects - if (Array.isArray(errorData.detail) && errorData.detail.length > 0) { - const firstError = errorData.detail[0]; - if (typeof firstError.msg === 'string' && typeof firstError.type === 'string') { - // Construct a message like "Field 'fieldname': error message" - // const field = firstError.loc && firstError.loc.length > 1 ? firstError.loc[1] : 'Input'; - // return `${field}: ${firstError.msg}`; - return firstError.msg; // Simpler: just the message - } - } - if (typeof errorData === 'string') { // Sometimes data itself is the error string - return errorData; - } - } - // Standard JavaScript Error object - if (err instanceof Error && err.message) { - return err.message; - } - } - // Fallback to a translated message - return t(fallbackMessageKey); -}; - // UI-specific properties that we add to items interface ItemWithUI extends Item { updating: boolean; @@ -492,6 +137,7 @@ interface ItemWithUI extends Item { editQuantity?: number | string | null; // Temporary quantity for inline editing editCategoryId?: number | null; // Temporary category for inline editing showFirework?: boolean; // For firework animation + group_id?: number; } interface ListStatus { @@ -515,23 +161,6 @@ interface Group { name: string; } -interface UserCostShare { - user_id: number; - user_identifier: string; - items_added_value: string | number; - amount_due: string | number; - balance: string | number; -} - -interface ListCostSummaryData { - list_id: number; - list_name: string; - total_list_cost: string | number; - num_participating_users: number; - equal_share_per_user: string | number; - user_balances: UserCostShare[]; -} - const route = useRoute(); const { isOnline } = useNetwork(); const notificationStore = useNotificationStore(); @@ -551,7 +180,7 @@ const categoryStore = useCategoryStore(); const { categories } = storeToRefs(categoryStore); const newItem = ref<{ name: string; quantity?: number | string; category_id?: number | null }>({ name: '', category_id: null }); -const itemNameInputRef = ref | null>(null); +const itemsListRef = ref | null>(null); const categoryOptions = computed(() => { type CategoryOption = { id: number; name: string }; @@ -563,22 +192,11 @@ const categoryOptions = computed(() => { // OCR const showOcrDialogState = ref(false); -// const ocrModalRef = ref(null); // Removed -const ocrLoading = ref(false); -const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR const addingOcrItems = ref(false); -const ocrError = ref(null); -const ocrFileInputRef = ref | null>(null); // Changed to VInput ref type -const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({ - accept: 'image/*', - multiple: false, -}); - // Cost Summary const showCostSummaryDialog = ref(false); -// const costSummaryModalRef = ref(null); // Removed -const listCostSummary = ref(null); +const listCostSummary = ref(null); const costSummaryLoading = ref(false); const costSummaryError = ref(null); @@ -591,9 +209,7 @@ const itemCompletionProgress = computed(() => { // Settle Share const authStore = useAuthStore(); const showSettleModal = ref(false); -// const settleModalRef = ref(null); // Removed const selectedSplitForSettlement = ref(null); -const parentExpenseOfSelectedSplit = ref(null); const settleAmount = ref(''); const settleAmountError = ref(null); const isSettlementLoading = computed(() => listDetailStore.isSettlingSplit); @@ -614,14 +230,6 @@ interface OfflineCreateItemPayload { category_id?: number | null; } -const formatCurrency = (value: string | number | undefined | null): string => { - if (value === undefined || value === null) return '$0.00'; - // Ensure that string "0.00" or "0" are handled correctly before parseFloat - if (typeof value === 'string' && !value.trim()) return '$0.00'; - const numValue = typeof value === 'string' ? parseFloat(value) : value; - return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`; -}; - const processListItems = (items: Item[]): ItemWithUI[] => { return items.map(item => ({ ...item, @@ -669,7 +277,7 @@ const fetchListDetails = async () => { await fetchListCostSummary(); } } catch (err: unknown) { - const errorMessage = getApiErrorMessage(err, 'listDetailPage.errors.fetchFailed'); + const errorMessage = getApiErrorMessage(err, 'listDetailPage.errors.fetchFailed', t); if (!list.value) { error.value = errorMessage; } else { @@ -708,32 +316,12 @@ const stopPolling = () => { if (pollingInterval.value) clearInterval(pollingInterval.value); }; -const isItemPendingSync = (item: Item) => { - return offlineStore.pendingActions.some(action => { - if (action.type === 'update_list_item' || action.type === 'delete_list_item') { - const payload = action.payload as { listId: string; itemId: string }; - return payload.itemId === String(item.id); - } - return false; - }); -}; - -const handleNewItemBlur = (event: Event) => { - const inputElement = event.target as HTMLInputElement; - if (inputElement.value.trim()) { - newItem.value.name = inputElement.value.trim(); - onAddItem(); - } -}; - const onAddItem = async () => { const itemName = newItem.value.name.trim(); if (!list.value || !itemName) { notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' }); - if (itemNameInputRef.value?.$el) { - (itemNameInputRef.value.$el as HTMLElement).focus(); - } + itemsListRef.value?.focusNewItemInput(); return; } addingItem.value = true; @@ -759,9 +347,7 @@ const onAddItem = async () => { newItem.value.name = ''; newItem.value.category_id = null; - if (itemNameInputRef.value?.$el) { - (itemNameInputRef.value.$el as HTMLElement).focus(); - } + itemsListRef.value?.focusNewItemInput(); if (!isOnline.value) { const offlinePayload: OfflineCreateItemPayload = { @@ -814,7 +400,7 @@ const onAddItem = async () => { } catch (err) { list.value.items = list.value.items.filter(i => i.id !== optimisticItem.id); notificationStore.addNotification({ - message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed'), + message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed', t), type: 'error' }); } finally { @@ -867,7 +453,7 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => { triggerFirework(); } catch (err) { item.is_complete = originalCompleteStatus; - notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed', t), type: 'error' }); } finally { item.updating = false; } @@ -910,7 +496,7 @@ const updateItemPrice = async (item: ItemWithUI) => { } catch (err) { item.price = originalPrice; item.priceInput = originalPriceInput; - notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed', t), type: 'error' }); } finally { item.updating = false; } @@ -941,88 +527,64 @@ const deleteItem = async (item: ItemWithUI) => { notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' }); } catch (err) { list.value.items = originalItems; - notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed', t), type: 'error' }); } finally { item.deleting = false; } }; -const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => { - updateItem(item, newCompleteStatus); +const handleCheckboxChange = (item: ItemWithUI, checked: boolean) => { + updateItem(item, checked); }; -const confirmDeleteItem = (item: ItemWithUI) => { - deleteItem(item); +const handleDragEnd = async (evt: any) => { + if (!list.value) return; + + const { item, newIndex } = evt; + + // Find the current index in the flat list to see if it actually moved + const oldIndex = list.value.items.findIndex(i => i.id === item.id); + if (oldIndex === newIndex) return; + + const originalList = [...list.value.items]; + + // Reorder the list optimistically + const movedItem = originalList.splice(oldIndex, 1)[0]; + originalList.splice(newIndex, 0, movedItem); + list.value.items = originalList; + + try { + await apiClient.put( + API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)), + { position: newIndex + 1, version: item.version } + ); + const updatedItemInList = list.value.items.find(i => i.id === item.id); + if (updatedItemInList) { + updatedItemInList.version++; + } + notificationStore.addNotification({ + message: t('listDetailPage.notifications.itemReorderedSuccess'), + type: 'success' + }); + } catch (err) { + list.value.items = originalList; // Revert on failure + notificationStore.addNotification({ + message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed', t), + type: 'error' + }); + } }; const openOcrDialog = () => { - ocrItems.value = []; - ocrError.value = null; - resetOcrFileDialog(); showOcrDialogState.value = true; - nextTick(() => { - 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 = ''; - } - }); -}; -const closeOcrDialog = () => { - showOcrDialogState.value = false; - ocrItems.value = []; - ocrError.value = null; }; -watch(ocrFiles, async (newFiles) => { - if (newFiles && newFiles.length > 0) { - const file = newFiles[0]; - await handleOcrUpload(file); - } -}); - -const handleOcrFileUpload = (event: Event) => { - const target = event.target as HTMLInputElement; - if (target.files && target.files.length > 0) { - handleOcrUpload(target.files[0]); - } -}; - -const handleOcrUpload = async (file: File) => { - if (!file) return; - ocrLoading.value = true; - ocrError.value = null; - ocrItems.value = []; - try { - const formData = new FormData(); - formData.append('image_file', file); - const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }); - 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 = t('listDetailPage.errors.ocrNoItems'); - } - } catch (err) { - ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed'); - } finally { - ocrLoading.value = false; - 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 = ''; - } - } -}; - -const addOcrItems = async () => { - if (!list.value || !ocrItems.value.length) return; +const addOcrItems = async (items: { name: string }[]) => { + if (!list.value || !items.length) return; addingOcrItems.value = true; let successCount = 0; try { - for (const item of ocrItems.value) { + for (const item of items) { if (!item.name.trim()) continue; const response = await apiClient.post( API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), @@ -1035,9 +597,9 @@ const addOcrItems = async () => { if (successCount > 0) { notificationStore.addNotification({ message: t('listDetailPage.notifications.itemsAddedSuccessOcr', { count: successCount }), type: 'success' }); } - closeOcrDialog(); + showOcrDialogState.value = false; } catch (err) { - notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.addOcrItemsFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.addOcrItemsFailed', t), type: 'error' }); } finally { addingOcrItems.value = false; } @@ -1051,7 +613,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 = getApiErrorMessage(err, 'listDetailPage.errors.loadCostSummaryFailed'); + costSummaryError.value = getApiErrorMessage(err, 'listDetailPage.errors.loadCostSummaryFailed', t); listCostSummary.value = null; } finally { costSummaryLoading.value = false; @@ -1073,36 +635,6 @@ const getGroupName = (groupId: number): string => { return group?.name || `Group ${groupId}`; }; -const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => { - const amount = listDetailStore.getPaidAmountForSplit(split.id); - return formatCurrency(amount); -}; - -const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => { - switch (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 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'); - } -}; - -const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnum): string => { - if (status === ExpenseSplitStatusEnum.PAID || status === ExpenseOverallStatusEnum.PAID) return 'status-paid'; - if (status === ExpenseSplitStatusEnum.PARTIALLY_PAID || status === ExpenseOverallStatusEnum.PARTIALLY_PAID) return 'status-partially_paid'; - if (status === ExpenseSplitStatusEnum.UNPAID || status === ExpenseOverallStatusEnum.UNPAID) return 'status-unpaid'; - return ''; -}; - useEventListener(window, 'keydown', (event: KeyboardEvent) => { if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) { const activeElement = document.activeElement; @@ -1113,8 +645,8 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => { return; } event.preventDefault(); - if (itemNameInputRef.value?.$el) { - (itemNameInputRef.value.$el as HTMLElement).focus(); + if (itemsListRef.value?.focusNewItemInput) { + itemsListRef.value.focusNewItemInput(); } } }); @@ -1220,7 +752,7 @@ const saveItemEdit = async (item: ItemWithUI) => { } catch (err) { notificationStore.addNotification({ - message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), + message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed', t), type: 'error' }); } finally { @@ -1234,7 +766,6 @@ const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => { return; } selectedSplitForSettlement.value = split; - parentExpenseOfSelectedSplit.value = expense; const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(split.id)); const owed = new Decimal(split.owed_amount); const remaining = owed.minus(alreadyPaid); @@ -1243,44 +774,8 @@ const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => { showSettleModal.value = true; }; -const closeSettleShareModal = () => { - showSettleModal.value = false; - selectedSplitForSettlement.value = null; - parentExpenseOfSelectedSplit.value = null; - settleAmount.value = ''; - settleAmountError.value = null; -}; - -const validateSettleAmount = (): boolean => { - settleAmountError.value = null; - if (!settleAmount.value.trim()) { - settleAmountError.value = t('listDetailPage.modals.settleShare.errors.enterAmount'); - return false; - } - const amount = new Decimal(settleAmount.value); - if (amount.isNaN() || amount.isNegative() || amount.isZero()) { - settleAmountError.value = t('listDetailPage.modals.settleShare.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')))) { - settleAmountError.value = t('listDetailPage.modals.settleShare.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) }); - return false; - } - } else { - settleAmountError.value = t('listDetailPage.modals.settleShare.errors.noSplitSelected'); - return false; - } - return true; -}; - -const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id || null); - const handleConfirmSettle = async () => { - if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) { + if (!selectedSplitForSettlement.value || !authStore.user?.id) { notificationStore.addNotification({ message: t('listDetailPage.notifications.settlementDataMissing'), type: 'error' }); return; } @@ -1293,14 +788,14 @@ const handleConfirmSettle = async () => { }; const success = await listDetailStore.settleExpenseSplit({ - list_id_for_refetch: String(currentListIdForRefetch.value), + list_id_for_refetch: String(list.value?.id), expense_split_id: selectedSplitForSettlement.value.id, activity_data: activityData, }); if (success) { notificationStore.addNotification({ message: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' }); - closeSettleShareModal(); + showSettleModal.value = false; } else { notificationStore.addNotification({ message: listDetailStore.error || t('listDetailPage.notifications.settleShareFailed'), type: 'error' }); } @@ -1316,259 +811,9 @@ const handleExpenseCreated = (expense: any) => { } }; -const handleCheckboxChange = (item: ItemWithUI, event: Event) => { - const target = event.target as HTMLInputElement; - if (target) { - updateItem(item, target.checked); - } -}; - -const handleDragEnd = async (evt: any) => { - if (!list.value || evt.oldIndex === evt.newIndex) return; - - const originalList = [...list.value.items]; - const item = list.value.items[evt.newIndex]; - const newPosition = evt.newIndex + 1; - - try { - await apiClient.put( - API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)), - { position: newPosition, version: item.version } - ); - const updatedItemInList = list.value.items.find(i => i.id === item.id); - if (updatedItemInList) { - updatedItemInList.version++; - } - notificationStore.addNotification({ - message: t('listDetailPage.notifications.itemReorderedSuccess'), - type: 'success' - }); - } catch (err) { - list.value.items = originalList; - notificationStore.addNotification({ - message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'), - type: 'error' - }); - } -}; - -const expandedExpenses = ref>(new Set()); - -const toggleExpense = (expenseId: number) => { - const newSet = new Set(expandedExpenses.value); - if (newSet.has(expenseId)) { - newSet.delete(expenseId); - } else { - newSet.add(expenseId); - } - expandedExpenses.value = newSet; -}; - -const isExpenseExpanded = (expenseId: number) => { - return expandedExpenses.value.has(expenseId); -}; - -const groupedItems = computed(() => { - if (!list.value?.items) return []; - const groups: Record = {}; - type Category = { id: number; name: string }; - - list.value.items.forEach(item => { - const categoryId = item.category_id; - const category = categories.value.find((c: Category) => c.id === categoryId); - const categoryName = category ? category.name : t('listDetailPage.items.noCategory'); - - if (!groups[categoryName]) { - groups[categoryName] = { categoryName, items: [] }; - } - groups[categoryName].items.push(item); - }); - - return Object.values(groups); -}); - + \ No newline at end of file diff --git a/fe/src/utils/errors.ts b/fe/src/utils/errors.ts new file mode 100644 index 0000000..f7cd968 --- /dev/null +++ b/fe/src/utils/errors.ts @@ -0,0 +1,31 @@ +// Helper to extract user-friendly error messages from API responses +export const getApiErrorMessage = (err: unknown, fallbackMessageKey: string, t: (key: string, ...args: any[]) => string): string => { + if (err && typeof err === 'object') { + // Check for FastAPI/DRF-style error response + if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) { + const errorData = err.response.data as any; // Type assertion for easier access + if (typeof errorData.detail === 'string') { + return errorData.detail; + } + if (typeof errorData.message === 'string') { // Common alternative + return errorData.message; + } + // FastAPI validation errors often come as an array of objects + if (Array.isArray(errorData.detail) && errorData.detail.length > 0) { + const firstError = errorData.detail[0]; + if (typeof firstError.msg === 'string' && typeof firstError.type === 'string') { + return firstError.msg; // Simpler: just the message + } + } + if (typeof errorData === 'string') { // Sometimes data itself is the error string + return errorData; + } + } + // Standard JavaScript Error object + if (err instanceof Error && err.message) { + return err.message; + } + } + // Fallback to a translated message + return t(fallbackMessageKey); +}; \ No newline at end of file -- 2.45.2 From 448a0705d2130cb53457ec9330e94d5e070e04e7 Mon Sep 17 00:00:00 2001 From: mohamad Date: Tue, 10 Jun 2025 08:16:55 +0200 Subject: [PATCH 2/2] feat: Implement comprehensive roadmap for feature updates and enhancements This commit introduces a detailed roadmap for implementing various features, focusing on backend and frontend improvements. Key additions include: - New database schema designs for financial audit logging, archiving lists, and categorizing items. - Backend logic for financial audit logging, archiving functionality, and chore subtasks. - Frontend UI updates for archiving lists, managing categories, and enhancing the chore interface. - Introduction of a guest user flow and integration of Redis for caching to improve performance. These changes aim to enhance the application's functionality, user experience, and maintainability. --- .cursor/rules/roadmap.mdc | 267 +++++++++++++++++ be/app/api/auth/guest.py | 20 +- be/app/schemas/item.py | 8 + fe/src/components/ChoreItem.vue | 237 ++++++++++++++- fe/src/components/list-detail/ItemsList.vue | 8 +- fe/src/components/list-detail/ListItem.vue | 67 ++++- fe/src/i18n/en.json | 10 +- fe/src/layouts/MainLayout.vue | 5 +- fe/src/pages/AuthCallbackPage.vue | 6 +- fe/src/pages/ExpensePage.vue | 33 ++- fe/src/pages/ExpensesPage.vue | 13 + fe/src/pages/GroupDetailPage.vue | 304 +------------------- fe/src/pages/ListDetailPage.vue | 149 +++++++++- fe/src/pages/ListsPage.vue | 4 - fe/src/router/routes.ts | 7 + fe/src/services/api.ts | 48 +++- fe/src/stores/auth.ts | 32 ++- fe/src/types/item.ts | 31 +- 18 files changed, 883 insertions(+), 366 deletions(-) create mode 100644 .cursor/rules/roadmap.mdc create mode 100644 fe/src/pages/ExpensesPage.vue diff --git a/.cursor/rules/roadmap.mdc b/.cursor/rules/roadmap.mdc new file mode 100644 index 0000000..8c78231 --- /dev/null +++ b/.cursor/rules/roadmap.mdc @@ -0,0 +1,267 @@ +--- +description: +globs: +alwaysApply: false +--- +Of course. Based on a thorough review of your project's structure and code, here is a detailed, LLM-friendly task list to implement the requested features. + +This plan is designed to be sequential and modular, focusing on backend database changes first, then backend logic and APIs, and finally the corresponding frontend implementation for each feature. + +--- + +### **High-Level Strategy & Recommendations** + +1. **Iterative Implementation:** Tackle one major feature at a time (e.g., complete Audit Logging, then Archiving, etc.). This keeps pull requests manageable and easier to review. +2. **Traceability:** The request for traceability is key. We will use timestamp-based flags (`archived_at`, `deleted_at`) instead of booleans and create dedicated history/log tables for critical actions. + +--- + +### **Phase 1: Database Schema Redesign** + +This is the most critical first step. All subsequent tasks depend on these changes. You will need to create a new Alembic migration to apply these. + +**File to Modify:** `be/app/models.py` +**Action:** Create a new Alembic migration file (`alembic revision -m "feature_updates_phase1"`) and implement the following changes in `upgrade()`. + +**1. Financial Audit Logging** +* Create a new table to log every financial transaction and change. This ensures complete traceability. + + ```python + # In be/app/models.py + class FinancialAuditLog(Base): + __tablename__ = 'financial_audit_log' + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # User who performed the action. Nullable for system actions. + action_type = Column(String, nullable=False, index=True) # e.g., 'EXPENSE_CREATED', 'SPLIT_PAID', 'SETTLEMENT_DELETED' + entity_type = Column(String, nullable=False) # e.g., 'Expense', 'ExpenseSplit', 'Settlement' + entity_id = Column(Integer, nullable=False) + details = Column(JSONB, nullable=True) # To store 'before' and 'after' states or other relevant data. + + user = relationship("User") + ``` + +**2. Archiving Lists and History** +* Modify the `lists` table to support soft deletion/archiving. + + ```python + # In be/app/models.py, class List(Base): + # REMOVE: is_deleted = Column(Boolean, default=False, nullable=False) # If it exists + archived_at = Column(DateTime(timezone=True), nullable=True, index=True) + ``` + +**3. Chore Subtasks** +* Add a self-referencing foreign key to the `chores` table. + + ```python + # In be/app/models.py, class Chore(Base): + parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True) + + # Add relationships + parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores") + child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan") + ``` + +**4. List Categories** +* Create a new `categories` table and link it to the `items` table. This allows items to be categorized. + + ```python + # In be/app/models.py + class Category(Base): + __tablename__ = 'categories' + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False, index=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Nullable for global categories + group_id = Column(Integer, ForeignKey('groups.id'), nullable=True) # Nullable for user-specific or global + # Add constraints to ensure either user_id or group_id is set, or both are null for global categories + __table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),) + + # In be/app/models.py, class Item(Base): + category_id = Column(Integer, ForeignKey('categories.id'), nullable=True) + category = relationship("Category") + ``` + +**5. Time Tracking for Chores** +* Create a new `time_entries` table to log time spent on chore assignments. + + ```python + # In be/app/models.py + class TimeEntry(Base): + __tablename__ = 'time_entries' + id = Column(Integer, primary_key=True, index=True) + chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + start_time = Column(DateTime(timezone=True), nullable=False) + end_time = Column(DateTime(timezone=True), nullable=True) + duration_seconds = Column(Integer, nullable=True) # Calculated on end_time set + + assignment = relationship("ChoreAssignment") + user = relationship("User") + ``` + +--- + +### **Phase 2: Backend Implementation** + +For each feature, implement the necessary backend logic. + +#### **Task 2.1: Implement Financial Audit Logging** + +* **Goal:** Automatically log all changes to expenses, splits, and settlements. +* **Tasks:** + 1. **CRUD (`be/app/crud/audit.py`):** + * Create a new file `audit.py`. + * Implement `create_financial_audit_log(db: AsyncSession, user_id: int, action_type: str, entity: Base, details: dict)`. This function will create a new log entry. + 2. **Integrate Logging:** + * Modify `be/app/crud/expense.py`: In `create_expense`, `update_expense`, `delete_expense`, call `create_financial_audit_log`. For updates, the `details` JSONB should contain `{"before": {...}, "after": {...}}`. + * Modify `be/app/crud/settlement.py`: Do the same for `create_settlement`, `update_settlement`, `delete_settlement`. + * Modify `be/app/crud/settlement_activity.py`: Do the same for `create_settlement_activity`. + 3. **API (`be/app/api/v1/endpoints/history.py` - new file):** + * Create a new endpoint `GET /history/financial/group/{group_id}` to view the audit log for a group. + * Create a new endpoint `GET /history/financial/user/me` for a user's personal financial history. + +#### **Task 2.2: Implement Archiving** + +* **Goal:** Allow users to archive lists instead of permanently deleting them. +* **Tasks:** + 1. **CRUD (`be/app/crud/list.py`):** + * Rename `delete_list` to `archive_list`. Instead of `db.delete(list_db)`, it should set `list_db.archived_at = datetime.now(timezone.utc)`. + * Modify `get_lists_for_user` to filter out archived lists by default: `.where(ListModel.archived_at.is_(None))`. + 2. **API (`be/app/api/v1/endpoints/lists.py`):** + * Update the `DELETE /{list_id}` endpoint to call `archive_list`. + * Create a new endpoint `GET /archived` to fetch archived lists for the user. + * Create a new endpoint `POST /{list_id}/unarchive` to set `archived_at` back to `NULL`. + +#### **Task 2.3: Implement Chore Subtasks & Unmarking Completion** + +* **Goal:** Allow chores to have a hierarchy and for completion to be reversible. +* **Tasks:** + 1. **Schemas (`be/app/schemas/chore.py`):** + * Update `ChorePublic` and `ChoreCreate` schemas to include `parent_chore_id: Optional[int]` and `child_chores: List[ChorePublic] = []`. + 2. **CRUD (`be/app/crud/chore.py`):** + * Modify `create_chore` and `update_chore` to handle the `parent_chore_id`. + * In `update_chore_assignment`, enhance the `is_complete=False` logic. When a chore is re-opened, log it to the history. Decide on the policy for the parent chore's `next_due_date` (recommendation: do not automatically roll it back; let the user adjust it manually if needed). + 3. **API (`be/app/api/v1/endpoints/chores.py`):** + * Update the `POST` and `PUT` endpoints for chores to accept `parent_chore_id`. + * The `PUT /assignments/{assignment_id}` endpoint already supports setting `is_complete`. Ensure it correctly calls the updated CRUD logic. + +#### **Task 2.4: Implement List Categories** + +* **Goal:** Allow items to be categorized for better organization. +* **Tasks:** + 1. **Schemas (`be/app/schemas/category.py` - new file):** + * Create `CategoryCreate`, `CategoryUpdate`, `CategoryPublic`. + 2. **CRUD (`be/app/crud/category.py` - new file):** + * Implement full CRUD functions for categories (`create_category`, `get_user_categories`, `update_category`, `delete_category`). + 3. **API (`be/app/api/v1/endpoints/categories.py` - new file):** + * Create endpoints for `GET /`, `POST /`, `PUT /{id}`, `DELETE /{id}` for categories. + 4. **Item Integration:** + * Update `ItemCreate` and `ItemUpdate` schemas in `be/app/schemas/item.py` to include `category_id: Optional[int]`. + * Update `crud_item.create_item` and `crud_item.update_item` to handle setting the `category_id`. + +#### **Task 2.5: Enhance OCR for Receipts** + +* **skipped** + +#### **Task 2.6: Implement "Continue as Guest"** + +* **Goal:** Allow users to use the app without creating a full account. +* **Tasks:** + 1. **DB Model (`be/app/models.py`):** + * Add `is_guest = Column(Boolean, default=False, nullable=False)` to the `User` model. + 2. **Auth (`be/app/api/auth/guest.py` - new file):** + * Create a new router for guest functionality. + * Implement a `POST /auth/guest` endpoint. This endpoint will: + * Create a new user with a unique but temporary-looking email (e.g., `guest_{uuid}@guest.mitlist.app`). + * Set `is_guest=True`. + * Generate and return JWT tokens for this guest user, just like a normal login. + 3. **Claim Account (`be/app/api/auth/guest.py`):** + * Implement a `POST /auth/guest/claim` endpoint (requires auth). This endpoint will take a new email and password, update the `is_guest=False`, set the new credentials, and mark the email for verification. + +#### **Task 2.7: Implement Redis** + +* **Goal:** Integrate Redis for caching to improve performance. +* **Tasks:** + 1. **Dependencies (`be/requirements.txt`):** Add `redis`. + 2. **Configuration (`be/app/config.py`):** Add `REDIS_URL` to settings. + 3. **Connection (`be/app/core/redis.py` - new file):** Create a Redis connection pool. + 4. **Caching (`be/app/core/cache.py` - new file):** Implement a simple caching decorator. + ```python + # Example decorator + def cache(expire_time: int = 3600): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # ... logic to check cache, return if hit ... + # ... if miss, call func, store result in cache ... + return result + return wrapper + return decorator + ``` + 5. **Apply Caching:** Apply the `@cache` decorator to read-heavy, non-volatile CRUD functions like `crud_group.get_group_by_id`. + +--- + +### **Phase 3: Frontend Implementation** + +Implement the UI for the new features, using your Valerie UI components. + +#### **Task 3.1: Implement Archiving UI** + +* **Goal:** Allow users to archive and view archived lists. +* **Files to Modify:** `fe/src/pages/ListsPage.vue`, `fe/src/stores/listStore.ts` (if you create one). +* **Tasks:** + 1. Change the "Delete" action on lists to "Archive". + 2. Add a toggle/filter to show archived lists. + 3. When viewing archived lists, show an "Unarchive" button. + +#### **Task 3.2: Implement Subtasks and Unmarking UI** + +* **Goal:** Update the chore interface for subtasks and undoing completion. +* **Files to Modify:** `fe/src/pages/ChoresPage.vue`, `fe/src/components/ChoreItem.vue` (if it exists). +* **Tasks:** + 1. Modify the chore list to be a nested/tree view to display parent-child relationships. + 2. Update the chore creation/edit modal to include a "Parent Chore" dropdown. + 3. On completed chores, change the "Completed" checkmark to an "Undo" button. Clicking it should call the API to set `is_complete` to `false`. + +#### **Task 3.3: Implement Category Management and Supermarkt Mode** + +* **Goal:** Add category features and the special "Supermarkt Mode". +* **Files to Modify:** `fe/src/pages/ListDetailPage.vue`, `fe/src/components/Item.vue`. +* **Tasks:** + 1. Create a new page/modal for managing categories (CRUD). + 2. In the `ListDetailPage`, add a "Category" dropdown when adding/editing an item. + 3. Display items grouped by category. + 4. **Supermarkt Mode:** + * Add a toggle button on the `ListDetailPage` to enter "Supermarkt Mode". + * When an item is checked, apply a temporary CSS class to other items in the same category. + * Ensure the price input field appears next to checked items. + * Add a `VProgressBar` at the top, with `value` bound to `completedItems.length` and `max` bound to `totalItems.length`. + +#### **Task 3.4: Implement Time Tracking UI** + +* **Goal:** Allow users to track time on chores. +* **Files to Modify:** `fe/src/pages/ChoresPage.vue`. +* **Tasks:** + 1. Add a "Start/Stop" timer button on each chore assignment. + 2. Clicking "Start" sends a `POST /time_entries` request. + 3. Clicking "Stop" sends a `PUT /time_entries/{id}` request. + 4. Display the total time spent on the chore. + + +#### **Task 3.5: Implement Guest Flow** + +* **Goal:** Provide a seamless entry point for new users. +* **Files to Modify:** `fe/src/pages/LoginPage.vue`, `fe/src/stores/auth.ts`, `fe/src/router/index.ts`. +* **Tasks:** + 1. On the `LoginPage`, add a "Continue as Guest" button. + 2. This button calls a new `authStore.loginAsGuest()` action. + 3. The action hits the `POST /auth/guest` endpoint, receives tokens, and stores them. + 4. The router logic needs adjustment to handle guest users. You might want to protect certain pages (like "Account Settings") even from guests. + 5. Add a persistent banner in the UI for guest users: "You are using a guest account. **Sign up** to save your data." + + +``` + + +**Final Note:** This is a comprehensive roadmap. Each major task can be broken down further into smaller sub-tasks. Good luck with the implementation \ No newline at end of file diff --git a/be/app/api/auth/guest.py b/be/app/api/auth/guest.py index e0958c6..23b9186 100644 --- a/be/app/api/auth/guest.py +++ b/be/app/api/auth/guest.py @@ -4,10 +4,10 @@ import uuid from app import models from app.schemas.user import UserCreate, UserClaim, UserPublic -from app.schemas.token import Token +from app.schemas.auth import Token from app.database import get_session -from app.auth import current_active_user -from app.core.security import create_access_token, get_password_hash +from app.auth import current_active_user, get_jwt_strategy, get_refresh_jwt_strategy +from app.core.security import get_password_hash from app.crud import user as crud_user router = APIRouter() @@ -23,8 +23,18 @@ async def create_guest_user(db: AsyncSession = Depends(get_session)): user_in = UserCreate(email=guest_email, password=guest_password) user = await crud_user.create_user(db, user_in=user_in, is_guest=True) - access_token = create_access_token(data={"sub": user.email}) - return {"access_token": access_token, "token_type": "bearer"} + # Use the same JWT strategy as regular login to generate both access and refresh tokens + access_strategy = get_jwt_strategy() + refresh_strategy = get_refresh_jwt_strategy() + + access_token = await access_strategy.write_token(user) + refresh_token = await refresh_strategy.write_token(user) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } @router.post("/guest/claim", response_model=UserPublic) async def claim_guest_account( diff --git a/be/app/schemas/item.py b/be/app/schemas/item.py index 2148b90..3cee512 100644 --- a/be/app/schemas/item.py +++ b/be/app/schemas/item.py @@ -3,6 +3,11 @@ from datetime import datetime from typing import Optional from decimal import Decimal +class UserReference(BaseModel): + id: int + name: Optional[str] = None + model_config = ConfigDict(from_attributes=True) + class ItemPublic(BaseModel): id: int list_id: int @@ -10,8 +15,11 @@ class ItemPublic(BaseModel): quantity: Optional[str] = None is_complete: bool price: Optional[Decimal] = None + category_id: Optional[int] = None added_by_id: int completed_by_id: Optional[int] = None + added_by_user: Optional[UserReference] = None + completed_by_user: Optional[UserReference] = None created_at: datetime updated_at: datetime version: int diff --git a/fe/src/components/ChoreItem.vue b/fe/src/components/ChoreItem.vue index 80de7bc..a00a3d9 100644 --- a/fe/src/components/ChoreItem.vue +++ b/fe/src/components/ChoreItem.vue @@ -113,11 +113,231 @@ export default { \ No newline at end of file diff --git a/fe/src/components/list-detail/ItemsList.vue b/fe/src/components/list-detail/ItemsList.vue index 872a4b5..344b8f5 100644 --- a/fe/src/components/list-detail/ItemsList.vue +++ b/fe/src/components/list-detail/ItemsList.vue @@ -4,10 +4,11 @@ :class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">

    {{ group.categoryName }}

    + :disabled="!isOnline || supermarktMode" class="neo-item-list" ghost-class="sortable-ghost" + drag-class="sortable-drag">