feat: Add CreateExpenseForm component and integrate into ListDetailPage
- Introduced CreateExpenseForm.vue for creating new expenses with fields for description, total amount, split type, and date. - Integrated the CreateExpenseForm into ListDetailPage.vue, allowing users to add expenses directly from the list view. - Enhanced UI with a modal for the expense creation form and added validation for required fields. - Updated styles for consistency across the application. - Implemented logic to refresh the expense list upon successful creation of a new expense.
This commit is contained in:
parent
e7b072c2bd
commit
52fc33b472
346
fe/src/components/CreateExpenseForm.vue
Normal file
346
fe/src/components/CreateExpenseForm.vue
Normal file
@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div class="modal-backdrop open" @click.self="closeForm">
|
||||
<div class="modal-container" ref="formModalRef" style="min-width: 550px;">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Expense</h3>
|
||||
<button class="close-button" @click="closeForm" aria-label="Close">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="handleSubmit" class="expense-form">
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
class="form-input"
|
||||
required
|
||||
placeholder="What was this expense for?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="totalAmount" class="form-label">Total Amount</label>
|
||||
<div class="amount-input-group">
|
||||
<span class="currency-symbol">$</span>
|
||||
<input
|
||||
type="number"
|
||||
id="totalAmount"
|
||||
v-model.number="formData.total_amount"
|
||||
class="form-input"
|
||||
required
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="splitType" class="form-label">Split Type</label>
|
||||
<select
|
||||
id="splitType"
|
||||
v-model="formData.split_type"
|
||||
class="form-input"
|
||||
required
|
||||
>
|
||||
<option value="EQUAL">Equal Split</option>
|
||||
<option value="EXACT_AMOUNTS">Exact Amounts</option>
|
||||
<option value="PERCENTAGE">Percentage</option>
|
||||
<option value="SHARES">Shares</option>
|
||||
<option value="ITEM_BASED">Item Based</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expenseDate" class="form-label">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="expenseDate"
|
||||
v-model="formData.expense_date"
|
||||
class="form-input"
|
||||
:max="today"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.split_type !== 'EQUAL'" class="form-group">
|
||||
<label class="form-label">Split Details</label>
|
||||
<div v-if="formData.split_type === 'EXACT_AMOUNTS'" class="splits-container">
|
||||
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="split.owed_amount"
|
||||
class="form-input"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="Amount"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="removeSplit(index)"
|
||||
:disabled="formData.splits_in.length <= 1"
|
||||
>
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-trash" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="addSplit"
|
||||
>
|
||||
Add Split
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add other split type inputs here -->
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-neutral" @click="closeForm">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary ml-2"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span v-if="isSubmitting" class="spinner-dots-sm"><span /><span /><span /></span>
|
||||
<span v-else>Create Expense</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import type { ExpenseCreate } from '@/types/expense';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
listId?: number;
|
||||
groupId?: number;
|
||||
itemId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'created', expense: any): void;
|
||||
}>();
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
const formModalRef = ref<HTMLElement | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const today = computed(() => {
|
||||
const date = new Date();
|
||||
return date.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
const formData = ref<ExpenseCreate>({
|
||||
description: '',
|
||||
total_amount: 0,
|
||||
currency: 'USD',
|
||||
expense_date: today.value,
|
||||
split_type: 'EQUAL',
|
||||
list_id: props.listId,
|
||||
group_id: props.groupId,
|
||||
item_id: props.itemId,
|
||||
paid_by_user_id: 0, // Will be set from auth store
|
||||
splits_in: [{ owed_amount: 0 }]
|
||||
});
|
||||
|
||||
const addSplit = () => {
|
||||
formData.value.splits_in?.push({ owed_amount: 0 });
|
||||
};
|
||||
|
||||
const removeSplit = (index: number) => {
|
||||
if (formData.value.splits_in && formData.value.splits_in.length > 1) {
|
||||
formData.value.splits_in.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.value.description || !formData.value.total_amount) {
|
||||
notificationStore.addNotification({
|
||||
message: 'Please fill in all required fields',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.EXPENSES.CREATE, formData.value);
|
||||
emit('created', response.data);
|
||||
closeForm();
|
||||
notificationStore.addNotification({
|
||||
message: 'Expense created successfully',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (err) {
|
||||
notificationStore.addNotification({
|
||||
message: (err instanceof Error ? err.message : String(err)) || 'Failed to create expense',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onClickOutside(formModalRef, closeForm);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.expense-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #111;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.amount-input-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.amount-input-group .form-input {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.splits-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.split-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #111;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-neutral {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner-dots-sm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.spinner-dots-sm span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.spinner-dots-sm span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.spinner-dots-sm span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,53 +1,47 @@
|
||||
<template>
|
||||
<div v-if="show" class="modal-backdrop-settle" @click.self="onCancel">
|
||||
<div class="modal-container-settle">
|
||||
<div class="modal-header-settle">
|
||||
<h3>Settle Your Share</h3>
|
||||
<button class="close-button-settle" @click="onCancel" aria-label="Close">×</button>
|
||||
<div v-if="show" class="modal-backdrop open" @click.self="$emit('cancel')">
|
||||
<div class="modal-container" ref="modalRef" style="min-width: 550px;">
|
||||
<div class="modal-header">
|
||||
<h3>Settle Share</h3>
|
||||
<button class="close-button" @click="$emit('cancel')" aria-label="Close">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body-settle" v-if="split">
|
||||
<p>You are about to settle your share for this expense.</p>
|
||||
<div class="info-item">
|
||||
<span>Owed by:</span>
|
||||
<strong>{{ split.user?.name || split.user?.email || `User ID ${split.user_id}` }}</strong>
|
||||
<div class="modal-body">
|
||||
<div v-if="isLoading" class="text-center">
|
||||
<div class="spinner-dots"><span /><span /><span /></div>
|
||||
<p>Processing settlement...</p>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span>Original Share:</span>
|
||||
<strong>{{ formatCurrency(split.owed_amount) }}</strong>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span>Already Paid:</span>
|
||||
<strong>{{ formatCurrency(paidAmount) }}</strong>
|
||||
</div>
|
||||
<hr class="my-3-settle" />
|
||||
<div class="info-item">
|
||||
<span>Amount to Settle Now:</span>
|
||||
<strong class="amount-to-settle">{{ formatCurrency(remainingAmount) }}</strong>
|
||||
</div>
|
||||
<!-- For MVP, amount is fixed to remaining. Input field removed. -->
|
||||
<!--
|
||||
<div class="form-group-settle">
|
||||
<label for="amountToSettle" class="form-label-settle">Amount to Settle:</label>
|
||||
<div v-else>
|
||||
<p>Settle amount for {{ split?.user?.name || split?.user?.email || `User ID: ${split?.user_id}` }}:</p>
|
||||
<div class="form-group">
|
||||
<label for="settleAmount" class="form-label">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
id="amountToSettle"
|
||||
class="form-input-settle"
|
||||
v-model="amountToSettleInput"
|
||||
v-model="amount"
|
||||
class="form-input"
|
||||
id="settleAmount"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
step="0.01"
|
||||
:readonly="true" // For MVP, fixed to remaining amount
|
||||
min="0"
|
||||
/>
|
||||
<p v-if="error" class="form-error-text">{{ error }}</p>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
<div class="modal-footer-settle">
|
||||
<button type="button" class="btn-neutral-settle" @click="onCancel" :disabled="isLoading">Cancel</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="$emit('cancel')" :disabled="isLoading">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary-settle ml-2-settle"
|
||||
@click="onConfirm"
|
||||
:disabled="isLoading || remainingAmount <= 0">
|
||||
<span v-if="isLoading" class="spinner-dots-sm-settle"><span /><span /><span /></span>
|
||||
<span v-else>Confirm Payment</span>
|
||||
class="btn btn-primary ml-2"
|
||||
@click="handleConfirm"
|
||||
:disabled="isLoading || !isValid"
|
||||
>
|
||||
<span v-if="isLoading" class="spinner-dots-sm"><span /><span /><span /></span>
|
||||
<span v-else>Confirm</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -55,90 +49,66 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, PropType } from 'vue';
|
||||
import { Decimal } from 'decimal.js'; // For precise arithmetic
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import type { ExpenseSplit } from '@/types/expense'
|
||||
import { Decimal } from 'decimal.js'
|
||||
|
||||
// Define interfaces for props inline for clarity in this component
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
split: ExpenseSplit | null
|
||||
paidAmount: number
|
||||
isLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', amount: number): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const amount = ref<string>('')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Close modal when clicking outside
|
||||
onClickOutside(modalRef, () => {
|
||||
emit('cancel')
|
||||
})
|
||||
|
||||
// Reset form when modal opens
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal && props.split) {
|
||||
const alreadyPaid = new Decimal(props.paidAmount)
|
||||
const owed = new Decimal(props.split.owed_amount)
|
||||
const remaining = owed.minus(alreadyPaid)
|
||||
amount.value = remaining.toFixed(2)
|
||||
error.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!amount.value.trim()) return false
|
||||
const numAmount = new Decimal(amount.value)
|
||||
if (numAmount.isNaN() || numAmount.isNegative() || numAmount.isZero()) return false
|
||||
if (props.split) {
|
||||
const alreadyPaid = new Decimal(props.paidAmount)
|
||||
const owed = new Decimal(props.split.owed_amount)
|
||||
const remaining = owed.minus(alreadyPaid)
|
||||
return numAmount.lessThanOrEqualTo(remaining.plus(new Decimal('0.001'))) // Epsilon for float issues
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isValid.value) return
|
||||
const numAmount = parseFloat(amount.value)
|
||||
emit('confirm', numAmount)
|
||||
}
|
||||
|
||||
export interface ExpenseSplitInfo { // Exporting to be potentially used by parent if needed
|
||||
id: number;
|
||||
user_id: number;
|
||||
owed_amount: string; // Expect string from backend for Decimal types
|
||||
user?: UserInfo | null;
|
||||
// Add other necessary fields from your actual ExpenseSplit type
|
||||
// e.g. status, settlement_activities if they affect logic here (not for MVP)
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
split: {
|
||||
type: Object as PropType<ExpenseSplitInfo | null>,
|
||||
required: true,
|
||||
},
|
||||
paidAmount: { // Amount already paid towards this split
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const remainingAmount = computed(() => {
|
||||
if (!props.split) return 0;
|
||||
try {
|
||||
const owed = new Decimal(props.split.owed_amount);
|
||||
const paid = new Decimal(props.paidAmount);
|
||||
const remaining = owed.minus(paid);
|
||||
return remaining.greaterThan(0) ? remaining.toNumber() : 0;
|
||||
} catch (e) {
|
||||
console.error("Error calculating remaining amount:", e);
|
||||
return 0; // Fallback in case of invalid decimal string
|
||||
}
|
||||
});
|
||||
|
||||
// For MVP, amountToSettle is always the full remainingAmount
|
||||
const amountToSettle = computed(() => remainingAmount.value);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (remainingAmount.value > 0) {
|
||||
emit('confirm', amountToSettle.value); // Emit the number value
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
// Helper to format currency (can be moved to a utility file)
|
||||
const formatCurrency = (value: string | number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '$0.00';
|
||||
let numValue: number;
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return '$0.00';
|
||||
numValue = parseFloat(value);
|
||||
} else {
|
||||
numValue = value;
|
||||
}
|
||||
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop-settle {
|
||||
background-color: rgba(0, 0, 0, 0.6); /* Darker overlay */
|
||||
.modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@ -147,144 +117,149 @@ const formatCurrency = (value: string | number | undefined | null): string => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1050; /* Ensure it's above other elements */
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container-settle {
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 12px; /* Softer radius */
|
||||
border: 2px solid #333; /* Slightly softer border */
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.25); /* Softer shadow */
|
||||
border-radius: 18px;
|
||||
border: 3px solid #111;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
width: 90%;
|
||||
max-width: 450px; /* Optimal width for a simple modal */
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
display: flex; /* For footer alignment */
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-header-settle {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0; /* Lighter border */
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header-settle h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.close-button-settle {
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: #555;
|
||||
padding: 0.5rem; /* Easier to click */
|
||||
}
|
||||
.close-button-settle:hover {
|
||||
color: #111;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal-body-settle {
|
||||
padding: 1.5rem;
|
||||
line-height: 1.6;
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-body-settle p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.info-item span {
|
||||
color: #555;
|
||||
}
|
||||
.info-item strong {
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
}
|
||||
.amount-to-settle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary-color, #3498db) !important; /* Use theme color */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.my-3-settle {
|
||||
border: 0;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.modal-footer-settle {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem; /* Consistent gap */
|
||||
background-color: #f9f9f9; /* Slight distinction for footer */
|
||||
border-bottom-left-radius: 12px; /* Match container radius */
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
|
||||
/* Generic button styles - assuming similar to existing .btn but scoped with -settle */
|
||||
.btn-neutral-settle, .btn-primary-settle {
|
||||
padding: 0.6rem 1.2rem;
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #111;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 2px solid #111; /* Neo-brutalist touch */
|
||||
box-shadow: 2px 2px 0 #111; /* Neo-brutalist touch */
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
border: none;
|
||||
}
|
||||
.btn-neutral-settle:hover, .btn-primary-settle:hover {
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: 3px 3px 0 #111;
|
||||
}
|
||||
.btn-neutral-settle:disabled, .btn-primary-settle:disabled {
|
||||
opacity: 0.6;
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-neutral-settle {
|
||||
background-color: #f0f0f0;
|
||||
.btn-neutral {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.btn-primary-settle {
|
||||
background-color: var(--primary-color, #3498db);
|
||||
.btn-primary {
|
||||
background: #111;
|
||||
color: white;
|
||||
border-color: #111; /* Ensure border matches */
|
||||
}
|
||||
.ml-2-settle {
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner-dots-sm-settle { /* For loading button */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.spinner-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #555;
|
||||
border-radius: 50%;
|
||||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.spinner-dots-sm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.spinner-dots-sm-settle span {
|
||||
|
||||
.spinner-dots-sm span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: white; /* Assuming primary button has light text */
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
animation: dot-pulse-settle 1.4s infinite ease-in-out both;
|
||||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
.spinner-dots-sm-settle span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.spinner-dots-sm-settle span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes dot-pulse-settle {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
.spinner-dots span:nth-child(1),
|
||||
.spinner-dots-sm span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.spinner-dots span:nth-child(2),
|
||||
.spinner-dots-sm span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,8 +1,8 @@
|
||||
// API Version
|
||||
export const API_VERSION = 'v1';
|
||||
export const API_VERSION = 'v1'
|
||||
|
||||
// API Base URL
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
// API Endpoints
|
||||
export const API_ENDPOINTS = {
|
||||
@ -19,7 +19,7 @@ export const API_ENDPOINTS = {
|
||||
// Users
|
||||
USERS: {
|
||||
PROFILE: '/users/me',
|
||||
UPDATE_PROFILE: '/api/v1/users/me',
|
||||
UPDATE_PROFILE: '/users/me',
|
||||
PASSWORD: '/api/v1/users/password',
|
||||
AVATAR: '/api/v1/users/avatar',
|
||||
SETTINGS: '/api/v1/users/settings',
|
||||
@ -116,4 +116,4 @@ export const API_ENDPOINTS = {
|
||||
METRICS: '/health/metrics',
|
||||
LOGS: '/health/logs',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -99,7 +99,15 @@
|
||||
|
||||
<!-- Expenses Section -->
|
||||
<section v-if="list" class="neo-expenses-section">
|
||||
<div class="neo-expenses-header">
|
||||
<h2 class="neo-expenses-title">Expenses</h2>
|
||||
<button class="neo-action-button" @click="showCreateExpenseForm = true">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
Add Expense
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
|
||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||
<p>Loading expenses...</p>
|
||||
@ -136,6 +144,14 @@
|
||||
Paid: {{ getPaidAmountForSplitDisplay(split) }}
|
||||
<span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
|
||||
class="neo-button neo-button-primary"
|
||||
@click="openSettleShareModal(expense, split)"
|
||||
:disabled="isSettlementLoading"
|
||||
>
|
||||
Settle My Share
|
||||
</button>
|
||||
<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">
|
||||
Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User ${activity.paid_by_user_id}`}} on {{ new Date(activity.paid_at).toLocaleDateString() }}
|
||||
@ -147,6 +163,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Create Expense Form -->
|
||||
<CreateExpenseForm
|
||||
v-if="showCreateExpenseForm"
|
||||
:list-id="list?.id"
|
||||
:group-id="list?.group_id"
|
||||
@close="showCreateExpenseForm = false"
|
||||
@created="handleExpenseCreated"
|
||||
/>
|
||||
|
||||
<!-- OCR Dialog -->
|
||||
<div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog">
|
||||
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
|
||||
@ -272,6 +297,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settle Share Modal -->
|
||||
<div v-if="showSettleModal" class="modal-backdrop open" @click.self="closeSettleShareModal">
|
||||
<div class="modal-container" ref="settleModalRef" style="min-width: 550px;">
|
||||
<div class="modal-header">
|
||||
<h3>Settle Share</h3>
|
||||
<button class="close-button" @click="closeSettleShareModal" aria-label="Close"><svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="isSettlementLoading" class="text-center">
|
||||
<div class="spinner-dots"><span /><span /><span /></div>
|
||||
<p>Processing settlement...</p>
|
||||
</div>
|
||||
<div v-else-if="settleAmountError" class="alert alert-error">{{ settleAmountError }}</div>
|
||||
<div v-else>
|
||||
<p>Settle amount for {{ selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` }}:</p>
|
||||
<div class="form-group">
|
||||
<label for="settleAmount" class="form-label">Amount</label>
|
||||
<input type="number" v-model="settleAmount" class="form-input" id="settleAmount" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" @click="handleConfirmSettle">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@ -283,8 +337,14 @@ import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vu
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
||||
import { useListDetailStore } from '@/stores/listDetailStore';
|
||||
import type { Expense, ExpenseSplit } from '@/types/expense'; // Ensure correct path
|
||||
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense'; // Ensure correct path
|
||||
import type { ListWithExpenses } from '@/types/list';
|
||||
import type { Expense, ExpenseSplit } from '@/types/expense';
|
||||
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Decimal } from 'decimal.js';
|
||||
import type { SettlementActivityCreate } from '@/types/expense';
|
||||
import SettleShareModal from '@/components/SettleShareModal.vue';
|
||||
import CreateExpenseForm from '@/components/CreateExpenseForm.vue';
|
||||
|
||||
|
||||
interface Item {
|
||||
@ -325,7 +385,7 @@ const route = useRoute();
|
||||
const { isOnline } = useNetwork();
|
||||
const notificationStore = useNotificationStore();
|
||||
const offlineStore = useOfflineStore();
|
||||
const list = ref<List | null>(null); // This is for items
|
||||
const list = ref<ListWithExpenses | null>(null);
|
||||
const loading = ref(true); // For initial list (items) loading
|
||||
const error = ref<string | null>(null); // For initial list (items) loading
|
||||
const addingItem = ref(false);
|
||||
@ -363,10 +423,24 @@ const listCostSummary = ref<ListCostSummaryData | null>(null);
|
||||
const costSummaryLoading = ref(false);
|
||||
const costSummaryError = ref<string | null>(null);
|
||||
|
||||
// Settle Share
|
||||
const authStore = useAuthStore();
|
||||
const showSettleModal = ref(false);
|
||||
const settleModalRef = ref<HTMLElement | null>(null);
|
||||
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
|
||||
const parentExpenseOfSelectedSplit = ref<Expense | null>(null);
|
||||
const settleAmount = ref<string>('');
|
||||
const settleAmountError = ref<string | null>(null);
|
||||
const isSettlementLoading = computed(() => listDetailStore.isSettlingSplit);
|
||||
|
||||
// Create Expense
|
||||
const showCreateExpenseForm = ref(false);
|
||||
|
||||
|
||||
onClickOutside(ocrModalRef, () => { showOcrDialogState.value = false; });
|
||||
onClickOutside(costSummaryModalRef, () => { showCostSummaryDialog.value = false; });
|
||||
onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; });
|
||||
onClickOutside(settleModalRef, () => { showSettleModal.value = false; });
|
||||
|
||||
|
||||
const formatCurrency = (value: string | number | undefined | null): string => {
|
||||
@ -377,10 +451,12 @@ const formatCurrency = (value: string | number | undefined | null): string => {
|
||||
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const processListItems = (items: Item[]): Item[] => {
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
priceInput: item.price !== null && item.price !== undefined ? item.price : ''
|
||||
const processListItems = (items: Item[]) => {
|
||||
return items.map((i: Item) => ({
|
||||
...i,
|
||||
updating: false,
|
||||
deleting: false,
|
||||
priceInput: i.price || '',
|
||||
}));
|
||||
};
|
||||
|
||||
@ -389,7 +465,7 @@ const fetchListDetails = async () => { // This is for items primarily
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id)));
|
||||
const rawList = response.data as List;
|
||||
const rawList = response.data as ListWithExpenses;
|
||||
rawList.items = processListItems(rawList.items);
|
||||
list.value = rawList; // Sets item-related list data
|
||||
|
||||
@ -413,7 +489,7 @@ const checkForUpdates = async () => {
|
||||
if (!list.value) return;
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
|
||||
const { updated_at: newListUpdatedAt, items: newItems } = response.data as List;
|
||||
const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses;
|
||||
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
|
||||
|
||||
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
||||
@ -584,14 +660,14 @@ const deleteItem = async (item: Item) => {
|
||||
itemId: String(item.id)
|
||||
}
|
||||
});
|
||||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||
list.value.items = list.value.items.filter((i: Item) => i.id !== item.id);
|
||||
item.deleting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
|
||||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||
list.value.items = list.value.items.filter((i: Item) => i.id !== item.id);
|
||||
} catch (err) {
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
||||
} finally {
|
||||
@ -722,8 +798,6 @@ watch(showCostSummaryDialog, (newVal) => {
|
||||
|
||||
// --- Expense and Settlement Status Logic ---
|
||||
const listDetailStore = useListDetailStore();
|
||||
// listWithExpenses is not directly used in template, expenses getter is used instead
|
||||
// const listWithExpenses = computed(() => listDetailStore.getList);
|
||||
const expenses = computed(() => listDetailStore.getExpenses);
|
||||
|
||||
const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
|
||||
@ -816,6 +890,91 @@ const editItem = (item: Item) => {
|
||||
});
|
||||
};
|
||||
|
||||
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
|
||||
if (split.user_id !== authStore.user?.id) {
|
||||
notificationStore.addNotification({ message: "You can only settle your own shares.", type: 'warning' });
|
||||
return;
|
||||
}
|
||||
selectedSplitForSettlement.value = split;
|
||||
parentExpenseOfSelectedSplit.value = expense;
|
||||
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(split.id));
|
||||
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;
|
||||
parentExpenseOfSelectedSplit.value = null;
|
||||
settleAmount.value = '';
|
||||
settleAmountError.value = null;
|
||||
};
|
||||
|
||||
const validateSettleAmount = (): boolean => {
|
||||
settleAmountError.value = null;
|
||||
if (!settleAmount.value.trim()) {
|
||||
settleAmountError.value = 'Please enter an amount.';
|
||||
return false;
|
||||
}
|
||||
const amount = new Decimal(settleAmount.value);
|
||||
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
|
||||
settleAmountError.value = 'Please enter a positive amount.';
|
||||
return false;
|
||||
}
|
||||
if (selectedSplitForSettlement.value) {
|
||||
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
|
||||
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
|
||||
const remaining = owed.minus(alreadyPaid);
|
||||
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { // Epsilon for float issues
|
||||
settleAmountError.value = `Amount cannot exceed remaining: ${formatCurrency(remaining.toFixed(2))}.`;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
settleAmountError.value = 'Error: No split selected.'; // Should not happen
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id || null);
|
||||
|
||||
const handleConfirmSettle = async () => {
|
||||
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) {
|
||||
notificationStore.addNotification({ message: 'Cannot process settlement: missing data.', type: 'error' });
|
||||
return;
|
||||
}
|
||||
// Use settleAmount.value which is the confirmed amount (remaining amount for MVP)
|
||||
const activityData: SettlementActivityCreate = {
|
||||
expense_split_id: selectedSplitForSettlement.value.id,
|
||||
paid_by_user_id: Number(authStore.user.id), // Convert to number
|
||||
amount_paid: new Decimal(settleAmount.value).toString(),
|
||||
paid_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const success = await listDetailStore.settleExpenseSplit({
|
||||
list_id_for_refetch: String(currentListIdForRefetch.value),
|
||||
expense_split_id: selectedSplitForSettlement.value.id,
|
||||
activity_data: activityData,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
notificationStore.addNotification({ message: 'Share settled successfully!', type: 'success' });
|
||||
closeSettleShareModal();
|
||||
} else {
|
||||
notificationStore.addNotification({ message: listDetailStore.error || 'Failed to settle share.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpenseCreated = (expense: any) => {
|
||||
// Refresh the expenses list
|
||||
if (list.value?.id) {
|
||||
listDetailStore.fetchListWithExpenses(String(list.value.id));
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -830,6 +989,13 @@ const editItem = (item: Item) => {
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
}
|
||||
|
||||
.neo-expenses-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.neo-expenses-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
|
@ -1,8 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/
|
||||
import router from '@/router'; // Import the router instance
|
||||
import { useAuthStore } from '@/stores/auth'; // Import the auth store
|
||||
import type { SettlementActivityCreate } from '@/types/expense'; // Import the type for the payload
|
||||
import axios from 'axios'
|
||||
import { API_BASE_URL } from '@/config/api-config' // api-config.ts can be moved to src/config/
|
||||
import router from '@/router' // Import the router instance
|
||||
import { useAuthStore } from '@/stores/auth' // Import the auth store
|
||||
import type { SettlementActivityCreate } from '@/types/expense' // Import the type for the payload
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
@ -11,76 +11,80 @@ const api = axios.create({
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // Enable sending cookies and authentication headers
|
||||
});
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token'); // Or use useStorage from VueUse
|
||||
const token = localStorage.getItem('token') // Or use useStorage from VueUse
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error); // Simpler error handling
|
||||
}
|
||||
);
|
||||
return Promise.reject(error) // Simpler error handling
|
||||
},
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const authStore = useAuthStore(); // Get auth store instance
|
||||
const originalRequest = error.config
|
||||
const authStore = useAuthStore() // Get auth store instance
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
originalRequest._retry = true
|
||||
try {
|
||||
const refreshTokenValue = authStore.refreshToken; // Get from store for consistency
|
||||
const refreshTokenValue = authStore.refreshToken // Get from store for consistency
|
||||
if (!refreshTokenValue) {
|
||||
console.error('No refresh token, redirecting to login');
|
||||
authStore.clearTokens(); // Clear tokens in store and localStorage
|
||||
await router.push('/auth/login');
|
||||
return Promise.reject(error);
|
||||
console.error('No refresh token, redirecting to login')
|
||||
authStore.clearTokens() // Clear tokens in store and localStorage
|
||||
await router.push('/auth/login')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const response = await api.post('/auth/jwt/refresh', { // Use base 'api' instance for refresh
|
||||
const response = await api.post('/auth/jwt/refresh', {
|
||||
// Use base 'api' instance for refresh
|
||||
refresh_token: refreshTokenValue,
|
||||
});
|
||||
})
|
||||
|
||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data;
|
||||
authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken });
|
||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data
|
||||
authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken })
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
return api(originalRequest)
|
||||
} catch (refreshError) {
|
||||
console.error('Refresh token failed:', refreshError);
|
||||
authStore.clearTokens(); // Clear tokens in store and localStorage
|
||||
await router.push('/auth/login');
|
||||
return Promise.reject(refreshError);
|
||||
console.error('Refresh token failed:', refreshError)
|
||||
authStore.clearTokens() // Clear tokens in store and localStorage
|
||||
await router.push('/auth/login')
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// Export the original axios too if some parts of your app used it directly
|
||||
const globalAxios = axios;
|
||||
const globalAxios = axios
|
||||
|
||||
export { api, globalAxios };
|
||||
export { api, globalAxios }
|
||||
|
||||
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
|
||||
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config'
|
||||
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
// Don't add /api/v1 prefix for auth endpoints
|
||||
if (endpoint.startsWith('/auth/')) {
|
||||
return `${API_BASE_URL}${endpoint}`
|
||||
}
|
||||
// Check if the endpoint already starts with /api/vX (like from API_ENDPOINTS)
|
||||
if (endpoint.startsWith('/api/')) {
|
||||
return `${API_BASE_URL}${endpoint}`;
|
||||
return `${API_BASE_URL}${endpoint}`
|
||||
}
|
||||
// Otherwise, prefix with /api/API_VERSION
|
||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||
};
|
||||
|
||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
|
||||
@ -90,11 +94,15 @@ export const apiClient = {
|
||||
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
||||
|
||||
// Specific method for settling an expense split
|
||||
settleExpenseSplit: (expenseSplitId: number, activityData: SettlementActivityCreate, config = {}) => {
|
||||
settleExpenseSplit: (
|
||||
expenseSplitId: number,
|
||||
activityData: SettlementActivityCreate,
|
||||
config = {},
|
||||
) => {
|
||||
// Construct the endpoint URL correctly, assuming API_VERSION is part of the base path or needs to be here
|
||||
const endpoint = `/expense_splits/${expenseSplitId}/settle`; // Path relative to /api/API_VERSION
|
||||
return api.post(getApiUrl(endpoint), activityData, config);
|
||||
}
|
||||
};
|
||||
const endpoint = `/expense_splits/${expenseSplitId}/settle` // Path relative to /api/API_VERSION
|
||||
return api.post(getApiUrl(endpoint), activityData, config)
|
||||
},
|
||||
}
|
||||
|
||||
export { API_ENDPOINTS }; // Also re-export for convenience
|
||||
export { API_ENDPOINTS } // Also re-export for convenience
|
||||
|
@ -1,94 +1,94 @@
|
||||
import { API_ENDPOINTS } from '@/config/api-config';
|
||||
import { apiClient } from '@/services/api';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import router from '@/router';
|
||||
|
||||
import { API_ENDPOINTS } from '@/config/api-config'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '@/router'
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
user: {
|
||||
email: string;
|
||||
name: string;
|
||||
id?: string | number;
|
||||
} | null;
|
||||
email: string
|
||||
name: string
|
||||
id?: string | number
|
||||
} | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const accessToken = ref<string | null>(localStorage.getItem('token'));
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'));
|
||||
const user = ref<AuthState['user']>(null);
|
||||
const accessToken = ref<string | null>(localStorage.getItem('token'))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
|
||||
const user = ref<AuthState['user']>(null)
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
const getUser = computed(() => user.value);
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const getUser = computed(() => user.value)
|
||||
|
||||
// Actions
|
||||
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
|
||||
accessToken.value = tokens.access_token;
|
||||
localStorage.setItem('token', tokens.access_token);
|
||||
accessToken.value = tokens.access_token
|
||||
localStorage.setItem('token', tokens.access_token)
|
||||
if (tokens.refresh_token) {
|
||||
refreshToken.value = tokens.refresh_token;
|
||||
localStorage.setItem('refreshToken', tokens.refresh_token);
|
||||
refreshToken.value = tokens.refresh_token
|
||||
localStorage.setItem('refreshToken', tokens.refresh_token)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearTokens = () => {
|
||||
accessToken.value = null;
|
||||
refreshToken.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
};
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
const setUser = (userData: AuthState['user']) => {
|
||||
user.value = userData;
|
||||
};
|
||||
user.value = userData
|
||||
}
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
if (!accessToken.value) {
|
||||
clearTokens();
|
||||
return null;
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
|
||||
setUser(response.data);
|
||||
return response.data;
|
||||
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE)
|
||||
setUser(response.data)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('AuthStore: Failed to fetch current user:', error);
|
||||
clearTokens();
|
||||
return null;
|
||||
console.error('AuthStore: Failed to fetch current user:', error)
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('username', email);
|
||||
formData.append('password', password);
|
||||
const formData = new FormData()
|
||||
formData.append('username', email)
|
||||
formData.append('password', password)
|
||||
|
||||
const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
setTokens({ access_token, refresh_token });
|
||||
await fetchCurrentUser();
|
||||
return response.data;
|
||||
};
|
||||
const { access_token, refresh_token } = response.data
|
||||
setTokens({ access_token, refresh_token })
|
||||
// Skip fetching profile data
|
||||
// await fetchCurrentUser();
|
||||
return response.data
|
||||
}
|
||||
|
||||
const signup = async (userData: { name: string; email: string; password: string }) => {
|
||||
const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
};
|
||||
clearTokens()
|
||||
await router.push('/auth/login')
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@ -103,5 +103,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
login,
|
||||
signup,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
@ -1,18 +1,20 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense';
|
||||
import type { SettlementActivityCreate } from '@/types/expense';
|
||||
import type { List } from '@/types/list';
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
|
||||
import type { SettlementActivityCreate } from '@/types/expense'
|
||||
import type { List } from '@/types/list'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
export interface ListWithExpenses extends List {
|
||||
expenses: Expense[];
|
||||
id: number
|
||||
expenses: Expense[]
|
||||
}
|
||||
|
||||
interface ListDetailState {
|
||||
currentList: ListWithExpenses | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isSettlingSplit: boolean;
|
||||
currentList: ListWithExpenses | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
isSettlingSplit: boolean
|
||||
}
|
||||
|
||||
export const useListDetailStore = defineStore('listDetail', {
|
||||
@ -25,101 +27,108 @@ export const useListDetailStore = defineStore('listDetail', {
|
||||
|
||||
actions: {
|
||||
async fetchListWithExpenses(listId: string) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.isLoading = true
|
||||
this.error = null
|
||||
try {
|
||||
// This assumes API_ENDPOINTS.LISTS.BY_ID(listId) generates a path like "/lists/{id}"
|
||||
// and getApiUrl (from services/api.ts) correctly prefixes it with API_BASE_URL and /api/API_VERSION if necessary.
|
||||
const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
|
||||
|
||||
const response = await apiClient.get(endpoint);
|
||||
this.currentList = response as ListWithExpenses;
|
||||
const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
|
||||
const response = await apiClient.get(endpoint)
|
||||
this.currentList = response.data as ListWithExpenses
|
||||
} catch (err: any) {
|
||||
this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details';
|
||||
this.currentList = null;
|
||||
console.error('Error fetching list details:', err);
|
||||
this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details'
|
||||
this.currentList = null
|
||||
console.error('Error fetching list details:', err)
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async settleExpenseSplit(payload: {
|
||||
list_id_for_refetch: string, // ID of the list to refetch after settlement
|
||||
expense_split_id: number,
|
||||
list_id_for_refetch: string // ID of the list to refetch after settlement
|
||||
expense_split_id: number
|
||||
activity_data: SettlementActivityCreate
|
||||
}): Promise<boolean> {
|
||||
this.isSettlingSplit = true;
|
||||
this.error = null;
|
||||
this.isSettlingSplit = true
|
||||
this.error = null
|
||||
try {
|
||||
// TODO: Uncomment and use when apiClient.settleExpenseSplit is available and correctly implemented in api.ts
|
||||
// For now, simulating the API call as it was not successfully added in the previous step.
|
||||
console.warn(`Simulating settlement for split ID: ${payload.expense_split_id} with data:`, payload.activity_data);
|
||||
console.warn(
|
||||
`Simulating settlement for split ID: ${payload.expense_split_id} with data:`,
|
||||
payload.activity_data,
|
||||
)
|
||||
// const createdActivity = await apiClient.settleExpenseSplit(payload.expense_split_id, payload.activity_data);
|
||||
// console.log('Settlement activity created (simulated):', createdActivity);
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network delay
|
||||
// End of placeholder for API call
|
||||
|
||||
// Refresh list data to show updated statuses.
|
||||
// Ensure currentList is not null and its ID matches before refetching,
|
||||
// or always refetch if list_id_for_refetch is the source of truth.
|
||||
if (payload.list_id_for_refetch) {
|
||||
await this.fetchListWithExpenses(payload.list_id_for_refetch);
|
||||
await this.fetchListWithExpenses(payload.list_id_for_refetch)
|
||||
} else if (this.currentList?.id) {
|
||||
// Fallback if list_id_for_refetch is not provided but currentList exists
|
||||
await this.fetchListWithExpenses(String(this.currentList.id));
|
||||
await this.fetchListWithExpenses(String(this.currentList.id))
|
||||
} else {
|
||||
console.warn("Could not refetch list details: list_id_for_refetch not provided and no currentList available.");
|
||||
console.warn(
|
||||
'Could not refetch list details: list_id_for_refetch not provided and no currentList available.',
|
||||
)
|
||||
}
|
||||
|
||||
this.isSettlingSplit = false;
|
||||
return true; // Indicate success
|
||||
this.isSettlingSplit = false
|
||||
return true // Indicate success
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to settle expense split.';
|
||||
this.error = errorMessage;
|
||||
console.error('Error settling expense split:', err);
|
||||
this.isSettlingSplit = false;
|
||||
return false; // Indicate failure
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to settle expense split.'
|
||||
this.error = errorMessage
|
||||
console.error('Error settling expense split:', err)
|
||||
this.isSettlingSplit = false
|
||||
return false // Indicate failure
|
||||
}
|
||||
},
|
||||
|
||||
setError(errorMessage: string) {
|
||||
this.error = errorMessage;
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.error = errorMessage
|
||||
this.isLoading = false
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
getList(state: ListDetailState): ListWithExpenses | null {
|
||||
return state.currentList;
|
||||
return state.currentList
|
||||
},
|
||||
getExpenses(state: ListDetailState): Expense[] {
|
||||
return state.currentList?.expenses || [];
|
||||
return state.currentList?.expenses || []
|
||||
},
|
||||
getPaidAmountForSplit: (state: ListDetailState) => (splitId: number): number => {
|
||||
let totalPaid = 0;
|
||||
getPaidAmountForSplit:
|
||||
(state: ListDetailState) =>
|
||||
(splitId: number): number => {
|
||||
let totalPaid = 0
|
||||
if (state.currentList && state.currentList.expenses) {
|
||||
for (const expense of state.currentList.expenses) {
|
||||
const split = expense.splits.find(s => s.id === splitId);
|
||||
const split = expense.splits.find((s) => s.id === splitId)
|
||||
if (split && split.settlement_activities) {
|
||||
totalPaid = split.settlement_activities.reduce((sum, activity) => {
|
||||
return sum + parseFloat(activity.amount_paid);
|
||||
}, 0);
|
||||
break;
|
||||
return sum + parseFloat(activity.amount_paid)
|
||||
}, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalPaid;
|
||||
return totalPaid
|
||||
},
|
||||
getExpenseSplitById: (state: ListDetailState) => (splitId: number): ExpenseSplit | undefined => {
|
||||
if (!state.currentList || !state.currentList.expenses) return undefined;
|
||||
getExpenseSplitById:
|
||||
(state: ListDetailState) =>
|
||||
(splitId: number): ExpenseSplit | undefined => {
|
||||
if (!state.currentList || !state.currentList.expenses) return undefined
|
||||
for (const expense of state.currentList.expenses) {
|
||||
const split = expense.splits.find(s => s.id === splitId);
|
||||
if (split) return split;
|
||||
}
|
||||
return undefined;
|
||||
const split = expense.splits.find((s) => s.id === splitId)
|
||||
if (split) return split
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
// Assuming List interface might be defined in fe/src/types/list.ts
|
||||
// If not, it should be defined like this:
|
||||
|
11
fe/src/types/item.ts
Normal file
11
fe/src/types/item.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface Item {
|
||||
id: number
|
||||
name: string
|
||||
quantity?: number | null
|
||||
is_complete: boolean
|
||||
price?: string | null // String representation of Decimal
|
||||
list_id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
version: number
|
||||
}
|
18
fe/src/types/list.ts
Normal file
18
fe/src/types/list.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Expense } from './expense'
|
||||
import type { Item } from './item'
|
||||
|
||||
export interface List {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
is_complete: boolean
|
||||
group_id?: number | null
|
||||
items: Item[]
|
||||
version: number
|
||||
updated_at: string
|
||||
expenses?: Expense[]
|
||||
}
|
||||
|
||||
export interface ListWithExpenses extends List {
|
||||
expenses: Expense[]
|
||||
}
|
Loading…
Reference in New Issue
Block a user