mitlist/fe/src/components/SettleShareModal.vue
Mohamad.Elsena 52fc33b472 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.
2025-05-22 13:05:49 +02:00

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>