
- 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.
266 lines
5.6 KiB
Vue
266 lines
5.6 KiB
Vue
<template>
|
|
<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">
|
|
<div v-if="isLoading" class="text-center">
|
|
<div class="spinner-dots"><span /><span /><span /></div>
|
|
<p>Processing settlement...</p>
|
|
</div>
|
|
<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"
|
|
v-model="amount"
|
|
class="form-input"
|
|
id="settleAmount"
|
|
required
|
|
:disabled="isLoading"
|
|
step="0.01"
|
|
min="0"
|
|
/>
|
|
<p v-if="error" class="form-error-text">{{ error }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-neutral" @click="$emit('cancel')" :disabled="isLoading">Cancel</button>
|
|
<button
|
|
type="button"
|
|
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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { onClickOutside } from '@vueuse/core'
|
|
import type { ExpenseSplit } from '@/types/expense'
|
|
import { Decimal } from 'decimal.js'
|
|
|
|
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)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-backdrop {
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal-container {
|
|
background: white;
|
|
border-radius: 18px;
|
|
border: 3px solid #111;
|
|
box-shadow: 6px 6px 0 #111;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid #eee;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.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;
|
|
color: #666;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.form-input {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 2px solid #111;
|
|
border-radius: 8px;
|
|
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: none;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-neutral {
|
|
background: #f3f4f6;
|
|
color: #111;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #111;
|
|
color: white;
|
|
}
|
|
|
|
.ml-2 {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.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 span {
|
|
width: 4px;
|
|
height: 4px;
|
|
background-color: white;
|
|
border-radius: 50%;
|
|
animation: dot-pulse 1.4s infinite ease-in-out both;
|
|
}
|
|
|
|
.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>
|