Initial commit
This commit is contained in:
96
app/components/transaction/Allocate.vue
Normal file
96
app/components/transaction/Allocate.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import AllocateItem from '@/components/transaction/AllocateItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
row: Object,
|
||||
pagename: String
|
||||
});
|
||||
|
||||
const { $getdata } = useNuxtApp();
|
||||
const transaction = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const paymentSchedules = ref([]);
|
||||
const incompletePaymentSchedules = computed(() =>
|
||||
paymentSchedules.value.filter(pm => pm.remain_amount > 0)
|
||||
)
|
||||
|
||||
async function getTransaction() {
|
||||
const transactionData = await $getdata('transaction', undefined, {
|
||||
filter: {
|
||||
id: props.row.id
|
||||
},
|
||||
values: 'date,txncurrent__detail,txncurrent__detail__status,customer__type,txncurrent,txncurrent__detail__amount,txncurrent__detail__amount_remaining,txncurrent__detail__status__name,product__zone_type__name,product__trade_code,product__cart__dealer,customer__legal_code,customer__legal_type__name,payment_plan,id,code,customer,customer__code,customer__fullname,customer__phone,product,phase,phase__name,phase__code,policy,policy__code,policy__name,origin_price,discount_amount,sale_price,deposit_amount,deposit_received,deposit_remaining,amount_received,amount_remain,create_time,update_time,total_received_amount,total_allocation_remain,total_allocation_amount'
|
||||
}, true);
|
||||
|
||||
transaction.value = transactionData;
|
||||
}
|
||||
|
||||
async function getPaymentSchedules() {
|
||||
isLoading.value = true;
|
||||
const paymentSchedulesData = await $getdata('payment_schedule', undefined, {
|
||||
filter: {
|
||||
txn_detail__transaction: transaction.value.id
|
||||
},
|
||||
sort: 'cycle',
|
||||
values: 'invoice__ref_code,invoice__amount,txn_detail__amount,batch_date,amount_remain,penalty_remain,penalty_paid,penalty_amount,penalty_reduce,ovd_days,remain_amount,paid_amount,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__status,txn_detail__status__name,txn_detail__transaction__product__code,txn_detail__phase__name,txn_detail,id,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__code,txn_detail__transaction__customer__legal_code,status__name,type__name,code,from_date,txn_detail__transaction__policy__code,to_date,amount,cycle,cycle_days,txn_detail__transaction,type,status,updater,entry,detail,txn_detail__transaction__code,txn_detail__code'
|
||||
});
|
||||
|
||||
paymentSchedules.value = paymentSchedulesData;
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
async function getStuff() {
|
||||
await getTransaction();
|
||||
await getPaymentSchedules();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getStuff();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="columns mx-0 mt-2 mb-5">
|
||||
<div class="column is-1">
|
||||
<p class="has-text-weight-semibold">Mã GD</p>
|
||||
<p>{{ transaction.code }}</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Sản phẩm</p>
|
||||
<p>{{ transaction.product__trade_code }}</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Khách hàng</p>
|
||||
<p>{{ transaction.customer__fullname }}</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Ngày</p>
|
||||
<FormatDate :date="transaction.date" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Đã nhận</p>
|
||||
<FormatNumber :value="transaction.total_received_amount" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Đã phân bổ</p>
|
||||
<FormatNumber :value="transaction.total_allocation_amount" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Chưa phân bổ</p>
|
||||
<FormatNumber :value="transaction.total_allocation_remain" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6 is-flex is-flex-direction-column is-gap-4">
|
||||
<template v-if="incompletePaymentSchedules.length > 0">
|
||||
<AllocateItem
|
||||
v-for="(paymentSchedule, i) in incompletePaymentSchedules"
|
||||
v-bind="{ paymentSchedule, i, onRefresh: getStuff }"
|
||||
/>
|
||||
</template>
|
||||
<div v-else-if="isLoading" class="is-flex is-justify-content-center">
|
||||
<SvgIcon v-bind="{ name: 'loading.svg', type: 'info', size: 24 }" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p class="my-4 fs-20 has-text-centered has-text-grey">Giao dịch đã phân bổ hoàn tất!</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
120
app/components/transaction/AllocateForm.vue
Normal file
120
app/components/transaction/AllocateForm.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
allo: Object,
|
||||
i: Number,
|
||||
length: Number,
|
||||
productId: Number,
|
||||
amount_remain: Number,
|
||||
totalByRef: Object,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['removeAllocation', 'setAllo']);
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
label: 'Gốc',
|
||||
value: 'PRINCIPAL',
|
||||
},
|
||||
{
|
||||
label: 'Lãi',
|
||||
value: 'PENALTY',
|
||||
},
|
||||
// {
|
||||
// label: 'Miễn giảm',
|
||||
// value: 'REDUCTION',
|
||||
// },
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<div class="mb-4" style="position: relative">
|
||||
<a
|
||||
v-if="length > 1"
|
||||
class="has-text-danger is-size-7"
|
||||
style="position: absolute; top: 0.5rem; right: 0.75rem; cursor: pointer"
|
||||
@click="emit('removeAllocation', i)"
|
||||
>
|
||||
Xóa
|
||||
</a>
|
||||
<div
|
||||
class="columns is-multiline is-mobile mb-0"
|
||||
style="border-top: 1px solid #bbbbbb"
|
||||
>
|
||||
<!-- InternalEntry Ref -->
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label">Bút toán <span class="has-text-danger">*</span></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'internalentry',
|
||||
field: 'label',
|
||||
column: ['code'],
|
||||
first: true,
|
||||
filter: {
|
||||
product: props.productId,
|
||||
allocation_remain__gt: 0,
|
||||
},
|
||||
}"
|
||||
@option="(entry) => {
|
||||
emit('setAllo', { key: 'date', value: entry.date, i }) ;
|
||||
emit('setAllo', { key: 'ref', value: entry.code, i });
|
||||
emit('setAllo', { key: 'allocation_remain', value: entry.allocation_remain, i });
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Type -->
|
||||
<div class="column is-2">
|
||||
<div class="field">
|
||||
<label class="label"
|
||||
>Loại <span class="has-text-danger">*</span></label
|
||||
>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="allo.type">
|
||||
<option
|
||||
v-for="option in typeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="amount_remain === 0 && allo.type === 'PRINCIPAL'"
|
||||
class="help is-danger"
|
||||
>Số tiền gốc là 0</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount -->
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label"
|
||||
>Giá trị <span class="has-text-danger">*</span></label
|
||||
>
|
||||
<div class="control">
|
||||
<InputNumber
|
||||
v-bind="{
|
||||
record: allo,
|
||||
attr: 'amount',
|
||||
defaultValue: true,
|
||||
}"
|
||||
@number="(value) => emit('setAllo', { key: 'amount', value, i })"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="allo.ref && totalByRef && totalByRef[allo.ref] > allo.allocation_remain"
|
||||
class="help is-danger"
|
||||
>
|
||||
Tổng giá trị phân bổ bút toán vượt số tiền chưa phân bổ:
|
||||
<FormatNumber :value="allo.allocation_remain" />
|
||||
(Tổng hiện tại: <FormatNumber :value="totalByRef[allo.ref]" />)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
203
app/components/transaction/AllocateItem.vue
Normal file
203
app/components/transaction/AllocateItem.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import AllocateForm from '@/components/transaction/AllocateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
paymentSchedule: Object,
|
||||
i: Number,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
const {
|
||||
$insertapi,
|
||||
$snackbar,
|
||||
$remove,
|
||||
} = useNuxtApp();
|
||||
|
||||
const allocations = ref([{}]);
|
||||
const showConfirmModal = ref();
|
||||
const isSubmitting = ref(false);
|
||||
const resetKey = ref(0);
|
||||
|
||||
function addAllocation() {
|
||||
allocations.value.push({});
|
||||
}
|
||||
|
||||
async function removeAllocation(index) {
|
||||
$remove(allocations.value, index);
|
||||
if (allocations.value.length === 0) {
|
||||
allocations.value = [{}];
|
||||
}
|
||||
}
|
||||
|
||||
function setAllo({ key, value, i }) {
|
||||
allocations.value[i][key] = value;
|
||||
}
|
||||
|
||||
function openConfirmModal() {
|
||||
showConfirmModal.value = {
|
||||
component: 'dialog/Confirm',
|
||||
title: 'Xác nhận',
|
||||
width: '600px',
|
||||
height: '150px',
|
||||
vbind: { content: `Xác nhận phân bổ tay cho lịch thanh toán ${props.paymentSchedule.code}?` },
|
||||
onConfirm: update
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const allocationsToSend = allocations.value
|
||||
.map(({ ref, date, type, amount }) => ({ ref, date, type, amount }));
|
||||
|
||||
const payload = {
|
||||
product: props.paymentSchedule.txn_detail__transaction__product,
|
||||
schedule_id: props.paymentSchedule.id,
|
||||
allocation_list: allocationsToSend,
|
||||
};
|
||||
|
||||
isSubmitting.value = true;
|
||||
const res = await $insertapi('manualallocate', payload);
|
||||
if (res.success) {
|
||||
emit('refresh');
|
||||
allocations.value = [{}];
|
||||
resetKey.value++;
|
||||
$snackbar(res.message);
|
||||
} else {
|
||||
$snackbar(res.message || "Đã có lỗi khi phân bổ tay");
|
||||
}
|
||||
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
|
||||
const totalByRef = computed(() => {
|
||||
const map = {};
|
||||
for (const allo of allocations.value) {
|
||||
if (allo.ref) {
|
||||
map[allo.ref] = (map[allo.ref] || 0) + (allo.amount || 0);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const formValid = computed(() => {
|
||||
if (allocations.value.some(allo => {
|
||||
if (Object.keys(allo).length === 0) return true;
|
||||
if (allo.amount === undefined || allo.type === undefined) return true;
|
||||
if (allo.ref && totalByRef.value[allo.ref] > allo.allocation_remain) return true;
|
||||
})) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="fs-15 is-flex is-gap-2">
|
||||
<div class="is-flex is-flex-direction-column is-align-items-center is-gap-1">
|
||||
<p class="fsb-17 is-flex is-justify-content-center is-align-items-center" style="
|
||||
border: 3px solid rgb(52, 92, 103);
|
||||
border-radius: 9999px;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
">
|
||||
{{ i + 1 }}
|
||||
</p>
|
||||
<div style="
|
||||
border: 3px solid #dddddd;
|
||||
border-radius: 9999px;
|
||||
flex-grow: 1;
|
||||
width: min-content;
|
||||
">
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-flex-grow-1">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<p><span class="has-text-weight-semibold">{{ paymentSchedule.code }}</span> - {{ paymentSchedule.type__name }}</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="has-text-weight-semibold">Từ ngày: </span>
|
||||
<FormatDate :date="paymentSchedule.from_date" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="has-text-weight-semibold">Đến ngày: </span>
|
||||
<FormatDate :date="paymentSchedule.to_date" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="has-text-weight-semibold">Ngày tính lãi: </span>
|
||||
<FormatDate :date="paymentSchedule.batch_date" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<span class="has-text-weight-semibold">Quá hạn: </span>
|
||||
<span>{{ paymentSchedule.ovd_days }} ngày</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Số tiền theo HĐ (Gốc)</p>
|
||||
<FormatNumber :value="paymentSchedule.amount" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Đã thu theo HĐ</p>
|
||||
<FormatNumber :value="paymentSchedule.paid_amount" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Số tiền còn theo HĐ</p>
|
||||
<FormatNumber :value="paymentSchedule.amount_remain" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Lãi phạt</p>
|
||||
<FormatNumber :value="paymentSchedule.penalty_amount" color="red" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Lãi còn lại</p>
|
||||
<FormatNumber :value="paymentSchedule.penalty_remain" color="red" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<p class="has-text-weight-semibold">Tổng tiền còn lại</p>
|
||||
<FormatNumber :value="paymentSchedule.remain_amount" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AllocateForm
|
||||
v-for="(allo, i) in allocations"
|
||||
:key="`${resetKey}-${i}`"
|
||||
v-bind="{
|
||||
allo,
|
||||
i,
|
||||
length: allocations.length,
|
||||
productId: paymentSchedule.txn_detail__transaction__product,
|
||||
amount_remain: paymentSchedule.amount_remain,
|
||||
totalByRef,
|
||||
onSetAllo: setAllo,
|
||||
onRemoveAllocation: removeAllocation,
|
||||
}"
|
||||
/>
|
||||
<button class="button is-pulled-right is-info is-light mb-4" @click="addAllocation">
|
||||
<span class="icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="12"
|
||||
width="12"
|
||||
viewBox="0 0 448 512"
|
||||
>
|
||||
<path
|
||||
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="openConfirmModal"
|
||||
:class="`button is-secondary ${isSubmitting ? 'is-loading' : ''}`"
|
||||
:disabled="isSubmitting || !formValid"
|
||||
>
|
||||
<span>Phân bổ</span>
|
||||
</button>
|
||||
<Modal
|
||||
v-bind="showConfirmModal"
|
||||
v-if="showConfirmModal"
|
||||
@close="showConfirmModal = undefined"
|
||||
/>
|
||||
<!-- <pre class="fs-12">{{ JSON.stringify(allocations, null, 2) }}</pre> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
195
app/components/transaction/ChangeCustomerModal.vue
Normal file
195
app/components/transaction/ChangeCustomerModal.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
import { useStore } from "~/stores/index";
|
||||
import { useAdvancedWorkflow } from "~/composables/useAdvancedWorkflow";
|
||||
import Contract from "~/components/application/Contract.vue";
|
||||
import Datepicker from "~/components/datepicker/Datepicker.vue";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps({
|
||||
transactionId: Number,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'modalevent']);
|
||||
|
||||
const { $getdata, $snackbar } = useNuxtApp();
|
||||
const store = useStore();
|
||||
const { updateTransactionCustomer, isLoading } = useAdvancedWorkflow();
|
||||
|
||||
const transaction = ref(null);
|
||||
const currentCustomer = ref(null);
|
||||
const newCustomer = ref(null);
|
||||
const workflowResult = ref(null);
|
||||
const showContract = ref(false);
|
||||
const contractId = ref(null);
|
||||
|
||||
// Contract Date
|
||||
const initialContractDate = dayjs().format('YYYY-MM-DD');
|
||||
const contractDate = ref(initialContractDate);
|
||||
const dateRecord = ref({ contractDate: initialContractDate });
|
||||
|
||||
const customerViewAddon = {
|
||||
component: "customer/CustomerView",
|
||||
width: "70%",
|
||||
height: "600px",
|
||||
title: "Chi tiết khách hàng"
|
||||
};
|
||||
|
||||
const customerViewAdd = {
|
||||
component: "customer/CustomerInfo2",
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
title: "Chỉnh sửa khách hàng",
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.transactionId) {
|
||||
transaction.value = await $getdata('transaction', { id: props.transactionId }, undefined, true);
|
||||
if (transaction.value && transaction.value.customer) {
|
||||
currentCustomer.value = await $getdata('customer', { id: transaction.value.customer }, undefined, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateContractDate(newDate) {
|
||||
contractDate.value = newDate;
|
||||
dateRecord.value.contractDate = newDate;
|
||||
}
|
||||
|
||||
function updateTransactionCustomerDisplay(result) {
|
||||
if (result?.success) {
|
||||
const contract = result?.contract;
|
||||
if (contract && contract.id) {
|
||||
$snackbar(`Đổi khách hàng thành công. Hợp đồng mới được tạo với ID: ${contract.id}`, { type: 'is-success' });
|
||||
contractId.value = contract.id;
|
||||
showContract.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmChangeCustomer() {
|
||||
if (!newCustomer.value) {
|
||||
$snackbar('Vui lòng chọn khách hàng mới.', { type: 'is-warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
workflowResult.value = null;
|
||||
|
||||
try {
|
||||
const result = await updateTransactionCustomer(
|
||||
props.transactionId,
|
||||
newCustomer.value.id,
|
||||
contractDate.value
|
||||
);
|
||||
workflowResult.value = result;
|
||||
updateTransactionCustomerDisplay(result);
|
||||
} catch (error) {
|
||||
console.error('Workflow execution error:', error);
|
||||
$snackbar('Có lỗi xảy ra khi thực thi workflow.', { type: 'is-danger' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!showContract">
|
||||
<div v-if="transaction && currentCustomer">
|
||||
<h3 class="fsb-18 mb-3">Mã giao dịch: {{ transaction.code }}</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="is-size-6 has-text-weight-bold mb-2">Khách hàng hiện tại</h4>
|
||||
<div class="is-flex is-flex-wrap-wrap is-gap-4">
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Mã</p>
|
||||
<p class="is-size-6">{{ currentCustomer.code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Tên</p>
|
||||
<p class="is-size-6">{{ currentCustomer.name || currentCustomer.fullname }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Loại</p>
|
||||
<p class="is-size-6"><span class="tag" :class="currentCustomer.type === 1 ? 'is-info' : 'is-warning'">{{
|
||||
currentCustomer.type === 1 ? 'Cá nhân' : 'Tổ chức' }}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Điện thoại</p>
|
||||
<p class="is-size-6">{{ currentCustomer.phone }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Email</p>
|
||||
<p class="is-size-6">{{ currentCustomer.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-3">
|
||||
<label class="label">Chọn khách hàng mới<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox v-bind="{
|
||||
api: 'customer',
|
||||
field: 'label',
|
||||
searchfield: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
column: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
first: true,
|
||||
viewaddon: customerViewAddon,
|
||||
addon: customerViewAdd,
|
||||
placeholder: 'Tìm khách hàng theo mã, tên, số điện thoại hoặc CMND'
|
||||
}" @option="(customer) => { newCustomer = customer }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="newCustomer" class="mb-4">
|
||||
<h4 class="is-size-6 has-text-weight-bold mb-2">Khách hàng mới được chọn</h4>
|
||||
<div class="is-flex is-flex-wrap-wrap is-gap-4">
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Mã</p>
|
||||
<p class="is-size-6">{{ newCustomer.code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Tên</p>
|
||||
<p class="is-size-6">{{ newCustomer.name || newCustomer.fullname }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Loại</p>
|
||||
<p class="is-size-6"><span class="tag" :class="newCustomer.type === 1 ? 'is-info' : 'is-warning'">{{
|
||||
newCustomer.type === 1 ? 'Cá nhân' : 'Tổ chức' }}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Điện thoại</p>
|
||||
<p class="is-size-6">{{ newCustomer.phone }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Email</p>
|
||||
<p class="is-size-6">{{ newCustomer.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label class="label">Ngày ký hợp đồng mới<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control" style="max-width: 200px;">
|
||||
<Datepicker
|
||||
:record="dateRecord"
|
||||
attr="contractDate"
|
||||
@date="updateContractDate"
|
||||
position="is-top-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="is-flex is-justify-content-flex-end is-gap-2">
|
||||
<button class="button" @click="$emit('close')">Hủy</button>
|
||||
<button class="button is-primary" :disabled="!newCustomer" :class="{ 'is-loading': isLoading }"
|
||||
@click="confirmChangeCustomer">
|
||||
Xác nhận
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else class="is-flex is-justify-content-center is-align-items-center" style="min-height: 200px;">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Contract :contractId="contractId" />
|
||||
</div>
|
||||
</template>
|
||||
312
app/components/transaction/TransactionConfirmModal.vue
Normal file
312
app/components/transaction/TransactionConfirmModal.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="content p-0">
|
||||
<div class="columns">
|
||||
<!-- Left Column: Transaction & Customer Info -->
|
||||
<div class="column is-half">
|
||||
<div class="mb-5">
|
||||
<p class="title is-5 mb-3">Thông tin Giao dịch</p>
|
||||
<p><strong>Loại giao dịch:</strong> {{ phaseInfo.name || '-' }}</p>
|
||||
<p><strong>Chính sách tài chính:</strong> {{ selectedPolicy.name || '-' }}</p>
|
||||
<p><strong>Giá gốc:</strong> {{ $numtoString(originPrice) }}</p>
|
||||
<p><strong>Số tiền đặt cọc:</strong> <span class="has-text-primary has-text-weight-bold">{{
|
||||
$numtoString(depositAmount) }}</span></p>
|
||||
<p><strong>Ngày ký hợp đồng:</strong> <span class="has-text-primary has-text-weight-bold">{{
|
||||
formatDate(initialContractDate) }}</span></p>
|
||||
<p><strong>Ngày hết hạn GD:</strong> <span class="has-text-primary has-text-weight-bold">{{
|
||||
formatDate(editableDueDate) }}</span></p>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<p class="title is-5 mb-3">Thông tin Khách hàng</p>
|
||||
<div class="columns">
|
||||
<!-- Original Customer Column -->
|
||||
<div class="column">
|
||||
<p><strong>{{ isIndividual ? 'Khách hàng' : 'Tổ chức' }}</strong></p>
|
||||
<p><strong>Mã KH:</strong> {{ selectedCustomer.code || '-' }}</p>
|
||||
<p><strong>{{ isIndividual ? 'Họ và tên:' : 'Tên tổ chức:' }}</strong> {{ selectedCustomer.fullname ||
|
||||
'-' }}</p>
|
||||
<p><strong>Số điện thoại:</strong> {{ selectedCustomer.phone || '-' }}</p>
|
||||
<p><strong>{{ isIndividual ? 'CCCD:' : 'GPKD/Mã số thuế:' }}</strong> {{ selectedCustomer.legal_code ||
|
||||
'-' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Co-owner Column -->
|
||||
<div class="column" v-if="coOwner && isIndividual">
|
||||
<p class="is-flex is-justify-content-space-between">
|
||||
<strong>Đồng sở hữu</strong>
|
||||
<button type="button" class="delete is-small" @click.stop="clearCoOwner()"></button>
|
||||
</p>
|
||||
<p><strong>Mã:</strong> {{ coOwner.people__code || '-' }}</p>
|
||||
<p><strong>Họ và tên:</strong> {{ coOwner.people__fullname || '-' }}</p>
|
||||
<p><strong>Số điện thoại:</strong> {{ coOwner.people__phone || '-' }}</p>
|
||||
<p><strong>CCCD:</strong> {{ coOwner.people__legal_code || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Co-owner SearchBox -->
|
||||
<div v-if="isIndividual && relatedPeople.length > 0" class="mt-2">
|
||||
<label class="label">Đồng sở hữu</label>
|
||||
<SearchBox :key="coOwner ? coOwner.id : 'empty'"
|
||||
v-bind="{ vdata: relatedPeople, field: 'people__fullname', column: ['people__code', 'people__fullname'], first: true, optionid: coOwner ? coOwner.id : null }"
|
||||
@option="selectCoOwnerByPerson" />
|
||||
</div>
|
||||
<div v-else-if="isIndividual && relatedPeople.length === 0" class="mt-2">
|
||||
<p class="has-text-grey-light is-size-7">Không có người liên quan nào được thêm</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Product Info -->
|
||||
<div class="column is-half" style="border-left: 1px solid #dbdbdb; padding-left: 2rem;">
|
||||
<div class="mb-5">
|
||||
<p class="title is-5 mb-3">Thông tin Sản phẩm</p>
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-half"><strong>Mã thương mại:</strong> {{ productData.trade_code || '-' }}</div>
|
||||
<div class="column is-half"><strong>Số tờ thửa:</strong> {{ productData.land_lot_code || '-' }}</div>
|
||||
<div class="column is-half"><strong>Mã quy hoạch:</strong> {{ productData.zone_code || '-' }}</div>
|
||||
<div class="column is-half"><strong>Phân khu:</strong> {{ productData.zone_type__name || '-' }}</div>
|
||||
<div class="column is-half"><strong>Loại sản phẩm:</strong> {{ productData.type__name || '-' }}</div>
|
||||
<div class="column is-half"><strong>Hướng cửa:</strong> {{ productData.direction__name || '-' }}</div>
|
||||
<div class="column is-half"><strong>Kích thước lô:</strong> {{ productData.land_lot_size || '-' }}</div>
|
||||
<div class="column is-half"><strong>Diện tích đất:</strong> {{ productData.lot_area ? $numtoString(productData.lot_area)
|
||||
+ ' m²' : '-' }}</div>
|
||||
<div class="column is-half"><strong>DT xây dựng:</strong> {{ productData.building_area ?
|
||||
$numtoString(productData.building_area) + ' m²' : '-' }}</div>
|
||||
<div class="column is-half"><strong>DT sàn:</strong> {{ productData.total_built_area ?
|
||||
$numtoString(productData.total_built_area) + ' m²' : '-' }}</div>
|
||||
<div class="column is-half"><strong>Tầng cao:</strong> {{ productData.number_of_floors || '-' }}</div>
|
||||
<div class="column is-half"><strong>Trạng thái:</strong> {{ productData.status__name || '-' }}</div>
|
||||
<div class="column is-half"><strong>Dự án:</strong> {{ productData.project__name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Số tiền thanh toán</label>
|
||||
<div class="control">
|
||||
<InputNumber :record="paymentRecord" attr="paymentAmount" placeholder="Nhập số tiền"
|
||||
:disabled="isPaymentInputDisabled" @number="updatePaymentAmount"
|
||||
:class="{ 'is-danger': !isPaymentAmountValid }"></InputNumber>
|
||||
</div>
|
||||
<p v-if="!isPaymentAmountValid" class="help is-danger">{{ paymentAmountError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Ngày ký hợp đồng</label>
|
||||
<p class="control has-text-weight-bold pt-2">{{ formatDate(initialContractDate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Ngày hết hạn giao dịch</label>
|
||||
<Datepicker :record="dateRecord" attr="dueDate" @date="updateDueDate" position="is-top-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Mã xác nhận</label>
|
||||
<div class="control">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input" type="text" placeholder="Nhập mã" v-model="userInputCaptcha">
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-static has-text-weight-bold has-background-grey-lighter"
|
||||
style=" font-family: 'Courier New', monospace; letter-spacing: 2px;">
|
||||
{{ captchaCode }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" @click="generateCaptcha" :title="'Tạo mã mới'">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'refresh.svg', type: 'primary', size: 23 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="is-flex is-justify-content-flex-end mt-4">
|
||||
<button class="button has-text-white is-success mr-2" :disabled="!isConfirmed" @click="handleConfirm">
|
||||
<span>Tạo giao dịch</span>
|
||||
</button>
|
||||
<button class="button" @click="$emit('close')">Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
import dayjs from 'dayjs';
|
||||
import Datepicker from '~/components/datepicker/Datepicker.vue';
|
||||
import InputNumber from '~/components/common/InputNumber.vue';
|
||||
import SearchBox from "~/components/SearchBox";
|
||||
|
||||
|
||||
export default {
|
||||
components: { Datepicker, InputNumber, SearchBox },
|
||||
props: {
|
||||
productData: { type: Object, required: true },
|
||||
phaseInfo: { type: Object, required: true },
|
||||
selectedPolicy: { type: Object, required: true },
|
||||
originPrice: { type: Number, required: true },
|
||||
discountValueDisplay: { type: Number, required: true },
|
||||
selectedCustomer: { type: Object, required: true },
|
||||
initialContractDate: { type: String, required: true },
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
emits: ['close', 'modalevent'],
|
||||
data() {
|
||||
const initialDueDate = dayjs(this.initialContractDate).add(0, 'day').format('YYYY-MM-DD');
|
||||
|
||||
return {
|
||||
editableDueDate: initialDueDate,
|
||||
dateRecord: { dueDate: initialDueDate },
|
||||
captchaCode: '',
|
||||
userInputCaptcha: '',
|
||||
paymentRecord: { paymentAmount: null },
|
||||
coOwner: null,
|
||||
relatedPeople: [],
|
||||
peopleAddon: { component: "people/People", width: "65%", height: "600px", title: "Người liên quan" }
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
depositAmount: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (this.phaseInfo.id === 'p0' && newVal === 0) {
|
||||
this.paymentRecord.paymentAmount = null;
|
||||
} else {
|
||||
this.paymentRecord.paymentAmount = newVal;
|
||||
}
|
||||
}
|
||||
},
|
||||
selectedCustomer: {
|
||||
immediate: true,
|
||||
async handler(newVal) {
|
||||
if (newVal && newVal.id) {
|
||||
await this.fetchRelatedPeople();
|
||||
} else {
|
||||
this.relatedPeople = [];
|
||||
this.coOwner = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isIndividual() {
|
||||
return this.selectedCustomer?.type === 1;
|
||||
},
|
||||
paymentAmount() {
|
||||
return this.paymentRecord.paymentAmount;
|
||||
},
|
||||
isPaymentInputDisabled() {
|
||||
return this.phaseInfo.id !== 'p0';
|
||||
},
|
||||
isPaymentAmountValid() {
|
||||
return this.paymentAmount > 0;
|
||||
},
|
||||
paymentAmountError() {
|
||||
if (this.isPaymentAmountValid) return '';
|
||||
return 'Số tiền thanh toán phải lớn hơn 0.';
|
||||
},
|
||||
isConfirmed() {
|
||||
return this.userInputCaptcha.toLowerCase() === this.captchaCode.toLowerCase() && this.userInputCaptcha !== '' && this.isPaymentAmountValid;
|
||||
},
|
||||
depositAmount() {
|
||||
return this.selectedPolicy?.deposit || 0;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.generateCaptcha();
|
||||
},
|
||||
methods: {
|
||||
async fetchRelatedPeople() {
|
||||
try {
|
||||
const { $getdata } = useNuxtApp();
|
||||
const apiName = this.isIndividual ? 'customerpeople' : 'legalrep';
|
||||
const filterKey = this.isIndividual ? 'customer' : 'organization';
|
||||
|
||||
let customerId = this.selectedCustomer.id;
|
||||
|
||||
// Nếu là organization, cần lấy organization ID
|
||||
if (!this.isIndividual) {
|
||||
const org = await $getdata('organization', { customer: customerId }, undefined, true);
|
||||
if (org) {
|
||||
customerId = org.id;
|
||||
} else {
|
||||
this.relatedPeople = [];
|
||||
this.coOwner = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const people = await $getdata(apiName, { [filterKey]: customerId });
|
||||
this.relatedPeople = Array.isArray(people) ? people : (people ? [people] : []);
|
||||
|
||||
// Auto-select người đầu tiên nếu có
|
||||
if (this.relatedPeople.length > 0) {
|
||||
this.coOwner = this.relatedPeople[0];
|
||||
} else {
|
||||
this.coOwner = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching related people:', error);
|
||||
this.relatedPeople = [];
|
||||
this.coOwner = null;
|
||||
}
|
||||
},
|
||||
clearCoOwner() {
|
||||
this.coOwner = null;
|
||||
},
|
||||
selectCoOwnerByPerson(selectedPerson) {
|
||||
// selectedPerson là một record từ customerpeople
|
||||
this.coOwner = selectedPerson || null;
|
||||
},
|
||||
formatDate(date) {
|
||||
return date ? dayjs(date).format('DD/MM/YYYY') : '-';
|
||||
},
|
||||
updateDueDate(newDate) {
|
||||
this.editableDueDate = newDate;
|
||||
this.dateRecord.dueDate = newDate;
|
||||
},
|
||||
updatePaymentAmount(value) {
|
||||
this.paymentRecord.paymentAmount = value;
|
||||
},
|
||||
generateCaptcha() {
|
||||
this.captchaCode = Math.random().toString(36).substring(2, 7).toUpperCase();
|
||||
this.userInputCaptcha = '';
|
||||
},
|
||||
handleConfirm() {
|
||||
if (this.isConfirmed) {
|
||||
this.$emit('modalevent', {
|
||||
name: 'confirm',
|
||||
data: {
|
||||
currentDate: this.initialContractDate,
|
||||
dueDate: this.editableDueDate,
|
||||
paymentAmount: this.depositAmount,
|
||||
depositReceived: this.paymentAmount,
|
||||
people: this.isIndividual && this.coOwner ? this.coOwner.people : null,
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
214
app/components/transaction/TransactionDetail.vue
Normal file
214
app/components/transaction/TransactionDetail.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { isNotNil } from 'es-toolkit';
|
||||
|
||||
// receive either txndetail or txndetailCode
|
||||
// pass index to show as timeline item
|
||||
const props = defineProps({
|
||||
txndetail: Object,
|
||||
index: Number,
|
||||
txndetailCode: Number,
|
||||
});
|
||||
|
||||
const { $getdata, $insertapi, $snackbar } = useNuxtApp();
|
||||
const store = useStore();
|
||||
const txndetail = ref(props.txndetail);
|
||||
|
||||
const showModal = ref(null);
|
||||
const paymentSchedules = ref(null);
|
||||
const transaction = ref(null);
|
||||
const isUploadingFile = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!props.txndetail) {
|
||||
const txndetailData = await $getdata('reservation', undefined, {
|
||||
filter: { code: props.txndetailCode },
|
||||
values: 'id,code,date,amount,amount_remaining,amount_received,due_date,transaction,customer_old__fullname,customer_new__fullname,transaction__code,phase,phase__name,creator,creator__fullname,status,status__name,approver,approver__fullname,approve_time,create_time,update_time'
|
||||
}, true);
|
||||
|
||||
txndetail.value = txndetailData;
|
||||
}
|
||||
|
||||
const schedules = await $getdata('payment_schedule', undefined, {
|
||||
filter: { txn_detail: txndetail.value.id },
|
||||
values: 'id,code,txn_detail,txn_detail__transaction__policy__code,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
});
|
||||
paymentSchedules.value = schedules;
|
||||
|
||||
// Fetch transaction info
|
||||
if (txndetail.value.transaction) {
|
||||
transaction.value = await $getdata('transaction', { id: txndetail.value.transaction }, undefined, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
paymentSchedules.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
function openModal() {
|
||||
showModal.value = {
|
||||
title: 'Hợp đồng',
|
||||
height: '40vh',
|
||||
width: '50%',
|
||||
component: 'transaction/TransactionFiles',
|
||||
vbind: { txndetail: txndetail.value.id }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(uploadedFiles) {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) return;
|
||||
|
||||
isUploadingFile.value = true;
|
||||
try {
|
||||
for (const fileRecord of uploadedFiles) {
|
||||
const payload = {
|
||||
txn_detail: txndetail.value.id,
|
||||
file: fileRecord.id,
|
||||
phase: txndetail.value.phase,
|
||||
};
|
||||
|
||||
const result = await $insertapi("transactionfile", payload);
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error || "Lưu file không thành công.");
|
||||
}
|
||||
}
|
||||
|
||||
$snackbar('File đã được upload thành công!', { type: 'is-success' });
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
$snackbar(error.message || 'Có lỗi khi upload file.', { type: 'is-danger' });
|
||||
} finally {
|
||||
isUploadingFile.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="txndetail" class="is-flex is-gap-2">
|
||||
<div v-if="props.index !== undefined" class="is-flex is-flex-direction-column is-align-items-center is-gap-1">
|
||||
<p class="is-size-5 has-text-weight-semibold is-flex is-justify-content-center is-align-items-center" style="
|
||||
border: 4px solid rgb(32, 72, 83);
|
||||
border-radius: 9999px;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
">
|
||||
{{ props.index + 1 }}
|
||||
</p>
|
||||
<div style="
|
||||
border: 3px solid #dddddd;
|
||||
border-radius: 9999px;
|
||||
flex-grow: 1;
|
||||
width: min-content;
|
||||
">
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-flex is-flex-direction-column is-align-items-baseline is-gap-2 is-flex-grow-1">
|
||||
<div class="columns is-flex-wrap-wrap is-1.5 fs-15 w-full">
|
||||
<div class="column is-12 my-0 columns">
|
||||
<p class="column is-3">
|
||||
<span>{{ txndetail.phase__name }} - </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.code }}</span>
|
||||
</p>
|
||||
<div v-if="phase != 7" class="column is-3 p-0">
|
||||
<p>
|
||||
<span>Số tiền: </span>
|
||||
<span class="has-text-weight-semibold">{{ $numtoString(txndetail.amount) }}</span>
|
||||
</p>
|
||||
<p class="fs-14 has-text-grey" v-if="isNotNil(txndetail.amount_received) && isNotNil(txndetail.amount_remaining)">
|
||||
<span>(</span>
|
||||
<span title="Đã thu" class="has-text-weight-semibold" style="color: hsl(120, 70%, 40%)">
|
||||
{{ $numtoString(txndetail.amount_received) }}
|
||||
</span>
|
||||
<span> • </span>
|
||||
<span title="Còn lại" class="has-text-weight-semibold" style="color: hsl(0, 50%, 50%)">
|
||||
{{ $numtoString(txndetail.amount_remaining) }}
|
||||
</span>
|
||||
<span>)</span>
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="phase != 7" class="column is-3">
|
||||
<span>Trạng thái: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.status__name }}</span>
|
||||
</p>
|
||||
<p v-if="phase === 7" class="column is-3">
|
||||
<span>Khách hàng mới: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.customer_new__fullname }}</span>
|
||||
</p>
|
||||
<p v-if="phase === 7" class="column is-3">
|
||||
<span>Người chuyển nhượng: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.creator__fullname }}</span>
|
||||
</p>
|
||||
<div class="column is-3 p-0 is-flex is-align-items-center is-gap-3">
|
||||
<a class="fsb-15 has-text-primary" @click.prevent="openModal">Hợp đồng</a>
|
||||
<!-- <span v-if="!store.dealer" class="mx-1 has-text-grey-light">•</span> -->
|
||||
<FileUpload
|
||||
v-if="txndetail.phase === 7 && !store.dealer && $getEditRights('edit', { code: 'transaction', category: 'topmenu' })"
|
||||
:type="['image', 'pdf']"
|
||||
@files="handleFileUpload"
|
||||
position="right"
|
||||
class="file-upload-inline"
|
||||
style="display: inline-block;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="column is-12 my-0 has-background-grey" />
|
||||
<p v-if="phase === 7" class="column is-12 mt-1 ml-1 columns">
|
||||
<span>Khách hàng <span class="has-text-weight-semibold">{{txndetail.customer_old__fullname}} </span> chuyển nhượng hợp đồng này cho khách hàng <span class="has-text-weight-semibold">{{txndetail.customer_new__fullname}} </span></span>
|
||||
</p>
|
||||
<div v-if="phase != 7" class="column is-12 mt-0 columns">
|
||||
<p class="column is-3">
|
||||
<span>Từ ngày: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.date ? $dayjs(txndetail.date).format('DD/MM/YYYY') : '' }}</span>
|
||||
</p>
|
||||
|
||||
<p class="column is-3">
|
||||
<span>Đến ngày: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.due_date ? $dayjs(txndetail.due_date).format('DD/MM/YYYY') : '' }}</span>
|
||||
</p>
|
||||
<p class="column is-3">
|
||||
<span>Người tạo: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.creator__fullname }}</span>
|
||||
</p>
|
||||
<p class="column is-3">
|
||||
<span>Người duyệt: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.approver__fullname }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<DataView v-if="paymentSchedules && paymentSchedules.length > 0" v-bind="{
|
||||
pagename: `txndetail-${txndetail.id}`,
|
||||
api: 'payment_schedule',
|
||||
params: {
|
||||
filter: { txn_detail: txndetail.id },
|
||||
sort: 'cycle',
|
||||
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,remain_amount,paid_amount,remain_amount,code,txn_detail,txn_detail__transaction__policy__code,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
},
|
||||
setting: 'payment_schedule_list_timeline',
|
||||
}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>Đã có lỗi khi lấy thông tin chi tiết giao dịch.</div>
|
||||
<Modal v-if="showModal" v-bind="showModal" @close="showModal = undefined" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.file-upload-inline) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:deep(.file-upload-inline .button) {
|
||||
height: 1.5em;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.875rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3273dc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.file-upload-inline .button:hover) {
|
||||
background: none;
|
||||
color: #1a5490;
|
||||
}
|
||||
</style>
|
||||
115
app/components/transaction/TransactionFiles.vue
Normal file
115
app/components/transaction/TransactionFiles.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div>
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th style="text-align: center;">Tải xuống</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="txnfile in txnfiles" :key="txnfile.id">
|
||||
<td>
|
||||
<div class="is-flex is-align-items-center">
|
||||
<span class="icon is-medium has-text-primary mr-2">
|
||||
<SvgIcon
|
||||
v-bind="{
|
||||
name: 'attach-file.svg',
|
||||
type: 'primary',
|
||||
size: 20,
|
||||
}"
|
||||
></SvgIcon>
|
||||
</span>
|
||||
<div class="is-clickable" @click="open(txnfile)">
|
||||
<p class="has-text-weight-semibold has-text-primary">
|
||||
{{ txnfile.file__name }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ $formatFileSize(txnfile.file__size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="buttons is-justify-content-center">
|
||||
<a
|
||||
class="button is-small is-info is-outlined"
|
||||
@click="download(txnfile)"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<span class="icon is-small"
|
||||
><SvgIcon
|
||||
v-bind="{
|
||||
name: 'download.svg',
|
||||
type: 'primary',
|
||||
size: 14,
|
||||
}"
|
||||
></SvgIcon
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="txnfiles.length === 0">
|
||||
<td colspan="4" class="has-text-centered py-4">
|
||||
<p class="has-text-grey">
|
||||
Chưa có tài liệu nào được đính kèm.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
const { $formatFileSize, $getdata, $getpath } = useNuxtApp();
|
||||
|
||||
const props = defineProps({
|
||||
txndetail: [String, Number]
|
||||
});
|
||||
|
||||
const showmodal = ref(undefined);
|
||||
const txnfiles = ref([]);
|
||||
|
||||
async function fetchTxnFiles() {
|
||||
if (props.txndetail) {
|
||||
const data = await $getdata('transactionfile', { txn_detail: props.txndetail });
|
||||
txnfiles.value = Array.isArray(data) ? data : (data ? [data] : []);
|
||||
}
|
||||
}
|
||||
|
||||
function open(v) {
|
||||
const fileName = v.file__name || '';
|
||||
const filePath = v.file__file || v.file;
|
||||
|
||||
if (!filePath) return;
|
||||
|
||||
if(fileName.indexOf('.png')>=0 || fileName.indexOf('.jpg')>=0 || fileName.indexOf('.jpeg')>=0) {
|
||||
showmodal.value = {
|
||||
title: fileName,
|
||||
component: 'media/ChipImage',
|
||||
vbind: {extend: false, file: v, image: `${$getpath()}static/files/${filePath}`}
|
||||
};
|
||||
return;
|
||||
}
|
||||
window.open(`${$getpath()}static/files/${filePath}`);
|
||||
}
|
||||
|
||||
function download(v) {
|
||||
const filePath = v.file__file || v.file;
|
||||
if (!filePath) return;
|
||||
|
||||
window.open(`${$getpath()}static/files/${filePath}`, v.file__name || '_blank');
|
||||
}
|
||||
|
||||
fetchTxnFiles();
|
||||
|
||||
watch(() => props.txndetail, () => {
|
||||
fetchTxnFiles();
|
||||
}, { immediate: true });
|
||||
|
||||
</script>
|
||||
127
app/components/transaction/TransactionList.vue
Normal file
127
app/components/transaction/TransactionList.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import TransactionDetail from '@/components/transaction/TransactionDetail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
transaction__code: String,
|
||||
});
|
||||
|
||||
const { $exportpdf, $getdata, $snackbar, $store } = useNuxtApp();
|
||||
const { dealer} = $store;
|
||||
const txn = ref(null);
|
||||
const txndetails = ref(null);
|
||||
const showModal = ref(null);
|
||||
|
||||
function openChangeCustomerModal() {
|
||||
showModal.value = {
|
||||
title: 'Đổi khách hàng',
|
||||
height: '40vh',
|
||||
width: '50%',
|
||||
component: 'transaction/ChangeCustomerModal',
|
||||
vbind: { transactionId: txn.value.id }
|
||||
}
|
||||
}
|
||||
|
||||
function print() {
|
||||
const fileName = `Giao-dich-${props.transaction__code}`;
|
||||
const docId = 'print-area';
|
||||
$exportpdf(docId, fileName, 'a4', 'landscape');
|
||||
$snackbar('Đang xuất PDF...', { type: 'is-info' });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const fetchedTxn = await $getdata('transaction', { code: props.transaction__code }, undefined, true);
|
||||
txn.value = fetchedTxn;
|
||||
|
||||
const fetchedTxndetails = await $getdata('reservation', undefined, {
|
||||
filter: {
|
||||
transaction__code: props.transaction__code
|
||||
},
|
||||
sort: 'create_time',
|
||||
values: 'id,code,date,amount,amount_remaining,amount_received,due_date,transaction,customer_old__fullname,customer_new__fullname,transaction__code,phase,phase__name,creator,creator__fullname,status,status__name,approver,approver__fullname,approve_time,create_time,update_time'
|
||||
});
|
||||
txndetails.value = fetchedTxndetails;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div id="print-area">
|
||||
<!-- mt-1 because bug: print empty first page -->
|
||||
<div class="columns is-multiline mx-0 mt-1 mb-4">
|
||||
<div class="column is-narrow is-flex is-align-items-center">
|
||||
<p class="has-text-weight-semibold">{{ transaction__code }}</p>
|
||||
</div>
|
||||
<div class="column columns is-multiline m-0">
|
||||
<div class="column is-4 p-1 is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Mã khách hàng: </span>
|
||||
<span class="has-text-weight-semibold">{{ txn?.customer__code }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 p-1 is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Tên khách hàng: </span>
|
||||
<span class="has-text-weight-semibold">{{ txn?.customer__fullname }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 p-1 is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Sản phẩm: </span>
|
||||
<span class="has-text-weight-semibold">{{ txn?.product__trade_code }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 p-1 is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Giá hợp đồng: </span>
|
||||
<span class="has-text-weight-semibold">{{ $numtoString(txn?.sale_price) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 p-1 is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Đã thu: </span>
|
||||
<span
|
||||
class="has-text-weight-semibold"
|
||||
style="color: hsl(120, 70%, 40%)"
|
||||
>
|
||||
{{ $numtoString(txn?.amount_received) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 p-1 is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Còn lại: </span>
|
||||
<span
|
||||
class="has-text-weight-semibold"
|
||||
style="color: hsl(0, 50%, 50%)"
|
||||
>
|
||||
{{ $numtoString(txn?.amount_remain) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ignore" class="column is-narrow is-flex is-align-items-center is-gap-2">
|
||||
<button
|
||||
v-if="txn?.phase === 4 && !dealer && $getEditRights('edit', { code: 'transaction', category: 'topmenu' })"
|
||||
@click="openChangeCustomerModal"
|
||||
class="button is-link"
|
||||
>
|
||||
Đổi khách hàng
|
||||
</button>
|
||||
<button @click="print" class="button is-light">
|
||||
In thông tin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-flex is-flex-direction-column is-gap-4">
|
||||
<TransactionDetail
|
||||
v-for="(txndetail, i) in txndetails"
|
||||
:key="txndetail.id"
|
||||
:index="i"
|
||||
:txndetail="txndetail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
v-if="showModal"
|
||||
v-bind="showModal"
|
||||
@close="showModal = undefined"
|
||||
/>
|
||||
</template>
|
||||
91
app/components/transaction/TransactionPhaseForm.vue
Normal file
91
app/components/transaction/TransactionPhaseForm.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<label
|
||||
v-for="item in filteredPhases"
|
||||
:key="item.id"
|
||||
class="py-3 is-clickable is-block"
|
||||
:class="{ 'has-background-success-light': selectedPhaseId === item.id }"
|
||||
style="border-bottom: 1px solid #dbdbdb;"
|
||||
>
|
||||
<div class="columns px-3 is-mobile is-vcentered is-gapless mb-0">
|
||||
<div class="column">
|
||||
<span class="is-size-6">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" :stroke="selectedPhaseId === item.id ? '#48c78e' : '#dbdbdb'" stroke-width="2"/>
|
||||
<circle v-if="selectedPhaseId === item.id" cx="12" cy="12" r="6" fill="#48c78e"/>
|
||||
</svg>
|
||||
<input
|
||||
type="radio"
|
||||
:value="item.id"
|
||||
v-model="selectedPhaseId"
|
||||
style="display: none;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-success has-text-white is-fullwidth"
|
||||
@click="submit"
|
||||
:disabled="!selectedPhaseId"
|
||||
>
|
||||
{{ isVietnamese ? 'Xác nhận' : 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
emits: ['modalevent', 'close'],
|
||||
props: {
|
||||
filterPhases: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedPhaseId: null,
|
||||
phases: [
|
||||
{ id: 'p1', name: 'Giữ chỗ', code: 'reserved', phase: 1, status: 3,transstatus :1 },
|
||||
{ id: 'p2a', name: 'Thỏa thuận thực hiện nguyện vọng', code: 'fulfillwish', phase: 4, status: 5,transstatus :1 },
|
||||
{ id: 'p2b', name: 'Đặt cọc', code: 'deposit', phase: 2, status: 4,transstatus :1 },
|
||||
{ id: 'p3', name: 'Mua bán', code: 'pertrade', phase: 3, status: 6,transstatus :1 }
|
||||
]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isVietnamese() {
|
||||
return this.store.lang === 'vi';
|
||||
},
|
||||
filteredPhases() {
|
||||
if (this.filterPhases.length === 0) {
|
||||
return this.phases; // If no filter is provided, show all phases
|
||||
}
|
||||
return this.phases.filter(phase => this.filterPhases.includes(phase.code));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (this.selectedPhaseId) {
|
||||
const selected = this.phases.find(p => p.id === this.selectedPhaseId);
|
||||
this.$emit('modalevent', { name: 'phaseSelected', data: selected });
|
||||
this.$emit('close');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
82
app/components/transaction/TransactionTypeView.vue
Normal file
82
app/components/transaction/TransactionTypeView.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div :id="docid" v-if="record">
|
||||
<div :id="docid1">
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.data && findFieldName("id")[this.lang] }}</label>
|
||||
<div class="control">
|
||||
<span class="hyperlink" @click="$copyToClipboard(record.id)">{{ record.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.data && findFieldName("code")[this.lang] }}</label>
|
||||
<div class="control">
|
||||
<span class="hyperlink" @click="$copyToClipboard(record.code)">{{ record.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.lang === 'vi' ? 'Tên' : 'Name' }}</label>
|
||||
<div class="control">
|
||||
{{ record.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.lang === 'vi' ? 'Chi tiết' : 'Detail' }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
props: ["row", "pagename"],
|
||||
data() {
|
||||
return {
|
||||
record: undefined,
|
||||
errors: {},
|
||||
showmodal: undefined,
|
||||
docid: this.$id(),
|
||||
docid1: this.$id(),
|
||||
data: this.store.common,
|
||||
isEditMode: this.isEditMode,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
lang() {
|
||||
return this.store.lang;
|
||||
},
|
||||
isVietnamese() {
|
||||
return this.store.lang === "vi";
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
this.record = await this.$getdata("transactiontype", { id: this.row.id }, undefined, true);
|
||||
},
|
||||
methods: {
|
||||
findFieldName(code) {
|
||||
let field = this.data.find((v) => v.code === code);
|
||||
return field;
|
||||
},
|
||||
copy(value) {
|
||||
this.$copyToClipboard(value);
|
||||
this.$snackbar("Đã copy vào clipboard.", "Copy", "Success");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
1103
app/components/transaction/TransactionView.vue
Normal file
1103
app/components/transaction/TransactionView.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user