mitlist/fe/src/components/QuickChoreAdd.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

647 lines
20 KiB
Vue

<template>
<Card variant="outlined" color="neutral" padding="md" class="quick-chore-add">
<div class="add-container">
<!-- Quick Add Input -->
<div class="input-group">
<div class="input-wrapper">
<Input ref="inputRef" v-model="choreName" :placeholder="placeholderText" :loading="isLoading"
size="md" class="quick-input" @keyup.enter="handleAdd" @focus="handleFocus" @blur="handleBlur"
@input="handleInput">
<template #prefix>
<BaseIcon name="heroicons:plus-20-solid" class="input-icon" />
</template>
</Input>
<!-- Suggestions Dropdown -->
<Transition name="dropdown">
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="suggestions-dropdown">
<div v-for="(suggestion, index) in filteredSuggestions" :key="suggestion.id" :class="[
'suggestion-item',
{ 'selected': selectedSuggestionIndex === index }
]" @click="selectSuggestion(suggestion)" @mouseenter="selectedSuggestionIndex = index">
<div class="suggestion-content">
<div class="suggestion-icon">
<BaseIcon :name="suggestion.icon" />
</div>
<div class="suggestion-text">
<span class="suggestion-title">{{ suggestion.name }}</span>
<span v-if="suggestion.description" class="suggestion-description">
{{ suggestion.description }}
</span>
</div>
<div v-if="suggestion.frequency" class="suggestion-meta">
<span class="frequency-badge">{{ suggestion.frequency }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<Button variant="solid" color="primary" size="md" :disabled="!canAdd" :loading="isLoading"
@click="() => handleAdd()" class="add-button">
<template #icon-left>
<BaseIcon name="heroicons:plus-20-solid" />
</template>
<span class="sr-only md:not-sr-only">Add</span>
</Button>
<!-- Smart Actions -->
<Button v-if="hasSmartSuggestions" variant="soft" color="success" size="md"
@click="showSmartSuggestions = !showSmartSuggestions" class="smart-button"
:class="{ 'active': showSmartSuggestions }">
<template #icon-left>
<BaseIcon name="heroicons:sparkles-20-solid" />
</template>
<span class="sr-only md:not-sr-only">Smart</span>
</Button>
</div>
</div>
<!-- Smart Suggestions Panel -->
<Transition name="expand">
<div v-if="showSmartSuggestions && smartSuggestions.length > 0" class="smart-panel">
<div class="smart-header">
<div class="smart-title">
<BaseIcon name="heroicons:lightbulb-20-solid" class="title-icon" />
<span>Quick suggestions</span>
</div>
<span class="smart-count">{{ smartSuggestions.length }}</span>
</div>
<div class="smart-suggestions">
<button v-for="suggestion in smartSuggestions" :key="suggestion.id"
@click="selectSuggestion(suggestion)" class="smart-suggestion">
<div class="suggestion-icon">
<BaseIcon :name="suggestion.icon" />
</div>
<div class="suggestion-content">
<span class="suggestion-name">{{ suggestion.name }}</span>
<span v-if="suggestion.description" class="suggestion-desc">
{{ suggestion.description }}
</span>
</div>
<div class="suggestion-action">
<BaseIcon name="heroicons:plus-circle-20-solid" class="plus-icon" />
</div>
</button>
</div>
</div>
</Transition>
<!-- Quick Tips -->
<div v-if="showTips" class="quick-tips">
<div class="tip-content">
<BaseIcon name="heroicons:information-circle-20-solid" class="tip-icon" />
<span class="tip-text">{{ currentTip }}</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useChoreStore } from '@/stores/choreStore'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import { Card, Input, Button } from '@/components/ui'
import BaseIcon from '@/components/BaseIcon.vue'
interface ChoreSuggestion {
id: string
name: string
description?: string
icon: string
frequency?: string
template?: {
description: string
frequency: 'daily' | 'weekly' | 'monthly' | 'one_time'
type: 'personal' | 'group'
}
}
const choreStore = useChoreStore()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// State
const inputRef = ref<InstanceType<typeof Input>>()
const choreName = ref('')
const isLoading = ref(false)
const isFocused = ref(false)
const showSuggestions = ref(false)
const showSmartSuggestions = ref(false)
const selectedSuggestionIndex = ref(-1)
const suggestions = ref<ChoreSuggestion[]>([])
// Smart contextual tips
const tips = [
"Try 'Water plants' or 'Take out trash'",
"Use frequency hints like 'Weekly laundry'",
"Quick chores get done faster!",
"Daily chores build great habits"
]
const currentTipIndex = ref(0)
// Computed
const canAdd = computed(() => choreName.value.trim().length > 0)
const placeholderText = computed(() => {
if (isLoading.value) return 'Adding chore...'
if (isFocused.value) return 'What needs to be done?'
const hour = new Date().getHours()
if (hour < 12) return 'Add a morning task...'
if (hour < 17) return 'Add an afternoon task...'
return 'Add an evening task...'
})
const filteredSuggestions = computed(() => {
if (!choreName.value.trim()) return []
const query = choreName.value.toLowerCase()
return suggestions.value
.filter(suggestion =>
suggestion.name.toLowerCase().includes(query) ||
suggestion.description?.toLowerCase().includes(query)
)
.slice(0, 5)
})
const smartSuggestions = computed(() => {
return suggestions.value
.filter(suggestion => suggestion.template)
.slice(0, 6)
})
const hasSmartSuggestions = computed(() => smartSuggestions.value.length > 0)
const showTips = computed(() =>
!isFocused.value && !choreName.value && !showSmartSuggestions.value
)
const currentTip = computed(() => tips[currentTipIndex.value])
// Generate chore suggestions based on common patterns
const generateSuggestions = (): ChoreSuggestion[] => {
const commonChores: ChoreSuggestion[] = [
// Daily tasks
{
id: 'make-bed',
name: 'Make bed',
description: 'Start the day organized',
icon: 'heroicons:home-20-solid',
frequency: 'Daily',
template: {
description: 'Make bed and tidy bedroom',
frequency: 'daily',
type: 'personal'
}
},
{
id: 'dishes',
name: 'Do dishes',
description: 'Keep kitchen clean',
icon: 'heroicons:beaker-20-solid',
frequency: 'Daily',
template: {
description: 'Wash dishes and clean kitchen',
frequency: 'daily',
type: 'personal'
}
},
{
id: 'take-out-trash',
name: 'Take out trash',
description: 'Empty all waste bins',
icon: 'heroicons:trash-20-solid',
frequency: 'Weekly',
template: {
description: 'Empty trash bins and take to curb',
frequency: 'weekly',
type: 'personal'
}
},
// Weekly tasks
{
id: 'laundry',
name: 'Do laundry',
description: 'Wash, dry, and fold clothes',
icon: 'heroicons:cog-6-tooth-20-solid',
frequency: 'Weekly',
template: {
description: 'Complete laundry cycle and put away clothes',
frequency: 'weekly',
type: 'personal'
}
},
{
id: 'vacuum',
name: 'Vacuum floors',
description: 'Clean all carpeted areas',
icon: 'heroicons:home-modern-20-solid',
frequency: 'Weekly',
template: {
description: 'Vacuum all rooms and common areas',
frequency: 'weekly',
type: 'personal'
}
},
{
id: 'grocery-shopping',
name: 'Grocery shopping',
description: 'Buy weekly groceries',
icon: 'heroicons:shopping-cart-20-solid',
frequency: 'Weekly',
template: {
description: 'Plan meals and buy groceries for the week',
frequency: 'weekly',
type: 'personal'
}
},
// Monthly tasks
{
id: 'clean-bathroom',
name: 'Deep clean bathroom',
description: 'Thorough bathroom cleaning',
icon: 'heroicons:sparkles-20-solid',
frequency: 'Monthly',
template: {
description: 'Deep clean bathroom including scrubbing and disinfecting',
frequency: 'monthly',
type: 'personal'
}
},
{
id: 'organize-closet',
name: 'Organize closet',
description: 'Sort and arrange clothing',
icon: 'heroicons:squares-2x2-20-solid',
frequency: 'Monthly',
template: {
description: 'Sort clothes, donate unused items, organize by season',
frequency: 'monthly',
type: 'personal'
}
},
// One-time tasks
{
id: 'water-plants',
name: 'Water plants',
description: 'Care for houseplants',
icon: 'heroicons:beaker-20-solid',
frequency: 'As needed',
template: {
description: 'Water all houseplants and check for care needs',
frequency: 'one_time',
type: 'personal'
}
},
{
id: 'pay-bills',
name: 'Pay bills',
description: 'Handle monthly payments',
icon: 'heroicons:banknotes-20-solid',
frequency: 'Monthly',
template: {
description: 'Review and pay monthly bills and subscriptions',
frequency: 'monthly',
type: 'personal'
}
}
]
return commonChores
}
// Event handlers
const handleFocus = () => {
isFocused.value = true
showSuggestions.value = true
}
const handleBlur = () => {
// Delay to allow suggestion clicks
setTimeout(() => {
isFocused.value = false
showSuggestions.value = false
selectedSuggestionIndex.value = -1
}, 200)
}
const handleInput = () => {
if (choreName.value.trim()) {
showSuggestions.value = true
selectedSuggestionIndex.value = -1
} else {
showSuggestions.value = false
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedSuggestionIndex.value = Math.min(
selectedSuggestionIndex.value + 1,
filteredSuggestions.value.length - 1
)
break
case 'ArrowUp':
event.preventDefault()
selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, -1)
break
case 'Enter':
event.preventDefault()
if (selectedSuggestionIndex.value >= 0) {
selectSuggestion(filteredSuggestions.value[selectedSuggestionIndex.value])
} else {
handleAdd()
}
break
case 'Escape':
showSuggestions.value = false
selectedSuggestionIndex.value = -1
break
}
}
const selectSuggestion = (suggestion: ChoreSuggestion) => {
choreName.value = suggestion.name
showSuggestions.value = false
showSmartSuggestions.value = false
selectedSuggestionIndex.value = -1
// Auto-add if it's a template suggestion
if (suggestion.template) {
nextTick(() => {
handleAdd(suggestion)
})
} else {
// Focus input for potential editing
nextTick(() => {
inputRef.value?.focus?.()
})
}
}
const handleAdd = async (suggestion?: ChoreSuggestion) => {
if (!canAdd.value || isLoading.value) return
isLoading.value = true
try {
const choreData = {
name: choreName.value.trim(),
description: suggestion?.template?.description || '',
frequency: suggestion?.template?.frequency || 'one_time',
type: suggestion?.template?.type || 'personal',
custom_interval_days: undefined,
next_due_date: new Date().toISOString().split('T')[0],
created_by_id: 0, // backend will override
}
await choreStore.create(choreData as any)
// Success feedback
notificationStore.addNotification({
type: 'success',
message: `✨ "${choreName.value.trim()}" added successfully!`,
})
// Haptic feedback
if ('vibrate' in navigator) {
navigator.vibrate([50, 50, 100])
}
// Reset form
choreName.value = ''
showSmartSuggestions.value = false
selectedSuggestionIndex.value = -1
// Focus input for continued adding
nextTick(() => {
inputRef.value?.focus?.()
})
} catch (error) {
console.error('Failed to create quick chore', error)
notificationStore.addNotification({
type: 'error',
message: 'Failed to add chore. Please try again.',
})
} finally {
isLoading.value = false
}
}
// Lifecycle
onMounted(() => {
suggestions.value = generateSuggestions()
// Cycle tips every 5 seconds
setInterval(() => {
currentTipIndex.value = (currentTipIndex.value + 1) % tips.length
}, 5000)
})
// Add keyboard event listener when focused
watch(isFocused, (focused) => {
if (focused) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
})
</script>
<style scoped>
.quick-chore-add {
@apply bg-gradient-to-r from-primary-50 to-primary-50/30 dark:from-primary-900 dark:to-primary-900/30;
@apply border-primary-200 dark:border-primary-800;
@apply transition-all duration-micro hover:shadow-medium;
}
.add-container {
@apply space-y-spacing-sm;
}
.input-group {
@apply flex items-center gap-spacing-sm;
}
.input-wrapper {
@apply flex-1 relative;
}
.quick-input {
@apply transition-all duration-micro;
}
.input-icon {
@apply w-4 h-4 text-text-secondary;
}
.suggestions-dropdown {
@apply absolute top-full left-0 right-0 z-50 mt-1;
@apply bg-surface-primary border border-border-secondary rounded-radius-lg shadow-elevation-medium;
@apply max-h-64 overflow-y-auto;
}
.suggestion-item {
@apply px-spacing-sm py-spacing-xs cursor-pointer transition-colors duration-micro;
@apply border-b border-border-subtle last:border-b-0;
@apply hover:bg-primary-50 dark:hover:bg-primary-950/30;
}
.suggestion-item.selected {
@apply bg-primary-100 dark:bg-primary-900/50 text-primary-900 dark:text-primary-100;
}
.suggestion-content {
@apply flex items-center gap-spacing-sm;
}
.suggestion-icon {
@apply w-8 h-8 bg-surface-secondary rounded-radius-lg flex items-center justify-center flex-shrink-0;
}
.suggestion-text {
@apply flex-1 min-w-0;
}
.suggestion-title {
@apply block font-medium text-text-primary truncate;
}
.suggestion-description {
@apply block text-label-sm text-text-secondary truncate;
}
.suggestion-meta {
@apply flex-shrink-0;
}
.frequency-badge {
@apply px-spacing-xs py-1 bg-success-100 dark:bg-success-900/50 text-success-700 dark:text-success-300;
@apply text-label-xs font-medium rounded-radius-sm;
}
.action-buttons {
@apply flex items-center gap-spacing-xs;
}
.add-button {
@apply transition-transform duration-micro hover:scale-105 active:scale-95;
}
.smart-button {
@apply transition-all duration-micro;
}
.smart-button.active {
@apply bg-success-100 dark:bg-success-900/50 text-success-700 dark:text-success-300;
@apply ring-2 ring-success-200 dark:ring-success-800;
}
.smart-panel {
@apply bg-surface-secondary border border-border-secondary rounded-radius-lg p-spacing-sm;
}
.smart-header {
@apply flex items-center justify-between mb-spacing-sm;
}
.smart-title {
@apply flex items-center gap-spacing-xs text-body-sm font-medium text-text-primary;
}
.title-icon {
@apply w-4 h-4 text-warning-500;
}
.smart-count {
@apply px-spacing-xs py-1 bg-warning-100 dark:bg-warning-900/50 text-warning-700 dark:text-warning-300;
@apply text-label-xs font-medium rounded-radius-full;
}
.smart-suggestions {
@apply grid grid-cols-1 sm:grid-cols-2 gap-spacing-xs;
}
.smart-suggestion {
@apply flex items-center gap-spacing-sm p-spacing-sm;
@apply bg-surface-primary border border-border-subtle rounded-radius-lg;
@apply transition-all duration-micro hover:shadow-elevation-soft hover:scale-[1.02];
@apply cursor-pointer text-left;
}
.suggestion-name {
@apply block font-medium text-text-primary text-body-sm;
}
.suggestion-desc {
@apply block text-label-xs text-text-secondary;
}
.suggestion-action {
@apply flex-shrink-0;
}
.plus-icon {
@apply w-5 h-5 text-primary-500 transition-transform duration-micro group-hover:scale-110;
}
.quick-tips {
@apply flex items-center justify-center py-spacing-xs;
}
.tip-content {
@apply flex items-center gap-spacing-xs text-label-sm text-text-secondary;
}
.tip-icon {
@apply w-4 h-4 text-primary-400;
}
.tip-text {
@apply transition-opacity duration-medium;
}
/* Transitions */
.dropdown-enter-active,
.dropdown-leave-active {
@apply transition-all duration-micro ease-medium;
}
.dropdown-enter-from {
@apply opacity-0 scale-95 translate-y-2;
}
.dropdown-leave-to {
@apply opacity-0 scale-95 translate-y-2;
}
.expand-enter-active,
.expand-leave-active {
@apply transition-all duration-medium ease-medium;
}
.expand-enter-from,
.expand-leave-to {
@apply opacity-0 max-h-0 overflow-hidden;
}
.expand-enter-to,
.expand-leave-from {
@apply opacity-100 max-h-96;
}
</style>