diff --git a/fe/index.html b/fe/index.html index ba107c0..2e5f5f4 100644 --- a/fe/index.html +++ b/fe/index.html @@ -3,13 +3,11 @@ - - mitlist diff --git a/fe/src/pages/GroupsPage.vue b/fe/src/pages/GroupsPage.vue index 00c2b74..56e3659 100644 --- a/fe/src/pages/GroupsPage.vue +++ b/fe/src/pages/GroupsPage.vue @@ -2,17 +2,26 @@
-
@@ -315,12 +344,51 @@ import VCheckbox from '@/components/valerie/VCheckbox.vue'; 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; deleting: boolean; priceInput: string | number | null; swiped: boolean; + isEditing?: boolean; // For inline editing state + editName?: string; // Temporary name for inline editing + editQuantity?: number | string | null; // Temporary quantity for inline editing + showFirework?: boolean; // For firework animation } interface List { @@ -334,6 +402,11 @@ interface List { group_id?: number; } +interface Group { + id: number; + name: string; +} + interface UserCostShare { user_id: number; user_identifier: string; @@ -364,8 +437,8 @@ const pollingInterval = ref | null>(null); const lastListUpdate = ref(null); const lastItemUpdate = ref(null); -const newItem = ref<{ name: string; quantity?: number }>({ name: '' }); -const itemNameInputRef = ref | null>(null); // Changed type +const newItem = ref<{ name: string; quantity?: number | string }>({ name: '' }); +const itemNameInputRef = ref | null>(null); // OCR const showOcrDialogState = ref(false); @@ -381,12 +454,6 @@ const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({ }); -// Confirmation Dialog -const showConfirmDialogState = ref(false); -// const confirmModalRef = ref(null); // Removed -const confirmDialogMessage = ref(''); -const pendingAction = ref<(() => Promise) | null>(null); - // Cost Summary const showCostSummaryDialog = ref(false); // const costSummaryModalRef = ref(null); // Removed @@ -407,13 +474,17 @@ const isSettlementLoading = computed(() => listDetailStore.isSettlingSplit); // Create Expense const showCreateExpenseForm = ref(false); -// Edit Item -const showEditDialog = ref(false); -// const editModalRef = ref(null); // Removed -const editingItem = ref(null); +// Edit Item - Refs for modal edit removed +// const showEditDialog = ref(false); +// const editingItem = ref(null); // onClickOutside for ocrModalRef, costSummaryModalRef, etc. are removed as VModal handles this. +// Define a more specific type for the offline item payload +interface OfflineCreateItemPayload { + name: string; + quantity?: string | number; // Align with the target type from the linter error +} const formatCurrency = (value: string | number | undefined | null): string => { if (value === undefined || value === null) return '$0.00'; @@ -429,7 +500,8 @@ const processListItems = (items: Item[]): ItemWithUI[] => { updating: false, deleting: false, priceInput: item.price || null, - swiped: false + swiped: false, + showFirework: false // Initialize firework state })); }; @@ -470,12 +542,11 @@ const fetchListDetails = async () => { await fetchListCostSummary(); } } catch (err: unknown) { - const apiErrorMessage = err instanceof Error ? err.message : String(err); - const fallbackErrorMessage = t('listDetailPage.errors.fetchFailed'); + const errorMessage = getApiErrorMessage(err, 'listDetailPage.errors.fetchFailed'); if (!list.value) { - error.value = apiErrorMessage || fallbackErrorMessage; + error.value = errorMessage; } else { - notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage: apiErrorMessage }), type: 'error' }); + notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage }), type: 'error' }); } } finally { itemsAreLoading.value = false; @@ -521,8 +592,18 @@ const isItemPendingSync = (item: Item) => { }); }; +const handleNewItemBlur = (event: Event) => { + const inputElement = event.target as HTMLInputElement; + if (inputElement.value.trim()) { + newItem.value.name = inputElement.value.trim(); + onAddItem(); + } +}; + const onAddItem = async () => { - if (!list.value || !newItem.value.name.trim()) { + 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(); @@ -531,13 +612,47 @@ const onAddItem = async () => { } addingItem.value = true; + // Create optimistic item + const optimisticItem: ItemWithUI = { + id: Date.now(), // Temporary ID + name: itemName, + quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null), + is_complete: false, + price: null, + version: 1, + updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + list_id: list.value.id, + updating: false, + deleting: false, + priceInput: null, + swiped: false + }; + + // Add item optimistically to the list + list.value.items.push(optimisticItem); + + // Clear input immediately for better UX + newItem.value.name = ''; + if (itemNameInputRef.value?.$el) { + (itemNameInputRef.value.$el as HTMLElement).focus(); + } + if (!isOnline.value) { - const offlinePayload: any = { // Define explicit type later if needed - name: newItem.value.name + const offlinePayload: OfflineCreateItemPayload = { + name: itemName }; - if (typeof newItem.value.quantity !== 'undefined') { - offlinePayload.quantity = String(newItem.value.quantity); + + const rawQuantity = newItem.value.quantity; + if (rawQuantity !== undefined && String(rawQuantity).trim() !== '') { + const numAttempt = Number(rawQuantity); + if (!isNaN(numAttempt)) { + offlinePayload.quantity = numAttempt; + } else { + offlinePayload.quantity = String(rawQuantity); + } } + offlineStore.addAction({ type: 'create_list_item', payload: { @@ -545,28 +660,9 @@ const onAddItem = async () => { itemData: offlinePayload } }); - const optimisticItem: ItemWithUI = { - 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, // Assuming initial version - updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - list_id: list.value.id, - updating: false, - deleting: false, - priceInput: null, - swiped: false - }; - list.value.items.push(optimisticItem); - newItem.value = { name: '' }; - if (itemNameInputRef.value?.$el) { - (itemNameInputRef.value.$el as HTMLElement).focus(); - } + addingItem.value = false; - notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); // Optimistic UI + notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); return; } @@ -574,19 +670,26 @@ const onAddItem = async () => { const response = await apiClient.post( API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), { - name: newItem.value.name, + name: itemName, quantity: newItem.value.quantity ? String(newItem.value.quantity) : null } ); + const addedItem = response.data as Item; - list.value.items.push(processListItems([addedItem])[0]); - newItem.value = { name: '' }; - if (itemNameInputRef.value?.$el) { - (itemNameInputRef.value.$el as HTMLElement).focus(); + // Replace optimistic item with real item from server + const index = list.value.items.findIndex(i => i.id === optimisticItem.id); + if (index !== -1) { + list.value.items[index] = processListItems([addedItem])[0]; } + notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); } catch (err) { - notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addItemFailed'), type: 'error' }); + // Remove optimistic item on error + list.value.items = list.value.items.filter(i => i.id !== optimisticItem.id); + notificationStore.addNotification({ + message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed'), + type: 'error' + }); } finally { addingItem.value = false; } @@ -598,6 +701,18 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => { const originalCompleteStatus = item.is_complete; item.is_complete = newCompleteStatus; + const triggerFirework = () => { + if (newCompleteStatus && !originalCompleteStatus) { + item.showFirework = true; + setTimeout(() => { + // Check if item still exists and is part of the current list before resetting + if (list.value && list.value.items.find(i => i.id === item.id)) { + item.showFirework = false; + } + }, 700); // Duration of firework animation (must match CSS) + } + }; + if (!isOnline.value) { offlineStore.addAction({ type: 'update_list_item', @@ -612,6 +727,7 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => { }); item.updating = false; notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI + triggerFirework(); // Trigger firework for offline success return; } @@ -622,9 +738,10 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => { ); item.version++; notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); + triggerFirework(); // Trigger firework for online success } catch (err) { item.is_complete = originalCompleteStatus; // Revert optimistic update - notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), type: 'error' }); } finally { item.updating = false; } @@ -667,7 +784,7 @@ const updateItemPrice = async (item: ItemWithUI) => { } catch (err) { item.price = originalPrice; // Revert optimistic update item.priceInput = originalPriceInput; - notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemPriceFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed'), type: 'error' }); } finally { item.updating = false; } @@ -698,37 +815,18 @@ const deleteItem = async (item: ItemWithUI) => { notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' }); } catch (err) { list.value.items = originalItems; // Revert optimistic UI - notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.deleteItemFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed'), type: 'error' }); } finally { item.deleting = false; } }; const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => { - 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; + updateItem(item, newCompleteStatus); }; const confirmDeleteItem = (item: ItemWithUI) => { - confirmDialogMessage.value = t('listDetailPage.confirmations.deleteMessage', { itemName: item.name }); - pendingAction.value = () => deleteItem(item); - showConfirmDialogState.value = true; -}; - -const handleConfirmedAction = async () => { - if (pendingAction.value) { - await pendingAction.value(); - } - cancelConfirmation(); -}; -const cancelConfirmation = () => { - showConfirmDialogState.value = false; - pendingAction.value = null; - confirmDialogMessage.value = ''; // Clear message + deleteItem(item); }; const openOcrDialog = () => { @@ -781,7 +879,7 @@ const handleOcrUpload = async (file: File) => { ocrError.value = t('listDetailPage.errors.ocrNoItems'); } } catch (err) { - ocrError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.ocrFailed'); + ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed'); } finally { ocrLoading.value = false; // Reset file input @@ -789,7 +887,7 @@ const handleOcrUpload = async (file: File) => { const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el; if (inputElement) (inputElement as HTMLInputElement).value = ''; } else if (ocrFileInputRef.value) { // Native input - (ocrFileInputRef.value as any).value = ''; + (ocrFileInputRef.value as any).value = ''; } } }; @@ -814,7 +912,7 @@ const addOcrItems = async () => { } closeOcrDialog(); } catch (err) { - notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addOcrItemsFailed'), type: 'error' }); + notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.addOcrItemsFailed'), type: 'error' }); } finally { addingOcrItems.value = false; } @@ -828,7 +926,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)) || t('listDetailPage.errors.loadCostSummaryFailed'); + costSummaryError.value = getApiErrorMessage(err, 'listDetailPage.errors.loadCostSummaryFailed'); listCostSummary.value = null; } finally { costSummaryLoading.value = false; @@ -843,6 +941,12 @@ watch(showCostSummaryDialog, (newVal) => { // --- Expense and Settlement Status Logic --- const listDetailStore = useListDetailStore(); const expenses = computed(() => listDetailStore.getExpenses); +const allFetchedGroups = ref([]); + +const getGroupName = (groupId: number): string => { + const group = allFetchedGroups.value.find((g: Group) => g.id === groupId); + return group?.name || `Group ${groupId}`; +}; const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => { const amount = listDetailStore.getPaidAmountForSplit(split.id); @@ -882,7 +986,7 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => { return; // Don't interfere with typing } // Check if any modal is open, if so, don't trigger - if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value || showEditDialog.value || showSettleModal.value || showCreateExpenseForm.value) { + if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) { return; } event.preventDefault(); @@ -892,24 +996,6 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => { } }); -let touchStartX = 0; -const SWIPE_THRESHOLD = 50; // Pixels - -const handleTouchStart = (event: TouchEvent) => { - touchStartX = event.changedTouches[0].clientX; -}; - -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 = (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; @@ -955,47 +1041,69 @@ onUnmounted(() => { stopPolling(); }); -const editItem = (item: Item) => { - editingItem.value = { ...item }; // Clone item for editing - showEditDialog.value = true; +const startItemEdit = (item: ItemWithUI) => { + // Ensure other items are not in edit mode (optional, but good for UX) + list.value?.items.forEach(i => { if (i.id !== item.id) i.isEditing = false; }); + item.isEditing = true; + item.editName = item.name; + item.editQuantity = item.quantity ?? ''; // Use empty string for VInput if null/undefined }; -const closeEditDialog = () => { - showEditDialog.value = false; - editingItem.value = null; +const cancelItemEdit = (item: ItemWithUI) => { + item.isEditing = false; + // editName and editQuantity are transient, no need to reset them to anything, + // as they are re-initialized in startItemEdit. }; -const handleConfirmEdit = async () => { - if (!editingItem.value || !list.value) return; +const saveItemEdit = async (item: ItemWithUI) => { + if (!list.value || !item.editName || String(item.editName).trim() === '') { + notificationStore.addNotification({ + message: t('listDetailPage.notifications.enterItemName'), // Re-use existing translation + type: 'warning' + }); + return; + } - const itemToUpdate = editingItem.value; // Already a clone + const payload = { + name: String(item.editName).trim(), + quantity: item.editQuantity ? String(item.editQuantity) : null, + version: item.version, + // Ensure completed status is preserved if it's part of the update endpoint implicitly or explicitly + // If your API updates 'completed' status too, you might need to send item.is_complete + // For now, assuming API endpoint for item update only takes name, quantity, version. + }; + + item.updating = true; // Use existing flag for visual feedback try { const response = await apiClient.put( - API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(itemToUpdate.id)), - { - name: itemToUpdate.name, - quantity: itemToUpdate.quantity?.toString(), // Ensure quantity is string or null - version: itemToUpdate.version - } + API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)), + payload ); 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([updatedItemFromApi])[0]; - } + // Update the original item with new data from API + item.name = updatedItemFromApi.name; + item.quantity = updatedItemFromApi.quantity; + item.version = updatedItemFromApi.version; + item.is_complete = updatedItemFromApi.is_complete; // Ensure this is updated if API returns it + item.price = updatedItemFromApi.price; // And price + item.updated_at = updatedItemFromApi.updated_at; + item.isEditing = false; // Exit edit mode notificationStore.addNotification({ - message: 'Item updated successfully', + message: t('listDetailPage.notifications.itemUpdatedSuccess'), // Re-use type: 'success' }); - closeEditDialog(); + } catch (err) { notificationStore.addNotification({ - message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'), + message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), // Re-use type: 'error' }); + // Optionally, keep item.isEditing = true so user can correct or cancel + } finally { + item.updating = false; } }; @@ -1083,6 +1191,13 @@ const handleExpenseCreated = (expense: any) => { } }; +const handleCheckboxChange = (item: ItemWithUI, event: Event) => { + const target = event.target as HTMLInputElement; + if (target) { + updateItem(item, target.checked); + } +}; + diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index d382ffa..b0c8a7c 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -47,8 +47,8 @@
  • @@ -465,7 +465,7 @@ onUnmounted(() => { .page-padding { padding: 1rem; - max-width: 1200px; + max-width: 1600px; margin: 0 auto; }