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

861 lines
29 KiB
Vue

<template>
<Dialog v-model="isOpen" class="chore-detail-sheet">
<div class="sheet-container">
<!-- Enhanced Header -->
<div class="sheet-header">
<div class="header-content">
<div class="header-main">
<div class="chore-icon">
<BaseIcon :name="getChoreIcon(chore)" class="icon" />
</div>
<div class="header-text">
<Heading :level="2" class="chore-title">
{{ chore?.name || 'Chore Details' }}
</Heading>
<div class="chore-meta">
<span class="chore-type">{{ chore?.type === 'group' ? 'Group Task' : 'Personal Task'
}}</span>
<span class="chore-status" :class="getStatusClass(chore)">
{{ getStatusText(chore) }}
</span>
</div>
</div>
</div>
<div class="header-actions">
<Button variant="ghost" size="sm" @click="toggleFavorite" v-if="chore">
<BaseIcon :name="isFavorite ? 'heroicons:heart-solid' : 'heroicons:heart-20-solid'"
:class="{ 'text-error-500': isFavorite }" />
</Button>
<Button variant="ghost" size="sm" @click="close" class="close-button">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</Button>
</div>
</div>
<!-- Quick Actions Bar -->
<div v-if="chore" class="quick-actions">
<Button v-if="canComplete" variant="solid" color="success" size="sm" @click="handleComplete"
:loading="isCompleting" class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:check-20-solid" />
</template>
Complete
</Button>
<Button v-if="canClaim" variant="solid" color="primary" size="sm" @click="handleClaim"
:loading="isClaiming" class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:hand-raised-20-solid" />
</template>
Claim
</Button>
<Button variant="outline" color="neutral" size="sm" @click="startTimer" v-if="!isTimerRunning"
class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:play-20-solid" />
</template>
Start Timer
</Button>
<Button variant="solid" color="warning" size="sm" @click="stopTimer" v-if="isTimerRunning"
class="quick-action">
<template #icon-left>
<BaseIcon name="heroicons:pause-20-solid" />
</template>
{{ formatTimerDuration(currentTimerDuration) }}
</Button>
</div>
</div>
<!-- Content Sections with Progressive Disclosure -->
<div class="sheet-content">
<!-- Primary Information (Always Visible) -->
<Card variant="elevated" color="neutral" padding="lg" class="info-section">
<div class="section-header">
<h3 class="section-title">Overview</h3>
<div class="section-badges">
<span v-if="chore?.frequency" class="frequency-badge">
{{ getFrequencyLabel(chore.frequency) }}
</span>
<span v-if="getDueDateBadge(chore)" class="due-date-badge" :class="getDueDateClass(chore)">
{{ getDueDateBadge(chore) }}
</span>
</div>
</div>
<div class="overview-grid">
<div class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:calendar-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Due Date</span>
<span class="item-value">{{ formatDate(chore?.next_due_date) }}</span>
</div>
</div>
<div class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:user-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Created by</span>
<span class="item-value">{{ chore?.creator?.full_name || chore?.creator?.email ||
'Unknown'
}}</span>
</div>
</div>
<div class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:arrow-path-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Frequency</span>
<span class="item-value">{{ getFrequencyDescription(chore) }}</span>
</div>
</div>
<div v-if="getCurrentAssignment(chore)" class="overview-item">
<div class="item-icon">
<BaseIcon name="heroicons:user-circle-20-solid" />
</div>
<div class="item-content">
<span class="item-label">Assigned to</span>
<span class="item-value">{{ getAssignedUserName(chore) }}</span>
</div>
</div>
</div>
</Card>
<!-- Description Section (Expandable) -->
<Card v-if="chore?.description" variant="outlined" color="neutral" padding="lg"
class="description-section">
<div class="expandable-section" @click="toggleSection('description')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:document-text-20-solid" class="section-icon" />
<h3 class="section-title">Description</h3>
</div>
<BaseIcon
:name="expandedSections.description ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.description" class="section-content">
<p class="description-text">{{ chore.description }}</p>
</div>
</TransitionExpand>
</Card>
<!-- History & Progress Section (Expandable) -->
<Card variant="outlined" color="neutral" padding="lg" class="history-section">
<div class="expandable-section" @click="toggleSection('history')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:clock-20-solid" class="section-icon" />
<h3 class="section-title">History & Progress</h3>
<span class="completion-count">{{ getCompletionCount(chore) }} completions</span>
</div>
<BaseIcon
:name="expandedSections.history ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.history" class="section-content">
<div v-if="chore?.assignments && chore.assignments.length > 0" class="assignments-list">
<div v-for="assignment in chore.assignments.slice(0, showAllHistory ? undefined : 3)"
:key="assignment.id" class="assignment-item">
<div class="assignment-status">
<div :class="['status-indicator', { 'completed': assignment.is_complete }]">
<BaseIcon
:name="assignment.is_complete ? 'heroicons:check-20-solid' : 'heroicons:clock-20-solid'" />
</div>
</div>
<div class="assignment-details">
<span class="assignment-user">{{ assignment.assigned_user?.full_name ||
assignment.assigned_user?.email }}</span>
<div class="assignment-dates">
<span class="due-date">Due: {{ formatDate(assignment.due_date) }}</span>
<span v-if="assignment.completed_at" class="completed-date">
Completed: {{ formatDate(assignment.completed_at) }}
</span>
</div>
</div>
</div>
<Button v-if="chore.assignments.length > 3 && !showAllHistory" variant="ghost"
color="primary" size="sm" @click="showAllHistory = true" class="show-more-button">
Show {{ chore.assignments.length - 3 }} more assignments
</Button>
</div>
<div v-else class="no-history">
<BaseIcon name="heroicons:calendar-x-mark-20-solid" class="no-history-icon" />
<p>No completion history yet</p>
</div>
</div>
</TransitionExpand>
</Card>
<!-- Sub-tasks Section (Expandable) -->
<Card v-if="chore?.child_chores?.length" variant="outlined" color="neutral" padding="lg"
class="subtasks-section">
<div class="expandable-section" @click="toggleSection('subtasks')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:list-bullet-20-solid" class="section-icon" />
<h3 class="section-title">Sub-tasks</h3>
<span class="subtask-count">{{ chore.child_chores.length }} tasks</span>
</div>
<BaseIcon
:name="expandedSections.subtasks ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.subtasks" class="section-content">
<div class="subtasks-list">
<div v-for="sub in chore.child_chores" :key="sub.id" class="subtask-item">
<div class="subtask-checkbox">
<BaseIcon name="heroicons:check-circle-20-solid" v-if="isSubtaskComplete(sub)"
class="completed-icon" />
<div v-else class="uncompleted-circle"></div>
</div>
<span class="subtask-name" :class="{ 'completed': isSubtaskComplete(sub) }">
{{ sub.name }}
</span>
</div>
</div>
</div>
</TransitionExpand>
</Card>
<!-- Scheduling Section (Expandable) -->
<Card variant="outlined" color="neutral" padding="lg" class="scheduling-section">
<div class="expandable-section" @click="toggleSection('scheduling')">
<div class="section-header-expandable">
<div class="section-header-content">
<BaseIcon name="heroicons:calendar-days-20-solid" class="section-icon" />
<h3 class="section-title">Scheduling</h3>
</div>
<BaseIcon
:name="expandedSections.scheduling ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="expand-icon" />
</div>
</div>
<TransitionExpand>
<div v-if="expandedSections.scheduling" class="section-content">
<div class="scheduling-grid">
<div class="scheduling-item">
<span class="scheduling-label">Next Due Date</span>
<span class="scheduling-value">{{ formatDate(chore?.next_due_date) }}</span>
</div>
<div v-if="chore?.last_completed_at" class="scheduling-item">
<span class="scheduling-label">Last Completed</span>
<span class="scheduling-value">{{ formatDate(chore.last_completed_at) }}</span>
</div>
<div class="scheduling-item">
<span class="scheduling-label">Created</span>
<span class="scheduling-value">{{ formatDate(chore?.created_at) }}</span>
</div>
<div class="scheduling-item">
<span class="scheduling-label">Last Updated</span>
<span class="scheduling-value">{{ formatDate(chore?.updated_at) }}</span>
</div>
</div>
</div>
</TransitionExpand>
</Card>
</div>
<!-- Footer Actions -->
<div class="sheet-footer">
<div class="footer-actions">
<Button variant="outline" color="neutral" @click="editChore" v-if="canEdit">
<template #icon-left>
<BaseIcon name="heroicons:pencil-20-solid" />
</template>
Edit
</Button>
<Button variant="outline" color="error" @click="deleteChore" v-if="canDelete">
<template #icon-left>
<BaseIcon name="heroicons:trash-20-solid" />
</template>
Delete
</Button>
<div class="spacer"></div>
<Button variant="ghost" color="neutral" @click="close">
Close
</Button>
</div>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { format, isToday, isTomorrow, isPast, isYesterday } from 'date-fns'
import type { ChoreWithCompletion } from '@/types/chore'
import BaseIcon from '@/components/BaseIcon.vue'
import { Dialog, Card, Button, Heading } from '@/components/ui'
import TransitionExpand from '@/components/ui/TransitionExpand.vue'
interface Props {
modelValue: boolean
chore: ChoreWithCompletion | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'complete', chore: ChoreWithCompletion): void
(e: 'claim', chore: ChoreWithCompletion): void
(e: 'edit', chore: ChoreWithCompletion): void
(e: 'delete', chore: ChoreWithCompletion): void
}>()
// State
const expandedSections = ref({
description: false,
history: false,
subtasks: false,
scheduling: false
})
const showAllHistory = ref(false)
const isFavorite = ref(false)
const isCompleting = ref(false)
const isClaiming = ref(false)
const isTimerRunning = ref(false)
const currentTimerDuration = ref(0)
const timerInterval = ref<number | null>(null)
// Computed
const isOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
const canComplete = computed(() => {
if (!props.chore) return false
const assignment = getCurrentAssignment(props.chore)
return assignment && !assignment.is_complete
})
const canClaim = computed(() => {
if (!props.chore) return false
return props.chore.type === 'group' && !getCurrentAssignment(props.chore)
})
const canEdit = computed(() => {
// Add logic based on user permissions
return true
})
const canDelete = computed(() => {
// Add logic based on user permissions
return true
})
// Methods
function close() {
emit('update:modelValue', false)
}
function formatDate(dateStr?: string) {
if (!dateStr) return 'Not set'
const date = new Date(dateStr)
if (isToday(date)) return 'Today'
if (isTomorrow(date)) return 'Tomorrow'
if (isYesterday(date)) return 'Yesterday'
return format(date, 'MMM d, yyyy')
}
function getChoreIcon(chore?: ChoreWithCompletion | null) {
if (!chore) return 'heroicons:clipboard-document-list-20-solid'
if (chore.type === 'group') return 'heroicons:user-group-20-solid'
return 'heroicons:user-20-solid'
}
function getStatusClass(chore?: ChoreWithCompletion | null) {
if (!chore) return ''
const assignment = getCurrentAssignment(chore)
if (assignment?.is_complete) return 'status-completed'
if (chore.next_due_date && isPast(new Date(chore.next_due_date))) {
return 'status-overdue'
}
return 'status-pending'
}
function getStatusText(chore?: ChoreWithCompletion | null) {
if (!chore) return 'Unknown'
const assignment = getCurrentAssignment(chore)
if (assignment?.is_complete) return 'Completed'
if (chore.next_due_date && isPast(new Date(chore.next_due_date))) {
return 'Overdue'
}
return 'Pending'
}
function getCurrentAssignment(chore?: ChoreWithCompletion | null) {
if (!chore?.assignments || chore.assignments.length === 0) return null
return chore.assignments[chore.assignments.length - 1]
}
function getAssignedUserName(chore?: ChoreWithCompletion | null) {
const assignment = getCurrentAssignment(chore)
if (!assignment?.assigned_user) return 'Unassigned'
return assignment.assigned_user.full_name || assignment.assigned_user.email
}
function getFrequencyLabel(frequency?: string) {
const map: Record<string, string> = {
one_time: 'One-time',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
custom: 'Custom'
}
return map[frequency || ''] || frequency || 'Unknown'
}
function getFrequencyDescription(chore?: ChoreWithCompletion | null) {
if (!chore) return 'Unknown'
const { frequency, custom_interval_days } = chore
if (frequency === 'custom' && custom_interval_days) {
return `Every ${custom_interval_days} days`
}
return getFrequencyLabel(frequency)
}
function getDueDateBadge(chore?: ChoreWithCompletion | null) {
if (!chore?.next_due_date) return null
const date = new Date(chore.next_due_date)
if (isToday(date)) return 'Due Today'
if (isTomorrow(date)) return 'Due Tomorrow'
if (isPast(date)) return 'Overdue'
return null
}
function getDueDateClass(chore?: ChoreWithCompletion | null) {
if (!chore?.next_due_date) return ''
const date = new Date(chore.next_due_date)
if (isPast(date)) return 'overdue'
if (isToday(date)) return 'due-today'
if (isTomorrow(date)) return 'due-tomorrow'
return ''
}
function getCompletionCount(chore?: ChoreWithCompletion | null) {
if (!chore?.assignments) return 0
return chore.assignments.filter(a => a.is_complete).length
}
function isSubtaskComplete(subtask: any) {
// This would need to be implemented based on your subtask completion logic
return false
}
function toggleSection(section: keyof typeof expandedSections.value) {
expandedSections.value[section] = !expandedSections.value[section]
}
function toggleFavorite() {
isFavorite.value = !isFavorite.value
// Implement favorite logic
}
async function handleComplete() {
if (!props.chore) return
isCompleting.value = true
try {
emit('complete', props.chore)
} finally {
isCompleting.value = false
}
}
async function handleClaim() {
if (!props.chore) return
isClaiming.value = true
try {
emit('claim', props.chore)
} finally {
isClaiming.value = false
}
}
function editChore() {
if (props.chore) {
emit('edit', props.chore)
}
}
function deleteChore() {
if (props.chore) {
emit('delete', props.chore)
}
}
function startTimer() {
isTimerRunning.value = true
currentTimerDuration.value = 0
timerInterval.value = window.setInterval(() => {
currentTimerDuration.value += 1
}, 1000)
}
function stopTimer() {
isTimerRunning.value = false
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
function formatTimerDuration(seconds: number) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Cleanup timer on unmount
onUnmounted(() => {
if (timerInterval.value) {
clearInterval(timerInterval.value)
}
})
</script>
<style scoped>
.chore-detail-sheet {
@apply max-w-2xl mx-auto;
}
.sheet-container {
@apply flex flex-col h-full max-h-[90vh];
}
/* Header Styles */
.sheet-header {
@apply space-y-4 p-6 pb-4;
@apply border-b border-border-primary;
}
.header-content {
@apply flex items-start justify-between;
}
.header-main {
@apply flex items-start gap-4 flex-1;
}
.chore-icon {
@apply flex-shrink-0 w-12 h-12;
@apply bg-primary-100 dark:bg-primary-900;
@apply rounded-lg flex items-center justify-center;
}
.chore-icon .icon {
@apply w-6 h-6 text-primary-600 dark:text-primary-400;
}
.header-text {
@apply space-y-2;
}
.chore-title {
@apply text-xl font-semibold text-text-primary;
@apply leading-tight;
}
.chore-meta {
@apply flex items-center gap-3;
}
.chore-type {
@apply text-sm text-text-secondary;
}
.chore-status {
@apply text-xs font-medium px-2 py-1 rounded-md;
}
.chore-status.status-completed {
@apply bg-success-100 text-success-700 dark:bg-success-900/50 dark:text-success-300;
}
.chore-status.status-overdue {
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
}
.chore-status.status-pending {
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
}
.header-actions {
@apply flex items-center gap-2;
}
.quick-actions {
@apply flex items-center gap-2 flex-wrap;
}
/* Content Styles */
.sheet-content {
@apply flex-1 overflow-y-auto p-6 space-y-4;
}
.info-section {
@apply space-y-4;
}
.section-header {
@apply flex items-center justify-between;
}
.section-title {
@apply text-lg font-semibold text-text-primary;
}
.section-badges {
@apply flex items-center gap-2;
}
.frequency-badge {
@apply text-xs font-medium px-2 py-1 rounded-md;
@apply bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300;
}
.due-date-badge {
@apply text-xs font-medium px-2 py-1 rounded-md;
}
.due-date-badge.overdue {
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
}
.due-date-badge.due-today {
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
}
.due-date-badge.due-tomorrow {
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
}
.overview-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.overview-item {
@apply flex items-center gap-3;
}
.item-icon {
@apply flex-shrink-0 w-8 h-8;
@apply bg-surface-elevated rounded-lg;
@apply flex items-center justify-center;
}
.item-icon svg {
@apply w-4 h-4 text-text-secondary;
}
.item-content {
@apply space-y-1;
}
.item-label {
@apply text-sm text-text-secondary;
}
.item-value {
@apply text-sm font-medium text-text-primary;
}
/* Expandable Sections */
.expandable-section {
@apply cursor-pointer;
}
.section-header-expandable {
@apply flex items-center justify-between py-2;
@apply transition-colors duration-micro hover:bg-surface-hover rounded-md;
}
.section-header-content {
@apply flex items-center gap-3;
}
.section-icon {
@apply w-5 h-5 text-text-secondary;
}
.expand-icon {
@apply w-5 h-5 text-text-secondary;
@apply transition-transform duration-micro;
}
.section-content {
@apply pt-4;
}
.completion-count,
.subtask-count {
@apply text-sm text-text-secondary;
}
/* Description Section */
.description-text {
@apply text-sm text-text-primary leading-relaxed;
@apply whitespace-pre-wrap;
}
/* History Section */
.assignments-list {
@apply space-y-3;
}
.assignment-item {
@apply flex items-start gap-3;
}
.assignment-status {
@apply flex-shrink-0;
}
.status-indicator {
@apply w-8 h-8 rounded-full flex items-center justify-center;
}
.status-indicator.completed {
@apply bg-success-100 text-success-600 dark:bg-success-900/50 dark:text-success-400;
}
.status-indicator:not(.completed) {
@apply bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400;
}
.assignment-details {
@apply space-y-1;
}
.assignment-user {
@apply text-sm font-medium text-text-primary;
}
.assignment-dates {
@apply space-y-1;
}
.due-date,
.completed-date {
@apply text-xs text-text-secondary;
@apply block;
}
.show-more-button {
@apply mt-3;
}
.no-history {
@apply text-center py-8 space-y-3;
}
.no-history-icon {
@apply w-12 h-12 text-text-secondary mx-auto;
}
/* Subtasks Section */
.subtasks-list {
@apply space-y-3;
}
.subtask-item {
@apply flex items-center gap-3;
}
.subtask-checkbox {
@apply flex-shrink-0;
}
.completed-icon {
@apply w-5 h-5 text-success-600 dark:text-success-400;
}
.uncompleted-circle {
@apply w-5 h-5 rounded-full border-2 border-neutral-300 dark:border-neutral-600;
}
.subtask-name {
@apply text-sm text-text-primary;
}
.subtask-name.completed {
@apply line-through text-text-secondary;
}
/* Scheduling Section */
.scheduling-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.scheduling-item {
@apply space-y-1;
}
.scheduling-label {
@apply text-sm text-text-secondary;
@apply block;
}
.scheduling-value {
@apply text-sm font-medium text-text-primary;
@apply block;
}
/* Footer */
.sheet-footer {
@apply p-6 pt-4;
@apply border-t border-border-primary;
}
.footer-actions {
@apply flex items-center gap-3;
}
.spacer {
@apply flex-1;
}
/* Responsive */
@media (max-width: 640px) {
.header-main {
@apply flex-col gap-3;
}
.overview-grid {
@apply grid-cols-1;
}
.scheduling-grid {
@apply grid-cols-1;
}
.quick-actions {
@apply justify-center;
}
}
</style>