ph5 #69
@ -241,7 +241,7 @@ async def create_group_chore(
|
||||
chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group})
|
||||
|
||||
try:
|
||||
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
|
||||
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id)
|
||||
except GroupNotFoundError as e:
|
||||
logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
|
||||
@ -397,7 +397,7 @@ async def list_my_assignments(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments")
|
||||
|
||||
@router.get(
|
||||
"/chores/{chore_id}/assignments",
|
||||
"/{chore_id}/assignments",
|
||||
response_model=PyList[ChoreAssignmentPublic],
|
||||
summary="List Chore Assignments",
|
||||
tags=["Chore Assignments"]
|
||||
|
@ -70,24 +70,37 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
||||
async def create_chore(
|
||||
db: AsyncSession,
|
||||
chore_in: ChoreCreate,
|
||||
user_id: int,
|
||||
group_id: Optional[int] = None
|
||||
user_id: int
|
||||
) -> Chore:
|
||||
"""Creates a new chore, either personal or within a specific group."""
|
||||
"""Creates a new chore, and if specified, an assignment for it."""
|
||||
async with db.begin_nested() if db.in_transaction() else db.begin():
|
||||
# Validate chore type and group
|
||||
if chore_in.type == ChoreTypeEnum.group:
|
||||
if not group_id:
|
||||
if not chore_in.group_id:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
group = await get_group_by_id(db, group_id)
|
||||
group = await get_group_by_id(db, chore_in.group_id)
|
||||
if not group:
|
||||
raise GroupNotFoundError(group_id)
|
||||
if not await is_user_member(db, group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
|
||||
raise GroupNotFoundError(chore_in.group_id)
|
||||
if not await is_user_member(db, chore_in.group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore_in.group_id}")
|
||||
else: # personal chore
|
||||
if group_id:
|
||||
if chore_in.group_id:
|
||||
raise ValueError("group_id must be None for personal chores")
|
||||
|
||||
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
|
||||
# Validate assigned user if provided
|
||||
if chore_in.assigned_to_user_id:
|
||||
if chore_in.type == ChoreTypeEnum.group:
|
||||
# For group chores, assigned user must be a member of the group
|
||||
if not await is_user_member(db, chore_in.group_id, chore_in.assigned_to_user_id):
|
||||
raise PermissionDeniedError(detail=f"Assigned user {chore_in.assigned_to_user_id} is not a member of group {chore_in.group_id}")
|
||||
else: # Personal chore
|
||||
# For personal chores, you can only assign it to yourself
|
||||
if chore_in.assigned_to_user_id != user_id:
|
||||
raise PermissionDeniedError(detail="Personal chores can only be assigned to the creator.")
|
||||
|
||||
assigned_user_id = chore_in.assigned_to_user_id
|
||||
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'assigned_to_user_id'})
|
||||
|
||||
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
|
||||
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
|
||||
if not parent_chore:
|
||||
@ -95,7 +108,6 @@ async def create_chore(
|
||||
|
||||
db_chore = Chore(
|
||||
**chore_data,
|
||||
group_id=group_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
@ -105,6 +117,24 @@ async def create_chore(
|
||||
db.add(db_chore)
|
||||
await db.flush()
|
||||
|
||||
# Create an assignment if a user was specified
|
||||
if assigned_user_id:
|
||||
assignment = ChoreAssignment(
|
||||
chore_id=db_chore.id,
|
||||
assigned_to_user_id=assigned_user_id,
|
||||
due_date=db_chore.next_due_date,
|
||||
is_complete=False
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.flush() # Flush to get the assignment ID
|
||||
await create_assignment_history_entry(
|
||||
db,
|
||||
assignment_id=assignment.id,
|
||||
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
|
||||
changed_by_user_id=user_id,
|
||||
event_data={'assigned_to': assigned_user_id}
|
||||
)
|
||||
|
||||
await create_chore_history_entry(
|
||||
db,
|
||||
chore_id=db_chore.id,
|
||||
|
@ -45,6 +45,7 @@ class ChoreBase(BaseModel):
|
||||
class ChoreCreate(ChoreBase):
|
||||
group_id: Optional[int] = None
|
||||
parent_chore_id: Optional[int] = None
|
||||
assigned_to_user_id: Optional[int] = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_group_id_with_type(self):
|
||||
|
@ -27,8 +27,9 @@
|
||||
</div>
|
||||
</label>
|
||||
<div class="neo-item-actions">
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed">
|
||||
{{ isActiveTimer ? 'Stop' : 'Start' }}
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
|
||||
:disabled="chore.is_completed || !chore.current_assignment_id">
|
||||
{{ isActiveTimer ? '⏸️' : '▶️' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
|
||||
📋
|
||||
@ -37,10 +38,10 @@
|
||||
📅
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
|
||||
Edit
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
|
||||
Delete
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,6 +11,7 @@ import ChoreItem from '@/components/ChoreItem.vue';
|
||||
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { UserPublic } from '@/types/user';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -28,6 +29,7 @@ interface ChoreFormData {
|
||||
type: 'personal' | 'group';
|
||||
group_id: number | undefined;
|
||||
parent_chore_id?: number | null;
|
||||
assigned_to_user_id?: number | null;
|
||||
}
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
@ -45,6 +47,10 @@ const selectedChoreHistory = ref<ChoreHistory[]>([])
|
||||
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
|
||||
const loadingHistory = ref(false)
|
||||
const loadingAssignments = ref(false)
|
||||
const groupMembers = ref<UserPublic[]>([])
|
||||
const loadingMembers = ref(false)
|
||||
const choreFormGroupMembers = ref<UserPublic[]>([])
|
||||
const loadingChoreFormMembers = ref(false)
|
||||
|
||||
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
|
||||
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
|
||||
@ -59,13 +65,14 @@ const initialChoreFormState: ChoreFormData = {
|
||||
type: 'personal',
|
||||
group_id: undefined,
|
||||
parent_chore_id: null,
|
||||
assigned_to_user_id: null,
|
||||
}
|
||||
|
||||
const choreForm = ref({ ...initialChoreFormState })
|
||||
const isLoading = ref(true)
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { isGuest } = storeToRefs(authStore);
|
||||
const { isGuest, user } = storeToRefs(authStore);
|
||||
|
||||
const timeEntryStore = useTimeEntryStore();
|
||||
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
|
||||
@ -89,17 +96,28 @@ const loadChores = async () => {
|
||||
|
||||
try {
|
||||
const fetchedChores = await choreService.getAllChores()
|
||||
const currentUserId = user.value?.id ? Number(user.value.id) : null;
|
||||
|
||||
const mappedChores = fetchedChores.map(c => {
|
||||
const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null;
|
||||
// Prefer the assignment that belongs to the current user, otherwise fallback to the first assignment
|
||||
const userAssignment = c.assignments?.find(a => a.assigned_to_user_id === currentUserId) ?? null;
|
||||
const displayAssignment = userAssignment ?? (c.assignments?.[0] ?? null);
|
||||
|
||||
return {
|
||||
...c,
|
||||
current_assignment_id: currentAssignment?.id ?? null,
|
||||
is_completed: currentAssignment?.is_complete ?? false,
|
||||
completed_at: currentAssignment?.completed_at ?? null,
|
||||
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
|
||||
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
|
||||
current_assignment_id: userAssignment?.id ?? null,
|
||||
is_completed: userAssignment?.is_complete ?? false,
|
||||
completed_at: userAssignment?.completed_at ?? null,
|
||||
assigned_user_name:
|
||||
displayAssignment?.assigned_user?.name ||
|
||||
displayAssignment?.assigned_user?.email ||
|
||||
'Unknown',
|
||||
completed_by_name:
|
||||
displayAssignment?.assigned_user?.name ||
|
||||
displayAssignment?.assigned_user?.email ||
|
||||
'Unknown',
|
||||
updating: false,
|
||||
}
|
||||
} as ChoreWithCompletion;
|
||||
});
|
||||
chores.value = mappedChores;
|
||||
cachedChores.value = mappedChores;
|
||||
@ -136,6 +154,30 @@ watch(() => choreForm.value.type, (newType) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch group members when a group is selected in the form
|
||||
watch(() => choreForm.value.group_id, async (newGroupId) => {
|
||||
if (newGroupId && choreForm.value.type === 'group') {
|
||||
loadingChoreFormMembers.value = true;
|
||||
try {
|
||||
choreFormGroupMembers.value = await groupService.getGroupMembers(newGroupId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members for form:', error);
|
||||
choreFormGroupMembers.value = [];
|
||||
} finally {
|
||||
loadingChoreFormMembers.value = false;
|
||||
}
|
||||
} else {
|
||||
choreFormGroupMembers.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Reload chores once the user information becomes available (e.g., after login refresh)
|
||||
watch(user, (newUser, oldUser) => {
|
||||
if (newUser && !oldUser) {
|
||||
loadChores().then(loadTimeEntries);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadChores().then(loadTimeEntries);
|
||||
loadGroups()
|
||||
@ -300,6 +342,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
|
||||
type: chore.type,
|
||||
group_id: chore.group_id ?? undefined,
|
||||
parent_chore_id: chore.parent_chore_id,
|
||||
assigned_to_user_id: chore.assigned_to_user_id,
|
||||
}
|
||||
showChoreModal.value = true
|
||||
}
|
||||
@ -415,6 +458,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
||||
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
|
||||
selectedChore.value = chore;
|
||||
showChoreDetailModal.value = true;
|
||||
groupMembers.value = []; // Reset
|
||||
|
||||
// Load assignments for this chore
|
||||
loadingAssignments.value = true;
|
||||
@ -429,6 +473,22 @@ const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
|
||||
} finally {
|
||||
loadingAssignments.value = false;
|
||||
}
|
||||
|
||||
// If it's a group chore, load members
|
||||
if (chore.type === 'group' && chore.group_id) {
|
||||
loadingMembers.value = true;
|
||||
try {
|
||||
groupMembers.value = await groupService.getGroupMembers(chore.group_id);
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members:', error);
|
||||
notificationStore.addNotification({
|
||||
message: 'Failed to load group members.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
loadingMembers.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openHistoryModal = async (chore: ChoreWithCompletion) => {
|
||||
@ -494,6 +554,46 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignChore = async (userId: number) => {
|
||||
if (!selectedChore.value) return;
|
||||
try {
|
||||
await choreService.createAssignment({
|
||||
chore_id: selectedChore.value.id,
|
||||
assigned_to_user_id: userId,
|
||||
due_date: selectedChore.value.next_due_date
|
||||
});
|
||||
notificationStore.addNotification({ message: 'Chore assigned successfully!', type: 'success' });
|
||||
// Refresh assignments
|
||||
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
|
||||
await loadChores(); // Also reload all chores to update main list
|
||||
} catch (error) {
|
||||
console.error('Failed to assign chore:', error);
|
||||
notificationStore.addNotification({ message: 'Failed to assign chore.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnassignChore = async (assignmentId: number) => {
|
||||
if (!selectedChore.value) return;
|
||||
try {
|
||||
await choreService.deleteAssignment(assignmentId);
|
||||
notificationStore.addNotification({ message: 'Chore unassigned successfully!', type: 'success' });
|
||||
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
|
||||
await loadChores();
|
||||
} catch (error) {
|
||||
console.error('Failed to unassign chore:', error);
|
||||
notificationStore.addNotification({ message: 'Failed to unassign chore.', type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const isUserAssigned = (userId: number) => {
|
||||
return selectedChoreAssignments.value.some(a => a.assigned_to_user_id === userId);
|
||||
};
|
||||
|
||||
const getAssignmentIdForUser = (userId: number): number | null => {
|
||||
const assignment = selectedChoreAssignments.value.find(a => a.assigned_to_user_id === userId);
|
||||
return assignment ? assignment.id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -579,7 +679,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</div>
|
||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||
</div>
|
||||
@ -600,14 +700,25 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group'" class="form-group">
|
||||
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group' && choreForm.group_id" class="form-group">
|
||||
<label class="form-label" for="chore-assign-user">{{ t('choresPage.form.assignTo', 'Assign To (Optional)')
|
||||
}}</label>
|
||||
<div v-if="loadingChoreFormMembers">Loading members...</div>
|
||||
<select v-else id="chore-assign-user" v-model="choreForm.assigned_to_user_id" class="form-input">
|
||||
<option :value="null">Don't assign now</option>
|
||||
<option v-for="member in choreFormGroupMembers" :key="member.id" :value="member.id">
|
||||
{{ member.name || member.email }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
|
||||
@ -619,7 +730,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||
t('choresPage.form.cancel', 'Cancel')
|
||||
}}</button>
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||
t('choresPage.form.create', 'Create') }}</button>
|
||||
</div>
|
||||
@ -644,7 +755,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||
}}</button>
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -669,7 +780,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
<div class="detail-item">
|
||||
<span class="label">Created by:</span>
|
||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Due date:</span>
|
||||
@ -701,7 +812,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
|
||||
<div class="assignment-main">
|
||||
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||
}}</span>
|
||||
}}</span>
|
||||
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||
</span>
|
||||
@ -715,6 +826,45 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Chore Section -->
|
||||
<div class="detail-section" v-if="selectedChore.type === 'group' && selectedChore.group_id">
|
||||
<h4>Assign to Member</h4>
|
||||
<div v-if="loadingMembers" class="loading-spinner">Loading members...</div>
|
||||
<div v-else-if="groupMembers.length === 0" class="no-data">No other members in this group.</div>
|
||||
<div v-else class="member-list">
|
||||
<div v-for="member in groupMembers" :key="member.id" class="member-item">
|
||||
<div class="member-info">
|
||||
<span class="member-name">{{ member.name || member.email }}</span>
|
||||
<span v-if="member.id === user?.id" class="badge badge-you">You</span>
|
||||
</div>
|
||||
<button v-if="isUserAssigned(member.id)" class="btn btn-sm btn-danger"
|
||||
@click="handleUnassignChore(getAssignmentIdForUser(member.id)!)">
|
||||
Unassign
|
||||
</button>
|
||||
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(member.id)">
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="selectedChore.type === 'personal' && user?.id">
|
||||
<h4>Assign to Me</h4>
|
||||
<div class="member-list">
|
||||
<div class="member-item">
|
||||
<span class="member-name">{{ user.name || user.email }}</span>
|
||||
<button v-if="isUserAssigned(Number(user.id))" class="btn btn-sm btn-danger"
|
||||
@click="handleUnassignChore(getAssignmentIdForUser(Number(user.id))!)">
|
||||
Unassign
|
||||
</button>
|
||||
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(Number(user.id))">
|
||||
Assign to Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
|
||||
@ -1045,6 +1195,12 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-you {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--dark);
|
||||
@ -1202,4 +1358,38 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.assignments-list,
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.assignment-item,
|
||||
.member-item {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f9fafb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignment-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -7,6 +7,14 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="mb-4 flex items-center gap-2 justify-between" v-if="!loading && !error">
|
||||
<div class="text-sm font-medium" v-if="authStore.getUser">
|
||||
Your outstanding balance: <span class="font-mono">{{ formatCurrency(userOutstanding, 'USD') }}</span>
|
||||
</div>
|
||||
<label class="flex items-center text-sm"><input type="checkbox" v-model="showRecurringOnly"
|
||||
class="mr-2">Show recurring only</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center">
|
||||
<div class="spinner-dots">
|
||||
<span></span>
|
||||
@ -38,6 +46,12 @@
|
||||
<div class="neo-item-content">
|
||||
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
|
||||
<span class="checkbox-text-span">{{ expense.description }}</span>
|
||||
<span v-if="isExpenseRecurring(expense)"
|
||||
class="ml-2 inline-block rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-xs font-semibold">Recurring
|
||||
<template v-if="getNextOccurrence(expense)">
|
||||
– next {{ getNextOccurrence(expense) }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="item-subtext">
|
||||
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
|
||||
'N/A'
|
||||
@ -64,11 +78,27 @@
|
||||
expense.split_type.replace('_', ' ') }})</h3>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="split in expense.splits" :key="split.id"
|
||||
class="flex justify-between items-center py-1 text-sm">
|
||||
class="flex justify-between items-center py-1 text-sm gap-2 flex-wrap">
|
||||
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|
||||
|| 'N/A' }} owes</span>
|
||||
<span class="font-mono text-gray-800 font-semibold">{{
|
||||
formatCurrency(split.owed_amount, expense.currency) }}</span>
|
||||
|
||||
<!-- Settlement progress -->
|
||||
<span
|
||||
v-if="split.settlement_activities && split.settlement_activities.length"
|
||||
class="text-xs text-gray-500">Paid: {{
|
||||
formatCurrency(calculatePaidAmount(split), expense.currency) }}</span>
|
||||
<span v-if="calculateRemainingAmount(split) > 0"
|
||||
class="text-xs text-red-600">Remaining: {{
|
||||
formatCurrency(calculateRemainingAmount(split), expense.currency)
|
||||
}}</span>
|
||||
|
||||
<!-- Settle button for current user -->
|
||||
<button
|
||||
v-if="authStore.getUser && authStore.getUser.id === split.user_id && calculateRemainingAmount(split) > 0"
|
||||
@click.stop="handleSettleSplit(split, expense)"
|
||||
class="ml-auto btn btn-sm btn-primary">Settle</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -175,60 +205,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
|
||||
|
||||
const props = defineProps<{
|
||||
groupId?: number | string;
|
||||
}>();
|
||||
|
||||
// Types are kept local to this component
|
||||
interface UserPublic {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
interface ExpenseSplit {
|
||||
id: number;
|
||||
expense_id: number;
|
||||
user_id: number;
|
||||
owed_amount: string; // Decimal is string
|
||||
share_percentage?: string;
|
||||
share_units?: number;
|
||||
user?: UserPublic;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 'unpaid' | 'paid' | 'partially_paid';
|
||||
paid_at?: string;
|
||||
}
|
||||
|
||||
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
|
||||
|
||||
interface Expense {
|
||||
id: number;
|
||||
description: string;
|
||||
total_amount: string; // Decimal is string
|
||||
currency: string;
|
||||
expense_date?: string;
|
||||
split_type: SplitType;
|
||||
list_id?: number;
|
||||
group_id?: number;
|
||||
item_id?: number;
|
||||
paid_by_user_id: number;
|
||||
is_recurring: boolean;
|
||||
recurrence_pattern?: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
created_by_user_id: number;
|
||||
splits: ExpenseSplit[];
|
||||
paid_by_user?: UserPublic;
|
||||
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
|
||||
next_occurrence?: string;
|
||||
last_occurrence?: string;
|
||||
parent_expense_id?: number;
|
||||
generated_expenses: Expense[];
|
||||
}
|
||||
// Pinia store for current user context
|
||||
const authStore = useAuthStore()
|
||||
const notifStore = useNotificationStore()
|
||||
|
||||
// Reactive state collections
|
||||
const expenses = ref<Expense[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
@ -237,6 +227,24 @@ const showModal = ref(false)
|
||||
const editingExpense = ref<Expense | null>(null)
|
||||
const formError = ref<string | null>(null)
|
||||
|
||||
// UI-level filters
|
||||
const showRecurringOnly = ref(false)
|
||||
|
||||
// Aggregate outstanding balance for current user across expenses
|
||||
const userOutstanding = computed(() => {
|
||||
const userId = authStore.getUser?.id
|
||||
if (!userId) return 0
|
||||
let remaining = 0
|
||||
expenses.value.forEach((exp) => {
|
||||
exp.splits.forEach((sp) => {
|
||||
if (sp.user_id === userId) {
|
||||
remaining += calculateRemainingAmount(sp)
|
||||
}
|
||||
})
|
||||
})
|
||||
return remaining
|
||||
})
|
||||
|
||||
const initialFormState: CreateExpenseData = {
|
||||
description: '',
|
||||
total_amount: '',
|
||||
@ -252,12 +260,16 @@ const initialFormState: CreateExpenseData = {
|
||||
const formState = reactive<any>({ ...initialFormState })
|
||||
|
||||
const filteredExpenses = computed(() => {
|
||||
let data = expenses.value
|
||||
if (props.groupId) {
|
||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
||||
return expenses.value.filter(expense => expense.group_id === groupIdNum);
|
||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
|
||||
data = data.filter((expense) => expense.group_id === groupIdNum)
|
||||
}
|
||||
return expenses.value;
|
||||
});
|
||||
if (showRecurringOnly.value) {
|
||||
data = data.filter((expense) => (expense as any).isRecurring || (expense as any).is_recurring)
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@ -333,6 +345,15 @@ const getStatusClass = (status: string) => {
|
||||
return statusMap[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
const getNextOccurrence = (expense: Expense): string | null => {
|
||||
const raw = (expense as any).next_occurrence ?? (expense as any).nextOccurrence ?? null
|
||||
return raw ? formatDate(raw) : null
|
||||
}
|
||||
|
||||
const isExpenseRecurring = (expense: Expense): boolean => {
|
||||
return Boolean((expense as any).isRecurring ?? (expense as any).is_recurring)
|
||||
}
|
||||
|
||||
const openCreateExpenseModal = () => {
|
||||
editingExpense.value = null
|
||||
Object.assign(formState, initialFormState)
|
||||
@ -351,7 +372,7 @@ const openEditExpenseModal = (expense: Expense) => {
|
||||
formState.total_amount = expense.total_amount
|
||||
formState.currency = expense.currency
|
||||
formState.split_type = expense.split_type
|
||||
formState.isRecurring = expense.is_recurring
|
||||
formState.isRecurring = (expense as any).is_recurring ?? (expense as any).isRecurring ?? false
|
||||
formState.list_id = expense.list_id
|
||||
formState.group_id = expense.group_id
|
||||
formState.item_id = expense.item_id
|
||||
@ -385,13 +406,15 @@ const handleFormSubmit = async () => {
|
||||
if (index !== -1) {
|
||||
expenses.value[index] = updatedExpense
|
||||
}
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
|
||||
} else {
|
||||
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
|
||||
expenses.value.unshift(newExpense)
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
notifStore.addNotification({ message: 'Expense created', type: 'success' })
|
||||
}
|
||||
closeModal()
|
||||
// re-fetch all expenses to ensure data consistency after create/update
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
} catch (err: any) {
|
||||
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
|
||||
console.error(err)
|
||||
@ -404,11 +427,46 @@ const handleDeleteExpense = async (expenseId: number) => {
|
||||
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
|
||||
await expenseService.deleteExpense(expenseId)
|
||||
expenses.value = expenses.value.filter(e => e.id !== expenseId)
|
||||
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to delete expense.'
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Settlement-related helpers
|
||||
// -----------------------------
|
||||
const calculatePaidAmount = (split: ExpenseSplit): number =>
|
||||
(split.settlement_activities || []).reduce((sum: number, act: SettlementActivity) => sum + parseFloat(act.amount_paid), 0)
|
||||
|
||||
const calculateRemainingAmount = (split: ExpenseSplit): number =>
|
||||
parseFloat(split.owed_amount) - calculatePaidAmount(split)
|
||||
|
||||
const handleSettleSplit = async (split: ExpenseSplit, parentExpense: Expense) => {
|
||||
if (!authStore.getUser?.id) {
|
||||
alert('You need to be logged in to settle an expense.')
|
||||
return
|
||||
}
|
||||
const remaining = calculateRemainingAmount(split)
|
||||
if (remaining <= 0) return
|
||||
|
||||
if (!confirm(`Settle ${formatCurrency(remaining, parentExpense.currency)} now?`)) return
|
||||
|
||||
try {
|
||||
await apiClient.post(`/financials/expense_splits/${split.id}/settle`, {
|
||||
expense_split_id: split.id,
|
||||
paid_by_user_id: authStore.getUser.id,
|
||||
amount_paid: remaining.toFixed(2),
|
||||
})
|
||||
|
||||
// refresh expense list to get updated data
|
||||
expenses.value = (await expenseService.getExpenses()) as Expense[]
|
||||
} catch (err: any) {
|
||||
console.error('Failed to settle split', err)
|
||||
alert(err.response?.data?.detail || 'Failed to settle split.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -690,6 +748,11 @@ select.form-input {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.spinner-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -758,4 +821,9 @@ select.form-input {
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-recurring {
|
||||
background-color: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
</style>
|
||||
|
@ -259,21 +259,24 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
|
||||
};
|
||||
|
||||
const fetchListDetails = async () => {
|
||||
// If we're here for the first time without any cached data, it's an initial load.
|
||||
if (pageInitialLoad.value) {
|
||||
pageInitialLoad.value = false;
|
||||
}
|
||||
itemsAreLoading.value = true;
|
||||
|
||||
// Only show the items loading spinner if we don't have any items to show.
|
||||
if (!list.value?.items.length) {
|
||||
itemsAreLoading.value = true;
|
||||
}
|
||||
|
||||
const routeId = String(route.params.id);
|
||||
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||||
|
||||
// Since we're fetching, remove the potentially stale cache.
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cachedFullData) {
|
||||
response = { data: JSON.parse(cachedFullData) };
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
} else {
|
||||
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||
}
|
||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||
|
||||
const rawList = response.data as ListWithExpenses;
|
||||
const localList: List = {
|
||||
@ -302,9 +305,6 @@ const fetchListDetails = async () => {
|
||||
}
|
||||
} finally {
|
||||
itemsAreLoading.value = false;
|
||||
if (!list.value && !error.value) {
|
||||
pageInitialLoad.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -687,9 +687,9 @@ onMounted(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||
const routeId = String(route.params.id);
|
||||
|
||||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||
if (listShellJSON) {
|
||||
const shellData = JSON.parse(listShellJSON);
|
||||
if (shellData.id === parseInt(routeId, 10)) {
|
||||
@ -709,6 +709,30 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||||
if (cachedFullData) {
|
||||
try {
|
||||
const rawList = JSON.parse(cachedFullData) as ListWithExpenses;
|
||||
const localList: List = {
|
||||
id: rawList.id,
|
||||
name: rawList.name,
|
||||
description: rawList.description ?? undefined,
|
||||
is_complete: rawList.is_complete,
|
||||
items: processListItems(rawList.items),
|
||||
version: rawList.version,
|
||||
updated_at: rawList.updated_at,
|
||||
group_id: rawList.group_id ?? undefined
|
||||
};
|
||||
list.value = localList;
|
||||
lastListUpdate.value = rawList.updated_at;
|
||||
lastItemCount.value = rawList.items.length;
|
||||
pageInitialLoad.value = false;
|
||||
} catch (e) {
|
||||
console.error("Error parsing cached list data", e);
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch categories relevant to the list (either personal or group)
|
||||
categoryStore.fetchCategories(list.value?.group_id);
|
||||
|
||||
|
@ -214,6 +214,12 @@ const loadCachedData = () => {
|
||||
|
||||
const fetchLists = async () => {
|
||||
error.value = null;
|
||||
|
||||
// Only show loading if we don't have any cached data to display
|
||||
if (lists.value.length === 0) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = currentGroupId.value
|
||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||
@ -228,7 +234,10 @@ const fetchLists = async () => {
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
|
||||
console.error(error.value, err);
|
||||
// If we have cached data and there's an error, don't clear the lists
|
||||
if (cachedLists.value.length === 0) lists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -245,7 +254,11 @@ const fetchArchivedLists = async () => {
|
||||
};
|
||||
|
||||
const fetchListsAndGroups = async () => {
|
||||
loading.value = true;
|
||||
// Only show loading if we don't have cached data
|
||||
if (lists.value.length === 0) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||
import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
|
||||
import type { Group, GroupCreate, GroupUpdate, GroupPublic } from '@/types/group';
|
||||
import type { ChoreHistory } from '@/types/chore';
|
||||
import type { UserPublic } from '@/types/user';
|
||||
|
||||
export const groupService = {
|
||||
async getUserGroups(): Promise<Group[]> {
|
||||
async getUserGroups(): Promise<GroupPublic[]> {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
return response.data;
|
||||
},
|
||||
@ -22,9 +23,18 @@ export const groupService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createGroup(groupData: Group): Promise<Group> {
|
||||
async createGroup(groupData: GroupCreate): Promise<Group> {
|
||||
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
}
|
||||
async updateGroup(id: number, groupData: GroupUpdate): Promise<Group> {
|
||||
const response = await apiClient.put(API_ENDPOINTS.GROUPS.BY_ID(String(id)), groupData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getGroupMembers(groupId: number): Promise<UserPublic[]> {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.MEMBERS(String(groupId)));
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
@ -117,6 +117,7 @@ export interface Expense {
|
||||
|
||||
overall_settlement_status: ExpenseOverallStatusEnum
|
||||
isRecurring: boolean
|
||||
is_recurring?: boolean
|
||||
nextOccurrence?: string
|
||||
lastOccurrence?: string
|
||||
recurrencePattern?: RecurrencePattern
|
||||
|
@ -1,6 +1,7 @@
|
||||
// fe/src/types/group.ts
|
||||
import type { AuthState } from '@/stores/auth';
|
||||
import type { ChoreHistory } from './chore';
|
||||
import type { UserPublic } from './user';
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
@ -10,3 +11,24 @@ export interface Group {
|
||||
members: AuthState['user'][];
|
||||
chore_history?: ChoreHistory[];
|
||||
}
|
||||
|
||||
export interface GroupCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GroupUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GroupPublic {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
created_by_id: number;
|
||||
created_at: string;
|
||||
creator?: UserPublic;
|
||||
members: UserPublic[];
|
||||
is_member?: boolean;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
full_name?: string | null; // Alias provided by backend in many responses
|
||||
email: string;
|
||||
// Add other relevant public user fields if necessary
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user