Merge pull request 'ph5' (#63) from ph5 into prod

Reviewed-on: #63
This commit is contained in:
mo 2025-06-08 12:29:29 +02:00
commit 2d70850840
8 changed files with 105 additions and 44 deletions

View File

@ -13,6 +13,7 @@ from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail from app.schemas.list import ListPublic, ListDetail
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
from app.schemas.user import UserPublic
from app.crud import group as crud_group from app.crud import group as crud_group
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import list as crud_list from app.crud import list as crud_list
@ -92,6 +93,33 @@ async def read_group(
return group return group
@router.get(
"/{group_id}/members",
response_model=List[UserPublic],
summary="Get Group Members",
tags=["Groups"]
)
async def read_group_members(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all members of a specific group, if the user is part of it."""
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group members")
group = await crud_group.get_group_by_id(db=db, group_id=group_id)
if not group:
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id)
# Extract and return just the user information from member associations
return [member_assoc.user for member_assoc in group.member_associations]
@router.post( @router.post(
"/{group_id}/invites", "/{group_id}/invites",

View File

@ -81,7 +81,8 @@
body { body {
font-family: 'Patrick Hand', cursive; font-family: 'Patrick Hand', cursive;
background-color: var(--light); background-color: var(--light);
background-image: var(--paper-texture); // background-image: var(--paper-texture);
// background-image: url('@/assets/11.webp');
// padding: 2rem 1rem;s // padding: 2rem 1rem;s
color: var(--dark); color: var(--dark);
font-size: 1.1rem; font-size: 1.1rem;

View File

@ -97,6 +97,8 @@
"addChore": "+", "addChore": "+",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"editChore": "Edit Chore",
"createChore": "Create Chore",
"empty": { "empty": {
"title": "No Chores Yet", "title": "No Chores Yet",
"message": "Get started by adding your first chore!", "message": "Get started by adding your first chore!",
@ -170,6 +172,23 @@
"loadingLabel": "Loading group details...", "loadingLabel": "Loading group details...",
"retryButton": "Retry", "retryButton": "Retry",
"groupNotFound": "Group not found or an error occurred.", "groupNotFound": "Group not found or an error occurred.",
"lists": {
"title": "Group Lists"
},
"generateScheduleModal": {
"title": "Generate Schedule"
},
"activityLog": {
"title": "Activity Log",
"emptyState": "No activity to show yet."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!",
"generateScheduleButton": "Generate Schedule"
},
"members": { "members": {
"title": "Group Members", "title": "Group Members",
"defaultRole": "Member", "defaultRole": "Member",
@ -201,12 +220,6 @@
"console": { "console": {
"noActiveInvite": "No active invite code found for this group." "noActiveInvite": "No active invite code found for this group."
}, },
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": { "expenses": {
"title": "Group Expenses", "title": "Group Expenses",
"manageButton": "Manage Expenses", "manageButton": "Manage Expenses",
@ -445,6 +458,8 @@
"addExpenseButton": "Add Expense", "addExpenseButton": "Add Expense",
"loading": "Loading expenses...", "loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.", "emptyState": "No expenses recorded for this list yet.",
"emptyStateTitle": "No Expenses",
"emptyStateMessage": "Add your first expense to get started.",
"paidBy": "Paid by:", "paidBy": "Paid by:",
"onDate": "on", "onDate": "on",
"owes": "owes", "owes": "owes",

View File

@ -94,6 +94,11 @@
}, },
"choresPage": { "choresPage": {
"title": "Taken", "title": "Taken",
"addChore": "+",
"edit": "Bewerken",
"delete": "Verwijderen",
"editChore": "Taak bewerken",
"createChore": "Nieuwe taak",
"tabs": { "tabs": {
"overdue": "Achterstallig", "overdue": "Achterstallig",
"today": "Vandaag", "today": "Vandaag",
@ -339,6 +344,16 @@
"partiallyPaid": "Gedeeltelijk betaald", "partiallyPaid": "Gedeeltelijk betaald",
"unpaid": "Onbetaald", "unpaid": "Onbetaald",
"unknown": "Onbekende status" "unknown": "Onbekende status"
},
"lists": {
"title": "Groepslijsten"
},
"generateScheduleModal": {
"title": "Schema genereren"
},
"activityLog": {
"title": "Activiteitenlogboek",
"emptyState": "Nog geen activiteiten om weer te geven."
} }
}, },
"accountPage": { "accountPage": {

View File

@ -718,6 +718,19 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
overflow: hidden; overflow: hidden;
} }
/* Status-based styling */
.schedule-group:has(.status-overdue) .neo-item-list-container {
box-shadow: 6px 6px 0 #c72d2d;
}
.schedule-group:has(.status-due-today) .neo-item-list-container {
box-shadow: 6px 6px 0 #b37814;
}
.status-completed {
opacity: 0.7;
}
/* Neo-style list items from ListDetailPage */ /* Neo-style list items from ListDetailPage */
.neo-item-list { .neo-item-list {
list-style: none; list-style: none;
@ -940,19 +953,6 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
font-style: italic; font-style: italic;
} }
/* Status-based styling */
.status-overdue {
border-left: 4px solid #ef4444;
}
.status-due-today {
border-left: 4px solid #f59e0b;
}
.status-completed {
opacity: 0.7;
}
/* Modal styles */ /* Modal styles */
.detail-modal .modal-container, .detail-modal .modal-container,
.history-modal .modal-container { .history-modal .modal-container {

View File

@ -83,7 +83,7 @@
<div class="flex justify-between items-center w-full mb-2"> <div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading> <VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton') <VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton')
}} }}
</VButton> </VButton>
</div> </div>
<div v-if="upcomingChores.length > 0" class="enhanced-chores-list"> <div v-if="upcomingChores.length > 0" class="enhanced-chores-list">
@ -299,10 +299,10 @@
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton') t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{ <VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton') t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton> }}</VButton>
</template> </template>
</VModal> </VModal>
@ -324,7 +324,7 @@
<div class="meta-item"> <div class="meta-item">
<span class="label">Created by:</span> <span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown' <span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span> }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<span class="label">Created:</span> <span class="label">Created:</span>

View File

@ -316,22 +316,23 @@
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" /> <VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary"> <div v-else-if="listCostSummary">
<div class="mb-3 cost-overview"> <div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{ <p><strong>{{ $t('listDetailPage.modals.costSummary.totalCostLabel') }}</strong> {{
formatCurrency(listCostSummary.total_list_cost) }}</p> formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{ <p><strong>{{ $t('listDetailPage.modals.costSummary.equalShareLabel') }}</strong> {{
formatCurrency(listCostSummary.equal_share_per_user) }}</p> formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{ <p><strong>{{ $t('listDetailPage.modals.costSummary.participantsLabel') }}</strong> {{
listCostSummary.num_participating_users }}</p> listCostSummary.num_participating_users }}</p>
</div> </div>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4> <h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
<div class="table-container mt-2"> <div class="table-container mt-2">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th> <th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th> <th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th> </th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th> <th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -348,7 +349,7 @@
</table> </table>
</div> </div>
</div> </div>
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p> <p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
</template> </template>
<template #footer> <template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }} <VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}
@ -357,7 +358,7 @@
</VModal> </VModal>
<!-- Settle Share Modal --> <!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')" <VModal v-model="showSettleModal" :title="$t('listDetailPage.modals.settleShare.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md"> @update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default> <template #default>
<div v-if="isSettlementLoading" class="text-center"> <div v-if="isSettlementLoading" class="text-center">
@ -365,11 +366,11 @@
</div> </div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" /> <VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else> <div v-else>
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', { <p>{{ $t('listDetailPage.modals.settleShare.settleAmountFor', {
userName: selectedSplitForSettlement?.user?.name userName: selectedSplitForSettlement?.user?.name
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
}) }}</p> }) }}</p>
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')" <VFormField :label="$t('listDetailPage.modals.settleShare.amountLabel')"
:error-message="settleAmountError || undefined"> :error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required /> <VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField> </VFormField>
@ -377,9 +378,10 @@
</template> </template>
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.settleShareModal.cancelButton') $t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') <VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton> }}</VButton>
</template> </template>
</VModal> </VModal>
@ -1215,12 +1217,12 @@ const closeSettleShareModal = () => {
const validateSettleAmount = (): boolean => { const validateSettleAmount = (): boolean => {
settleAmountError.value = null; settleAmountError.value = null;
if (!settleAmount.value.trim()) { if (!settleAmount.value.trim()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount'); settleAmountError.value = t('listDetailPage.modals.settleShare.errors.enterAmount');
return false; return false;
} }
const amount = new Decimal(settleAmount.value); const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) { if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount'); settleAmountError.value = t('listDetailPage.modals.settleShare.errors.positiveAmount');
return false; return false;
} }
if (selectedSplitForSettlement.value) { if (selectedSplitForSettlement.value) {
@ -1228,11 +1230,11 @@ const validateSettleAmount = (): boolean => {
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount); const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid); const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) }); settleAmountError.value = t('listDetailPage.modals.settleShare.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false; return false;
} }
} else { } else {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected'); settleAmountError.value = t('listDetailPage.modals.settleShare.errors.noSplitSelected');
return false; return false;
} }
return true; return true;

View File

@ -29,8 +29,8 @@
<div class="neo-lists-grid"> <div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card" <div v-for="list in lists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)" :class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd" @touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
:data-list-id="list.id"> @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div> <div class="neo-list-header">{{ list.name }}</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div> <div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list"> <ul class="neo-item-list">