From d8db5721f491ea568ed77d56dd12a6ea21d79d82 Mon Sep 17 00:00:00 2001 From: mohamad Date: Thu, 5 Jun 2025 00:46:23 +0200 Subject: [PATCH 1/2] Refactor GroupsPage and ListDetailPage for improved loading and error handling --- fe/index.html | 2 - fe/src/pages/GroupsPage.vue | 92 +- fe/src/pages/ListDetailPage.vue | 1541 +++++++++++++++++-------------- fe/src/pages/ListsPage.vue | 6 +- 4 files changed, 928 insertions(+), 713 deletions(-) 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; } -- 2.45.2 From b9aace0c4e948ca536ff14874586a030b7a130f1 Mon Sep 17 00:00:00 2001 From: mohamad Date: Thu, 5 Jun 2025 01:04:34 +0200 Subject: [PATCH 2/2] Update dependencies and refactor ListDetailPage for drag-and-drop functionality - Updated `vue-i18n` and related dependencies to version 9.14.4 for improved localization support. - Added `vuedraggable` to enable drag-and-drop functionality for list items in `ListDetailPage.vue`. - Refactored the item list structure to accommodate drag handles and improved item actions. - Enhanced styling for drag-and-drop interactions and item actions for better user experience. --- fe/package-lock.json | 133 +++++--------- fe/package.json | 3 +- fe/src/pages/ListDetailPage.vue | 317 +++++++++++++++++++++++--------- 3 files changed, 273 insertions(+), 180 deletions(-) diff --git a/fe/package-lock.json b/fe/package-lock.json index 23a1c65..91662ef 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -18,8 +18,9 @@ "motion": "^12.15.0", "pinia": "^3.0.2", "vue": "^3.5.13", - "vue-i18n": "^12.0.0-alpha.2", + "vue-i18n": "^9.9.1", "vue-router": "^4.5.1", + "vuedraggable": "^4.1.0", "workbox-background-sync": "^7.3.0" }, "devDependencies": { @@ -2585,11 +2586,27 @@ } } }, - "node_modules/@intlify/bundle-utils/node_modules/@intlify/message-compiler": { + "node_modules/@intlify/core-base": { + "version": "9.14.4", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.4.tgz", + "integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.4", + "@intlify/shared": "9.14.4" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { "version": "9.14.4", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.4.tgz", "integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==", - "dev": true, + "license": "MIT", "dependencies": { "@intlify/shared": "9.14.4", "source-map-js": "^1.0.2" @@ -2601,54 +2618,10 @@ "url": "https://github.com/sponsors/kazupon" } }, - "node_modules/@intlify/bundle-utils/node_modules/@intlify/shared": { + "node_modules/@intlify/shared": { "version": "9.14.4", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.4.tgz", "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==", - "dev": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/core-base": { - "version": "12.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-12.0.0-alpha.2.tgz", - "integrity": "sha512-sPWvQ1Z4Wyw9Kp8xqjAk2sMOeZ4pO7p/NL3Eol8l9a7iPyMTuHyJ2DZVbOBG6zDnCupvLAqnRMAT1LAgvx0QRQ==", - "license": "MIT", - "dependencies": { - "@intlify/message-compiler": "12.0.0-alpha.2", - "@intlify/shared": "12.0.0-alpha.2" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/message-compiler": { - "version": "12.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-12.0.0-alpha.2.tgz", - "integrity": "sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==", - "license": "MIT", - "dependencies": { - "@intlify/shared": "12.0.0-alpha.2", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/shared": { - "version": "12.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-12.0.0-alpha.2.tgz", - "integrity": "sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==", "license": "MIT", "engines": { "node": ">= 16" @@ -2696,18 +2669,6 @@ } } }, - "node_modules/@intlify/unplugin-vue-i18n/node_modules/@intlify/shared": { - "version": "9.14.4", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.4.tgz", - "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==", - "dev": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, "node_modules/@intlify/unplugin-vue-i18n/node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2715,29 +2676,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@intlify/vue-i18n-core": { - "version": "12.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/@intlify/vue-i18n-core/-/vue-i18n-core-12.0.0-alpha.2.tgz", - "integrity": "sha512-y1NPEcPbD8xqWGiaEREkA9WxxWbxmd8IurN176w39MenZKEf5P20rYyk0w4r718+w/9jjm0m5zR1ed6uMaZT2Q==", - "license": "MIT", - "dependencies": { - "@intlify/core-base": "12.0.0-alpha.2", - "@intlify/shared": "12.0.0-alpha.2", - "@vue/devtools-api": "^6.5.0" - }, - "engines": { - "node": ">= 16" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/@intlify/vue-i18n-core/node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -11211,6 +11149,12 @@ "dev": true, "license": "MIT" }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -12820,14 +12764,13 @@ } }, "node_modules/vue-i18n": { - "version": "12.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-12.0.0-alpha.2.tgz", - "integrity": "sha512-ZSaZrDV/PhD4hLVybo84bkaNRnkGDF7GpI0Fcmn9Yj+2Kq5C3nE7I5iRbo+DiHyRGrwZ/IV1VP3naFAhcNJGsg==", + "version": "9.14.4", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.4.tgz", + "integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==", "license": "MIT", "dependencies": { - "@intlify/core-base": "12.0.0-alpha.2", - "@intlify/shared": "12.0.0-alpha.2", - "@intlify/vue-i18n-core": "12.0.0-alpha.2", + "@intlify/core-base": "9.14.4", + "@intlify/shared": "9.14.4", "@vue/devtools-api": "^6.5.0" }, "engines": { @@ -12894,6 +12837,18 @@ "typescript": ">=5.0.0" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/fe/package.json b/fe/package.json index 7b222f3..67af8ef 100644 --- a/fe/package.json +++ b/fe/package.json @@ -31,6 +31,7 @@ "vue": "^3.5.13", "vue-i18n": "^9.9.1", "vue-router": "^4.5.1", + "vuedraggable": "^4.1.0", "workbox-background-sync": "^7.3.0" }, "devDependencies": { @@ -74,4 +75,4 @@ "workbox-routing": "^7.3.0", "workbox-strategies": "^7.3.0" } -} \ No newline at end of file +} diff --git a/fe/src/pages/ListDetailPage.vue b/fe/src/pages/ListDetailPage.vue index d59997c..a0d0392 100644 --- a/fe/src/pages/ListDetailPage.vue +++ b/fe/src/pages/ListDetailPage.vue @@ -47,76 +47,123 @@ -
      -
    • -
      - - + + + +
    • + +
    • - +

      {{ $t('listDetailPage.expensesSection.title') }}

      @@ -191,7 +238,7 @@
      - + @@ -299,9 +346,9 @@ @@ -314,8 +361,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management -import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed +import { apiClient, API_ENDPOINTS } from '@/config/api'; +import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; import { useNotificationStore } from '@/stores/notifications'; import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline'; import { useListDetailStore } from '@/stores/listDetailStore'; @@ -340,7 +387,7 @@ 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'; -// VTextarea and VSelect are not used in this part of the refactor for ListDetailPage +import draggable from 'vuedraggable'; const { t } = useI18n(); @@ -1198,6 +1245,32 @@ const handleCheckboxChange = (item: ItemWithUI, event: Event) => { } }; +const handleDragEnd = async (evt: any) => { + if (!list.value || evt.oldIndex === evt.newIndex) return; + + const item = list.value.items[evt.newIndex]; + const newPosition = evt.newIndex + 1; // Assuming backend uses 1-based indexing + + try { + await apiClient.put( + API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)), + { position: newPosition, version: item.version } + ); + item.version++; + notificationStore.addNotification({ + message: t('listDetailPage.notifications.itemReorderedSuccess'), + type: 'success' + }); + } catch (err) { + // Revert the order on error + list.value.items = [...list.value.items]; + notificationStore.addNotification({ + message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'), + type: 'error' + }); + } +}; + -- 2.45.2