557 lines
17 KiB
Vue
557 lines
17 KiB
Vue
<template>
|
|
<v-container fluid>
|
|
<!-- Header -->
|
|
<v-row class="mb-6">
|
|
<v-col>
|
|
<h1 class="text-h3 font-weight-bold mb-2">Payment Management</h1>
|
|
<p class="text-body-1 text-medium-emphasis">Track and manage all payments and transactions</p>
|
|
</v-col>
|
|
<v-col cols="auto">
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
prepend-icon="mdi-cash-plus"
|
|
@click="showRecordPaymentDialog = true"
|
|
>
|
|
Record Payment
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Stats Cards -->
|
|
<v-row class="mb-6">
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">${{ stats.totalRevenue.toLocaleString() }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Total Revenue</div>
|
|
</div>
|
|
<v-icon size="32" color="success">mdi-cash</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">${{ stats.pendingPayments.toLocaleString() }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Pending</div>
|
|
</div>
|
|
<v-icon size="32" color="warning">mdi-clock-outline</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">{{ stats.failedTransactions }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Failed</div>
|
|
</div>
|
|
<v-icon size="32" color="error">mdi-alert-circle-outline</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">{{ stats.successfulTransactions }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Successful</div>
|
|
</div>
|
|
<v-icon size="32" color="info">mdi-swap-horizontal</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Filters -->
|
|
<v-card class="mb-6" elevation="0">
|
|
<v-card-text>
|
|
<v-row>
|
|
<v-col cols="12" md="3">
|
|
<v-text-field
|
|
v-model="searchQuery"
|
|
label="Search payments"
|
|
prepend-inner-icon="mdi-magnify"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="2">
|
|
<v-select
|
|
v-model="statusFilter"
|
|
label="Status"
|
|
:items="statusOptions"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="2">
|
|
<v-select
|
|
v-model="typeFilter"
|
|
label="Type"
|
|
:items="typeOptions"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="2">
|
|
<v-text-field
|
|
v-model="dateFrom"
|
|
label="From Date"
|
|
type="date"
|
|
variant="outlined"
|
|
density="compact"
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="2">
|
|
<v-text-field
|
|
v-model="dateTo"
|
|
label="To Date"
|
|
type="date"
|
|
variant="outlined"
|
|
density="compact"
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="1">
|
|
<v-btn
|
|
variant="outlined"
|
|
color="primary"
|
|
block
|
|
@click="exportPayments"
|
|
>
|
|
Export
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Payments Table -->
|
|
<v-card elevation="2">
|
|
<v-data-table
|
|
:headers="headers"
|
|
:items="filteredPayments"
|
|
:search="searchQuery"
|
|
:loading="loading"
|
|
class="elevation-0"
|
|
hover
|
|
:items-per-page="10"
|
|
>
|
|
<template v-slot:item.transaction_id="{ item }">
|
|
<code class="text-caption">{{ item.transaction_id }}</code>
|
|
</template>
|
|
|
|
<template v-slot:item.member="{ item }">
|
|
<div class="py-2">
|
|
<div class="font-weight-medium">{{ item.member_name }}</div>
|
|
<div class="text-caption text-medium-emphasis">{{ item.member_email }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-slot:item.amount="{ item }">
|
|
<span class="font-weight-medium">${{ item.amount.toFixed(2) }}</span>
|
|
</template>
|
|
|
|
<template v-slot:item.type="{ item }">
|
|
<v-chip
|
|
:color="getTypeColor(item.type)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ item.type }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template v-slot:item.status="{ item }">
|
|
<v-chip
|
|
:color="getStatusColor(item.status)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ item.status }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template v-slot:item.date="{ item }">
|
|
<span class="text-body-2">{{ formatDate(item.date) }}</span>
|
|
</template>
|
|
|
|
<template v-slot:item.actions="{ item }">
|
|
<v-btn
|
|
icon="mdi-eye"
|
|
size="small"
|
|
variant="text"
|
|
@click="viewPayment(item)"
|
|
/>
|
|
<v-btn
|
|
icon="mdi-dots-vertical"
|
|
size="small"
|
|
variant="text"
|
|
>
|
|
<v-menu activator="parent">
|
|
<v-list density="compact">
|
|
<v-list-item @click="viewReceipt(item)">
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-receipt</v-icon>
|
|
View Receipt
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="sendReceipt(item)">
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-email</v-icon>
|
|
Email Receipt
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item
|
|
@click="refundPayment(item)"
|
|
:disabled="item.status !== 'Completed'"
|
|
>
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-cash-refund</v-icon>
|
|
Issue Refund
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-divider />
|
|
<v-list-item
|
|
@click="markAsPaid(item)"
|
|
:disabled="item.status === 'Completed'"
|
|
class="text-success"
|
|
>
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-check</v-icon>
|
|
Mark as Paid
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item
|
|
@click="voidPayment(item)"
|
|
class="text-error"
|
|
:disabled="item.status === 'Voided'"
|
|
>
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-cancel</v-icon>
|
|
Void Payment
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</v-btn>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
|
|
<!-- Record Payment Dialog -->
|
|
<v-dialog v-model="showRecordPaymentDialog" max-width="600">
|
|
<v-card>
|
|
<v-card-title>Record Payment</v-card-title>
|
|
<v-card-text>
|
|
<v-form ref="paymentForm">
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<v-autocomplete
|
|
v-model="paymentForm.member_id"
|
|
label="Member"
|
|
:items="membersList"
|
|
item-title="name"
|
|
item-value="id"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-select
|
|
v-model="paymentForm.type"
|
|
label="Payment Type"
|
|
:items="typeOptions"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="paymentForm.amount"
|
|
label="Amount"
|
|
prefix="$"
|
|
type="number"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-select
|
|
v-model="paymentForm.method"
|
|
label="Payment Method"
|
|
:items="['Credit Card', 'Check', 'Cash', 'Bank Transfer']"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="paymentForm.reference"
|
|
label="Reference Number"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12">
|
|
<v-textarea
|
|
v-model="paymentForm.notes"
|
|
label="Notes"
|
|
variant="outlined"
|
|
rows="2"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
</v-form>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn variant="text" @click="showRecordPaymentDialog = false">Cancel</v-btn>
|
|
<v-btn color="primary" variant="flat" @click="savePayment">Record</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'admin'
|
|
});
|
|
|
|
// State
|
|
const loading = ref(false);
|
|
const showRecordPaymentDialog = ref(false);
|
|
const searchQuery = ref('');
|
|
const statusFilter = ref(null);
|
|
const typeFilter = ref(null);
|
|
const dateFrom = ref('');
|
|
const dateTo = ref('');
|
|
|
|
// Stats
|
|
const stats = ref({
|
|
totalRevenue: 0,
|
|
pendingPayments: 0,
|
|
successfulTransactions: 0,
|
|
failedTransactions: 0
|
|
});
|
|
|
|
// Form data
|
|
const paymentForm = ref({
|
|
member_id: '',
|
|
type: '',
|
|
amount: 0,
|
|
method: '',
|
|
reference: '',
|
|
notes: ''
|
|
});
|
|
|
|
// Options
|
|
const statusOptions = ['Completed', 'Pending', 'Failed', 'Refunded', 'Voided'];
|
|
const typeOptions = ['Membership', 'Event', 'Donation', 'Other'];
|
|
|
|
// Mock members list
|
|
const membersList = [
|
|
{ id: '1', name: 'John Smith' },
|
|
{ id: '2', name: 'Sarah Johnson' },
|
|
{ id: '3', name: 'Michael Williams' }
|
|
];
|
|
|
|
// Table configuration
|
|
const headers = [
|
|
{ title: 'Transaction ID', key: 'transaction_id', sortable: true },
|
|
{ title: 'Member', key: 'member', sortable: true },
|
|
{ title: 'Amount', key: 'amount', sortable: true },
|
|
{ title: 'Type', key: 'type', sortable: true },
|
|
{ title: 'Status', key: 'status', sortable: true },
|
|
{ title: 'Date', key: 'date', sortable: true },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
|
|
];
|
|
|
|
// Real dues payment data
|
|
const payments = ref([]);
|
|
|
|
// Computed
|
|
const filteredPayments = computed(() => {
|
|
let filtered = [...payments.value];
|
|
|
|
if (statusFilter.value) {
|
|
filtered = filtered.filter(p => p.status === statusFilter.value);
|
|
}
|
|
|
|
if (typeFilter.value) {
|
|
filtered = filtered.filter(p => p.type === typeFilter.value);
|
|
}
|
|
|
|
if (dateFrom.value) {
|
|
const from = new Date(dateFrom.value);
|
|
filtered = filtered.filter(p => new Date(p.date) >= from);
|
|
}
|
|
|
|
if (dateTo.value) {
|
|
const to = new Date(dateTo.value);
|
|
filtered = filtered.filter(p => new Date(p.date) <= to);
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
|
|
// Methods
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'Completed': return 'success';
|
|
case 'Pending': return 'warning';
|
|
case 'Failed': return 'error';
|
|
case 'Refunded': return 'info';
|
|
case 'Voided': return 'default';
|
|
default: return 'default';
|
|
}
|
|
};
|
|
|
|
const getTypeColor = (type: string) => {
|
|
switch (type) {
|
|
case 'Membership': return 'primary';
|
|
case 'Event': return 'info';
|
|
case 'Donation': return 'success';
|
|
default: return 'default';
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return new Date(date).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const viewPayment = (payment: any) => {
|
|
console.log('View payment:', payment);
|
|
};
|
|
|
|
const viewReceipt = (payment: any) => {
|
|
console.log('View receipt:', payment);
|
|
};
|
|
|
|
const sendReceipt = (payment: any) => {
|
|
console.log('Send receipt:', payment);
|
|
};
|
|
|
|
const refundPayment = (payment: any) => {
|
|
console.log('Refund payment:', payment);
|
|
};
|
|
|
|
const markAsPaid = (payment: any) => {
|
|
payment.status = 'Completed';
|
|
};
|
|
|
|
const voidPayment = (payment: any) => {
|
|
payment.status = 'Voided';
|
|
};
|
|
|
|
const exportPayments = () => {
|
|
console.log('Export payments');
|
|
};
|
|
|
|
const savePayment = () => {
|
|
console.log('Save payment:', paymentForm.value);
|
|
showRecordPaymentDialog.value = false;
|
|
};
|
|
|
|
// Load dues payment data from members
|
|
const loadPayments = async () => {
|
|
try {
|
|
// Fetch members from API
|
|
const response = await $fetch('/api/members');
|
|
|
|
// Check for both possible response structures
|
|
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
|
|
|
|
if (membersList && membersList.length > 0) {
|
|
const paymentRecords = [];
|
|
let transactionCounter = 1;
|
|
|
|
// Generate payment records from member dues data
|
|
for (const member of membersList) {
|
|
// If member has last_dues_paid, create a payment record
|
|
if (member.last_dues_paid) {
|
|
paymentRecords.push({
|
|
id: transactionCounter++,
|
|
transaction_id: `TXN-${new Date(member.last_dues_paid).getFullYear()}-${String(transactionCounter).padStart(3, '0')}`,
|
|
member_name: `${member.first_name} ${member.last_name}`,
|
|
member_email: member.email,
|
|
amount: member.dues_amount || 50, // Default annual dues
|
|
type: 'Membership Dues',
|
|
status: 'Completed',
|
|
date: new Date(member.last_dues_paid),
|
|
method: member.last_payment_method || 'Unknown'
|
|
});
|
|
}
|
|
|
|
// If member has dues due/overdue, create a pending payment record
|
|
if (member.dues_status === 'Due' || member.dues_status === 'Overdue') {
|
|
const dueDate = member.payment_due_date ? new Date(member.payment_due_date) : null;
|
|
if (dueDate) {
|
|
paymentRecords.push({
|
|
id: transactionCounter++,
|
|
transaction_id: `TXN-PENDING-${String(transactionCounter).padStart(3, '0')}`,
|
|
member_name: `${member.first_name} ${member.last_name}`,
|
|
member_email: member.email,
|
|
amount: member.dues_amount || 50,
|
|
type: 'Membership Dues',
|
|
status: member.dues_status === 'Overdue' ? 'Overdue' : 'Pending',
|
|
date: dueDate,
|
|
method: 'Awaiting Payment'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by date descending (most recent first)
|
|
paymentRecords.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
|
|
payments.value = paymentRecords;
|
|
|
|
// Calculate stats
|
|
const completed = paymentRecords.filter(p => p.status === 'Completed');
|
|
const pending = paymentRecords.filter(p => p.status === 'Pending' || p.status === 'Overdue');
|
|
|
|
stats.value = {
|
|
totalRevenue: completed.reduce((sum, p) => sum + p.amount, 0),
|
|
pendingPayments: pending.reduce((sum, p) => sum + p.amount, 0),
|
|
successfulTransactions: completed.length,
|
|
failedTransactions: paymentRecords.filter(p => p.status === 'Failed').length
|
|
};
|
|
|
|
console.log(`[admin-payments] Generated ${paymentRecords.length} payment records from member dues data`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading payments:', error);
|
|
// Keep empty array if load fails
|
|
}
|
|
};
|
|
|
|
// Load data on mount
|
|
onMounted(async () => {
|
|
await loadPayments();
|
|
});
|
|
</script> |