mitlist/fe/src/components/SmartShoppingList.vue
mohamad d6c5e6fcfd chore: Remove package-lock.json and enhance financials API with user summaries
This commit includes the following changes:

- Deleted the `package-lock.json` file to streamline dependency management.
- Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits.
- Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion.
- Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking.
- Refactored the costs service to improve group balance summary calculations.

These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management.
2025-06-28 21:37:26 +02:00

828 lines
26 KiB
Vue

<template>
<div class="smart-shopping-list">
<!-- List Header with Smart Actions -->
<div class="list-header">
<div class="header-info">
<h2 class="list-title">{{ list.name }}</h2>
<div class="list-stats">
<span class="item-count">{{ incompletedItems.length }} items</span>
<span v-if="claimedByOthersCount > 0" class="claimed-indicator">
{{ claimedByOthersCount }} claimed by others
</span>
</div>
</div>
<!-- Quick Actions -->
<div class="header-actions">
<Button v-if="hasCompletedItemsWithoutPrices" variant="soft" size="sm" @click="showReceiptScanner"
class="receipt-scanner-btn">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Scan Receipt
</Button>
<Button v-if="canCreateExpense" variant="primary" size="sm" @click="showExpensePrompt"
class="create-expense-btn">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Create Expense
</Button>
</div>
</div>
<!-- Conflict Alerts -->
<TransitionGroup name="alert" tag="div" class="space-y-3 mb-4">
<Alert v-for="conflict in activeConflicts" :key="conflict.id" type="warning" :message="conflict.message"
class="conflict-alert" @dismiss="dismissConflict(conflict.id)" />
</TransitionGroup>
<!-- Smart Shopping Items -->
<div class="shopping-items">
<!-- Unclaimed Items -->
<div v-if="unclaimedItems.length > 0" class="item-group">
<h3 class="group-title">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
Available to Shop ({{ unclaimedItems.length }})
</h3>
<div class="items-list">
<SmartShoppingItem v-for="item in unclaimedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" @claim="handleClaimItem" @complete="handleCompleteItem"
@update-price="handleUpdatePrice" @edit="$emit('edit-item', item)" />
</div>
</div>
<!-- My Claimed Items -->
<div v-if="myClaimedItems.length > 0" class="item-group">
<h3 class="group-title claimed-by-me">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
My Items ({{ myClaimedItems.length }})
</h3>
<div class="items-list">
<SmartShoppingItem v-for="item in myClaimedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" :is-claimed-by-me="true" @unclaim="handleUnclaimItem"
@complete="handleCompleteItem" @update-price="handleUpdatePrice"
@edit="$emit('edit-item', item)" />
</div>
</div>
<!-- Others' Claimed Items -->
<div v-if="othersClaimedItems.length > 0" class="item-group">
<h3 class="group-title claimed-by-others">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
Claimed by Others ({{ othersClaimedItems.length }})
</h3>
<div class="items-list">
<SmartShoppingItem v-for="item in othersClaimedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" :is-claimed-by-others="true" @edit="$emit('edit-item', item)" readonly />
</div>
</div>
<!-- Completed Items -->
<div v-if="completedItems.length > 0" class="item-group">
<h3 class="group-title completed">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Completed ({{ completedItems.length }})
</h3>
<div class="items-list completed-items">
<SmartShoppingItem v-for="item in completedItems" :key="item.id" :item="item" :list="list"
:is-online="isOnline" :is-completed="true" @update-price="handleUpdatePrice"
@edit="$emit('edit-item', item)" />
</div>
</div>
</div>
<!-- Undo Toast -->
<Transition name="undo-toast">
<div v-if="undoAction" class="undo-toast">
<div class="undo-content">
<span class="undo-message">{{ undoAction.message }}</span>
</div>
<Button variant="ghost" size="sm" @click="performUndo" class="undo-button">
Undo
</Button>
</div>
</Transition>
<!-- Receipt Scanner Modal -->
<Dialog v-model="showReceiptScanner" title="Scan Receipt">
<div class="receipt-scanner">
<div class="scanner-area">
<input ref="fileInput" type="file" accept="image/*" capture="environment"
@change="handleReceiptUpload" class="hidden" />
<Button @click="triggerFileUpload" variant="outline" class="upload-btn">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Choose Receipt Image
</Button>
</div>
<div v-if="scanningReceipt" class="scanning-status">
<Spinner size="sm" />
<span>Scanning receipt...</span>
</div>
<div v-if="scannedData" class="scanned-results">
<h4 class="results-title">Detected Items & Prices:</h4>
<div class="detected-items">
<div v-for="(detectedItem, index) in scannedData.items" :key="index" class="detected-item">
<span class="item-name">{{ detectedItem.name }}</span>
<span class="item-price">${{ detectedItem.price.toFixed(2) }}</span>
<Button size="sm" variant="outline" @click="applyScannedPrice(detectedItem)">
Apply
</Button>
</div>
</div>
</div>
</div>
</Dialog>
<!-- Expense Creation Prompt -->
<Dialog v-model="showExpensePrompt" title="Create Expense from Shopping">
<div class="expense-prompt">
<p class="prompt-message">
You have {{ completedItemsWithPrices.length }} completed items with prices totaling
<strong>${{ totalCompletedValue.toFixed(2) }}</strong>.
Would you like to create an expense for this shopping trip?
</p>
<div class="expense-options">
<div class="option-card" @click="createExpenseType = 'equal'">
<input type="radio" id="equal-split" v-model="createExpenseType" value="equal"
class="sr-only" />
<label for="equal-split" class="option-label">
<h4>Equal Split</h4>
<p>Split total amount equally among all group members</p>
</label>
</div>
<div class="option-card" @click="createExpenseType = 'item-based'">
<input type="radio" id="item-split" v-model="createExpenseType" value="item-based"
class="sr-only" />
<label for="item-split" class="option-label">
<h4>Item-Based Split</h4>
<p>Each person pays for items they added to the list</p>
</label>
</div>
</div>
<div class="prompt-actions">
<Button variant="outline" @click="showExpensePrompt = false">
Not Now
</Button>
<Button variant="primary" @click="createExpenseFromShopping">
Create Expense
</Button>
</div>
</div>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import type { PropType } from 'vue'
import { useListsStore } from '@/stores/listsStore'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import type { Item } from '@/types/item'
import type { List } from '@/types/list'
// UI Components
import { Button, Alert, Dialog, Spinner } from '@/components/ui'
import SmartShoppingItem from './SmartShoppingItem.vue'
interface UndoAction {
id: string
type: 'claim' | 'unclaim' | 'complete' | 'price-update'
message: string
originalState: any
item: Item
timeRemaining: number
}
interface Conflict {
id: string
message: string
type: 'simultaneous-claim' | 'concurrent-edit' | 'price-conflict'
}
const props = defineProps({
list: {
type: Object as PropType<List>,
required: true
},
items: {
type: Array as PropType<Item[]>,
required: true
},
isOnline: {
type: Boolean,
default: true
}
})
const emit = defineEmits([
'edit-item',
'create-expense'
])
// Stores
const listsStore = useListsStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// Reactive state
const undoAction = ref<UndoAction | null>(null)
const undoProgress = ref(100)
const activeConflicts = ref<Conflict[]>([])
const showReceiptScanner = ref(false)
const showExpensePrompt = ref(false)
const scanningReceipt = ref(false)
const scannedData = ref(null)
const createExpenseType = ref<'equal' | 'item-based'>('equal')
const fileInput = ref<HTMLInputElement>()
// Computed properties for intelligent item grouping
const currentUser = computed(() => authStore.user)
const incompletedItems = computed(() =>
props.items.filter(item => !item.is_complete)
)
const completedItems = computed(() =>
props.items.filter(item => item.is_complete)
)
const unclaimedItems = computed(() =>
incompletedItems.value.filter(item => !item.claimed_by_user_id)
)
const myClaimedItems = computed(() =>
incompletedItems.value.filter(item =>
item.claimed_by_user_id === currentUser.value?.id
)
)
const othersClaimedItems = computed(() =>
incompletedItems.value.filter(item =>
item.claimed_by_user_id && item.claimed_by_user_id !== currentUser.value?.id
)
)
const claimedByOthersCount = computed(() => othersClaimedItems.value.length)
const completedItemsWithPrices = computed(() =>
completedItems.value.filter(item => {
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
return price > 0
})
)
const hasCompletedItemsWithoutPrices = computed(() =>
completedItems.value.some(item => {
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
return price <= 0
})
)
const canCreateExpense = computed(() =>
completedItemsWithPrices.value.length > 0 && props.list.group_id
)
const totalCompletedValue = computed(() =>
completedItemsWithPrices.value.reduce((sum, item) => {
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
return sum + price
}, 0)
)
// Item action handlers with optimistic updates and undo functionality
const handleClaimItem = async (item: Item) => {
if (!props.isOnline) {
notificationStore.addNotification({
type: 'error',
message: 'Cannot claim items while offline'
})
return
}
// Check for simultaneous claims
if (item.claimed_by_user_id) {
addConflict({
id: `claim-conflict-${item.id}`,
type: 'simultaneous-claim',
message: `"${item.name}" was just claimed by ${item.claimed_by_user?.name}. Refresh to see latest state.`
})
return
}
const originalState = { ...item }
// Optimistic update
item.claimed_by_user_id = currentUser.value?.id ? Number(currentUser.value.id) : null
item.claimed_by_user = currentUser.value || null
item.claimed_at = new Date().toISOString()
try {
await listsStore.claimItem(item.id)
// Add undo action
addUndoAction({
id: `claim-${item.id}`,
type: 'claim',
message: `Claimed "${item.name}"`,
originalState,
item,
timeRemaining: 10000
})
// Haptic feedback
if ('vibrate' in navigator) {
navigator.vibrate(50)
}
} catch (error) {
// Rollback optimistic update
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to claim item. Please try again.'
})
}
}
const handleUnclaimItem = async (item: Item) => {
const originalState = { ...item }
// Optimistic update
item.claimed_by_user_id = null
item.claimed_by_user = null
item.claimed_at = null
try {
await listsStore.unclaimItem(item.id)
addUndoAction({
id: `unclaim-${item.id}`,
type: 'unclaim',
message: `Unclaimed "${item.name}"`,
originalState,
item,
timeRemaining: 10000
})
} catch (error) {
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to unclaim item. Please try again.'
})
}
}
const handleCompleteItem = async (item: Item, completed: boolean) => {
const originalState = { ...item }
// Optimistic update
item.is_complete = completed
try {
// Call the actual API through the store
await listsStore.updateItem(item.id, { is_complete: completed })
if (completed) {
addUndoAction({
id: `complete-${item.id}`,
type: 'complete',
message: `Completed "${item.name}"`,
originalState,
item,
timeRemaining: 10000
})
// Celebration feedback
if ('vibrate' in navigator) {
navigator.vibrate([100, 50, 100])
}
}
} catch (error) {
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to update item. Please try again.'
})
}
}
const handleUpdatePrice = async (item: Item, price: number) => {
const originalState = { ...item }
// Optimistic update
item.price = price
try {
await listsStore.updateItem(item.id, { price })
addUndoAction({
id: `price-${item.id}`,
type: 'price-update',
message: `Updated price for "${item.name}" to $${price.toFixed(2)}`,
originalState,
item,
timeRemaining: 10000
})
} catch (error) {
Object.assign(item, originalState)
notificationStore.addNotification({
type: 'error',
message: 'Failed to update price. Please try again.'
})
}
}
// Undo system
const addUndoAction = (action: UndoAction) => {
// Clear any existing undo action
if (undoAction.value) {
clearUndoTimer()
}
undoAction.value = action
undoProgress.value = 100
// Start countdown timer
const startTime = Date.now()
const timer = setInterval(() => {
const elapsed = Date.now() - startTime
const remaining = Math.max(0, action.timeRemaining - elapsed)
undoProgress.value = (remaining / action.timeRemaining) * 100
if (remaining <= 0) {
clearInterval(timer)
undoAction.value = null
}
}, 50)
// Auto-clear after timeout
setTimeout(() => {
if (undoAction.value?.id === action.id) {
undoAction.value = null
}
}, action.timeRemaining)
}
const performUndo = async () => {
if (!undoAction.value) return
const action = undoAction.value
undoAction.value = null
try {
// Restore original state
Object.assign(action.item, action.originalState)
// Make the API call to revert
switch (action.type) {
case 'claim':
await listsStore.unclaimItem(action.item.id)
break
case 'unclaim':
await listsStore.claimItem(action.item.id)
break
case 'complete':
await listsStore.updateItem(action.item.id, { is_complete: action.originalState.is_complete })
break
case 'price-update':
await listsStore.updateItem(action.item.id, { price: action.originalState.price })
break
}
notificationStore.addNotification({
type: 'success',
message: 'Action undone successfully'
})
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to undo action. Please try again.'
})
}
}
const clearUndoTimer = () => {
undoAction.value = null
}
// Conflict management
const addConflict = (conflict: Conflict) => {
activeConflicts.value.push(conflict)
// Auto-dismiss after 10 seconds
setTimeout(() => {
dismissConflict(conflict.id)
}, 10000)
}
const dismissConflict = (conflictId: string) => {
activeConflicts.value = activeConflicts.value.filter(c => c.id !== conflictId)
}
// Receipt scanning
const openReceiptScanner = () => {
showReceiptScanner.value = true
}
const triggerFileUpload = () => {
fileInput.value?.click()
}
const handleReceiptUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
scanningReceipt.value = true
try {
// This would integrate with Tesseract.js or a backend OCR service
// For now, we'll simulate the scanning process
await new Promise(resolve => setTimeout(resolve, 2000))
// Mock scanned data - in reality this would come from OCR
scannedData.value = {
items: [
{ name: 'Milk', price: 4.99 },
{ name: 'Bread', price: 3.50 },
{ name: 'Eggs', price: 6.99 }
]
} as any
} catch (error) {
notificationStore.addNotification({
type: 'error',
message: 'Failed to scan receipt. Please try again.'
})
} finally {
scanningReceipt.value = false
}
}
const applyScannedPrice = (detectedItem: any) => {
// Find matching item in the list and apply the price
const matchingItem = completedItems.value.find(item =>
item.name.toLowerCase().includes(detectedItem.name.toLowerCase()) ||
detectedItem.name.toLowerCase().includes(item.name.toLowerCase())
)
if (matchingItem) {
handleUpdatePrice(matchingItem, detectedItem.price)
notificationStore.addNotification({
type: 'success',
message: `Applied $${detectedItem.price.toFixed(2)} to "${matchingItem.name}"`
})
}
}
// Expense creation
const promptExpenseCreation = () => {
showExpensePrompt.value = true
}
const createExpenseFromShopping = () => {
emit('create-expense', {
type: createExpenseType.value,
items: completedItemsWithPrices.value,
total: totalCompletedValue.value
})
showExpensePrompt.value = false
notificationStore.addNotification({
type: 'success',
message: 'Expense creation initiated'
})
}
// Lifecycle
onMounted(() => {
// Connect to WebSocket for real-time updates if online
if (props.isOnline && props.list.id) {
listsStore.connectWebSocket(props.list.id, authStore.token)
}
})
onUnmounted(() => {
listsStore.disconnectWebSocket()
clearUndoTimer()
})
// Watch for online status changes
watch(() => props.isOnline, (isOnline) => {
if (isOnline && props.list.id) {
listsStore.connectWebSocket(props.list.id, authStore.token)
} else {
listsStore.disconnectWebSocket()
}
})
</script>
<style scoped>
.smart-shopping-list {
@apply max-w-4xl mx-auto space-y-6;
}
.list-header {
@apply flex items-start justify-between p-4 bg-white rounded-lg shadow-soft border border-neutral-200;
}
.header-info {
@apply flex-1;
}
.list-title {
@apply text-xl font-semibold text-neutral-900 mb-1;
}
.list-stats {
@apply flex items-center space-x-4 text-sm text-neutral-600;
}
.claimed-indicator {
@apply px-2 py-1 bg-amber-100 text-amber-800 rounded-full text-xs font-medium;
}
.header-actions {
@apply flex items-center space-x-3;
}
.item-group {
@apply bg-white rounded-lg shadow-soft border border-neutral-200 overflow-hidden;
}
.group-title {
@apply flex items-center px-4 py-3 bg-neutral-50 border-b border-neutral-200 font-medium text-neutral-900;
}
.group-title.claimed-by-me {
@apply bg-blue-50 text-blue-900;
}
.group-title.claimed-by-others {
@apply bg-amber-50 text-amber-900;
}
.group-title.completed {
@apply bg-green-50 text-green-900;
}
.items-list {
@apply divide-y divide-neutral-200;
}
.completed-items {
@apply opacity-75;
}
.conflict-alert {
@apply border-l-4 border-l-amber-500;
}
.undo-toast {
@apply fixed bottom-4 right-4 z-50 bg-neutral-900 text-white rounded-lg shadow-floating p-4 min-w-80;
@apply flex items-center justify-between;
}
.undo-content {
@apply flex-1 relative;
}
.undo-message {
@apply text-sm font-medium;
}
.undo-progress {
@apply absolute bottom-0 left-0 h-0.5 bg-blue-500 transition-all duration-50 ease-linear;
}
.undo-button {
@apply ml-4 text-blue-400 hover:text-blue-300;
}
.receipt-scanner {
@apply space-y-6;
}
.scanner-area {
@apply flex flex-col items-center justify-center p-8 border-2 border-dashed border-neutral-300 rounded-lg;
}
.scanning-status {
@apply flex items-center justify-center space-x-3 p-4 bg-blue-50 rounded-lg;
}
.scanned-results {
@apply space-y-4;
}
.results-title {
@apply font-semibold text-neutral-900;
}
.detected-items {
@apply space-y-2;
}
.detected-item {
@apply flex items-center justify-between p-3 bg-neutral-50 rounded-lg;
}
.item-name {
@apply font-medium text-neutral-900;
}
.item-price {
@apply text-green-600 font-mono;
}
.expense-prompt {
@apply space-y-6;
}
.prompt-message {
@apply text-neutral-700 leading-relaxed;
}
.expense-options {
@apply space-y-3;
}
.option-card {
@apply border border-neutral-200 rounded-lg p-4 cursor-pointer transition-all duration-200;
@apply hover:border-blue-300 hover:bg-blue-50;
}
.option-card:has(input:checked) {
@apply border-blue-500 bg-blue-50;
}
.option-label {
@apply cursor-pointer;
}
.option-label h4 {
@apply font-semibold text-neutral-900 mb-1;
}
.option-label p {
@apply text-sm text-neutral-600;
}
.prompt-actions {
@apply flex justify-end space-x-3;
}
/* Transitions */
.alert-enter-active,
.alert-leave-active {
@apply transition-all duration-300 ease-out;
}
.alert-enter-from,
.alert-leave-to {
@apply opacity-0 transform translate-y-2;
}
.undo-toast-enter-active,
.undo-toast-leave-active {
@apply transition-all duration-300 ease-out;
}
.undo-toast-enter-from,
.undo-toast-leave-to {
@apply opacity-0 transform translate-x-full;
}
</style>