
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.
647 lines
20 KiB
Vue
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> |