
This commit introduces the following changes: - Updated styling for overdue and due-today chore statuses in ChoresPage, replacing border styles with box shadows for better visibility. - Adjusted opacity for completed chores to enhance UI clarity. - Minor formatting fixes in GroupDetailPage for improved button and text alignment. These updates aim to enhance the user experience by providing clearer visual cues and a more polished interface.
2174 lines
66 KiB
Vue
2174 lines
66 KiB
Vue
<template>
|
|
<main class="container page-padding">
|
|
<div class="group-detail-container">
|
|
<div v-if="loading" class="text-center">
|
|
<VSpinner :label="t('groupDetailPage.loadingLabel')" />
|
|
</div>
|
|
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
|
|
<template #actions>
|
|
<VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}
|
|
</VButton>
|
|
</template>
|
|
</VAlert>
|
|
<div v-else-if="group">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<VHeading :level="1" :text="group.name" class="header-title-text" />
|
|
<div class="member-avatar-list">
|
|
<div ref="avatarsContainerRef" class="member-avatars">
|
|
<div v-for="member in group.members" :key="member.id" class="member-avatar">
|
|
<div @click="toggleMemberMenu(member.id)" class="avatar-circle" :title="member.email">
|
|
{{ member.email.charAt(0).toUpperCase() }}
|
|
</div>
|
|
<div v-show="activeMemberMenu === member.id" ref="memberMenuRef" class="member-menu" @click.stop>
|
|
<div class="popup-header">
|
|
<span class="font-semibold truncate">{{ member.email }}</span>
|
|
<VButton variant="neutral" size="sm" :icon-only="true" iconLeft="x" @click="activeMemberMenu = null"
|
|
:aria-label="t('groupDetailPage.members.closeMenuLabel')" />
|
|
</div>
|
|
<div class="member-menu-content">
|
|
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')"
|
|
:variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
|
|
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" class="w-full text-left"
|
|
@click="removeMember(member.id)" :disabled="removingMember === member.id">
|
|
<VSpinner v-if="removingMember === member.id" size="sm" class="mr-1" />
|
|
{{ t('groupDetailPage.members.removeButton') }}
|
|
</VButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button ref="addMemberButtonRef" @click="toggleInviteUI" class="add-member-btn"
|
|
:aria-label="t('groupDetailPage.invites.title')">
|
|
+
|
|
</button>
|
|
|
|
<!-- Invite Members Popup -->
|
|
<div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup">
|
|
<div class="popup-header">
|
|
<VHeading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
|
|
</VHeading>
|
|
<VButton variant="neutral" size="sm" :icon-only="true" iconLeft="x" @click="showInviteUI = false"
|
|
:aria-label="t('groupDetailPage.invites.closeInviteLabel')" />
|
|
</div>
|
|
<p class="text-sm text-gray-500 my-2">{{ t('groupDetailPage.invites.description') }}</p>
|
|
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
|
<VSpinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
|
|
t('groupDetailPage.invites.regenerateButton') :
|
|
t('groupDetailPage.invites.generateButton') }}
|
|
</VButton>
|
|
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
|
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
|
|
<div class="flex items-center gap-2">
|
|
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
|
|
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler"
|
|
:aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
|
|
</div>
|
|
</VFormField>
|
|
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="neo-section-container">
|
|
<!-- Lists Section -->
|
|
<div class="neo-section">
|
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.lists.title') }}</VHeading>
|
|
<ListsPage :group-id="groupId" />
|
|
</div>
|
|
|
|
<!-- Chores Section -->
|
|
<div class="mt-4 neo-section">
|
|
<div class="flex justify-between items-center w-full mb-2">
|
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
|
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton')
|
|
}}
|
|
</VButton>
|
|
</div>
|
|
<div v-if="upcomingChores.length > 0" class="enhanced-chores-list">
|
|
<div v-for="chore in upcomingChores" :key="chore.id" class="enhanced-chore-item"
|
|
:class="`status-${getDueDateStatus(chore)} ${getChoreStatusInfo(chore).isCompleted ? 'completed' : ''}`"
|
|
@click="openChoreDetailModal(chore)">
|
|
<div class="chore-main-content">
|
|
<div class="chore-icon-container">
|
|
<div class="chore-status-indicator" :class="{
|
|
'overdue': getDueDateStatus(chore) === 'overdue',
|
|
'due-today': getDueDateStatus(chore) === 'due-today',
|
|
'completed': getChoreStatusInfo(chore).isCompleted
|
|
}">
|
|
{{ getChoreStatusInfo(chore).isCompleted ? '✅' :
|
|
getDueDateStatus(chore) === 'overdue' ? '⚠️' :
|
|
getDueDateStatus(chore) === 'due-today' ? '📅' : '📋' }}
|
|
</div>
|
|
</div>
|
|
<div class="chore-text-content">
|
|
<div class="chore-header">
|
|
<span class="neo-chore-name" :class="{ completed: getChoreStatusInfo(chore).isCompleted }">
|
|
{{ chore.name }}
|
|
</span>
|
|
<div class="chore-badges">
|
|
<VBadge :text="formatFrequency(chore.frequency)"
|
|
:variant="getFrequencyBadgeVariant(chore.frequency)" />
|
|
<VBadge v-if="getDueDateStatus(chore) === 'overdue'" text="Overdue" variant="danger" />
|
|
<VBadge v-if="getDueDateStatus(chore) === 'due-today'" text="Due Today" variant="warning" />
|
|
<VBadge v-if="getChoreStatusInfo(chore).isCompleted" text="Completed" variant="success" />
|
|
</div>
|
|
</div>
|
|
<div class="chore-details">
|
|
<div class="chore-due-info">
|
|
<span class="due-label">Due:</span>
|
|
<span class="due-date" :class="getDueDateStatus(chore)">
|
|
{{ formatDate(chore.next_due_date) }}
|
|
<span v-if="getDueDateStatus(chore) === 'due-today'" class="today-indicator">(Today)</span>
|
|
<span v-if="getDueDateStatus(chore) === 'overdue'" class="overdue-indicator">
|
|
({{ formatDistanceToNow(new Date(chore.next_due_date), { addSuffix: true }) }})
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div class="chore-assignment-info">
|
|
<span class="assignment-label">Assigned to:</span>
|
|
<span class="assigned-user">{{ getChoreStatusInfo(chore).assignedUserName }}</span>
|
|
</div>
|
|
<div v-if="chore.description" class="chore-description">
|
|
{{ chore.description }}
|
|
</div>
|
|
<div
|
|
v-if="getChoreStatusInfo(chore).isCompleted && getChoreStatusInfo(chore).currentAssignment?.completed_at"
|
|
class="completion-info">
|
|
Completed {{ formatDistanceToNow(new
|
|
Date(getChoreStatusInfo(chore).currentAssignment!.completed_at!),
|
|
{ addSuffix: true }) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="chore-actions">
|
|
<VButton size="sm" variant="neutral" @click.stop="openChoreDetailModal(chore)" title="View Details">
|
|
👁️
|
|
</VButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-center py-4">
|
|
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
|
|
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Group Activity Log Section -->
|
|
<div class="mt-4 neo-section">
|
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
|
|
<div v-if="groupHistoryLoading" class="text-center">
|
|
<VSpinner />
|
|
</div>
|
|
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
|
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
|
{{ formatHistoryEntry(entry) }}
|
|
</li>
|
|
</ul>
|
|
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
|
|
</div>
|
|
|
|
<!-- Expenses Section -->
|
|
<div class="mt-4 neo-section">
|
|
<div class="flex justify-between items-center w-full mb-2">
|
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
|
|
|
|
</div>
|
|
<div v-if="recentExpenses.length > 0" class="neo-expense-list">
|
|
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item-wrapper">
|
|
<div class="neo-expense-item" @click="toggleExpense(expense.id)"
|
|
:class="{ 'is-expanded': isExpenseExpanded(expense.id) }">
|
|
<div class="expense-main-content">
|
|
<div class="expense-icon-container">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" x2="12" y1="2" y2="22"></line>
|
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="expense-text-content">
|
|
<div class="neo-expense-header">
|
|
{{ expense.description }}
|
|
</div>
|
|
<div class="neo-expense-details">
|
|
{{ formatCurrency(expense.total_amount) }} —
|
|
{{ t('groupDetailPage.expenses.paidBy') }} <strong>{{ expense.paid_by_user?.name ||
|
|
expense.paid_by_user?.email }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="expense-side-content">
|
|
<span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)">
|
|
{{ getOverallExpenseStatusText(expense.overall_settlement_status) }}
|
|
</span>
|
|
<div class="expense-toggle-icon">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
class="feather feather-chevron-down">
|
|
<polyline points="6 9 12 15 18 9"></polyline>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="isExpenseExpanded(expense.id)" class="neo-splits-container">
|
|
<div class="neo-splits-list">
|
|
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
|
<div class="split-col split-user">
|
|
<strong>{{ split.user?.name || split.user?.email ||
|
|
t('groupDetailPage.expenses.fallbackUserName',
|
|
{
|
|
userId: split.user_id
|
|
}) }}</strong>
|
|
</div>
|
|
<div class="split-col split-owes">
|
|
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
|
|
formatCurrency(split.owed_amount) }}</strong>
|
|
</div>
|
|
<div class="split-col split-status">
|
|
<span class="neo-expense-status" :class="getStatusClass(split.status)">
|
|
{{ getSplitStatusText(split.status) }}
|
|
</span>
|
|
</div>
|
|
<div class="split-col split-paid-info">
|
|
<div v-if="split.paid_at" class="paid-details">
|
|
{{ t('groupDetailPage.expenses.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
|
|
<span v-if="split.paid_at"> {{ t('groupDetailPage.expenses.onDate') }} {{ new
|
|
Date(split.paid_at).toLocaleDateString() }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="split-col split-action">
|
|
<button
|
|
v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
|
|
class="btn btn-sm btn-primary" @click="openSettleShareModal(expense, split)"
|
|
:disabled="isSettlementLoading">
|
|
{{ t('groupDetailPage.expenses.settleShareButton') }}
|
|
</button>
|
|
</div>
|
|
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
|
|
class="neo-settlement-activities">
|
|
<li v-for="activity in split.settlement_activities" :key="activity.id">
|
|
{{ t('groupDetailPage.expenses.activityLabel') }} {{
|
|
formatCurrency(activity.amount_paid) }}
|
|
{{
|
|
t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name ||
|
|
t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }}
|
|
{{
|
|
t('groupDetailPage.expenses.onDate') }} {{ new
|
|
Date(activity.paid_at).toLocaleDateString() }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-center py-4">
|
|
<VIcon name="payments" size="lg" class="opacity-50 mb-2" />
|
|
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
|
|
|
|
<!-- Settle Share Modal -->
|
|
<VModal v-model="showSettleModal" :title="t('groupDetailPage.settleShareModal.title')"
|
|
@update:modelValue="!$event && closeSettleShareModal()" size="md">
|
|
<template #default>
|
|
<div v-if="isSettlementLoading" class="text-center">
|
|
<VSpinner :label="t('groupDetailPage.loading.settlement')" />
|
|
</div>
|
|
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
|
|
<div v-else>
|
|
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
|
|
userName: selectedSplitForSettlement?.user?.name
|
|
|| selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', {
|
|
userId:
|
|
selectedSplitForSettlement?.user_id
|
|
})
|
|
}) }}</p>
|
|
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
|
|
:error-message="settleAmountError || undefined">
|
|
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
|
|
</VFormField>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
|
t('groupDetailPage.settleShareModal.cancelButton')
|
|
}}</VButton>
|
|
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
|
t('groupDetailPage.settleShareModal.confirmButton')
|
|
}}</VButton>
|
|
</template>
|
|
</VModal>
|
|
|
|
<!-- Enhanced Chore Detail Modal -->
|
|
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
|
|
<template #default>
|
|
<div v-if="selectedChore" class="chore-detail-content">
|
|
<!-- Chore Overview -->
|
|
<div class="chore-overview-section">
|
|
<div class="chore-status-summary">
|
|
<div class="status-badges">
|
|
<VBadge :text="formatFrequency(selectedChore.frequency)"
|
|
:variant="getFrequencyBadgeVariant(selectedChore.frequency)" />
|
|
<VBadge v-if="getDueDateStatus(selectedChore) === 'overdue'" text="Overdue" variant="danger" />
|
|
<VBadge v-if="getDueDateStatus(selectedChore) === 'due-today'" text="Due Today" variant="warning" />
|
|
<VBadge v-if="getChoreStatusInfo(selectedChore).isCompleted" text="Completed" variant="success" />
|
|
</div>
|
|
<div class="chore-meta-info">
|
|
<div class="meta-item">
|
|
<span class="label">Created by:</span>
|
|
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
|
}}</span>
|
|
</div>
|
|
<div class="meta-item">
|
|
<span class="label">Created:</span>
|
|
<span class="value">{{ format(new Date(selectedChore.created_at), 'MMM d, yyyy') }}</span>
|
|
</div>
|
|
<div class="meta-item">
|
|
<span class="label">Next due:</span>
|
|
<span class="value" :class="getDueDateStatus(selectedChore)">
|
|
{{ formatDate(selectedChore.next_due_date) }}
|
|
<span v-if="getDueDateStatus(selectedChore) === 'due-today'">(Today)</span>
|
|
</span>
|
|
</div>
|
|
<div v-if="selectedChore.custom_interval_days" class="meta-item">
|
|
<span class="label">Custom interval:</span>
|
|
<span class="value">Every {{ selectedChore.custom_interval_days }} days</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="selectedChore.description" class="chore-description-full">
|
|
<VHeading :level="5">Description</VHeading>
|
|
<p>{{ selectedChore.description }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Current Assignments -->
|
|
<div class="assignments-section">
|
|
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
|
|
<div v-if="loadingAssignments" class="loading-assignments">
|
|
<VSpinner size="sm" />
|
|
<span>Loading assignments...</span>
|
|
</div>
|
|
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
|
|
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
|
|
<template v-if="editingAssignment?.id === assignment.id">
|
|
<!-- Inline Editing UI -->
|
|
<div class="editing-assignment">
|
|
<VFormField label="Assigned to:">
|
|
<VSelect v-if="group?.members"
|
|
:options="group.members.map(m => ({ value: m.id, label: m.email }))"
|
|
:model-value="editingAssignment.assigned_to_user_id || 0"
|
|
@update:model-value="val => editingAssignment && (editingAssignment.assigned_to_user_id = val)" />
|
|
</VFormField>
|
|
<VFormField label="Due date:">
|
|
<VInput type="date" :model-value="editingAssignment.due_date ?? ''"
|
|
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
|
|
</VFormField>
|
|
<div class="editing-actions">
|
|
<VButton @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</VButton>
|
|
<VButton @click="cancelAssignmentEdit" variant="neutral" size="sm">{{ t('shared.cancel') }}
|
|
</VButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="assignment-info">
|
|
<div class="assignment-header">
|
|
<div class="assigned-user-info">
|
|
<span class="user-name">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
|
|| 'Unknown User' }}</span>
|
|
<VBadge v-if="assignment.is_complete" text="Completed" variant="success" />
|
|
<VBadge v-else-if="isAssignmentOverdue(assignment)" text="Overdue" variant="danger" />
|
|
</div>
|
|
<div class="assignment-actions">
|
|
<VButton v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
|
|
variant="neutral">
|
|
{{ t('shared.edit') }}
|
|
</VButton>
|
|
</div>
|
|
</div>
|
|
<div class="assignment-details">
|
|
<div class="detail-item">
|
|
<span class="label">Due:</span>
|
|
<span class="value">{{ formatDate(assignment.due_date) }}</span>
|
|
</div>
|
|
<div v-if="assignment.is_complete && assignment.completed_at" class="detail-item">
|
|
<span class="label">Completed:</span>
|
|
<span class="value">
|
|
{{ formatDate(assignment.completed_at) }}
|
|
({{ formatDistanceToNow(new Date(assignment.completed_at), { addSuffix: true }) }})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p>
|
|
</div>
|
|
|
|
<!-- Assignment History -->
|
|
<div
|
|
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
|
|
class="assignment-history-section">
|
|
<VHeading :level="4">Assignment History</VHeading>
|
|
<div class="history-timeline">
|
|
<div v-for="assignment in selectedChore.assignments" :key="`history-${assignment.id}`">
|
|
<div v-if="assignment.history && assignment.history.length > 0">
|
|
<h6 class="assignment-history-header">{{ assignment.assigned_user?.email || 'Unknown User' }}</h6>
|
|
<div v-for="historyEntry in assignment.history" :key="historyEntry.id" class="history-entry">
|
|
<div class="history-timestamp">{{ format(new Date(historyEntry.timestamp), 'MMM d, yyyy HH:mm') }}
|
|
</div>
|
|
<div class="history-event">{{ formatHistoryEntry(historyEntry) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chore History -->
|
|
<div class="chore-history-section">
|
|
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
|
|
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
|
|
<div v-for="entry in selectedChore.history" :key="entry.id" class="history-entry">
|
|
<div class="history-timestamp">{{ format(new Date(entry.timestamp), 'MMM d, yyyy HH:mm') }}</div>
|
|
<div class="history-event">{{ formatHistoryEntry(entry) }}</div>
|
|
<div v-if="entry.changed_by_user" class="history-user">by {{ entry.changed_by_user.email }}</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="no-history">{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</VModal>
|
|
|
|
<!-- Generate Schedule Modal -->
|
|
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
|
|
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
|
|
<VInput type="date" v-model="scheduleForm.start_date" />
|
|
</VFormField>
|
|
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
|
|
<VInput type="date" v-model="scheduleForm.end_date" />
|
|
</VFormField>
|
|
<!-- Member selection can be added here if desired -->
|
|
<template #footer>
|
|
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
|
|
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
|
|
t('groupDetailPage.generateScheduleModal.generateButton') }}</VButton>
|
|
</template>
|
|
</VModal>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed, reactive } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
// import { useRoute } from 'vue-router';
|
|
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
|
import { useClipboard, useStorage } from '@vueuse/core';
|
|
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
|
import { useNotificationStore } from '@/stores/notifications';
|
|
import { choreService } from '../services/choreService'
|
|
import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
|
|
import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
|
|
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
|
|
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { Decimal } from 'decimal.js';
|
|
import type { BadgeVariant } from '@/components/valerie/VBadge.vue';
|
|
import VHeading from '@/components/valerie/VHeading.vue';
|
|
import VSpinner from '@/components/valerie/VSpinner.vue';
|
|
import VAlert from '@/components/valerie/VAlert.vue';
|
|
import VCard from '@/components/valerie/VCard.vue';
|
|
import VList from '@/components/valerie/VList.vue';
|
|
import VListItem from '@/components/valerie/VListItem.vue';
|
|
import VButton from '@/components/valerie/VButton.vue';
|
|
import VBadge from '@/components/valerie/VBadge.vue';
|
|
import VInput from '@/components/valerie/VInput.vue';
|
|
import VFormField from '@/components/valerie/VFormField.vue';
|
|
import VIcon from '@/components/valerie/VIcon.vue';
|
|
import VModal from '@/components/valerie/VModal.vue';
|
|
import VSelect from '@/components/valerie/VSelect.vue';
|
|
import { onClickOutside } from '@vueuse/core'
|
|
import { groupService } from '../services/groupService'; // New service
|
|
|
|
const { t } = useI18n();
|
|
|
|
// Caching setup
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
interface CachedGroup { group: Group; timestamp: number; }
|
|
const cachedGroups = useStorage<Record<string, CachedGroup>>('cached-groups-v1', {});
|
|
|
|
interface CachedChores { chores: Chore[]; timestamp: number; }
|
|
const cachedUpcomingChores = useStorage<Record<string, CachedChores>>('cached-group-chores-v1', {});
|
|
|
|
// interface CachedExpenses { expenses: Expense[]; timestamp: number; }
|
|
// const cachedRecentExpenses = useStorage<Record<string, CachedExpenses>>('cached-group-expenses-v1', {});
|
|
|
|
interface Group {
|
|
id: string | number;
|
|
name: string;
|
|
members?: GroupMember[];
|
|
}
|
|
|
|
interface GroupMember {
|
|
id: number;
|
|
email: string;
|
|
role?: string;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
id: string;
|
|
}>();
|
|
|
|
// const route = useRoute();
|
|
// const $q = useQuasar(); // Not used anymore
|
|
|
|
const notificationStore = useNotificationStore();
|
|
const group = ref<Group | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const inviteCode = ref<string | null>(null);
|
|
const inviteExpiresAt = ref<string | null>(null);
|
|
const generatingInvite = ref(false);
|
|
const copySuccess = ref(false);
|
|
const removingMember = ref<number | null>(null);
|
|
const showInviteUI = ref(false);
|
|
const activeMemberMenu = ref<number | null>(null);
|
|
|
|
const memberMenuRef = ref(null)
|
|
const inviteUIRef = ref(null)
|
|
const addMemberButtonRef = ref(null)
|
|
const avatarsContainerRef = ref(null)
|
|
|
|
onClickOutside(memberMenuRef, () => {
|
|
activeMemberMenu.value = null
|
|
}, { ignore: [avatarsContainerRef] })
|
|
|
|
onClickOutside(inviteUIRef, () => {
|
|
showInviteUI.value = false
|
|
}, { ignore: [addMemberButtonRef] })
|
|
|
|
// groupId is directly from props.id now, which comes from the route path param
|
|
const groupId = computed(() => props.id);
|
|
|
|
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
|
|
source: computed(() => inviteCode.value || '')
|
|
});
|
|
|
|
// Chores state
|
|
const upcomingChores = ref<Chore[]>([])
|
|
|
|
// Add new state for expenses
|
|
const recentExpenses = ref<Expense[]>([])
|
|
const expandedExpenses = ref<Set<number>>(new Set());
|
|
const authStore = useAuthStore();
|
|
|
|
// Settle Share Modal State
|
|
const showSettleModal = ref(false);
|
|
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
|
|
const settleAmount = ref<string>('');
|
|
const settleAmountError = ref<string | null>(null);
|
|
const isSettlementLoading = ref(false);
|
|
|
|
// New State
|
|
const showChoreDetailModal = ref(false);
|
|
const selectedChore = ref<Chore | null>(null);
|
|
const editingAssignment = ref<Partial<ChoreAssignment> | null>(null);
|
|
|
|
const showGenerateScheduleModal = ref(false);
|
|
const scheduleForm = reactive({
|
|
start_date: '',
|
|
end_date: '',
|
|
member_ids: []
|
|
});
|
|
const generatingSchedule = ref(false);
|
|
|
|
const groupChoreHistory = ref<ChoreHistory[]>([]);
|
|
const groupHistoryLoading = ref(false);
|
|
|
|
const loadingAssignments = ref(false);
|
|
const selectedChoreAssignments = ref<ChoreAssignment[]>([]);
|
|
|
|
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
|
if (err && typeof err === 'object') {
|
|
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
|
const errorData = err.response.data as any;
|
|
if (typeof errorData.detail === 'string') {
|
|
return errorData.detail;
|
|
}
|
|
if (typeof errorData.message === 'string') {
|
|
return errorData.message;
|
|
}
|
|
if (Array.isArray(errorData.detail) && errorData.detail.length > 0) {
|
|
const firstError = errorData.detail[0];
|
|
if (typeof firstError.msg === 'string' && typeof firstError.type === 'string') {
|
|
return firstError.msg;
|
|
}
|
|
}
|
|
if (typeof errorData === 'string') {
|
|
return errorData;
|
|
}
|
|
}
|
|
if (err instanceof Error && err.message) {
|
|
return err.message;
|
|
}
|
|
}
|
|
return t(fallbackMessageKey);
|
|
};
|
|
|
|
const fetchActiveInviteCode = async () => {
|
|
if (!groupId.value) return;
|
|
// Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
|
|
if (response.data && response.data.code) {
|
|
inviteCode.value = response.data.code;
|
|
inviteExpiresAt.value = response.data.expires_at; // Store expiry
|
|
} else {
|
|
inviteCode.value = null; // No active code found
|
|
inviteExpiresAt.value = null;
|
|
}
|
|
} catch (err: any) {
|
|
if (err.response && err.response.status === 404) {
|
|
inviteCode.value = null; // Explicitly set to null on 404
|
|
inviteExpiresAt.value = null;
|
|
// Optional: notify user or set a flag to show "generate one" message more prominently
|
|
console.info(t('groupDetailPage.console.noActiveInvite'));
|
|
} else {
|
|
const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchActiveInvite');
|
|
// error.value = message; // This would display a large error banner, might be too much
|
|
console.error('Error fetching active invite code:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
}
|
|
}
|
|
};
|
|
|
|
const fetchGroupDetails = async () => {
|
|
if (!groupId.value) return;
|
|
const groupIdStr = String(groupId.value);
|
|
const cached = cachedGroups.value[groupIdStr];
|
|
|
|
// If we have any cached data (even stale), show it first to avoid loading spinner.
|
|
if (cached) {
|
|
group.value = cached.group;
|
|
loading.value = false;
|
|
} else {
|
|
// Only show loading spinner if there is no cached data at all.
|
|
loading.value = true;
|
|
}
|
|
|
|
// Reset error state for the new fetch attempt
|
|
error.value = null;
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupIdStr));
|
|
group.value = response.data;
|
|
// Update cache on successful fetch
|
|
cachedGroups.value[groupIdStr] = {
|
|
group: response.data,
|
|
timestamp: Date.now(),
|
|
};
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchGroupDetails');
|
|
// Only show the main error banner if we have no data at all to show
|
|
if (!group.value) {
|
|
error.value = message;
|
|
}
|
|
console.error('Error fetching group details:', err);
|
|
// Always show a notification for failures, even background ones
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
// If we were showing the loader, hide it.
|
|
if (loading.value) {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
// Fetch active invite code after group details are loaded or retrieved from cache
|
|
await fetchActiveInviteCode();
|
|
};
|
|
|
|
const generateInviteCode = async () => {
|
|
if (!groupId.value) return;
|
|
generatingInvite.value = true;
|
|
copySuccess.value = false;
|
|
try {
|
|
const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
|
|
if (response.data && response.data.code) {
|
|
inviteCode.value = response.data.code;
|
|
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' });
|
|
} else {
|
|
// Should not happen if POST is successful and returns the code
|
|
throw new Error(t('groupDetailPage.invites.errors.newDataInvalid'));
|
|
}
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t('groupDetailPage.notifications.generateInviteError');
|
|
console.error('Error generating invite code:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
generatingInvite.value = false;
|
|
}
|
|
};
|
|
|
|
const copyInviteCodeHandler = async () => {
|
|
if (!clipboardIsSupported.value || !inviteCode.value) {
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.clipboardNotSupported'), type: 'warning' });
|
|
return;
|
|
}
|
|
await copy(inviteCode.value);
|
|
if (copied.value) {
|
|
copySuccess.value = true;
|
|
setTimeout(() => (copySuccess.value = false), 2000);
|
|
// Optionally, notify success via store if preferred over inline message
|
|
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
|
|
} else {
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' });
|
|
}
|
|
};
|
|
|
|
const canRemoveMember = (member: GroupMember): boolean => {
|
|
// Simplification: For now, assume a user with role 'owner' can remove anyone but another owner.
|
|
// A real implementation would check the current user's ID against the member to prevent self-removal.
|
|
const isOwner = group.value?.members?.find(m => m.id === member.id)?.role === 'owner';
|
|
return !isOwner;
|
|
};
|
|
|
|
const removeMember = async (memberId: number) => {
|
|
if (!groupId.value) return;
|
|
|
|
removingMember.value = memberId;
|
|
try {
|
|
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId)));
|
|
// Refresh group details to update the members list
|
|
await fetchGroupDetails();
|
|
notificationStore.addNotification({
|
|
message: t('groupDetailPage.notifications.removeMemberSuccess'),
|
|
type: 'success'
|
|
});
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t('groupDetailPage.notifications.removeMemberFailed');
|
|
console.error('Error removing member:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
removingMember.value = null;
|
|
}
|
|
};
|
|
|
|
// Chores methods
|
|
const loadUpcomingChores = async () => {
|
|
if (!groupId.value) return
|
|
const groupIdStr = String(groupId.value);
|
|
const cached = cachedUpcomingChores.value[groupIdStr];
|
|
|
|
if (cached) {
|
|
upcomingChores.value = cached.chores;
|
|
}
|
|
|
|
try {
|
|
const chores = await choreService.getChores(Number(groupId.value))
|
|
const sortedChores = chores
|
|
.sort((a, b) => new Date(a.next_due_date).getTime() - new Date(b.next_due_date).getTime())
|
|
.slice(0, 5)
|
|
upcomingChores.value = sortedChores;
|
|
cachedUpcomingChores.value[groupIdStr] = {
|
|
chores: sortedChores,
|
|
timestamp: Date.now()
|
|
};
|
|
} catch (error) {
|
|
console.error(t('groupDetailPage.errors.failedToLoadUpcomingChores'), error)
|
|
}
|
|
}
|
|
|
|
const formatDate = (date: string) => {
|
|
return format(new Date(date), 'MMM d, yyyy')
|
|
}
|
|
|
|
const getDueDateStatus = (chore: Chore) => {
|
|
const today = startOfDay(new Date());
|
|
const dueDate = startOfDay(new Date(chore.next_due_date));
|
|
|
|
if (dueDate < today) return 'overdue';
|
|
if (isEqual(dueDate, today)) return 'due-today';
|
|
return 'upcoming';
|
|
}
|
|
|
|
const getChoreStatusInfo = (chore: Chore) => {
|
|
const currentAssignment = chore.assignments && chore.assignments.length > 0 ? chore.assignments[0] : null;
|
|
const isCompleted = currentAssignment?.is_complete ?? false;
|
|
const assignedUser = currentAssignment?.assigned_user;
|
|
const dueDateStatus = getDueDateStatus(chore);
|
|
|
|
return {
|
|
currentAssignment,
|
|
isCompleted,
|
|
assignedUser,
|
|
dueDateStatus,
|
|
assignedUserName: assignedUser?.name || assignedUser?.email || 'Unassigned'
|
|
};
|
|
}
|
|
|
|
const formatFrequency = (frequency: ChoreFrequency) => {
|
|
const options: Record<ChoreFrequency, string> = {
|
|
one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys
|
|
daily: t('choresPage.frequencyOptions.daily'),
|
|
weekly: t('choresPage.frequencyOptions.weekly'),
|
|
monthly: t('choresPage.frequencyOptions.monthly'),
|
|
custom: t('choresPage.frequencyOptions.custom')
|
|
};
|
|
return options[frequency] || frequency;
|
|
};
|
|
|
|
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
|
|
const colorMap: Record<ChoreFrequency, BadgeVariant> = {
|
|
one_time: 'neutral',
|
|
daily: 'info',
|
|
weekly: 'success',
|
|
monthly: 'accent', // Using accent for purple as an example
|
|
custom: 'warning'
|
|
};
|
|
return colorMap[frequency] || 'secondary';
|
|
};
|
|
|
|
// Add new methods for expenses
|
|
const loadRecentExpenses = async () => {
|
|
if (!groupId.value) return
|
|
try {
|
|
const response = await apiClient.get(
|
|
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value || ''}&limit=5&detailed=true`
|
|
)
|
|
recentExpenses.value = response.data
|
|
} catch (error) {
|
|
console.error(t('groupDetailPage.errors.failedToLoadRecentExpenses'), error)
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.loadExpensesFailed'), type: 'error' });
|
|
}
|
|
}
|
|
|
|
const formatAmount = (amount: string) => {
|
|
return parseFloat(amount).toFixed(2)
|
|
}
|
|
|
|
const formatSplitType = (type: string) => {
|
|
// Assuming 'type' is like 'exact_amounts' or 'item_based'
|
|
const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`;
|
|
// This creates keys like 'groupDetailPage.expenses.splitTypes.exactAmounts'
|
|
// Check if translation exists, otherwise fallback to a simple formatted string
|
|
// For simplicity in this subtask, we'll assume keys will be added.
|
|
// A more robust solution would check i18n.global.te(key) or have a fallback.
|
|
return t(key);
|
|
};
|
|
|
|
const getSplitTypeBadgeVariant = (type: string): BadgeVariant => {
|
|
const colorMap: Record<string, BadgeVariant> = {
|
|
equal: 'info',
|
|
exact_amounts: 'success',
|
|
percentage: 'accent', // Using accent for purple
|
|
shares: 'warning',
|
|
item_based: 'secondary', // Using secondary for teal as an example
|
|
};
|
|
return colorMap[type] || 'neutral';
|
|
};
|
|
|
|
const formatCurrency = (value: string | number | undefined | null): string => {
|
|
if (value === undefined || value === null) return '$0.00';
|
|
if (typeof value === 'string' && !value.trim()) return '$0.00';
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
|
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
|
|
};
|
|
|
|
const getPaidAmountForSplit = (split: ExpenseSplit): Decimal => {
|
|
if (!split.settlement_activities) return new Decimal(0);
|
|
return split.settlement_activities.reduce((sum, activity) => {
|
|
return sum.plus(new Decimal(activity.amount_paid));
|
|
}, new Decimal(0));
|
|
}
|
|
|
|
const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
|
|
const amount = getPaidAmountForSplit(split);
|
|
return formatCurrency(amount.toString());
|
|
};
|
|
|
|
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
|
|
switch (status) {
|
|
case ExpenseSplitStatusEnum.PAID: return t('groupDetailPage.status.paid');
|
|
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('groupDetailPage.status.partiallyPaid');
|
|
case ExpenseSplitStatusEnum.UNPAID: return t('groupDetailPage.status.unpaid');
|
|
default: return t('groupDetailPage.status.unknown');
|
|
}
|
|
};
|
|
|
|
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
|
|
switch (status) {
|
|
case ExpenseOverallStatusEnum.PAID: return t('groupDetailPage.status.settled');
|
|
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('groupDetailPage.status.partiallySettled');
|
|
case ExpenseOverallStatusEnum.UNPAID: return t('groupDetailPage.status.unsettled');
|
|
default: return t('groupDetailPage.status.unknown');
|
|
}
|
|
};
|
|
|
|
const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnum): string => {
|
|
if (status === ExpenseSplitStatusEnum.PAID || status === ExpenseOverallStatusEnum.PAID) return 'status-paid';
|
|
if (status === ExpenseSplitStatusEnum.PARTIALLY_PAID || status === ExpenseOverallStatusEnum.PARTIALLY_PAID) return 'status-partially_paid';
|
|
if (status === ExpenseSplitStatusEnum.UNPAID || status === ExpenseOverallStatusEnum.UNPAID) return 'status-unpaid';
|
|
return '';
|
|
};
|
|
|
|
const toggleExpense = (expenseId: number) => {
|
|
const newSet = new Set(expandedExpenses.value);
|
|
if (newSet.has(expenseId)) {
|
|
newSet.delete(expenseId);
|
|
} else {
|
|
newSet.add(expenseId);
|
|
}
|
|
expandedExpenses.value = newSet;
|
|
};
|
|
|
|
const isExpenseExpanded = (expenseId: number) => {
|
|
return expandedExpenses.value.has(expenseId);
|
|
};
|
|
|
|
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
|
|
if (split.user_id !== authStore.user?.id) {
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.cannotSettleOthersShares'), type: 'warning' });
|
|
return;
|
|
}
|
|
selectedSplitForSettlement.value = split;
|
|
const alreadyPaid = getPaidAmountForSplit(split);
|
|
const owed = new Decimal(split.owed_amount);
|
|
const remaining = owed.minus(alreadyPaid);
|
|
settleAmount.value = remaining.toFixed(2);
|
|
settleAmountError.value = null;
|
|
showSettleModal.value = true;
|
|
};
|
|
|
|
const closeSettleShareModal = () => {
|
|
showSettleModal.value = false;
|
|
selectedSplitForSettlement.value = null;
|
|
settleAmount.value = '';
|
|
settleAmountError.value = null;
|
|
};
|
|
|
|
const validateSettleAmount = (): boolean => {
|
|
settleAmountError.value = null;
|
|
if (!settleAmount.value.trim()) {
|
|
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.enterAmount');
|
|
return false;
|
|
}
|
|
const amount = new Decimal(settleAmount.value);
|
|
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
|
|
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.positiveAmount');
|
|
return false;
|
|
}
|
|
if (selectedSplitForSettlement.value) {
|
|
const alreadyPaid = getPaidAmountForSplit(selectedSplitForSettlement.value);
|
|
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
|
|
const remaining = owed.minus(alreadyPaid);
|
|
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
|
|
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
|
|
return false;
|
|
}
|
|
} else {
|
|
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.noSplitSelected');
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const handleConfirmSettle = async () => {
|
|
if (!validateSettleAmount()) return;
|
|
if (!selectedSplitForSettlement.value || !authStore.user?.id) {
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.settlementDataMissing'), type: 'error' });
|
|
return;
|
|
}
|
|
|
|
isSettlementLoading.value = true;
|
|
try {
|
|
const activityData: SettlementActivityCreate = {
|
|
expense_split_id: selectedSplitForSettlement.value.id,
|
|
paid_by_user_id: Number(authStore.user.id),
|
|
amount_paid: new Decimal(settleAmount.value).toString(),
|
|
paid_at: new Date().toISOString(),
|
|
};
|
|
|
|
await apiClient.post(API_ENDPOINTS.FINANCIALS.SETTLEMENTS, activityData);
|
|
|
|
notificationStore.addNotification({ message: t('groupDetailPage.notifications.settleShareSuccess'), type: 'success' });
|
|
closeSettleShareModal();
|
|
await loadRecentExpenses();
|
|
} catch (err) {
|
|
const message = getApiErrorMessage(err, 'groupDetailPage.notifications.settleShareFailed');
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
isSettlementLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleMemberMenu = (memberId: number) => {
|
|
if (activeMemberMenu.value === memberId) {
|
|
activeMemberMenu.value = null;
|
|
} else {
|
|
activeMemberMenu.value = memberId;
|
|
// Close invite UI if it's open
|
|
showInviteUI.value = false;
|
|
}
|
|
};
|
|
|
|
const toggleInviteUI = () => {
|
|
showInviteUI.value = !showInviteUI.value;
|
|
if (showInviteUI.value) {
|
|
activeMemberMenu.value = null; // Close any open member menu
|
|
}
|
|
};
|
|
|
|
const openChoreDetailModal = async (chore: Chore) => {
|
|
selectedChore.value = chore;
|
|
showChoreDetailModal.value = true;
|
|
|
|
// Load assignments for this chore
|
|
loadingAssignments.value = true;
|
|
try {
|
|
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
|
|
} catch (error) {
|
|
console.error('Failed to load chore assignments:', error);
|
|
notificationStore.addNotification({
|
|
message: 'Failed to load chore assignments.',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
loadingAssignments.value = false;
|
|
}
|
|
|
|
// Optionally lazy load history if not already loaded with the chore
|
|
if (!chore.history || chore.history.length === 0) {
|
|
try {
|
|
const history = await choreService.getChoreHistory(chore.id);
|
|
selectedChore.value = {
|
|
...selectedChore.value,
|
|
history: history
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to load chore history:', error);
|
|
notificationStore.addNotification({
|
|
message: 'Failed to load chore history.',
|
|
type: 'error'
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const startAssignmentEdit = (assignment: ChoreAssignment) => {
|
|
editingAssignment.value = { ...assignment, due_date: format(new Date(assignment.due_date), 'yyyy-MM-dd') };
|
|
};
|
|
|
|
const cancelAssignmentEdit = () => {
|
|
editingAssignment.value = null;
|
|
};
|
|
|
|
const saveAssignmentEdit = async (assignmentId: number) => {
|
|
if (!editingAssignment.value || !editingAssignment.value.due_date) {
|
|
notificationStore.addNotification({ message: 'Due date is required.', type: 'error' });
|
|
return;
|
|
}
|
|
try {
|
|
const updatedAssignment = await choreService.updateAssignment(assignmentId, {
|
|
due_date: editingAssignment.value.due_date,
|
|
assigned_to_user_id: editingAssignment.value.assigned_to_user_id
|
|
});
|
|
// Update local state
|
|
loadUpcomingChores(); // Re-fetch all chores to get updates
|
|
cancelAssignmentEdit();
|
|
notificationStore.addNotification({ message: 'Assignment updated', type: 'success' });
|
|
} catch (error) {
|
|
notificationStore.addNotification({ message: 'Failed to update assignment', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleGenerateSchedule = async () => {
|
|
generatingSchedule.value = true;
|
|
try {
|
|
await groupService.generateSchedule(String(groupId.value), scheduleForm);
|
|
notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' });
|
|
showGenerateScheduleModal.value = false;
|
|
loadUpcomingChores(); // Refresh the chore list
|
|
} catch (error) {
|
|
notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' });
|
|
} finally {
|
|
generatingSchedule.value = false;
|
|
}
|
|
};
|
|
|
|
const loadGroupChoreHistory = async () => {
|
|
if (!groupId.value) return;
|
|
groupHistoryLoading.value = true;
|
|
try {
|
|
groupChoreHistory.value = await groupService.getGroupChoreHistory(String(groupId.value));
|
|
} catch (err) {
|
|
console.error("Failed to load group chore history", err);
|
|
notificationStore.addNotification({ message: 'Could not load group activity.', type: 'error' });
|
|
} finally {
|
|
groupHistoryLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const formatHistoryEntry = (entry: ChoreHistory | ChoreAssignmentHistory): string => {
|
|
const user = entry.changed_by_user?.email || 'System';
|
|
const eventType = entry.event_type.toLowerCase().replace(/_/g, ' ');
|
|
|
|
let action = '';
|
|
switch (entry.event_type) {
|
|
case 'created':
|
|
action = 'created this chore';
|
|
break;
|
|
case 'updated':
|
|
action = 'updated this chore';
|
|
break;
|
|
case 'completed':
|
|
action = 'completed the assignment';
|
|
break;
|
|
case 'reopened':
|
|
action = 'reopened the assignment';
|
|
break;
|
|
case 'assigned':
|
|
action = 'was assigned to this chore';
|
|
break;
|
|
case 'unassigned':
|
|
action = 'was unassigned from this chore';
|
|
break;
|
|
case 'reassigned':
|
|
action = 'was reassigned this chore';
|
|
break;
|
|
case 'due_date_changed':
|
|
action = 'changed the due date';
|
|
break;
|
|
case 'deleted':
|
|
action = 'deleted this chore';
|
|
break;
|
|
default:
|
|
action = eventType;
|
|
}
|
|
|
|
let details = '';
|
|
if (entry.event_data) {
|
|
const changes = Object.entries(entry.event_data).map(([key, value]) => {
|
|
if (typeof value === 'object' && value !== null && 'old' in value && 'new' in value) {
|
|
const fieldName = key.replace(/_/g, ' ');
|
|
return `${fieldName}: "${value.old}" → "${value.new}"`;
|
|
}
|
|
return `${key}: ${JSON.stringify(value)}`;
|
|
});
|
|
if (changes.length > 0) {
|
|
details = ` (${changes.join(', ')})`;
|
|
}
|
|
}
|
|
|
|
return `${user} ${action}${details}`;
|
|
};
|
|
|
|
const isAssignmentOverdue = (assignment: ChoreAssignment): boolean => {
|
|
const dueDate = new Date(assignment.due_date);
|
|
const today = startOfDay(new Date());
|
|
return dueDate < today;
|
|
};
|
|
|
|
onMounted(() => {
|
|
fetchGroupDetails();
|
|
loadUpcomingChores();
|
|
loadRecentExpenses();
|
|
loadGroupChoreHistory();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-padding {
|
|
padding: 1rem;
|
|
padding-block-end: 3rem;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.mt-1 {
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.mt-2 {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.mt-3 {
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.mt-4 {
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.mb-3 {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.ml-1 {
|
|
margin-left: 0.25rem;
|
|
}
|
|
|
|
.w-full {
|
|
width: 100%;
|
|
}
|
|
|
|
.neo-section-container {
|
|
border: 3px solid #111;
|
|
border-radius: 18px;
|
|
background: rgb(255, 248, 240);
|
|
box-shadow: 6px 6px 0 #111;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.neo-section {
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.neo-section:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.neo-section-header {
|
|
font-weight: 900;
|
|
font-size: 1.25rem;
|
|
margin: 0;
|
|
margin-bottom: 1rem;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.neo-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 0;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.neo-grid .neo-section {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.neo-grid .neo-section:first-child {
|
|
border-right: 1px solid #eee;
|
|
}
|
|
|
|
@media (max-width: 620px) {
|
|
.neo-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.neo-grid .neo-section:first-child {
|
|
border-right: none;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
}
|
|
|
|
.member-avatar-list {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.member-avatars {
|
|
display: flex;
|
|
padding-left: 12px;
|
|
}
|
|
|
|
.member-avatar {
|
|
position: relative;
|
|
margin-left: -12px;
|
|
}
|
|
|
|
.avatar-circle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background-color: var(--primary);
|
|
color: var(--dark);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 900;
|
|
border: 2px solid var(--light);
|
|
cursor: pointer;
|
|
transition: transform 0.2s ease;
|
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.avatar-circle:hover {
|
|
transform: scale(1.1);
|
|
z-index: 10;
|
|
}
|
|
|
|
.member-menu {
|
|
position: absolute;
|
|
top: 110%;
|
|
right: -10px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
border: 2px solid var(--dark);
|
|
box-shadow: var(--shadow-md);
|
|
width: 220px;
|
|
z-index: 100;
|
|
overflow: hidden;
|
|
/* padding: 0.5rem; */
|
|
}
|
|
|
|
.popup-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 0.5rem 0.5rem 1rem;
|
|
border-bottom: 2px solid #eee;
|
|
}
|
|
|
|
.member-menu-content {
|
|
padding: 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.add-member-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: var(--light);
|
|
border: 2px dashed var(--dark);
|
|
color: var(--dark);
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
margin-left: -8px;
|
|
z-index: 1;
|
|
}
|
|
|
|
.add-member-btn:hover {
|
|
background: var(--secondary);
|
|
transform: scale(1.1);
|
|
border-style: solid;
|
|
}
|
|
|
|
.header-title-text {
|
|
margin: 0;
|
|
}
|
|
|
|
.invite-popup {
|
|
position: absolute;
|
|
top: calc(16%);
|
|
right: 10%;
|
|
width: 27%;
|
|
background: white;
|
|
border-radius: 12px;
|
|
border: 2px solid var(--dark);
|
|
box-shadow: var(--shadow-md);
|
|
z-index: 100;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
/* Members List Styles */
|
|
.neo-members-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-member-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
transition: transform 0.1s ease-in-out;
|
|
}
|
|
|
|
.neo-member-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.neo-member-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-member-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-member-role {
|
|
font-size: 0.875rem;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 1rem;
|
|
background: #e0e0e0;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.neo-member-role.owner {
|
|
background: #111;
|
|
color: white;
|
|
}
|
|
|
|
/* Invite Code Styles */
|
|
.neo-invite-code {
|
|
background: #fafafa;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
border: 2px solid #111;
|
|
}
|
|
|
|
.neo-label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.neo-input-group {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-input {
|
|
flex: 1;
|
|
padding: 0.75rem;
|
|
border: 2px solid #111;
|
|
border-radius: 8px;
|
|
font-family: monospace;
|
|
font-size: 1rem;
|
|
background: white;
|
|
}
|
|
|
|
.neo-success-text {
|
|
color: var(--success);
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
/* Empty State Styles */
|
|
.neo-empty-state {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: #666;
|
|
}
|
|
|
|
.neo-empty-state .icon {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Responsive Adjustments */
|
|
@media (max-width: 900px) {
|
|
.neo-grid {
|
|
/* The gap is removed to allow for border-based separators */
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.page-padding {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.neo-member-item {
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.neo-member-info {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
}
|
|
}
|
|
|
|
/* Chores List Styles */
|
|
.neo-chores-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-chore-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
transition: transform 0.1s ease-in-out;
|
|
}
|
|
|
|
.neo-chore-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.neo-chore-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-chore-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-chore-due {
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
}
|
|
|
|
/* Expenses List Styles */
|
|
.neo-expenses-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-expense-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
transition: transform 0.1s ease-in-out;
|
|
}
|
|
|
|
.neo-expense-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-expense-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-expense-date {
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
}
|
|
|
|
.neo-expense-details {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-expense-amount {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-chip {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 1rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.neo-chip.blue {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.neo-chip.green {
|
|
background: #e8f5e9;
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.neo-chip.purple {
|
|
background: #f3e5f5;
|
|
color: #7b1fa2;
|
|
}
|
|
|
|
.neo-chip.orange {
|
|
background: #fff3e0;
|
|
color: #f57c00;
|
|
}
|
|
|
|
.neo-chip.teal {
|
|
background: #e0f2f1;
|
|
color: #00796b;
|
|
}
|
|
|
|
.neo-chip.grey {
|
|
background: #f5f5f5;
|
|
color: #616161;
|
|
}
|
|
|
|
.neo-expense-list {
|
|
background-color: rgb(255, 248, 240);
|
|
/* Container for expense items */
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid #f0e5d8;
|
|
}
|
|
|
|
.neo-expense-item-wrapper {
|
|
border-bottom: 1px solid #f0e5d8;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.neo-expense-item-wrapper:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.neo-expense-item {
|
|
padding: 1rem 1.2rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.neo-expense-item:hover {
|
|
background-color: rgba(0, 0, 0, 0.02);
|
|
}
|
|
|
|
.neo-expense-item.is-expanded .expense-toggle-icon {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.expense-main-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.expense-icon-container {
|
|
color: #d99a53;
|
|
}
|
|
|
|
.expense-text-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.expense-side-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.expense-toggle-icon {
|
|
color: #888;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.neo-expense-header {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.1rem;
|
|
}
|
|
|
|
.neo-expense-details,
|
|
.neo-split-details {
|
|
font-size: 0.9rem;
|
|
color: #555;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
|
|
.neo-expense-details strong,
|
|
.neo-split-details strong {
|
|
color: #111;
|
|
}
|
|
|
|
.neo-expense-status {
|
|
display: inline-block;
|
|
padding: 0.25em 0.6em;
|
|
font-size: 0.85em;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
vertical-align: baseline;
|
|
border-radius: 0.375rem;
|
|
margin-left: 0.5rem;
|
|
color: #22c55e;
|
|
}
|
|
|
|
.status-unpaid {
|
|
background-color: #fee2e2;
|
|
color: #dc2626;
|
|
}
|
|
|
|
.status-partially_paid {
|
|
background-color: #ffedd5;
|
|
color: #f97316;
|
|
}
|
|
|
|
.status-paid {
|
|
background-color: #dcfce7;
|
|
color: #22c55e;
|
|
}
|
|
|
|
.neo-splits-container {
|
|
padding: 0.5rem 1.2rem 1.2rem;
|
|
background-color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
.neo-splits-list {
|
|
margin-top: 0rem;
|
|
padding-left: 0;
|
|
border-left: none;
|
|
}
|
|
|
|
.neo-split-item {
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px dashed #f0e5d8;
|
|
display: grid;
|
|
grid-template-areas:
|
|
"user owes status paid action"
|
|
"activities activities activities activities activities";
|
|
grid-template-columns: 1.5fr 1fr 1fr 1.5fr auto;
|
|
gap: 0.5rem 1rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.neo-split-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.split-col.split-user {
|
|
grid-area: user;
|
|
}
|
|
|
|
.split-col.split-owes {
|
|
grid-area: owes;
|
|
}
|
|
|
|
.split-col.split-status {
|
|
grid-area: status;
|
|
}
|
|
|
|
.split-col.split-paid-info {
|
|
grid-area: paid;
|
|
}
|
|
|
|
.split-col.split-action {
|
|
grid-area: action;
|
|
justify-self: end;
|
|
}
|
|
|
|
.split-col.neo-settlement-activities {
|
|
grid-area: activities;
|
|
font-size: 0.8em;
|
|
color: #555;
|
|
padding-left: 1em;
|
|
list-style-type: disc;
|
|
margin-top: 0.5em;
|
|
}
|
|
|
|
.neo-settlement-activities {
|
|
font-size: 0.8em;
|
|
color: #555;
|
|
padding-left: 1em;
|
|
list-style-type: disc;
|
|
margin-top: 0.5em;
|
|
}
|
|
|
|
.neo-settlement-activities li {
|
|
margin-top: 0.2em;
|
|
}
|
|
|
|
/* Enhanced Chores List Styles */
|
|
.enhanced-chores-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.enhanced-chore-item {
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
border-radius: 12px;
|
|
padding: 1rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
position: relative;
|
|
}
|
|
|
|
.enhanced-chore-item:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.enhanced-chore-item.status-overdue {
|
|
border-left: 6px solid #ef4444;
|
|
}
|
|
|
|
.enhanced-chore-item.status-due-today {
|
|
border-left: 6px solid #f59e0b;
|
|
}
|
|
|
|
.enhanced-chore-item.completed {
|
|
opacity: 0.8;
|
|
background: #f0f9ff;
|
|
}
|
|
|
|
.chore-main-content {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.chore-icon-container {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chore-status-indicator {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.2rem;
|
|
background: #e5e7eb;
|
|
}
|
|
|
|
.chore-status-indicator.overdue {
|
|
background: #fee2e2;
|
|
color: #dc2626;
|
|
}
|
|
|
|
.chore-status-indicator.due-today {
|
|
background: #fef3c7;
|
|
color: #d97706;
|
|
}
|
|
|
|
.chore-status-indicator.completed {
|
|
background: #d1fae5;
|
|
color: #059669;
|
|
}
|
|
|
|
.chore-text-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.chore-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 0.5rem;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-chore-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
color: #111;
|
|
}
|
|
|
|
.neo-chore-name.completed {
|
|
text-decoration: line-through;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.chore-badges {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.chore-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
}
|
|
|
|
.chore-due-info,
|
|
.chore-assignment-info {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.due-label,
|
|
.assignment-label {
|
|
font-weight: 600;
|
|
color: #374151;
|
|
}
|
|
|
|
.due-date.overdue {
|
|
color: #dc2626;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.due-date.due-today {
|
|
color: #d97706;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.today-indicator,
|
|
.overdue-indicator {
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.chore-description {
|
|
margin-top: 0.25rem;
|
|
font-style: italic;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.completion-info {
|
|
margin-top: 0.25rem;
|
|
color: #059669;
|
|
font-weight: 500;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.chore-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* Chore Detail Modal Styles */
|
|
.chore-detail-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.chore-overview-section {
|
|
border-bottom: 1px solid #e5e7eb;
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
.chore-status-summary {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.status-badges {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.chore-meta-info {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.meta-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.meta-item .label {
|
|
font-weight: 600;
|
|
color: #374151;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.meta-item .value {
|
|
color: #111;
|
|
}
|
|
|
|
.meta-item .value.overdue {
|
|
color: #dc2626;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.chore-description-full {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.chore-description-full p {
|
|
color: #374151;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.assignments-section,
|
|
.assignment-history-section,
|
|
.chore-history-section {
|
|
border-bottom: 1px solid #e5e7eb;
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
.assignments-section:last-child,
|
|
.assignment-history-section:last-child,
|
|
.chore-history-section:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.assignments-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.assignment-card {
|
|
background: #f9fafb;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.editing-assignment {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.editing-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.assignment-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.assignment-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.assigned-user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 600;
|
|
color: #111;
|
|
}
|
|
|
|
.assignment-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.assignment-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.detail-item .label {
|
|
font-weight: 600;
|
|
color: #374151;
|
|
min-width: 80px;
|
|
}
|
|
|
|
.detail-item .value {
|
|
color: #111;
|
|
}
|
|
|
|
.no-assignments,
|
|
.no-history {
|
|
color: #6b7280;
|
|
font-style: italic;
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.history-timeline {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.assignment-history-header {
|
|
font-weight: 600;
|
|
color: #374151;
|
|
margin-bottom: 0.5rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
padding-bottom: 0.25rem;
|
|
}
|
|
|
|
.history-entry {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
padding: 0.75rem;
|
|
background: #f9fafb;
|
|
border-radius: 6px;
|
|
border-left: 3px solid #d1d5db;
|
|
}
|
|
|
|
.history-timestamp {
|
|
font-size: 0.8rem;
|
|
color: #6b7280;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.history-event {
|
|
color: #374151;
|
|
}
|
|
|
|
.history-user {
|
|
font-size: 0.85rem;
|
|
color: #6b7280;
|
|
font-style: italic;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.chore-header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.chore-meta-info {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.assignment-header {
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.loading-assignments {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
color: #6b7280;
|
|
font-style: italic;
|
|
}
|
|
</style>
|