
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.
828 lines
26 KiB
Vue
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> |