Add admin UI to cancel payment transactions.

Show cancelled rows in history with distinct styling and let admins
confirm cancellation so replacement transactions can be added later.
This commit is contained in:
2026-08-16 22:38:00 +03:30
parent 047344e2ed
commit 1664a05ca4
3 changed files with 93 additions and 7 deletions
+2 -1
View File
@@ -9,5 +9,6 @@ export const paymentApi = {
update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data), update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`), delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`),
recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/admin/transactions/${paymentId}`, data), recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/admin/transactions/${paymentId}`, data),
updateTransaction: (transactionId, data) => axiosInstance.put(`/payments/admin/transactions/${transactionId}`, data) updateTransaction: (transactionId, data) => axiosInstance.put(`/payments/admin/transactions/${transactionId}`, data),
cancelTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/cancel`)
}; };
+3 -1
View File
@@ -46,7 +46,8 @@ const PAYMENT_LABELS = {
paid: 'تسویه‌شده', paid: 'تسویه‌شده',
partial: 'پرداخت جزئی', partial: 'پرداخت جزئی',
pending: 'در انتظار پرداخت', pending: 'در انتظار پرداخت',
overdue: 'معوق' overdue: 'معوق',
cancelled: 'لغوشده'
}; };
const PENDING_STUDENT_LABELS = { const PENDING_STUDENT_LABELS = {
@@ -65,6 +66,7 @@ const severity = computed(() => {
if (val === 'partial' || val === 'پرداخت جزئی') return 'contrast'; if (val === 'partial' || val === 'پرداخت جزئی') return 'contrast';
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info'; if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger'; if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
if (val === 'cancelled' || val === 'canceled' || val === 'لغوشده') return 'secondary';
} }
if (props.type === 'session') { if (props.type === 'session') {
+88 -5
View File
@@ -67,15 +67,21 @@
</PermissionGate> </PermissionGate>
</div> </div>
<DataTable :value="payment.transactions || []" class="p-datatable-sm text-sm"> <DataTable
:value="payment.transactions || []"
class="p-datatable-sm text-sm"
:rowClass="transactionRowClass"
>
<Column field="amount" header="مبلغ تراکنش"> <Column field="amount" header="مبلغ تراکنش">
<template #body="{ data }"> <template #body="{ data }">
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان <span :class="{ 'line-through text-muted': isTransactionCancelled(data) }">
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
</span>
</template> </template>
</Column> </Column>
<Column field="status" header="وضعیت"> <Column field="status" header="وضعیت">
<template #body="{ data }"> <template #body="{ data }">
<StatusTag :status="data.status || (data.date ? 'paid' : 'pending')" type="payment" /> <StatusTag :status="transactionStatus(data)" type="payment" />
</template> </template>
</Column> </Column>
<Column field="method" header="روش پرداخت"> <Column field="method" header="روش پرداخت">
@@ -108,10 +114,31 @@
<span class="white-space-pre-wrap">{{ data.notes || '—' }}</span> <span class="white-space-pre-wrap">{{ data.notes || '—' }}</span>
</template> </template>
</Column> </Column>
<Column header="عملیات" style="width: 5rem"> <Column header="عملیات" style="width: 7rem">
<template #body="{ data }"> <template #body="{ data }">
<PermissionGate permission="payments:update"> <PermissionGate permission="payments:update">
<Button icon="pi pi-pencil" text rounded severity="secondary" size="small" @click="openEditTransaction(data)" /> <div class="flex gap-1">
<Button
v-if="!isTransactionCancelled(data)"
icon="pi pi-pencil"
text
rounded
severity="secondary"
size="small"
v-tooltip.top="'ویرایش'"
@click="openEditTransaction(data)"
/>
<Button
v-if="!isTransactionCancelled(data)"
icon="pi pi-ban"
text
rounded
severity="danger"
size="small"
v-tooltip.top="'لغو تراکنش'"
@click="openCancelTransaction(data)"
/>
</div>
</PermissionGate> </PermissionGate>
</template> </template>
</Column> </Column>
@@ -209,6 +236,20 @@
<Button label="ذخیره تغییرات" icon="pi pi-check" severity="success" :loading="isSubmittingEdit" @click="handleUpdateTransaction" /> <Button label="ذخیره تغییرات" icon="pi pi-check" severity="success" :loading="isSubmittingEdit" @click="handleUpdateTransaction" />
</template> </template>
</Dialog> </Dialog>
<Dialog v-model:visible="showCancelModal" header="لغو تراکنش" modal :style="{ width: '420px' }" :closable="!isSubmittingCancel">
<div class="flex align-items-start gap-3 py-2">
<i class="pi pi-exclamation-triangle text-3xl text-orange-500" />
<p class="text-sm text-color m-0 line-height-3">
این تراکنش لغو میشود و در محاسبات صورتحساب لحاظ نخواهد شد، اما در تاریخچه باقی میماند.
پس از لغو میتوانید تراکنش جدید ثبت کنید.
</p>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" :disabled="isSubmittingCancel" @click="showCancelModal = false" />
<Button label="لغو تراکنش" icon="pi pi-ban" severity="danger" :loading="isSubmittingCancel" @click="handleCancelTransaction" />
</template>
</Dialog>
</div> </div>
</template> </template>
@@ -241,9 +282,12 @@ const { showSuccess, showError } = useToast();
const payment = ref(null); const payment = ref(null);
const showTransactionModal = ref(false); const showTransactionModal = ref(false);
const showEditModal = ref(false); const showEditModal = ref(false);
const showCancelModal = ref(false);
const isSubmittingTrx = ref(false); const isSubmittingTrx = ref(false);
const isSubmittingEdit = ref(false); const isSubmittingEdit = ref(false);
const isSubmittingCancel = ref(false);
const editingTransactionId = ref(null); const editingTransactionId = ref(null);
const cancellingTransactionId = ref(null);
const methodOptions = [ const methodOptions = [
{ label: 'کارت به کارت', value: 'card' }, { label: 'کارت به کارت', value: 'card' },
@@ -281,6 +325,15 @@ const editForm = reactive({
const payableAmount = computed(() => getPayableAmount(payment.value || {})); const payableAmount = computed(() => getPayableAmount(payment.value || {}));
const remainingAmount = computed(() => Math.max(0, payableAmount.value - (payment.value?.paidAmount || 0))); const remainingAmount = computed(() => Math.max(0, payableAmount.value - (payment.value?.paidAmount || 0)));
const isTransactionCancelled = (trx) => String(trx?.status || '').toLowerCase() === 'cancelled';
const transactionStatus = (trx) => {
if (isTransactionCancelled(trx)) return 'cancelled';
return trx.status || (trx.date ? 'paid' : 'pending');
};
const transactionRowClass = (trx) => (isTransactionCancelled(trx) ? 'transaction-row-cancelled' : '');
const fetchPayment = async () => { const fetchPayment = async () => {
try { try {
const res = await paymentApi.getOne(paymentId); const res = await paymentApi.getOne(paymentId);
@@ -328,6 +381,7 @@ const handleRecordTransaction = async () => {
}; };
const openEditTransaction = (trx) => { const openEditTransaction = (trx) => {
if (isTransactionCancelled(trx)) return;
editingTransactionId.value = trx._id || trx.id; editingTransactionId.value = trx._id || trx.id;
editForm.amount = trx.amount || 0; editForm.amount = trx.amount || 0;
editForm.status = trx.status || (trx.date ? 'paid' : 'pending'); editForm.status = trx.status || (trx.date ? 'paid' : 'pending');
@@ -379,7 +433,36 @@ const handleUpdateTransaction = async () => {
} }
}; };
const openCancelTransaction = (trx) => {
if (isTransactionCancelled(trx)) return;
cancellingTransactionId.value = trx._id || trx.id;
showCancelModal.value = true;
};
const handleCancelTransaction = async () => {
if (!cancellingTransactionId.value) return;
isSubmittingCancel.value = true;
try {
await paymentApi.cancelTransaction(cancellingTransactionId.value);
await fetchPayment();
showSuccess('تراکنش با موفقیت لغو شد');
showCancelModal.value = false;
cancellingTransactionId.value = null;
} catch (err) {
showError(err);
} finally {
isSubmittingCancel.value = false;
}
};
onMounted(() => { onMounted(() => {
fetchPayment(); fetchPayment();
}); });
</script> </script>
<style scoped>
:deep(.transaction-row-cancelled) {
opacity: 0.65;
}
</style>