Add cost summary feature to ListDetailPage; implement API endpoints for costs and enhance UI with a dialog for displaying cost details, including user balances and total costs.

This commit is contained in:
mohamad 2025-05-08 23:38:07 +02:00
parent f6a50e0d6a
commit 7bbec7ad5f
2 changed files with 148 additions and 1 deletions

View File

@ -86,6 +86,13 @@ export const API_ENDPOINTS = {
HISTORY: '/ocr/history', HISTORY: '/ocr/history',
}, },
// Costs
COSTS: {
BASE: '/costs',
LIST_SUMMARY: (listId: string | number) => `/costs/lists/${listId}/cost-summary`,
GROUP_BALANCE_SUMMARY: (groupId: string | number) => `/costs/groups/${groupId}/balance-summary`,
},
// Financials // Financials
FINANCIALS: { FINANCIALS: {
EXPENSES: '/financials/expenses', EXPENSES: '/financials/expenses',

View File

@ -19,6 +19,13 @@
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<h1 class="text-h4 q-mb-none">{{ list.name }}</h1> <h1 class="text-h4 q-mb-none">{{ list.name }}</h1>
<q-space /> <q-space />
<q-btn
color="info"
icon="insights"
label="Cost Summary"
class="q-mr-sm"
@click="showCostSummaryDialog = true"
/>
<q-btn <q-btn
color="secondary" color="secondary"
icon="camera_alt" icon="camera_alt"
@ -96,6 +103,66 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Cost Summary Dialog -->
<q-dialog v-model="showCostSummaryDialog">
<q-card style="min-width: 500px">
<q-card-section>
<div class="text-h6">List Cost Summary</div>
</q-card-section>
<q-card-section v-if="costSummaryLoading" class="text-center">
<q-spinner-dots color="primary" size="2em" />
<p>Loading cost summary...</p>
</q-card-section>
<q-card-section v-else-if="costSummaryError" class="text-white bg-red q-py-sm">
<q-icon name="warning" />
{{ costSummaryError }}
</q-card-section>
<q-card-section v-else-if="listCostSummary">
<div class="q-mb-md">
<div><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</div>
<div><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</div>
<div><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</div>
</div>
<q-table
title="User Balances"
:rows="listCostSummary.user_balances"
:columns="userBalancesColumns"
row-key="user_id"
flat
bordered
dense
>
<template v-slot:body-cell-items_added_value="props">
<q-td :props="props">
{{ formatCurrency(props.row.items_added_value) }}
</q-td>
</template>
<template v-slot:body-cell-amount_due="props">
<q-td :props="props">
{{ formatCurrency(props.row.amount_due) }}
</q-td>
</template>
<template v-slot:body-cell-balance="props">
<q-td :props="props">
<q-badge :color="props.row.balance >= 0 ? 'positive' : 'negative'">
{{ formatCurrency(props.row.balance) }}
</q-badge>
</q-td>
</template>
</q-table>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Add Item Form --> <!-- Add Item Form -->
<q-form @submit="onAddItem" class="q-mb-lg"> <q-form @submit="onAddItem" class="q-mb-lg">
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@ -204,7 +271,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api'; import { apiClient, API_ENDPOINTS } from 'src/config/api';
import { useQuasar, QFile } from 'quasar'; import { useQuasar, QFile } from 'quasar';
@ -231,6 +298,23 @@ interface List {
updated_at: string; updated_at: string;
} }
interface UserCostShare {
user_id: number;
user_identifier: string;
items_added_value: string | number; // API returns string, but we'll parse to number
amount_due: string | number;
balance: string | number;
}
interface ListCostSummaryData {
list_id: number;
list_name: string;
total_list_cost: string | number;
num_participating_users: number;
equal_share_per_user: string | number;
user_balances: UserCostShare[];
}
const route = useRoute(); const route = useRoute();
const $q = useQuasar(); const $q = useQuasar();
@ -267,6 +351,26 @@ const pendingAction = ref<(() => Promise<void>) | null>(null);
// Add ref for item name input // Add ref for item name input
const itemNameInput = ref<{ focus: () => void } | null>(null); const itemNameInput = ref<{ focus: () => void } | null>(null);
// Cost Summary
const showCostSummaryDialog = ref(false);
const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null);
const userBalancesColumns = [
{ name: 'user_identifier', label: 'User', field: 'user_identifier', align: 'left' as const, sortable: true },
{ name: 'items_added_value', label: 'Items Added Value', field: 'items_added_value', align: 'right' as const, sortable: true },
{ name: 'amount_due', label: 'Amount Due', field: 'amount_due', align: 'right' as const, sortable: true },
{ name: 'balance', label: 'Balance', field: 'balance', align: 'right' as const, sortable: true },
];
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) return '$0.00';
return `$${numValue.toFixed(2)}`;
};
const fetchListDetails = async () => { const fetchListDetails = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@ -282,6 +386,7 @@ const fetchListDetails = async () => {
lastItemUpdate.value = response.data.items.reduce((latest: string, item: Item) => { lastItemUpdate.value = response.data.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest; return item.updated_at > latest ? item.updated_at : latest;
}, ''); }, '');
await fetchListCostSummary(); // Fetch cost summary after list details
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to fetch list details:', err); console.error('Failed to fetch list details:', err);
error.value = error.value =
@ -521,6 +626,36 @@ const deleteItem = async (item: Item) => {
} }
}; };
const fetchListCostSummary = async () => {
if (!list.value || list.value.id === 0) return;
costSummaryLoading.value = true;
costSummaryError.value = null;
try {
const response = await apiClient.get(
API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id)
);
listCostSummary.value = response.data;
} catch (err: unknown) {
console.error('Error fetching cost summary:', err);
costSummaryError.value =
(err as { response?: { data?: { detail?: string } } }).response?.data?.detail ||
'Failed to load cost summary. Please try again.';
listCostSummary.value = null; // Clear any old data
} finally {
costSummaryLoading.value = false;
}
};
// Call fetchListCostSummary when the dialog is opened
watch(showCostSummaryDialog, (newValue) => {
if (newValue && !listCostSummary.value && list.value.id !== 0) {
void fetchListCostSummary();
}
});
// Watch for changes in items that might affect cost summary
// e.g., price changes, item completion, which are handled by fetchListDetails then fetchListCostSummary
// Add keyboard event listeners // Add keyboard event listeners
onMounted(() => { onMounted(() => {
console.log('Component mounted, route params:', route.params); console.log('Component mounted, route params:', route.params);
@ -556,4 +691,9 @@ onUnmounted(() => {
opacity: 0.7; opacity: 0.7;
pointer-events: none; pointer-events: none;
} }
.q-table__title {
font-size: 1.1rem;
font-weight: 500;
}
</style> </style>