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