490 lines
20 KiB
Vue
490 lines
20 KiB
Vue
<!-- /src/views/payments/PaymentDetailView.vue -->
|
||
<template>
|
||
<div class="payment-detail-view" v-if="payment">
|
||
<PageHeader :title="$t('payments.paymentDetail')" :subtitle="`کد صورتحساب: ${payment._id || payment.id}`">
|
||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
|
||
</PageHeader>
|
||
|
||
<div class="grid mb-4">
|
||
<!-- Summary Card -->
|
||
<div class="col-12 lg:col-4">
|
||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
|
||
<h3 class="text-lg font-bold mb-3">خلاصه صورتحساب</h3>
|
||
<div class="flex flex-column gap-3">
|
||
<div>
|
||
<span class="text-muted text-xs block mb-1">نام کاربر / دانشجو</span>
|
||
<span class="font-bold text-color text-base">{{ payment.user?.name || payment.userName || 'کاربر' }}</span>
|
||
</div>
|
||
<div v-if="payment.classes && payment.classes.length">
|
||
<span class="text-muted text-xs block mb-1">کلاسهای مربوطه</span>
|
||
<div class="flex flex-wrap gap-1 mt-1">
|
||
<Tag v-for="c in payment.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
|
||
</div>
|
||
</div>
|
||
<div v-else-if="payment.course">
|
||
<span class="text-muted text-xs block mb-1">دوره آموزشی</span>
|
||
<span class="font-semibold text-color text-sm">{{ payment.course?.title }}</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-muted text-xs block mb-1">مبلغ کل صورتحساب</span>
|
||
<span class="font-bold text-color text-xl">{{ toPersianDigits((payment.amount || 0).toLocaleString()) }} تومان</span>
|
||
</div>
|
||
<div v-if="payment.discount">
|
||
<span class="text-muted text-xs block mb-1">تخفیف</span>
|
||
<span class="font-semibold text-orange-500 text-lg">{{ toPersianDigits((payment.discount || 0).toLocaleString()) }} تومان</span>
|
||
</div>
|
||
<div v-if="payment.discount">
|
||
<span class="text-muted text-xs block mb-1">مبلغ قابل پرداخت</span>
|
||
<span class="font-bold text-color text-xl">{{ toPersianDigits(payableAmount.toLocaleString()) }} تومان</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-muted text-xs block mb-1">مبلغ کل دریافتی</span>
|
||
<span class="font-bold text-green-600 text-lg">{{ toPersianDigits((payment.paidAmount || 0).toLocaleString()) }} تومان</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-muted text-xs block mb-1">باقیمانده</span>
|
||
<span class="font-bold text-red-500 text-lg">{{ toPersianDigits(remainingAmount.toLocaleString()) }} تومان</span>
|
||
</div>
|
||
<div>
|
||
<span class="text-muted text-xs block mb-1">وضعیت پرداخت</span>
|
||
<StatusTag :status="payment.status" type="payment" />
|
||
</div>
|
||
<div>
|
||
<span class="text-muted text-xs block mb-1">یادداشت</span>
|
||
<p class="text-sm text-color m-0 white-space-pre-wrap line-height-3">{{ payment.notes || '—' }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Transaction History & Form -->
|
||
<div class="col-12 lg:col-8">
|
||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
|
||
<div class="flex align-items-center justify-content-between mb-3">
|
||
<h3 class="text-lg font-bold m-0">تاریخچه تراکنشهای دریافتی</h3>
|
||
<PermissionGate permission="payments:update">
|
||
<Button label="ثبت تراکنش جدید" icon="pi pi-plus" size="small" severity="success" @click="openTransactionModal" />
|
||
</PermissionGate>
|
||
</div>
|
||
|
||
<DataTable
|
||
:value="payment.transactions || []"
|
||
class="p-datatable-sm text-sm"
|
||
:rowClass="transactionRowClass"
|
||
>
|
||
<Column field="amount" header="مبلغ تراکنش">
|
||
<template #body="{ data }">
|
||
<span :class="{ 'line-through text-muted': isTransactionCancelled(data) }">
|
||
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
|
||
</span>
|
||
</template>
|
||
</Column>
|
||
<Column field="status" header="وضعیت">
|
||
<template #body="{ data }">
|
||
<StatusTag :status="transactionStatus(data)" type="payment" />
|
||
</template>
|
||
</Column>
|
||
<Column field="method" header="روش پرداخت">
|
||
<template #body="{ data }">
|
||
<Tag
|
||
v-if="data.method"
|
||
:value="data.method === 'online' ? 'درگاه آنلاین' : (data.method === 'card' ? 'کارت به کارت' : 'نقدی')"
|
||
severity="info"
|
||
/>
|
||
<span v-else class="text-muted">—</span>
|
||
</template>
|
||
</Column>
|
||
<Column field="receiptNumber" header="شماره فیش / پیگیری">
|
||
<template #body="{ data }">
|
||
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '-') }}</span>
|
||
</template>
|
||
</Column>
|
||
<Column field="dueDate" header="سررسید">
|
||
<template #body="{ data }">
|
||
{{ formatJalali(data.dueDate) }}
|
||
</template>
|
||
</Column>
|
||
<Column field="date" header="تاریخ پرداخت">
|
||
<template #body="{ data }">
|
||
{{ data.date ? formatJalali(data.date) : '—' }}
|
||
</template>
|
||
</Column>
|
||
<Column field="notes" header="یادداشت">
|
||
<template #body="{ data }">
|
||
<span class="white-space-pre-wrap">{{ data.notes || '—' }}</span>
|
||
</template>
|
||
</Column>
|
||
<Column header="عملیات" style="width: 7rem">
|
||
<template #body="{ data }">
|
||
<PermissionGate permission="payments:update">
|
||
<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>
|
||
</template>
|
||
</Column>
|
||
</DataTable>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Record New Transaction Modal -->
|
||
<Dialog v-model:visible="showTransactionModal" header="ثبت تراکنش جدید" modal :style="{ width: '450px' }">
|
||
<div class="flex flex-column gap-3 py-2">
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
|
||
<InputGroup>
|
||
<InputNumber v-model="trxForm.amount" class="w-full text-sm" :min="1" />
|
||
<InputGroupAddon>تومان</InputGroupAddon>
|
||
</InputGroup>
|
||
</div>
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
||
<Dropdown v-model="trxForm.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||
</div>
|
||
<div v-if="trxForm.status === 'paid'" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">روش پرداخت *</label>
|
||
<Dropdown v-model="trxForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||
</div>
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||
<DatePicker v-model="trxForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||
</div>
|
||
<div v-if="trxForm.status === 'paid'" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">تاریخ پرداخت</label>
|
||
<DatePicker v-model="trxForm.date" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||
</div>
|
||
<div v-if="trxForm.status === 'paid'" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">شماره فیش / پیگیری</label>
|
||
<InputText v-model.trim="trxForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||
</div>
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm" for="trx-notes">یادداشت</label>
|
||
<Textarea
|
||
id="trx-notes"
|
||
v-model="trxForm.notes"
|
||
rows="3"
|
||
class="w-full text-sm"
|
||
maxlength="5000"
|
||
placeholder="یادداشت داخلی درباره این تراکنش…"
|
||
/>
|
||
</div>
|
||
<NotifyChannelsField :notify="trxNotify" />
|
||
</div>
|
||
<template #footer>
|
||
<Button label="انصراف" text severity="secondary" @click="showTransactionModal = false" />
|
||
<Button label="ثبت تراکنش" icon="pi pi-check" severity="success" :loading="isSubmittingTrx" @click="handleRecordTransaction" />
|
||
</template>
|
||
</Dialog>
|
||
|
||
<!-- Edit Transaction Modal -->
|
||
<Dialog v-model:visible="showEditModal" header="ویرایش تراکنش" modal :style="{ width: '450px' }">
|
||
<div class="flex flex-column gap-3 py-2">
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">مبلغ (تومان) *</label>
|
||
<InputGroup>
|
||
<InputNumber v-model="editForm.amount" class="w-full text-sm" :min="0" />
|
||
<InputGroupAddon>تومان</InputGroupAddon>
|
||
</InputGroup>
|
||
</div>
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
||
<Dropdown v-model="editForm.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||
</div>
|
||
<div v-if="editForm.status === 'paid'" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">روش پرداخت</label>
|
||
<Dropdown v-model="editForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||
</div>
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||
<DatePicker v-model="editForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||
</div>
|
||
<div v-if="editForm.status === 'paid'" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">تاریخ پرداخت</label>
|
||
<DatePicker v-model="editForm.date" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||
</div>
|
||
<div v-if="editForm.status === 'paid'" class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm">شماره فیش / پیگیری</label>
|
||
<InputText v-model.trim="editForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||
</div>
|
||
<div class="flex flex-column gap-2">
|
||
<label class="font-semibold text-sm" for="edit-trx-notes">یادداشت</label>
|
||
<Textarea
|
||
id="edit-trx-notes"
|
||
v-model="editForm.notes"
|
||
rows="3"
|
||
class="w-full text-sm"
|
||
maxlength="5000"
|
||
placeholder="یادداشت داخلی درباره این تراکنش…"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<Button label="انصراف" text severity="secondary" @click="showEditModal = false" />
|
||
<Button label="ذخیره تغییرات" icon="pi pi-check" severity="success" :loading="isSubmittingEdit" @click="handleUpdateTransaction" />
|
||
</template>
|
||
</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>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue';
|
||
import { useRoute } from 'vue-router';
|
||
import { paymentApi } from '@/api/paymentApi';
|
||
import { usePersianDate } from '@/composables/usePersianDate';
|
||
import { useToast } from '@/composables/useToast';
|
||
import { getPayableAmount } from '@/utils/paymentAmount';
|
||
import PageHeader from '@/components/common/PageHeader.vue';
|
||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||
import StatusTag from '@/components/common/StatusTag.vue';
|
||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||
import Button from 'primevue/button';
|
||
import Tag from 'primevue/tag';
|
||
import DataTable from 'primevue/datatable';
|
||
import Column from 'primevue/column';
|
||
import Dialog from 'primevue/dialog';
|
||
import Dropdown from 'primevue/select';
|
||
import InputText from 'primevue/inputtext';
|
||
import InputNumber from 'primevue/inputnumber';
|
||
import Textarea from 'primevue/textarea';
|
||
import DatePicker from 'vue3-persian-datetime-picker';
|
||
|
||
const route = useRoute();
|
||
const paymentId = route.params.id;
|
||
const { toPersianDigits, formatJalali, toJalaliPickerValue, toGregorianIso, getTodayJalali } = usePersianDate();
|
||
const { showSuccess, showError } = useToast();
|
||
|
||
const payment = ref(null);
|
||
const showTransactionModal = ref(false);
|
||
const showEditModal = ref(false);
|
||
const showCancelModal = ref(false);
|
||
const isSubmittingTrx = ref(false);
|
||
const isSubmittingEdit = ref(false);
|
||
const isSubmittingCancel = ref(false);
|
||
const editingTransactionId = ref(null);
|
||
const cancellingTransactionId = ref(null);
|
||
const trxNotify = reactive({ sms: true, email: true, bot: true });
|
||
|
||
const methodOptions = [
|
||
{ label: 'کارت به کارت', value: 'card' },
|
||
{ label: 'درگاه آنلاین', value: 'online' },
|
||
{ label: 'نقدی / شبا', value: 'cash' }
|
||
];
|
||
|
||
const statusOptions = [
|
||
{ label: 'پرداخت شده', value: 'paid' },
|
||
{ label: 'در انتظار پرداخت', value: 'pending' }
|
||
];
|
||
|
||
const defaultTrxForm = () => ({
|
||
amount: 500000,
|
||
status: 'paid',
|
||
method: 'card',
|
||
receiptNumber: '',
|
||
dueDate: getTodayJalali(),
|
||
date: getTodayJalali(),
|
||
notes: ''
|
||
});
|
||
|
||
const trxForm = reactive(defaultTrxForm());
|
||
|
||
const openTransactionModal = () => {
|
||
Object.assign(trxForm, defaultTrxForm());
|
||
if (remainingAmount.value > 0) {
|
||
trxForm.amount = remainingAmount.value;
|
||
}
|
||
trxForm.dueDate = getTodayJalali();
|
||
trxForm.date = getTodayJalali();
|
||
showTransactionModal.value = true;
|
||
};
|
||
|
||
const editForm = reactive({
|
||
amount: 0,
|
||
status: 'paid',
|
||
method: 'card',
|
||
receiptNumber: '',
|
||
dueDate: '',
|
||
date: '',
|
||
notes: ''
|
||
});
|
||
|
||
const payableAmount = computed(() => getPayableAmount(payment.value || {}));
|
||
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 () => {
|
||
try {
|
||
const res = await paymentApi.getOne(paymentId);
|
||
payment.value = res.data || res;
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
};
|
||
|
||
const handleRecordTransaction = async () => {
|
||
if (!trxForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
|
||
if (!trxForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||
const dueDate = toGregorianIso(trxForm.dueDate);
|
||
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
||
|
||
isSubmittingTrx.value = true;
|
||
try {
|
||
const payload = {
|
||
amount: trxForm.amount,
|
||
status: trxForm.status,
|
||
dueDate,
|
||
notes: trxForm.notes || undefined,
|
||
notify: { ...trxNotify }
|
||
};
|
||
if (trxForm.status === 'paid') {
|
||
payload.method = trxForm.method;
|
||
payload.receiptNumber = trxForm.receiptNumber || '';
|
||
const paymentDate = toGregorianIso(trxForm.date);
|
||
if (paymentDate) payload.date = paymentDate;
|
||
}
|
||
|
||
const res = await paymentApi.recordTransaction(paymentId, payload);
|
||
payment.value = res.data || res;
|
||
showSuccess('تراکنش جدید با موفقیت ثبت شد');
|
||
showTransactionModal.value = false;
|
||
Object.assign(trxForm, defaultTrxForm());
|
||
trxNotify.sms = true;
|
||
trxNotify.email = true;
|
||
trxNotify.bot = true;
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isSubmittingTrx.value = false;
|
||
}
|
||
};
|
||
|
||
|
||
const openEditTransaction = (trx) => {
|
||
if (isTransactionCancelled(trx)) return;
|
||
editingTransactionId.value = trx._id || trx.id;
|
||
editForm.amount = trx.amount || 0;
|
||
editForm.status = trx.status || (trx.date ? 'paid' : 'pending');
|
||
editForm.method = trx.method || 'card';
|
||
editForm.receiptNumber = trx.receiptNumber || '';
|
||
editForm.dueDate = toJalaliPickerValue(trx.dueDate);
|
||
editForm.date = toJalaliPickerValue(trx.date);
|
||
editForm.notes = trx.notes || '';
|
||
showEditModal.value = true;
|
||
};
|
||
|
||
const handleUpdateTransaction = async () => {
|
||
if (!editForm.amount && editForm.amount !== 0) {
|
||
showError('لطفا مبلغ را وارد کنید');
|
||
return;
|
||
}
|
||
if (!editForm.dueDate) {
|
||
showError('لطفا تاریخ سررسید را وارد کنید');
|
||
return;
|
||
}
|
||
const dueDate = toGregorianIso(editForm.dueDate);
|
||
if (!dueDate) {
|
||
showError('تاریخ سررسید نامعتبر است');
|
||
return;
|
||
}
|
||
|
||
isSubmittingEdit.value = true;
|
||
try {
|
||
const payload = {
|
||
amount: Number(editForm.amount),
|
||
status: editForm.status,
|
||
dueDate,
|
||
notes: editForm.notes
|
||
};
|
||
if (editForm.status === 'paid') {
|
||
payload.method = editForm.method;
|
||
payload.receiptNumber = editForm.receiptNumber;
|
||
payload.date = toGregorianIso(editForm.date);
|
||
}
|
||
|
||
await paymentApi.updateTransaction(editingTransactionId.value, payload);
|
||
await fetchPayment();
|
||
showSuccess('تراکنش با موفقیت بهروزرسانی شد');
|
||
showEditModal.value = false;
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isSubmittingEdit.value = false;
|
||
}
|
||
};
|
||
|
||
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(() => {
|
||
fetchPayment();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
:deep(.transaction-row-cancelled) {
|
||
opacity: 0.65;
|
||
}
|
||
</style>
|