Initial commit
This commit is contained in:
21
app/components/application/ApplicationImage.vue
Normal file
21
app/components/application/ApplicationImage.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<ImageGallery
|
||||
v-bind="{ row, pagename, show, api: 'applicationfile' }"
|
||||
@remove="emit('remove')"
|
||||
@update="update"
|
||||
></ImageGallery>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImageGallery from "../media/ImageGallery.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
row: Object,
|
||||
pagename: String,
|
||||
api: String,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["remove"]);
|
||||
|
||||
</script>
|
||||
964
app/components/application/CalculationView.vue
Normal file
964
app/components/application/CalculationView.vue
Normal file
@@ -0,0 +1,964 @@
|
||||
<template>
|
||||
<div class="fixed-grid has-12-cols pb-2">
|
||||
<div id="customer-selection" class="grid px-3 py-3 m-0 has-background-white">
|
||||
<!-- Product Selection -->
|
||||
<div v-if="!productId" class="cell is-col-span-5">
|
||||
<p class="is-size-6 has-text-weight-bold mb-2">Chọn sản phẩm</p>
|
||||
<SearchBox v-bind="{
|
||||
api: 'product',
|
||||
field: 'label',
|
||||
searchfield: ['code', 'trade_code', 'type__name', 'land_lot_size', 'zone_type__name'],
|
||||
column: ['code', 'trade_code', 'type__name', 'land_lot_size', 'zone_type__name'],
|
||||
first: true,
|
||||
filter: { status: 2, cart__dealer: store.dealer?.id },
|
||||
viewaddon: productViewAddon,
|
||||
}" @option="handleProductSelection" />
|
||||
</div>
|
||||
|
||||
<!-- Customer Selection -->
|
||||
<div class="cell" :class="productId ? 'is-col-span-10' : 'is-col-span-5'">
|
||||
<p class="is-size-6 has-text-weight-bold mb-2">Chọn khách hàng</p>
|
||||
<SearchBox v-bind="{
|
||||
api: 'customer',
|
||||
field: 'label',
|
||||
searchfield: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
column: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
first: true,
|
||||
disabled: !productData,
|
||||
viewaddon: customerViewAddon,
|
||||
addon: $getEditRights('edit', { code: 'customer', category: 'topmenu' }) ? customerViewAdd : undefined,
|
||||
}" @option="handleCustomerSelection" />
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="cell is-col-span-2 is-flex is-align-items-flex-end">
|
||||
<button v-if="contractData" class="button is-info has-text-white is-fullwidth" @click="openContractModal">
|
||||
<span>Xem hợp đồng</span>
|
||||
</button>
|
||||
<button v-else-if="$getEditRights()" class="button is-success has-text-white is-fullwidth" :disabled="!canCreateTransaction"
|
||||
:class="{ 'is-loading': isCreatingTransaction }" @click="createTransaction" style="height: 2.5em;">
|
||||
Tạo giao dịch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policy Selection -->
|
||||
<div v-if="productData && policies.length > 0" class="px-3 py-3 m-0 has-background-white mb-3">
|
||||
<p class="is-size-6 has-text-weight-bold mb-3">Chọn chính sách thanh toán</p>
|
||||
<div class="tabs is-toggle">
|
||||
<ul>
|
||||
<li v-for="policy in policies" :key="policy.id" :class="{ 'is-active': selectedPolicy?.id === policy.id }">
|
||||
<a @click="selectPolicy(policy)">
|
||||
<span>{{ policy.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contract Date -->
|
||||
<div v-if="productData && selectedPolicy" class="px-3 mb-3">
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Ngày ký</label>
|
||||
<Datepicker :record="dateRecord" attr="contractDate" @date="updateContractDate" position="is-bottom-left" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gift Selection -->
|
||||
<div v-if="productData && selectedPolicy" class="px-3 mb-3 has-background-white p-4">
|
||||
<p class="is-size-6 has-text-weight-bold mb-3">Chọn quà tặng</p>
|
||||
<div v-if="availableGifts.length > 0" class="buttons">
|
||||
<button
|
||||
v-for="gift in availableGifts"
|
||||
:key="gift.id"
|
||||
class="button"
|
||||
:class="isGiftSelected(gift.id) ? 'is-success' : 'is-primary is-outlined'"
|
||||
@click="toggleGift(gift)">
|
||||
<span class="icon" v-if="isGiftSelected(gift.id)">
|
||||
<SvgIcon v-bind="{ name: 'check.svg', type: 'white', size: 16 }"></SvgIcon>
|
||||
</span>
|
||||
<span>{{ gift.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="notification is-light">
|
||||
Không có quà tặng khả dụng
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount Selection -->
|
||||
<div v-if="productData && selectedPolicy && selectedPolicy.contract_allocation_percentage == 100" class="px-3 mb-5">
|
||||
<p class="is-size-6 has-text-weight-bold mb-2">Chọn chiết khấu</p>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12" v-for="(row, index) in discountRows" :key="row.key"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragover="dragOver"
|
||||
@drop="drop"
|
||||
@dragend="dragEnd"
|
||||
:data-index="index"
|
||||
style="cursor: move; border-bottom: 1px solid #204853;">
|
||||
|
||||
<div class="columns is-mobile is-vcentered m-0 is-variable is-1">
|
||||
<div class="column is-narrow" style="display: flex; align-items: center; justify-content: center;">
|
||||
<SvgIcon v-bind="{ name: 'dot.svg', type: 'primary', size: 16 }"></SvgIcon>
|
||||
</div>
|
||||
|
||||
<div class="column" :class="row.selectedData?.type === 1 ? 'is-4' : 'is-6'">
|
||||
<SearchBox v-bind="{
|
||||
api: 'discounttype',
|
||||
field: 'label',
|
||||
searchfield: ['code', 'name', 'value'],
|
||||
column: ['code', 'name', 'value'],
|
||||
first: true,
|
||||
clearable: true,
|
||||
placeholder: 'Chọn loại chiết khấu...'
|
||||
}" @option="(val) => handleRowSelect(index, val)" />
|
||||
</div>
|
||||
|
||||
<!-- THÊM DROPDOWN CHỌ BASE PRICE TYPE -->
|
||||
<div v-if="row.selectedData?.type === 1" class="column is-3">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="row.basePriceType" @change="recalculateDiscount(index)">
|
||||
<option value="contract">Giá trị hợp đồng</option>
|
||||
<option value="with_vat">Giá đã VAT</option>
|
||||
<option value="without_vat">Giá chưa VAT</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4" >
|
||||
<div class="control has-icons-right">
|
||||
<input
|
||||
class="input has-text-centered has-text-weight-bold"
|
||||
type="number"
|
||||
v-model.number="row.customValue"
|
||||
@input="validateRowValue(index)"
|
||||
placeholder="0"
|
||||
:disabled="!row.selectedData">
|
||||
<span v-if="row.selectedData" class="icon is-right has-text-grey is-size-7" style="height: 100%">
|
||||
{{ row.selectedData.type === 1 ? '%' : 'đ' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<button class="button is-warning is-fullwidth" @click="removeDiscountRow(index)">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 16 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<button class="button is-text is-fullwidth" @click="addNewDiscountRow">
|
||||
<SvgIcon v-bind="{ name: 'add4.svg', type: 'primary', size: 18 }" />
|
||||
<span class="ml-2">Thêm chiết khấu</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Early Payment Section -->
|
||||
<div
|
||||
v-if="productData && selectedPolicy && selectedPolicy.contract_allocation_percentage == 100 && selectedPolicy.method === 1"
|
||||
class="px-3 has-background-white p-4">
|
||||
<div class="level is-mobile mb-3">
|
||||
<div class="level-left">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="enableEarlyPayment" @change="handleEarlyPaymentToggle">
|
||||
<span class="is-size-6 has-text-weight-semibold has-text-primary ml-2">
|
||||
Thanh toán sớm (2 - {{ maxEarlyCycles }}) đợt
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="enableEarlyPayment && maxEarlyCycles >= 2" class="field">
|
||||
<label class="label">Số đợt thanh toán sớm</label>
|
||||
<div class="control" style="max-width: 200px;">
|
||||
<input class="input" type="number" v-model.number="earlyPaymentCycles" :min="2" :max="maxEarlyCycles"
|
||||
@input="validateEarlyCycles">
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Payment Schedule Presentation Component -->
|
||||
<PaymentSchedulePresentation v-if="productData && policies.length > 0" :productData="productData"
|
||||
:selectedPolicy="selectedPolicy" :selectedCustomer="selectedCustomer" :calculatorData="calculatorOutputData"
|
||||
:isLoading="isCreatingTransaction" @print="printContent" />
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<Modal v-if="showPhaseModal" component="transaction/TransactionPhaseForm" title="Chọn loại giao dịch" width="40%"
|
||||
height="auto" @close="showPhaseModal = false" @phaseSelected="handlePhaseSelection"
|
||||
:vbind="{ filterPhases: phaseFilterOptions }" />
|
||||
<Modal v-if="showConfirmModal" @close="showConfirmModal = false" @confirm="executeTransactionCreation"
|
||||
v-bind="confirmModalConfig" />
|
||||
<Modal v-if="showContractModal" @close="showContractModal = false" @dataevent="handleContractUpdated"
|
||||
v-bind="contractModalConfig" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import SearchBox from '~/components/SearchBox.vue';
|
||||
import Modal from '~/components/Modal.vue';
|
||||
import TransactionConfirmModal from '~/components/transaction/TransactionConfirmModal.vue';
|
||||
import PaymentSchedulePresentation from './PaymentSchedulePresentation.vue';
|
||||
import Datepicker from '~/components/datepicker/Datepicker.vue';
|
||||
import SvgIcon from '~/components/SvgIcon.vue';
|
||||
import { useAdvancedWorkflow } from '@/composables/useAdvancedWorkflow';
|
||||
import { usePaymentCalculator } from '@/composables/usePaymentCalculator';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SearchBox,
|
||||
Modal,
|
||||
TransactionConfirmModal,
|
||||
PaymentSchedulePresentation,
|
||||
Datepicker,
|
||||
SvgIcon,
|
||||
},
|
||||
props: {
|
||||
productId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const { $snackbar, $getdata, $dialog, $exportpdf } = useNuxtApp();
|
||||
const { isLoading, createWorkflowTransaction } = useAdvancedWorkflow();
|
||||
|
||||
const calculator = usePaymentCalculator();
|
||||
|
||||
return {
|
||||
store,
|
||||
$snackbar,
|
||||
$getdata,
|
||||
$dialog,
|
||||
$exportpdf,
|
||||
isCreatingTransaction: isLoading,
|
||||
createFullTransaction: createWorkflowTransaction,
|
||||
calculator
|
||||
};
|
||||
},
|
||||
data() {
|
||||
const initialContractDate = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
return {
|
||||
productData: null,
|
||||
selectedCustomer: null,
|
||||
policies: [],
|
||||
selectedPolicy: null,
|
||||
allPaymentPlans: [],
|
||||
paymentPlans: [],
|
||||
|
||||
// Gift Selection
|
||||
availableGifts: [],
|
||||
selectedGifts: [],
|
||||
|
||||
discountRows: [{
|
||||
key: Date.now(),
|
||||
selectedData: null,
|
||||
customValue: 0,
|
||||
basePriceType: 'contract',
|
||||
calculatedAmount: 0
|
||||
}],
|
||||
draggedIndex: null,
|
||||
|
||||
// Early Payment
|
||||
enableEarlyPayment: false,
|
||||
earlyPaymentCycles: 2,
|
||||
|
||||
// Contract Date
|
||||
contractDate: initialContractDate,
|
||||
dateRecord: { contractDate: initialContractDate },
|
||||
|
||||
productViewAddon: {
|
||||
component: "product/ProductView",
|
||||
width: "70%",
|
||||
height: "500px",
|
||||
title: "Thông tin sản phẩm",
|
||||
},
|
||||
customerViewAddon: {
|
||||
component: "customer/CustomerView",
|
||||
width: "70%",
|
||||
height: "500px",
|
||||
title: "Thông tin khách hàng",
|
||||
},
|
||||
customerViewAdd: {
|
||||
component: "customer/CustomerInfo2",
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
title: this.store.lang === "en" ? "Edit Customer" : "Chỉnh sửa khách hàng",
|
||||
},
|
||||
showPhaseModal: false,
|
||||
selectedPhaseInfo: null,
|
||||
showConfirmModal: false,
|
||||
contractData: null,
|
||||
showContractModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canCreateTransaction() {
|
||||
return this.productData && this.selectedCustomer && this.selectedPolicy;
|
||||
},
|
||||
|
||||
maxEarlyCycles() {
|
||||
return this.paymentPlans?.length || 0;
|
||||
},
|
||||
|
||||
calculatorOutputData() {
|
||||
return {
|
||||
originPrice: this.calculator.originalPrice.value,
|
||||
totalDiscount: this.calculator.totalDiscount.value,
|
||||
salePrice: this.calculator.finalTotal.value,
|
||||
allocatedPrice: this.calculator.finalTotal.value,
|
||||
originalPaymentSchedule: this.calculator.originalPaymentSchedule.value,
|
||||
finalPaymentSchedule: this.calculator.finalPaymentSchedule.value,
|
||||
earlyDiscountDetails: this.calculator.earlyDiscountDetails.value,
|
||||
totalRemaining: this.calculator.totalRemaining.value,
|
||||
detailedDiscounts: this.calculator.detailedDiscounts.value,
|
||||
baseDate: this.calculator.startDate.value
|
||||
};
|
||||
},
|
||||
|
||||
phaseFilterOptions() {
|
||||
if (!this.selectedPolicy) {
|
||||
return ['reserved', 'deposit', 'fulfillwish'];
|
||||
}
|
||||
|
||||
if (this.store.dealer) {
|
||||
return ['reserved', 'deposit'];
|
||||
}
|
||||
if (this.selectedPolicy.code === 'CS-TT-THNV') {
|
||||
return ['fulfillwish', 'reserved'];
|
||||
} else {
|
||||
return ['deposit', 'reserved'];
|
||||
}
|
||||
},
|
||||
|
||||
confirmModalConfig() {
|
||||
if (!this.productData || !this.selectedPhaseInfo || !this.selectedPolicy || !this.selectedCustomer) return {};
|
||||
return {
|
||||
component: "transaction/TransactionConfirmModal",
|
||||
title: "Xác nhận Giao dịch",
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
vbind: {
|
||||
productData: this.productData,
|
||||
phaseInfo: this.selectedPhaseInfo,
|
||||
selectedPolicy: this.selectedPolicy,
|
||||
originPrice: this.calculator.originalPrice.value,
|
||||
discountValueDisplay: this.calculator.totalDiscount.value,
|
||||
selectedCustomer: this.selectedCustomer,
|
||||
initialContractDate: this.contractDate
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
contractModalConfig() {
|
||||
if (!this.contractData) return {};
|
||||
return {
|
||||
component: "application/Contract",
|
||||
title: "Hợp đồng giao dịch",
|
||||
width: "95%",
|
||||
height: "95vh",
|
||||
vbind: {
|
||||
row: {
|
||||
id: this.contractData.id
|
||||
}
|
||||
},
|
||||
event: "contractUpdated",
|
||||
eventname: "dataevent",
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// Watch discountRows để cập nhật vào calculator
|
||||
discountRows: {
|
||||
handler(newRows) {
|
||||
const validDiscounts = newRows
|
||||
.filter(r => r.selectedData)
|
||||
.map(r => {
|
||||
if (r.selectedData.type === 1) {
|
||||
// Phần trăm - tính toán dựa trên base price type
|
||||
let basePrice = 0;
|
||||
switch (r.basePriceType) {
|
||||
case 'with_vat':
|
||||
basePrice = this.productData?.origin_price || 0;
|
||||
break;
|
||||
case 'without_vat':
|
||||
basePrice = this.productData?.price_excluding_vat ||
|
||||
(this.productData?.origin_price || 0) / 1.1;
|
||||
break;
|
||||
case 'contract':
|
||||
default:
|
||||
return {
|
||||
id: r.selectedData.id,
|
||||
name: r.selectedData.name,
|
||||
code: r.selectedData.code,
|
||||
type: r.selectedData.type,
|
||||
value: r.customValue || 0
|
||||
};
|
||||
}
|
||||
|
||||
const calculatedAmount = (basePrice * (r.customValue || 0)) / 100;
|
||||
r.calculatedAmount = calculatedAmount;
|
||||
|
||||
return {
|
||||
id: r.selectedData.id,
|
||||
name: `${r.selectedData.name} (${r.basePriceType === 'with_vat' ? 'Giá đã VAT' : 'Giá chưa VAT'})`,
|
||||
code: r.selectedData.code,
|
||||
type: 2, // Chuyển sang type 2 (tiền mặt) sau khi tính toán
|
||||
value: Math.round(calculatedAmount)
|
||||
};
|
||||
} else {
|
||||
// Tiền mặt
|
||||
r.calculatedAmount = 0;
|
||||
return {
|
||||
id: r.selectedData.id,
|
||||
name: r.selectedData.name,
|
||||
code: r.selectedData.code,
|
||||
type: r.selectedData.type,
|
||||
value: r.customValue || 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
this.calculator.discounts.value = validDiscounts;
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
earlyPaymentCycles(newVal) {
|
||||
if (this.enableEarlyPayment && newVal > 0) {
|
||||
this.calculator.earlyPaymentCycles.value = newVal;
|
||||
}
|
||||
},
|
||||
|
||||
enableEarlyPayment(newVal) {
|
||||
if (!newVal) {
|
||||
this.calculator.earlyPaymentCycles.value = 0;
|
||||
} else {
|
||||
this.calculator.earlyPaymentCycles.value = this.earlyPaymentCycles;
|
||||
}
|
||||
},
|
||||
|
||||
contractDate(newVal) {
|
||||
if (newVal) {
|
||||
this.calculator.startDate.value = new Date(newVal);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
await this.loadAllData();
|
||||
if (this.productId) {
|
||||
await this.loadProductById(this.productId);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadProductById(id) {
|
||||
const product = await this.$getdata('product', { id: id }, undefined, true);
|
||||
if (product) {
|
||||
await this.handleProductSelection(product);
|
||||
} else {
|
||||
this.$snackbar(`Không tìm thấy sản phẩm với ID: ${id}`, { type: 'is-danger' });
|
||||
}
|
||||
},
|
||||
|
||||
updateContractDate(newDate) {
|
||||
this.contractDate = newDate;
|
||||
this.dateRecord.contractDate = newDate;
|
||||
this.calculator.startDate.value = new Date(newDate);
|
||||
},
|
||||
|
||||
handleEarlyPaymentToggle() {
|
||||
if (this.enableEarlyPayment) {
|
||||
this.earlyPaymentCycles = Math.min(2, this.maxEarlyCycles);
|
||||
this.calculator.earlyPaymentCycles.value = this.earlyPaymentCycles;
|
||||
} else {
|
||||
this.calculator.earlyPaymentCycles.value = 0;
|
||||
}
|
||||
},
|
||||
|
||||
validateEarlyCycles() {
|
||||
if (this.earlyPaymentCycles < 2) {
|
||||
this.earlyPaymentCycles = 2;
|
||||
}
|
||||
if (this.earlyPaymentCycles > this.maxEarlyCycles) {
|
||||
this.earlyPaymentCycles = this.maxEarlyCycles;
|
||||
}
|
||||
this.calculator.earlyPaymentCycles.value = this.earlyPaymentCycles;
|
||||
},
|
||||
|
||||
// Gift Selection Methods
|
||||
isGiftSelected(giftId) {
|
||||
return this.selectedGifts.some(g => g.id === giftId);
|
||||
},
|
||||
|
||||
toggleGift(gift) {
|
||||
const index = this.selectedGifts.findIndex(g => g.id === gift.id);
|
||||
if (index > -1) {
|
||||
// Đã chọn -> bỏ chọn
|
||||
this.selectedGifts.splice(index, 1);
|
||||
} else {
|
||||
// Chưa chọn -> thêm vào
|
||||
this.selectedGifts.push({ id: gift.id });
|
||||
}
|
||||
},
|
||||
|
||||
async loadAvailableGifts() {
|
||||
try {
|
||||
const gifts = await this.$getdata('gift', undefined, undefined, false);
|
||||
if (gifts && Array.isArray(gifts)) {
|
||||
this.availableGifts = gifts;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading gifts:', error);
|
||||
this.availableGifts = [];
|
||||
}
|
||||
},
|
||||
|
||||
addNewDiscountRow() {
|
||||
this.discountRows.push({
|
||||
key: Date.now(),
|
||||
selectedData: null,
|
||||
customValue: 0,
|
||||
basePriceType: 'contract',
|
||||
calculatedAmount: 0
|
||||
});
|
||||
},
|
||||
|
||||
removeDiscountRow(index) {
|
||||
this.discountRows.splice(index, 1);
|
||||
if (!this.discountRows.length) this.addNewDiscountRow();
|
||||
},
|
||||
|
||||
handleRowSelect(index, data) {
|
||||
const row = this.discountRows[index];
|
||||
if (!data) {
|
||||
row.selectedData = null;
|
||||
row.customValue = 0;
|
||||
row.basePriceType = 'contract';
|
||||
row.calculatedAmount = 0;
|
||||
} else {
|
||||
row.selectedData = data;
|
||||
row.customValue = data.value;
|
||||
if (data.type === 1) {
|
||||
row.basePriceType = 'contract';
|
||||
this.recalculateDiscount(index);
|
||||
} else {
|
||||
row.calculatedAmount = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
recalculateDiscount(index) {
|
||||
const row = this.discountRows[index];
|
||||
if (!row.selectedData || row.selectedData.type !== 1) return;
|
||||
|
||||
let basePrice = 0;
|
||||
switch (row.basePriceType) {
|
||||
case 'with_vat':
|
||||
basePrice = this.productData?.origin_price || 0;
|
||||
break;
|
||||
case 'without_vat':
|
||||
basePrice = this.productData?.price_excluding_vat ||
|
||||
(this.productData?.origin_price || 0) / 1.1;
|
||||
break;
|
||||
case 'contract':
|
||||
default:
|
||||
row.calculatedAmount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
row.calculatedAmount = (basePrice * (row.customValue || 0)) / 100;
|
||||
},
|
||||
|
||||
validateRowValue(index) {
|
||||
const row = this.discountRows[index];
|
||||
if (!row.selectedData) return;
|
||||
|
||||
if (row.selectedData.type === 1) {
|
||||
if (row.customValue > 100) row.customValue = 100;
|
||||
if (row.customValue < 0) row.customValue = 0;
|
||||
this.recalculateDiscount(index);
|
||||
} else {
|
||||
if (row.customValue < 0) row.customValue = 0;
|
||||
}
|
||||
},
|
||||
|
||||
dragStart(e) {
|
||||
this.draggedIndex = parseInt(e.currentTarget.getAttribute('data-index'));
|
||||
e.currentTarget.style.opacity = '0.5';
|
||||
},
|
||||
|
||||
dragOver(e) {
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget;
|
||||
if (target.getAttribute('data-index') !== null) {
|
||||
target.style.borderTop = '2px solid #204853';
|
||||
}
|
||||
},
|
||||
|
||||
drop(e) {
|
||||
e.preventDefault();
|
||||
const dropIndex = parseInt(e.currentTarget.getAttribute('data-index'));
|
||||
|
||||
if (this.draggedIndex !== null && this.draggedIndex !== dropIndex) {
|
||||
const draggedRow = this.discountRows[this.draggedIndex];
|
||||
this.discountRows.splice(this.draggedIndex, 1);
|
||||
this.discountRows.splice(dropIndex, 0, draggedRow);
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-index]').forEach(row => {
|
||||
row.style.borderTop = '';
|
||||
row.style.opacity = '1';
|
||||
});
|
||||
},
|
||||
|
||||
dragEnd(e) {
|
||||
document.querySelectorAll('[data-index]').forEach(row => {
|
||||
row.style.borderTop = '';
|
||||
row.style.opacity = '1';
|
||||
});
|
||||
this.draggedIndex = null;
|
||||
},
|
||||
|
||||
handleCustomerSelection(customer) {
|
||||
this.selectedCustomer = customer;
|
||||
},
|
||||
|
||||
async handleProductSelection(product) {
|
||||
if (!product) {
|
||||
this.productData = null;
|
||||
this.selectedPolicy = null;
|
||||
this.paymentPlans = [];
|
||||
this.discountRows = [{
|
||||
key: Date.now(),
|
||||
selectedData: null,
|
||||
customValue: 0,
|
||||
basePriceType: 'contract',
|
||||
calculatedAmount: 0
|
||||
}];
|
||||
this.selectedCustomer = null;
|
||||
this.contractData = null;
|
||||
this.enableEarlyPayment = false;
|
||||
this.earlyPaymentCycles = 2;
|
||||
this.selectedGifts = [];
|
||||
|
||||
this.calculator.originPrice.value = 0;
|
||||
this.calculator.discounts.value = [];
|
||||
this.calculator.paymentPlan.value = [];
|
||||
this.calculator.paidAmount.value = 0;
|
||||
this.calculator.earlyPaymentCycles.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!product.trade_code && !product.code) {
|
||||
this.$snackbar('Không tìm thấy mã sản phẩm hợp lệ', { type: 'is-danger' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.productData = product;
|
||||
this.selectedPolicy = null;
|
||||
this.paymentPlans = [];
|
||||
this.discountRows = [{
|
||||
key: Date.now(),
|
||||
selectedData: null,
|
||||
customValue: 0,
|
||||
basePriceType: 'contract',
|
||||
calculatedAmount: 0
|
||||
}];
|
||||
this.contractData = null;
|
||||
this.enableEarlyPayment = false;
|
||||
this.earlyPaymentCycles = 2;
|
||||
this.selectedGifts = [];
|
||||
|
||||
this.calculator.originPrice.value = product.origin_price || 0;
|
||||
this.calculator.discounts.value = [];
|
||||
this.calculator.startDate.value = new Date(this.contractDate);
|
||||
this.calculator.earlyPaymentCycles.value = 0;
|
||||
|
||||
if (this.policies.length > 0) {
|
||||
this.selectPolicy(this.policies[0]);
|
||||
} else {
|
||||
this.$snackbar('Không có chính sách thanh toán cho sản phẩm này', { type: 'is-warning' });
|
||||
}
|
||||
},
|
||||
|
||||
async loadAllData() {
|
||||
try {
|
||||
this.policies = await this.$getdata("salepolicy", { enable: "True" }, undefined, false);
|
||||
this.allPaymentPlans = await this.$getdata("paymentplan", { policy__enable: "True" }, undefined, false);
|
||||
if (this.allPaymentPlans) {
|
||||
this.allPaymentPlans.sort((a, b) => a.cycle - b.cycle);
|
||||
}
|
||||
|
||||
// Load gifts
|
||||
await this.loadAvailableGifts();
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
selectPolicy(policy) {
|
||||
this.selectedPolicy = policy;
|
||||
this.loadPlansForPolicy(policy.id);
|
||||
|
||||
this.calculator.contractAllocationPercentage.value = policy.contract_allocation_percentage || 100;
|
||||
|
||||
this.enableEarlyPayment = false;
|
||||
this.earlyPaymentCycles = 2;
|
||||
this.calculator.earlyPaymentCycles.value = 0;
|
||||
|
||||
if (policy.contract_allocation_percentage < 100) {
|
||||
this.discountRows = [{
|
||||
key: Date.now(),
|
||||
selectedData: null,
|
||||
customValue: 0,
|
||||
basePriceType: 'contract',
|
||||
calculatedAmount: 0
|
||||
}];
|
||||
}
|
||||
},
|
||||
|
||||
loadPlansForPolicy(id) {
|
||||
const policy = this.policies.find(pol => pol.id === id);
|
||||
if (!policy) return;
|
||||
|
||||
this.selectedPolicy = policy;
|
||||
this.paymentPlans = this.allPaymentPlans.filter(plan => plan.policy === id);
|
||||
|
||||
if (this.paymentPlans.length > 0) {
|
||||
this.calculator.paymentPlan.value = this.paymentPlans.map(p => ({
|
||||
cycle: p.cycle,
|
||||
value: p.value,
|
||||
type: p.type,
|
||||
days: p.days,
|
||||
payment_note: p.payment_note,
|
||||
due_note: p.due_note
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
createTransaction() {
|
||||
const invalidRow = this.discountRows.find(r => r.selectedData && r.selectedData.type !== 1 && r.customValue < 1000);
|
||||
if (invalidRow) {
|
||||
this.$snackbar('Giá trị tiền mặt phải >= 1.000', { type: 'is-warning' });
|
||||
return;
|
||||
}
|
||||
this.showPhaseModal = true;
|
||||
},
|
||||
|
||||
handlePhaseSelection(phaseInfo) {
|
||||
this.showPhaseModal = false;
|
||||
this.selectedPhaseInfo = phaseInfo;
|
||||
this.showConfirmModal = true;
|
||||
},
|
||||
|
||||
async executeTransactionCreation({ currentDate, dueDate, paymentAmount, depositReceived, people }) {
|
||||
this.showConfirmModal = false;
|
||||
|
||||
this.calculator.startDate.value = new Date(currentDate);
|
||||
this.calculator.paidAmount.value = 0;
|
||||
|
||||
const finalSchedule = this.calculator.finalPaymentSchedule.value;
|
||||
|
||||
const paymentPlansForBackend = finalSchedule.map((plan, index) => ({
|
||||
amount: plan.amount,
|
||||
due_days: plan.days,
|
||||
cycle: plan.cycle,
|
||||
note: plan.payment_note,
|
||||
is_early_merged: plan.is_merged || false,
|
||||
merged_cycles: plan.is_merged ? (plan.original_cycles || []) : [],
|
||||
raw_amount: plan.amount,
|
||||
paid_amount: plan.paid_amount,
|
||||
remain_amount: plan.remain_amount,
|
||||
from_date: dayjs(plan.from_date).format('YYYY-MM-DD'),
|
||||
to_date: dayjs(plan.to_date).format('YYYY-MM-DD'),
|
||||
due_note: plan.due_note || ''
|
||||
}));
|
||||
|
||||
if (paymentPlansForBackend.length > 0) {
|
||||
paymentPlansForBackend[0].remain_amount = Math.max(0, paymentPlansForBackend[0].remain_amount - paymentAmount);
|
||||
paymentPlansForBackend[0].amount = Math.max(0, paymentPlansForBackend[0].amount - paymentAmount);
|
||||
}
|
||||
|
||||
const totalEarlyDiscount = this.enableEarlyPayment
|
||||
? this.calculator.totalEarlyDiscount.value
|
||||
: 0;
|
||||
|
||||
const params = {
|
||||
product: this.productData,
|
||||
customer: this.selectedCustomer,
|
||||
policy: this.selectedPolicy,
|
||||
phaseInfo: this.selectedPhaseInfo,
|
||||
priceAfterDiscount: this.calculator.finalTotal.value,
|
||||
discountValue: this.calculator.totalDiscount.value,
|
||||
detailedDiscounts: this.calculator.detailedDiscounts.value,
|
||||
paymentPlans: paymentPlansForBackend,
|
||||
currentDate,
|
||||
reservationDueDate: dueDate,
|
||||
reservationAmount: paymentAmount,
|
||||
depositReceived: depositReceived,
|
||||
people: people,
|
||||
earlyDiscountAmount: totalEarlyDiscount,
|
||||
gifts: this.selectedGifts // Thêm danh sách quà tặng
|
||||
};
|
||||
|
||||
const result = await this.createFullTransaction(params);
|
||||
|
||||
if (result && result.transaction) {
|
||||
this.contractData = result.transaction;
|
||||
this.showContractModal = true;
|
||||
} else {
|
||||
this.$snackbar('Tạo giao dịch thất bại. Vui lòng thử lại.', { type: 'is-danger' });
|
||||
}
|
||||
|
||||
this.selectedPhaseInfo = null;
|
||||
},
|
||||
|
||||
openContractModal() {
|
||||
if (!this.contractData) {
|
||||
this.$snackbar('Chưa có dữ liệu hợp đồng để xem.', { type: 'is-warning' });
|
||||
return;
|
||||
}
|
||||
this.showContractModal = true;
|
||||
},
|
||||
|
||||
handleContractUpdated(eventData) {
|
||||
if (eventData?.data) {
|
||||
this.contractData = { ...this.contractData, ...eventData.data };
|
||||
this.$snackbar("Hợp đồng đã được cập nhật");
|
||||
}
|
||||
},
|
||||
|
||||
printContent() {
|
||||
if (!this.productData || !this.selectedPolicy) {
|
||||
this.$snackbar('Vui lòng tìm sản phẩm và chọn chính sách', { type: 'is-warning' });
|
||||
return;
|
||||
}
|
||||
const docId = 'print-area';
|
||||
const fileName = `${this.selectedPolicy?.name || 'Payment Schedule'} - ${this.productData?.code}`;
|
||||
const printElement = document.getElementById(docId);
|
||||
if (!printElement) return;
|
||||
|
||||
const scheduleContainers = printElement.querySelectorAll('.schedule-container');
|
||||
const stickyHeaders = printElement.querySelectorAll('.table-container.schedule-container thead th');
|
||||
const ignoreButtons = printElement.querySelectorAll('#ignore-print');
|
||||
scheduleContainers.forEach(container => {
|
||||
container.style.maxHeight = 'none';
|
||||
container.style.overflow = 'visible';
|
||||
});
|
||||
stickyHeaders.forEach(header => {
|
||||
header.style.position = 'static';
|
||||
});
|
||||
ignoreButtons.forEach(button => {
|
||||
button.style.display = 'none';
|
||||
});
|
||||
this.$exportpdf(docId, fileName, 'a3');
|
||||
setTimeout(() => {
|
||||
scheduleContainers.forEach(container => {
|
||||
container.style.maxHeight = '';
|
||||
container.style.overflow = '';
|
||||
});
|
||||
stickyHeaders.forEach(header => {
|
||||
header.style.position = '';
|
||||
});
|
||||
ignoreButtons.forEach(button => {
|
||||
button.style.display = '';
|
||||
});
|
||||
}, 1200);
|
||||
this.$snackbar('Đang xuất PDF...', { type: 'is-info' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#customer-selection {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
top: 52px;
|
||||
}
|
||||
|
||||
.modal-card-body #customer-selection {
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.button.is-dashed-border {
|
||||
border: 1px dashed #b5b5b5;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button.is-dashed-border:hover {
|
||||
border-color: #204853;
|
||||
color: #204853 !important;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.schedule-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-container.schedule-container thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid #dbdbdb !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
li.is-active a,
|
||||
li a:hover {
|
||||
color: white !important;
|
||||
background-color: #204853 !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.schedule-container {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.table-container.schedule-container thead th {
|
||||
position: static;
|
||||
}
|
||||
|
||||
#ignore-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
148
app/components/application/CommPayment.vue
Normal file
148
app/components/application/CommPayment.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div :id="docid">
|
||||
<div :id="docid1">
|
||||
<Caption v-bind="{ title: isVietnamese? 'Thanh toán' : 'Payment', type: 'has-text-warning' }"></Caption>
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-4 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ dataLang && findFieldName("loan_code")[lang] }}</label>
|
||||
<div class="control">
|
||||
<span class="hyperlink" @click="$copyToClipboard(record.code)">{{ record?.code || "/" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ dataLang && findFieldName("name")[lang] }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record?.fullname || "/" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ dataLang && findFieldName("phone_number")[lang] }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record?.phone || "/" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ dataLang && findFieldName("modalcollaboratorcode")[lang] }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record?.collaborator__code || "/" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ isVietnamese ? "Họ tên CTV" : "CTV name" }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record?.collaborator__fullname || "/" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ dataLang && findFieldName("commissionamount")[lang] }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record?.commission ? $numtoString(record.commission) : "/" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-5 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ isVietnamese ? " Trạng thái" : "Status" }}</label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
true
|
||||
v-bind="{
|
||||
api: 'paymentstatus',
|
||||
field: isVietnamese ? 'name' : 'en',
|
||||
column: ['code'],
|
||||
first: true,
|
||||
optionid: record.payment_status ? record.payment_status : 1,
|
||||
}"
|
||||
@option="selected('payment_status', $event)"
|
||||
position="is-top-left"
|
||||
></SearchBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="mt-2 border-bottom"></div> -->
|
||||
<div class="buttons mt-5" id="ignore">
|
||||
<button class="button is-primary has-text-white mt-2" @click="handleUpdate()">
|
||||
{{ dataLang && findFieldName("update")[lang] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useStore } from "@/stores/index";
|
||||
import { useNuxtApp } from "#app";
|
||||
const nuxtApp = useNuxtApp();
|
||||
|
||||
const {
|
||||
$updatepage,
|
||||
$getdata,
|
||||
$updateapi,
|
||||
$insertapi,
|
||||
$copyToClipboard,
|
||||
$empty,
|
||||
$snackbar,
|
||||
$numtoString,
|
||||
$formatNumber,
|
||||
} = nuxtApp;
|
||||
const store = useStore();
|
||||
const lang = computed(() => store.lang);
|
||||
const isVietnamese = computed(() => lang.value === "vi");
|
||||
const dataLang = ref(store.common);
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const props = defineProps({
|
||||
row: Object,
|
||||
api: String,
|
||||
pagename: String,
|
||||
});
|
||||
const record = ref(props.row);
|
||||
const findFieldName = (code) => {
|
||||
let field = dataLang.value.find((v) => v.code === code);
|
||||
return field;
|
||||
};
|
||||
|
||||
const selected = (fieldName, value) => {
|
||||
if (value) {
|
||||
record.value.payment_status = value.id;
|
||||
record.value.payment_status__code = value.code;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await $updateapi(props.api, record.value);
|
||||
let ele = await $getdata(props.api, { id: record.value.id }, undefined, true);
|
||||
$updatepage(props.pagename, ele);
|
||||
$snackbar(isVietnamese.value ? "Cập nhật thành công" : "Update successful");
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
console.error("Error updating data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
record.value = await $getdata(props.api, { id: record.value.id }, undefined, true);
|
||||
});
|
||||
</script>
|
||||
262
app/components/application/Contract.vue
Normal file
262
app/components/application/Contract.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div :id="docid">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="has-text-centered mt-5 mb-5" style="min-height: 500px">
|
||||
<button class="button is-primary is-loading is-large"></button>
|
||||
<p class="mt-4 has-text-primary has-text-weight-semibold">
|
||||
{{ isVietnamese ? 'Đang tải hợp đồng...' : 'Loading contracts...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No contract state -->
|
||||
<div v-else-if="!hasContracts" class="has-text-centered mt-5 mb-5" style="min-height: 500px">
|
||||
<article class="message is-primary">
|
||||
<div class="message-body" style="font-size: 17px; text-align: left; color: black">
|
||||
{{
|
||||
isVietnamese
|
||||
? "Chưa có hợp đồng. Vui lòng tạo giao dịch và hợp đồng trước."
|
||||
: "No contract available. Please create transaction and contract first."
|
||||
}}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Contracts list -->
|
||||
<template v-else>
|
||||
<!-- Tabs khi có nhiều hợp đồng -->
|
||||
<div class="tabs border-bottom" id="ignore" v-if="contractsList.length > 1">
|
||||
<ul class="tabs-list">
|
||||
<li class="tabs-item" style="border: none" v-for="(contract, index) in contractsList" :key="index"
|
||||
:class="{ 'bg-primary has-text-white': activeContractIndex === index }" @click="switchContract(index)">
|
||||
<a class="tabs-link">
|
||||
<span>{{ contract.document[0]?.name || contract.document[0]?.en || `Contract ${index + 1}` }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contract content -->
|
||||
<div v-if="currentContract && pdfFileUrl && hasValidDocument">
|
||||
<div class="contract-content mt-2">
|
||||
<iframe :src="`https://mozilla.github.io/pdf.js/web/viewer.html?file=${pdfFileUrl}`" width="100%"
|
||||
height="90vh" scrolling="no" style="border: none; height: 75vh; top: 0; left: 0; right: 0; bottom: 0">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download buttons -->
|
||||
<div class="mt-4" id="ignore">
|
||||
<button v-if="hasValidDocument" class="button is-primary has-text-white mr-4" @click="downloadDocx">
|
||||
{{ isVietnamese ? "Tải file docx" : "Download contract as docx" }}
|
||||
</button>
|
||||
|
||||
<button v-if="hasValidDocument" class="button is-primary has-text-white mr-4" @click="downloadPdf">
|
||||
{{ isVietnamese ? "Tải file pdf" : "Download contract as pdf" }}
|
||||
</button>
|
||||
|
||||
<p v-if="contractError" class="has-text-danger mt-2">
|
||||
{{ contractError }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
props: {
|
||||
contractId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
row: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
directDocument: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ["contractCreated", "update", "close", "dataevent"],
|
||||
data() {
|
||||
return {
|
||||
docid: this.$id(),
|
||||
contractsList: [],
|
||||
activeContractIndex: 0,
|
||||
isLoading: false,
|
||||
contractError: null,
|
||||
lang: this.store.lang,
|
||||
isVietnamese: this.store.lang === "vi",
|
||||
link: this.$getpath().indexOf("dev") >= 0 ? "dev.utopia.y99.vn" : "utopia.y99.vn",
|
||||
pdfFileUrl: undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasContracts() {
|
||||
return this.contractsList && this.contractsList.length > 0;
|
||||
},
|
||||
currentContract() {
|
||||
return this.hasContracts ? this.contractsList[this.activeContractIndex] : null;
|
||||
},
|
||||
hasValidDocument() {
|
||||
if (!this.currentContract) return false;
|
||||
return this.currentContract.document &&
|
||||
this.currentContract.document.length > 0 &&
|
||||
this.currentContract.document[0]?.pdf;
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.contractError = null;
|
||||
|
||||
if (this.directDocument) {
|
||||
this.contractsList = [
|
||||
{ document: [this.directDocument] }
|
||||
];
|
||||
this.updatePdfUrl(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let contracts = [];
|
||||
let fetchParams = null;
|
||||
|
||||
if (this.contractId) {
|
||||
fetchParams = { id: this.contractId };
|
||||
}
|
||||
else if (this.row?.id) {
|
||||
fetchParams = { transaction: this.row.id };
|
||||
}
|
||||
|
||||
if (!fetchParams) {
|
||||
throw new Error(
|
||||
this.isVietnamese
|
||||
? 'Không có ID hợp đồng hoặc transaction để tải.'
|
||||
: 'No contract ID or transaction provided to load.'
|
||||
);
|
||||
}
|
||||
|
||||
contracts = await this.$getdata(
|
||||
'contract',
|
||||
fetchParams,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (!contracts || contracts.length === 0) {
|
||||
throw new Error(
|
||||
this.isVietnamese
|
||||
? 'Không tìm thấy hợp đồng.'
|
||||
: 'Contract not found.'
|
||||
);
|
||||
}
|
||||
|
||||
this.contractsList = contracts;
|
||||
console.log(this.contractsList);
|
||||
|
||||
if (this.hasContracts) {
|
||||
this.updatePdfUrl(this.activeContractIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading contracts:', error);
|
||||
this.contractError = error.message || (
|
||||
this.isVietnamese
|
||||
? 'Lỗi khi tải danh sách hợp đồng.'
|
||||
: 'Error loading contracts list.'
|
||||
);
|
||||
this.contractsList = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updatePdfUrl(index) {
|
||||
const contract = this.contractsList[index];
|
||||
if (contract?.document && contract.document[0]?.pdf) {
|
||||
this.pdfFileUrl = `${this.$getpath()}download-contract/${contract.document[0].pdf}`;
|
||||
} else {
|
||||
this.pdfFileUrl = undefined;
|
||||
}
|
||||
},
|
||||
|
||||
switchContract(index) {
|
||||
this.activeContractIndex = index;
|
||||
this.updatePdfUrl(index);
|
||||
},
|
||||
|
||||
downloadDocx() {
|
||||
if (!this.hasValidDocument) {
|
||||
this.$snackbar(
|
||||
this.isVietnamese ? "Không có file để tải" : "No file to download",
|
||||
{ type: 'is-warning' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = this.currentContract.document[0].file;
|
||||
const url = `${this.$getpath()}download/?name=${filename}&type=contract`;
|
||||
this.$download(url, filename);
|
||||
},
|
||||
|
||||
downloadPdf() {
|
||||
if (!this.hasValidDocument) {
|
||||
this.$snackbar(
|
||||
this.isVietnamese ? "Không có file để tải" : "No file to download",
|
||||
{ type: 'is-warning' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = this.currentContract.document[0].pdf;
|
||||
const url = `${this.$getpath()}download/?name=${filename}&type=contract`;
|
||||
this.$download(url, filename);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-content {
|
||||
max-width: 95%;
|
||||
margin: 0 auto;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
font-family: "Times New Roman", serif;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tabs-item {
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.tabs-item.bg-primary:hover a {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.tabs-link {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.contract-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
423
app/components/application/ContractPaymentUpload.vue
Normal file
423
app/components/application/ContractPaymentUpload.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Loading Overlay -->
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="mb-5 pb-3" style="border-bottom: 2px solid #e8e8e8;">
|
||||
<div class="buttons has-addons ">
|
||||
<button @click="viewMode = 'list'" :class="['button', viewMode === 'list' ? 'is-primary' : 'is-light']">
|
||||
Danh sách
|
||||
</button>
|
||||
<button @click="viewMode = 'gallery'" :class="['button', viewMode === 'gallery' ? 'is-primary' : 'is-light']">
|
||||
Thư viện
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Document Types List -->
|
||||
<div v-if="phasedoctypes && phasedoctypes.length > 0">
|
||||
<div v-for="doctype in phasedoctypes" :key="doctype.id" class="mb-6">
|
||||
|
||||
<!-- Document Type Header with Upload Button -->
|
||||
<div class="level is-mobile mb-4">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<p class="is-size-6 has-text-weight-semibold has-text-primary">
|
||||
{{ doctype.doctype__name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<FileUpload
|
||||
v-if="$getEditRights()"
|
||||
:type="['file', 'image', 'pdf']"
|
||||
@files="(files) => handleUpload(files, doctype.doctype)"
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'">
|
||||
<div v-if="getFilesByDocType(doctype.doctype).length > 0">
|
||||
<div v-for="file in getFilesByDocType(doctype.doctype)" :key="file.id"
|
||||
class="is-flex is-justify-content-space-between is-align-items-center py-3 px-4 has-background-warning has-text-white"
|
||||
style="border-bottom: #e8e8e8 solid 1px; transition: all 0.2s ease; opacity: 0.95; cursor: pointer;"
|
||||
@mouseenter="$event.currentTarget.style.opacity = '1'"
|
||||
@mouseleave="$event.currentTarget.style.opacity = '0.95'">
|
||||
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p class="is-size-7 has-text-weight-semibold has-text-white mb-1" style="word-break: break-word;">
|
||||
{{ file.name || file.file__name }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-white-bis">
|
||||
{{ $formatFileSize(file.file__size) }} • {{ $dayjs(file.create_time).format("DD/MM/YYYY HH:mm") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="buttons are-small ml-3">
|
||||
<button @click="viewFile(file)" class="button has-background-white has-text-primary ">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{
|
||||
name: 'view.svg',
|
||||
type: 'success',
|
||||
size: 18,
|
||||
}"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="downloadFile(file)" class="button has-background-white has-text-primary ">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{
|
||||
name: 'download.svg',
|
||||
type: 'success',
|
||||
size: 18,
|
||||
}"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="deleteFile(file.id)" class="button has-background-white has-text-danger ">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{
|
||||
name: 'bin.svg',
|
||||
type: 'danger',
|
||||
size: 18,
|
||||
}"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="has-text-grey-light is-size-7 has-text-centered py-5">
|
||||
Chưa có file nào
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery View -->
|
||||
<div v-if="viewMode === 'gallery'">
|
||||
<div v-if="getFilesByDocType(doctype.doctype).length > 0" class="columns is-multiline is-variable is-2">
|
||||
<div v-for="file in getFilesByDocType(doctype.doctype)" :key="file.id"
|
||||
class="column is-half-tablet is-one-third-desktop">
|
||||
<div class="has-background-warning has-text-white"
|
||||
style="border-radius: 6px; overflow: hidden; height: 100%; display: flex; flex-direction: column; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(50, 115, 220, 0.2);"
|
||||
@mouseenter="$event.currentTarget.style.transform = 'translateY(-4px)'; $event.currentTarget.style.boxShadow = '0 6px 16px rgba(50, 115, 220, 0.3)'"
|
||||
@mouseleave="$event.currentTarget.style.transform = 'translateY(0)'; $event.currentTarget.style.boxShadow = '0 2px 8px rgba(50, 115, 220, 0.2)'">
|
||||
|
||||
<div
|
||||
style="flex: 1; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(255, 255, 255, 0.1); min-height: 140px;">
|
||||
<div v-if="isImage(file.file__name)"
|
||||
style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<img :src="`${$getpath()}static/files/${file.file__file}`" :alt="file.file__name"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div v-else class="has-text-white-ter" style="font-size: 48px; line-height: 1;">
|
||||
FILE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 12px 16px;">
|
||||
<p class="is-size-7 has-text-weight-semibold has-text-white mb-1" :title="file.file__name"
|
||||
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
{{ file.file__name }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-white-bis mb-3">{{ $formatFileSize(file.file__size) }}</p>
|
||||
|
||||
<div class="buttons are-small is-centered">
|
||||
<button @click="viewFile(file)" class="button has-background-white has-text-primary ">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{
|
||||
name: 'view.svg',
|
||||
type: 'success',
|
||||
size: 18,
|
||||
}"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="downloadFile(file)" class="button has-background-white has-text-primary ">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{
|
||||
name: 'download.svg',
|
||||
type: 'success',
|
||||
size: 18,
|
||||
}"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="deleteFile(file.id)" class="button has-background-white has-text-danger ">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{
|
||||
name: 'bin.svg',
|
||||
type: 'danger',
|
||||
size: 18,
|
||||
}"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="has-text-grey-light is-size-7 has-text-centered py-5">
|
||||
Chưa có file nào
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- If no phase doctypes -->
|
||||
<div v-else-if="!isLoading" class="has-text-centered py-6">
|
||||
<p class="has-text-grey-light is-size-7">Chưa có loại tài liệu được định nghĩa cho giai đoạn này.</p>
|
||||
</div>
|
||||
|
||||
<Modal @close="showmodal = undefined" @modalevent="handleModalEvent" v-bind="showmodal" v-if="showmodal"></Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContractPaymentUpload",
|
||||
setup() {
|
||||
const { $formatFileSize, $dayjs, $getpath } = useNuxtApp();
|
||||
return { $formatFileSize, $dayjs, $getpath };
|
||||
},
|
||||
props: {
|
||||
row: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
transaction: null,
|
||||
files: [],
|
||||
isLoading: false,
|
||||
showmodal: undefined,
|
||||
phasedoctypes: [],
|
||||
viewMode: 'list',
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const transactionId = this.row.id;
|
||||
this.transaction = await $getdata("transaction", { id: transactionId }, undefined, true);
|
||||
|
||||
if (this.transaction?.phase) {
|
||||
await this.fetchPhaseDoctypes();
|
||||
}
|
||||
|
||||
await this.fetchFiles();
|
||||
} catch (error) {
|
||||
console.error("Error during component creation:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchPhaseDoctypes() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
if (!this.transaction?.phase) return;
|
||||
|
||||
try {
|
||||
const phasedoctypesData = await $getdata('phasedoctype', {
|
||||
phase: this.transaction.phase,
|
||||
}, undefined, false);
|
||||
|
||||
if (phasedoctypesData) {
|
||||
this.phasedoctypes = Array.isArray(phasedoctypesData) ? phasedoctypesData : [phasedoctypesData];
|
||||
} else {
|
||||
this.phasedoctypes = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi tải phase doctypes:", error);
|
||||
this.phasedoctypes = [];
|
||||
}
|
||||
},
|
||||
async fetchFiles() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
if (!this.row.id) return;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const detail = await $getdata('reservation', {
|
||||
id: this.transaction.txncurrent__detail
|
||||
}, undefined, true)
|
||||
const filesArray = await $getdata('transactionfile', {
|
||||
txn_detail: detail.id,
|
||||
}, undefined, false);
|
||||
|
||||
if (filesArray) {
|
||||
this.files = (Array.isArray(filesArray) ? filesArray : [filesArray]).sort((a, b) => new Date(b.create_time) - new Date(a.create_time));
|
||||
} else {
|
||||
this.files = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi tải danh sách file:", error);
|
||||
this.files = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
getFilesByDocType(docTypeId) {
|
||||
return this.files.filter(file => file.file__doc_type === docTypeId || (file.file__doc_type == null && docTypeId == null));
|
||||
},
|
||||
getFileExtension(fileName) {
|
||||
return fileName ? fileName.split('.').pop().toLowerCase() : 'file';
|
||||
},
|
||||
isImage(fileName) {
|
||||
const ext = this.getFileExtension(fileName);
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext);
|
||||
},
|
||||
isViewableDocument(fileName) {
|
||||
const ext = this.getFileExtension(fileName);
|
||||
return ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext);
|
||||
},
|
||||
async handleUpload(uploadedFiles, docTypeId) {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) return;
|
||||
|
||||
this.isLoading = true;
|
||||
const { $patchapi, $getdata, $insertapi } = useNuxtApp();
|
||||
|
||||
try {
|
||||
for (const fileRecord of uploadedFiles) {
|
||||
if (docTypeId) {
|
||||
await $patchapi('file', {
|
||||
id: fileRecord.id,
|
||||
doc_type: docTypeId
|
||||
});
|
||||
}
|
||||
|
||||
const detail = await $getdata('reservation', {
|
||||
id: this.transaction.txncurrent__detail,
|
||||
}, undefined, true)
|
||||
|
||||
const payload = {
|
||||
txn_detail: detail.id,
|
||||
file: fileRecord.id,
|
||||
phase: this.transaction?.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.");
|
||||
}
|
||||
}
|
||||
|
||||
await this.fetchFiles();
|
||||
this.$emit('upload-completed');
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi lưu file:", error);
|
||||
alert("Đã xảy ra lỗi khi tải file lên. Vui lòng thử lại.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
downloadFile(file) {
|
||||
const { $getpath } = useNuxtApp();
|
||||
const filePath = file.file__file || file.file;
|
||||
if (!filePath) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `${$getpath()}static/files/${filePath}`;
|
||||
link.download = file.file__name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
deleteFile(fileId) {
|
||||
this.showmodal = {
|
||||
component: 'dialog/Confirm',
|
||||
title: 'Xác nhận xóa',
|
||||
height: '10vh',
|
||||
width: '40%',
|
||||
vbind: {
|
||||
content: 'Bạn có chắc chắn muốn xóa file này không?'
|
||||
},
|
||||
onConfirm: async () => {
|
||||
this.isLoading = true;
|
||||
const { $deleteapi } = useNuxtApp();
|
||||
try {
|
||||
const result = await $deleteapi("transactionfile", fileId);
|
||||
if (result && !result.error) {
|
||||
await this.fetchFiles();
|
||||
} else {
|
||||
throw new Error(result.error || "Xóa file không thành công.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi xóa file:", error);
|
||||
alert("Đã xảy ra lỗi khi xóa file. Vui lòng thử lại.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
handleModalEvent(event) {
|
||||
if (event.name === 'confirm' && typeof this.showmodal?.onConfirm === 'function') {
|
||||
this.showmodal.onConfirm();
|
||||
}
|
||||
},
|
||||
viewFile(file) {
|
||||
const { $getpath } = useNuxtApp();
|
||||
const fileName = file.file__name || '';
|
||||
const filePath = file.file__file || file.file;
|
||||
if (!filePath) return;
|
||||
|
||||
const isImageFile = this.isImage(fileName);
|
||||
const isViewable = this.isViewableDocument(fileName);
|
||||
const fileUrl = `${$getpath()}static/files/${filePath}`;
|
||||
|
||||
if (isImageFile) {
|
||||
this.showmodal = {
|
||||
title: fileName,
|
||||
component: 'media/ChipImage',
|
||||
vbind: {
|
||||
extend: false,
|
||||
file: file,
|
||||
image: fileUrl,
|
||||
show: ['download', 'delete']
|
||||
}
|
||||
};
|
||||
} else if (isViewable) {
|
||||
// Mở Google Viewer trực tiếp trong tab mới
|
||||
const viewerUrl = `https://docs.google.com/gview?url=${fileUrl}&embedded=false`;
|
||||
window.open(viewerUrl, '_blank');
|
||||
} else {
|
||||
this.downloadFile(file);
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3273dc;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
4
app/components/application/NoPermission.vue
Normal file
4
app/components/application/NoPermission.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Viewer: display when click tem from another dealer -->
|
||||
<template>
|
||||
<p>Rất tiếc, bạn hiện chưa có quyền xem thông tin sản phẩm này.</p>
|
||||
</template>
|
||||
881
app/components/application/PaymentSchedule.vue
Normal file
881
app/components/application/PaymentSchedule.vue
Normal file
@@ -0,0 +1,881 @@
|
||||
<template>
|
||||
<div v-if="productData" class="grid px-3">
|
||||
<div class="cell is-col-span-12">
|
||||
<div v-if="filteredPolicies.length > 1 && !policyId && !isPrecalculated" class="tabs is-boxed mb-4">
|
||||
<ul>
|
||||
<li v-for="pol in filteredPolicies" :key="pol.id" :class="{ 'is-active': activeTab === pol.id }">
|
||||
<a @click="$emit('policy-selected', pol)">
|
||||
<span v-if="activeTab === pol.id" class="has-text-weight-bold">{{ pol.code }}</span>
|
||||
<span v-else>{{ pol.code }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="filteredPolicies.length === 0" class="notification is-light is-size-6">
|
||||
Không có chính sách thanh toán.
|
||||
</div>
|
||||
|
||||
<div id="schedule-content">
|
||||
<div v-if="selectedPolicy" id="print-area" :class="{ 'is-loading': isLoadingPlans }">
|
||||
<div class="mb-4 is-flex is-justify-content-space-between is-align-items-center">
|
||||
<h3 class="title is-4 has-text-primary mb-1">
|
||||
{{ selectedPolicy.name }}
|
||||
</h3>
|
||||
<div>
|
||||
<span class="button is-white">
|
||||
<span class="has-text-weight-semibold">Đơn vị: VNĐ</span>
|
||||
</span>
|
||||
<button class="button is-light" @click="$emit('print')" id="ignore-print">
|
||||
<span class="is-size-6">In</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid #eee;"></div>
|
||||
<div class="fixed-grid has-4-cols-mobile has-7-cols-desktop">
|
||||
<div class="grid">
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Sản phẩm</p>
|
||||
<p class="has-text-primary has-text-weight-medium">{{ productData.trade_code || productData.code }}</p>
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Giá niêm yết</p>
|
||||
<p class="has-text-primary">{{ $numtoString(originPrice) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Tổng chiết khấu</p>
|
||||
<p class="has-text-danger has-text-weight-bold">{{ $numtoString(discountValueDisplay) }}</p>
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Giá sau chiết khấu</p>
|
||||
<p class="has-text-black has-text-weight-bold">{{ $numtoString(priceAfterDiscount) }}</p>
|
||||
</div>
|
||||
<div v-if="selectedPolicy.contract_allocation_percentage < 100"class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Giá trị bảo đảm</p>
|
||||
<p class="has-text-primary">{{ $numtoString(allocatedPrice) }}</p>
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Đặt cọc</p>
|
||||
<p class="has-text-primary">{{ $numtoString(selectedPolicy.deposit) }}</p>
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Khách hàng</p>
|
||||
<p v-if="selectedCustomer" class="has-text-primary has-text-weight-medium">{{ selectedCustomer.code }} -
|
||||
{{ selectedCustomer.fullname }}</p>
|
||||
<p v-else class="has-text-grey is-italic is-size-6">Chưa chọn</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-bottom: 1px solid #eee;"></div>
|
||||
|
||||
<div v-if="detailedDiscounts.length > 0" class="mt-4 mb-4">
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
CHI TIẾT CHIẾT KHẤU:
|
||||
</p>
|
||||
<table class="table is-fullwidth is-bordered is-narrow">
|
||||
<thead>
|
||||
<tr class="has-background-primary">
|
||||
<th class="has-background-primary has-text-white" colspan="2">Diễn giải chiết khấu</th>
|
||||
<th class="has-background-primary has-text-right has-text-white" width="15%">Giá trị</th>
|
||||
<th class="has-background-primary has-text-right has-text-white" width="20%">Thành tiền</th>
|
||||
<th class="has-background-primary has-text-right has-text-white" width="20%">Còn lại</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="has-text-grey-light">
|
||||
<td colspan="4" class="has-text-right pt-1 pb-1">Giá gốc</td>
|
||||
<td class="has-text-right has-text-weight-bold pt-1 pb-1">{{ $numtoString(originPrice) }}</td>
|
||||
</tr>
|
||||
<tr v-for="(item, idx) in detailedDiscounts" :key="`discount-${idx}`">
|
||||
<td width="5%" class="has-text-centered">{{ idx + 1 }}</td>
|
||||
<td><span class="has-text-weight-semibold">{{ item.name }}</span> <span
|
||||
class="tag is-primary has-text-white is-rounded border ml-1">{{ item.code }}</span></td>
|
||||
<td class="has-text-right">{{ item.customType === 1 ? item.customValue + '%' :
|
||||
$numtoString(item.customValue) }}</td>
|
||||
<td class="has-text-right has-text-danger">-{{ $numtoString(item.amount) }}</td>
|
||||
<td class="has-text-right has-text-primary">{{ $numtoString(item.remaining) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="enableEarlyPayment && earlyPaymentCycles > 0" class="mt-4">
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
BẢNG DÙNG TIỀN THEO CHÍNH SÁCH BÁN HÀNG
|
||||
</p>
|
||||
<table class="table is-fullwidth is-hoverable is-bordered is-size-6">
|
||||
<thead>
|
||||
<tr class="has-background-primary">
|
||||
<th class="has-background-primary has-text-white">Tiến độ</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Số tiền TT (VND)</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Ngày đến hạn TT</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Số ngày</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Tỷ lệ thanh toán</th>
|
||||
<th class="has-background-primary has-text-white">Ghi chú</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>0</td>
|
||||
<td class="has-text-right">{{ $numtoString(selectedPolicy.deposit) }}</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td>Tiền đặt cọc</td>
|
||||
</tr>
|
||||
<tr v-for="plan in enhancedCashFlowPlans" :key="plan.id">
|
||||
<td>{{ plan.cycle }}</td>
|
||||
<td class="has-text-right">{{ $numtoString(plan.originalCalculatedAmount) }}</td>
|
||||
<td class="has-text-right">{{ plan.dueDate }}</td>
|
||||
<td class="has-text-right">{{ plan.days }}</td>
|
||||
<td class="has-text-right">{{ plan.displayValue }}</td>
|
||||
<td>{{ plan.payment_note }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="has-background-light">
|
||||
<th colspan="1" class="has-text-right has-text-weight-bold">Tổng cộng</th>
|
||||
<th class="has-text-right has-text-primary has-text-weight-bold">{{ $numtoString(priceAfterDiscount) }}</th>
|
||||
<th colspan="4"></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPolicy && selectedPolicy.method === 1 && selectedPolicy.contract_allocation_percentage == 100" class="mb-4 mt-4">
|
||||
<div class="level is-mobile mb-3">
|
||||
<div class="level-left">
|
||||
<label class="checkbox" id="ignore-print">
|
||||
<a class="mr-5" @click="doTick()">
|
||||
<SvgIcon v-bind="{name: enableEarlyPayment? 'check4.svg' : 'uncheck.svg', type: 'primary', size: 28}" />
|
||||
</a>
|
||||
<span class="is-size-5 has-text-weight-semibold has-text-primary mr-5">
|
||||
Thanh toán sớm
|
||||
(2 - {{ Math.max(2, plansToRender.length) }}) kỳ
|
||||
</span>
|
||||
</label>
|
||||
<transition name="fade" id="ignore-print">
|
||||
<div v-if="enableEarlyPayment && plansToRender.length >= 2" class="field">
|
||||
<div class="control">
|
||||
<input class="input" type="number" v-model.number="earlyPaymentCycles" :min="2"
|
||||
:max="Math.max(2, plansToRender.length)" @change="updateEarlyPaymentPlans">
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="enableEarlyPayment && earlyPaymentCycles > 0" class="mt-4">
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
BẢNG DÙNG TIỀN THEO CHƯƠNG TRÌNH THANH TOÁN SỚM BẰNG VỐN TỰ CÓ
|
||||
</p>
|
||||
<table class="table is-fullwidth is-hoverable is-bordered is-size-6">
|
||||
<thead>
|
||||
<tr class="has-background-primary">
|
||||
<th class="has-background-primary has-text-white">Tiến độ</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Số tiền TT (VND)</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Ngày đến hạn TT</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Ngày TT thực tế</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Số ngày TT trước hạn</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Lãi suất/ngày</th>
|
||||
<th class="has-background-primary has-text-white has-text-right">Số tiền Chiết khấu TT (VND)</th>
|
||||
<th class="has-background-primary has-text-white">Ghi chú</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>0</td>
|
||||
<td class="has-text-right">{{ $numtoString(selectedPolicy.deposit) }}</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td class="has-text-right">-</td>
|
||||
<td class="has-text-right has-text-danger">0</td>
|
||||
<td>Tiền đặt cọc</td>
|
||||
</tr>
|
||||
<tr v-for="plan in enhancedCashFlowPlans" :key="plan.id">
|
||||
<td>{{ plan.cycle }}</td>
|
||||
<td class="has-text-right">{{ $numtoString(plan.originalCalculatedAmount) }}</td>
|
||||
<td class="has-text-right">{{ plan.dueDate }}</td>
|
||||
<td class="has-text-right">
|
||||
<span v-if="plan.isEarly">{{ plan.actualDueDate }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<span v-if="plan.isEarly">{{ plan.days }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<span v-if="plan.isEarly">0.019%</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="has-text-right has-text-danger">{{ $numtoString(plan.discountAmount) }}</td>
|
||||
<td>{{ plan.payment_note }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="has-background-light">
|
||||
<th colspan="6" class="has-text-right has-text-weight-bold">Tổng cộng</th>
|
||||
<th class="has-text-right has-text-danger has-text-weight-bold">{{ $numtoString(totalEarlyDiscount) }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="" style="border-top: 1px solid #eee;">
|
||||
<div class="level is-mobile is-size-6 m-0">
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<span class="is-uppercase is-size-4 has-text-weight-semibold">Tổng cộng: </span>
|
||||
<div class="is-flex is-align-items-center is-flex-wrap-wrap">
|
||||
<span class="has-text-success has-text-weight-bold is-size-4">
|
||||
{{ $numtoString(allocatedPrice) }}
|
||||
</span>
|
||||
<span v-if="totalEarlyDiscount > 0" class="has-text-danger has-text-weight-bold is-size-4 ml-3">
|
||||
- {{ $numtoString(totalEarlyDiscount) }}
|
||||
</span>
|
||||
<span v-if="totalEarlyDiscount > 0" class="has-text-success has-text-weight-bold is-size-4 ml-3">
|
||||
= {{ $numtoString(totalRealPayment) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="displayPaymentPlans.length > 0">
|
||||
<div class="level m-0 is-mobile mt-4">
|
||||
<div class="level-left">
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
LỊCH THANH TOÁN
|
||||
</p>
|
||||
<span v-if="baseDate" class="tag is-info is-light ml-2">
|
||||
Từ ngày: {{ formatDate(baseDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="level-right" id="ignore-print">
|
||||
<div class="buttons are-small has-addons">
|
||||
<button class="button" @click="viewMode = 'table'"
|
||||
:class="viewMode === 'table' ? 'is-link is-selected' : 'is-light'">
|
||||
<span class="is-size-6">Bảng</span>
|
||||
</button>
|
||||
<button class="button" @click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'is-link is-selected' : 'is-light'">
|
||||
<span class="is-size-6">Thẻ</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="viewMode === 'table'" class="table-container schedule-container">
|
||||
<table class="table is-fullwidth is-hoverable is-size-6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border:none;">Đợt thanh toán</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border:none;">Diễn giải</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Tỷ lệ</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Số tiền</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Thời gian</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Hạn thanh toán</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(plan, index) in calculatedPlans" :key="plan.id || index" style="border-bottom: 1px solid #f5f5f5;">
|
||||
<td class="is-vcentered" style="border:none;">
|
||||
<span class="has-text-primary has-text-weight-medium">
|
||||
Đợt {{ plan.displayCycle }}
|
||||
</span>
|
||||
<span v-if="plan.isEarlyPaymentMerged" class="tag is-warning is-light ml-1 is-size-6">
|
||||
GỘP SỚM
|
||||
</span>
|
||||
</td>
|
||||
<td class="is-vcentered" style="border:none;">
|
||||
<div v-if="plan.isEarlyPaymentMerged" class="content is-size-6">
|
||||
<p class="mb-1 has-text-weight-semibold">
|
||||
Thanh toán sớm gộp
|
||||
(Đợt {{ plan.mergedCycles.join(', ') }})
|
||||
</p>
|
||||
<p class="has-text-grey">{{ plan.payment_note || '-' }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span>{{ plan.payment_note || '-' }}</span>
|
||||
<div v-if="plan.due_note" class="is-size-6 mt-1">{{ plan.due_note }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="has-text-right is-vcentered" style="border:none;">
|
||||
<span v-if="plan.isEarlyPaymentMerged">{{ plan.mergedCyclesRates }}</span>
|
||||
<span v-else>{{ plan.displayValue }}</span>
|
||||
</td>
|
||||
<td class="has-text-right is-vcentered" style="border:none;">
|
||||
<div v-if="plan.isEarlyPaymentMerged" class="content is-size-6">
|
||||
<div class="is-size-6 has-text-info mb-1">
|
||||
Tổng các đợt: {{ $numtoString(plan.mergedRawAmount) }}
|
||||
</div>
|
||||
<div class="is-size-6 has-text-danger mb-1">
|
||||
- Số tiền chiết khấu sớm: {{ $numtoString(totalEarlyDiscount) }}
|
||||
</div>
|
||||
<div v-if="selectedPolicy && selectedPolicy.deposit > 0" class="is-size-6 has-text-danger mb-1">
|
||||
- Đặt cọc: {{ $numtoString(selectedPolicy.deposit) }}
|
||||
</div>
|
||||
<span class="has-text-primary has-text-weight-bold">
|
||||
Còn lại: {{ $numtoString(plan.calculatedAmount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="plan.isFirstPlan && selectedPolicy && selectedPolicy.deposit > 0">
|
||||
<div class="is-size-6 has-text-primary mb-1">
|
||||
{{ $numtoString(plan.amountBeforeDeposit) }}
|
||||
</div>
|
||||
<div class="is-size-6 has-text-danger mb-1">
|
||||
- {{ $numtoString(selectedPolicy.deposit) }}
|
||||
</div>
|
||||
<span class="has-text-primary has-text-weight-bold">
|
||||
Còn lại: {{ $numtoString(plan.calculatedAmount) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="has-text-primary has-text-weight-bold">{{ $numtoString(plan.calculatedAmount) }}</span>
|
||||
</td>
|
||||
<td class="has-text-right pr-0 is-vcentered" style="border:none;">
|
||||
<span v-if="plan.days" class="has-text-success">{{ plan.days }} ngày</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="has-text-right pr-3 is-vcentered" style="border:none;">
|
||||
<span v-if="plan.dueDate" class="has-text-success">{{ plan.dueDate }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else-if="viewMode === 'list'" class="schedule-container">
|
||||
<div v-for="(plan, index) in calculatedPlans" :key="plan.id || index" class="mb-4 pr-2 pb-3" style="border-bottom: 2px solid #f5f5f5;" :class="plan.isEarlyPaymentMerged ? ' p-3' : ''">
|
||||
<div class="level is-mobile mb-1">
|
||||
<div class="level-left">
|
||||
<span class="tag is-white p-0 mr-2 border">{{ plan.displayCycle }}</span>
|
||||
<span class="has-text-primary has-text-weight-medium is-size-6">
|
||||
Đợt {{ plan.displayCycle }}
|
||||
</span>
|
||||
<span v-if="plan.isEarlyPaymentMerged" class="tag is-warning is-light ml-1 is-size-6">
|
||||
GỘP SỚM
|
||||
</span>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div v-if="plan.isEarlyPaymentMerged" class="has-text-right">
|
||||
<p class="has-text-info has-text-weight-bold is-size-6">{{ $numtoString(plan.calculatedAmount) }}</p>
|
||||
<p class="has-text-grey is-size-6">Với chiết khấu sớm</p>
|
||||
</div>
|
||||
<div v-else-if="plan.isFirstPlan && selectedPolicy && selectedPolicy.deposit > 0" class="has-text-right">
|
||||
<div class="is-size-6 has-text-grey">
|
||||
{{ $numtoString(plan.amountBeforeDeposit) }} - {{ $numtoString(selectedPolicy.deposit) }}
|
||||
</div>
|
||||
<span class="has-text-primary has-text-weight-bold is-size-6">{{ $numtoString(plan.calculatedAmount) }}</span>
|
||||
</div>
|
||||
<span v-else class="has-text-primary has-text-weight-bold is-size-6">{{ $numtoString(plan.calculatedAmount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="plan.isEarlyPaymentMerged" class="content is-size-6 mb-2">
|
||||
<p class="has-text-weight-semibold">
|
||||
Thanh toán sớm gộp
|
||||
(Đợt {{ plan.mergedCycles.join(', ') }})
|
||||
</p>
|
||||
<p class="has-text-grey is-size-6 mt-1">{{ plan.mergedCyclesRates }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="plan.payment_note" class="is-size-6 mb-1">
|
||||
<span class="has-text-grey">Diễn giải:</span> {{ plan.payment_note }}
|
||||
</div>
|
||||
<div v-if="plan.due_note" class="is-size-6 mb-1">{{ plan.due_note }}</div>
|
||||
<div class="level is-mobile is-size-6">
|
||||
<div class="level-left">
|
||||
<span v-if="plan.dueDate" class="has-text-success">
|
||||
Hạn: {{ plan.dueDate }} - {{ plan.days }}ngày
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<span v-if="!plan.isEarlyPaymentMerged">{{ plan.displayValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="section has-text-centered">
|
||||
<p v-if="isLoadingPlans" class="is-size-6 has-text-info is-flex is-align-items-center is-gap-2">
|
||||
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
|
||||
<span>Đang tải kế hoạch...</span>
|
||||
</p>
|
||||
<p v-else class="is-size-6">Chưa có dữ liệu kế hoạch</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
import dayjs from 'dayjs';
|
||||
import { useNuxtApp } from '#app';
|
||||
|
||||
const EARLY_PAYMENT_DAILY_RATE = 0.019;
|
||||
|
||||
export default {
|
||||
name: 'PaymentScheduleComplete',
|
||||
props: {
|
||||
productData: Object,
|
||||
policies: Array,
|
||||
activeTab: [String, Number],
|
||||
selectedPolicy: Object,
|
||||
originPrice: Number,
|
||||
discountValueDisplay: Number,
|
||||
priceAfterDiscount: Number,
|
||||
selectedCustomer: Object,
|
||||
detailedDiscounts: Array,
|
||||
paymentPlans: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
allPaymentPlans: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
policyId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
baseDate: {
|
||||
type: [String, Date],
|
||||
default: null
|
||||
},
|
||||
isPrecalculated: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['policy-selected', 'print', 'plans-loaded', 'early-payment-change', 'calculated-plans-change'],
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const { $getdata } = useNuxtApp();
|
||||
return { store, $getdata };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
viewMode: 'table',
|
||||
localPaymentPlans: [],
|
||||
isLoadingPlans: false,
|
||||
enableEarlyPayment: false,
|
||||
earlyPaymentCycles: 0,
|
||||
earlyPaymentDetails: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredPolicies() {
|
||||
if (!this.policies || this.policies.length === 0) return [];
|
||||
if (this.policyId) {
|
||||
const foundPolicy = this.policies.find(p => p.id === this.policyId || p.id == this.policyId);
|
||||
return foundPolicy ? [foundPolicy] : [];
|
||||
}
|
||||
return this.policies;
|
||||
},
|
||||
|
||||
calculationStartDate() {
|
||||
if (this.baseDate) {
|
||||
return dayjs(this.baseDate);
|
||||
}
|
||||
return dayjs();
|
||||
},
|
||||
|
||||
plansToRender() {
|
||||
if (this.paymentPlans && this.paymentPlans.length > 0) {
|
||||
return this.paymentPlans;
|
||||
}
|
||||
return this.localPaymentPlans;
|
||||
},
|
||||
|
||||
allocatedPrice() {
|
||||
if (!this.selectedPolicy) {
|
||||
return this.priceAfterDiscount;
|
||||
}
|
||||
const basePrice = this.priceAfterDiscount;
|
||||
if (this.selectedPolicy.contract_allocation_percentage > 0) {
|
||||
const allocation = Number(this.selectedPolicy.contract_allocation_percentage);
|
||||
return (basePrice * allocation) / 100;
|
||||
}
|
||||
return basePrice;
|
||||
},
|
||||
|
||||
hasEarlyPaymentDiscount() {
|
||||
return this.enableEarlyPayment && this.earlyPaymentDetails && this.earlyPaymentDetails.length > 0;
|
||||
},
|
||||
|
||||
totalEarlyDiscount() {
|
||||
if (!this.hasEarlyPaymentDiscount) return 0;
|
||||
return this.earlyPaymentDetails.reduce((sum, d) => sum + d.discountAmount, 0);
|
||||
},
|
||||
|
||||
totalEarlyPayment() {
|
||||
if (!this.hasEarlyPaymentDiscount) return 0;
|
||||
return this.earlyPaymentDetails.reduce((sum, d) => sum + d.netAmount, 0);
|
||||
},
|
||||
|
||||
mergedRawAmount() {
|
||||
if (!this.hasEarlyPaymentDiscount) return 0;
|
||||
return this.earlyPaymentDetails.reduce((sum, d) => sum + d.rawAmount, 0);
|
||||
},
|
||||
|
||||
finalBalanceAfterEarlyDiscount() {
|
||||
if (this.detailedDiscounts.length === 0) {
|
||||
return this.originPrice - this.totalEarlyDiscount;
|
||||
}
|
||||
const lastBalance = this.detailedDiscounts[this.detailedDiscounts.length - 1].remaining;
|
||||
return lastBalance - this.totalEarlyDiscount;
|
||||
},
|
||||
|
||||
enhancedCashFlowPlans() {
|
||||
if (!this.enableEarlyPayment || !this.plansToRender.length) return [];
|
||||
const earlyDetailsByCycle = new Map(this.earlyPaymentDetails.map(d => [d.cycle, d]));
|
||||
|
||||
return this.plansToRender.map(plan => {
|
||||
const originalAmount = plan.type === 1
|
||||
? (this.allocatedPrice * Number(plan.value)) / 100
|
||||
: Number(plan.value);
|
||||
const earlyDetail = earlyDetailsByCycle.get(plan.cycle);
|
||||
|
||||
if (earlyDetail) {
|
||||
return {
|
||||
...plan,
|
||||
isEarly: true,
|
||||
originalCalculatedAmount: originalAmount,
|
||||
discountAmount: earlyDetail.discountAmount,
|
||||
netAmount: earlyDetail.netAmount,
|
||||
actualDueDate: this.calculationStartDate.format('DD/MM/YYYY'),
|
||||
dueDate: plan.days > 0 ? this.calculationStartDate.add(plan.days, 'day').format('DD/MM/YYYY') : null,
|
||||
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...plan,
|
||||
isEarly: false,
|
||||
originalCalculatedAmount: originalAmount,
|
||||
discountAmount: 0,
|
||||
netAmount: originalAmount,
|
||||
actualDueDate: null,
|
||||
dueDate: plan.days > 0 ? this.calculationStartDate.add(plan.days, 'day').format('DD/MM/YYYY') : null,
|
||||
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value)
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
displayPaymentPlans() {
|
||||
if (!this.plansToRender.length || !this.selectedPolicy) return [];
|
||||
|
||||
if (!this.hasEarlyPaymentDiscount || this.earlyPaymentCycles === 0) {
|
||||
let cycleCounter = 1;
|
||||
return this.plansToRender.map((plan, index) => {
|
||||
let calculatedAmount = plan.type === 1
|
||||
? (this.allocatedPrice * Number(plan.value)) / 100
|
||||
: Number(plan.value);
|
||||
const isFirstPlan = index === 0;
|
||||
const amountBeforeDeposit = calculatedAmount;
|
||||
|
||||
if (isFirstPlan && this.selectedPolicy && this.selectedPolicy.deposit > 0) {
|
||||
calculatedAmount -= this.selectedPolicy.deposit;
|
||||
}
|
||||
|
||||
let dueDate = null;
|
||||
const daysDiff = plan.days || 0;
|
||||
if (daysDiff > 0) {
|
||||
const dueDateObj = this.calculationStartDate.add(daysDiff, 'day');
|
||||
dueDate = dueDateObj.format('DD/MM/YYYY');
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
displayCycle: cycleCounter++,
|
||||
calculatedAmount: calculatedAmount,
|
||||
amountBeforeDeposit: amountBeforeDeposit,
|
||||
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value),
|
||||
dueDate: dueDate,
|
||||
isEarlyPaymentMerged: false,
|
||||
isFirstPlan: isFirstPlan
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const earlyPaymentCycles = this.earlyPaymentCycles;
|
||||
const basePrice = this.allocatedPrice;
|
||||
const displayPlans = [];
|
||||
let cycleCounter = 1;
|
||||
|
||||
const mergedCyclesInfo = this.earlyPaymentDetails.map(d => {
|
||||
return `Đợt ${d.cycle}: ${d.type === 1 ? d.value + '%' : this.$numtoString(d.value)}`;
|
||||
}).join(' + ');
|
||||
|
||||
displayPlans.push({
|
||||
cycle: earlyPaymentCycles,
|
||||
displayCycle: cycleCounter++,
|
||||
mergedCycles: Array.from({ length: this.earlyPaymentCycles }, (_, i) => i + 1),
|
||||
mergedCyclesRates: mergedCyclesInfo,
|
||||
mergedRawAmount: this.mergedRawAmount,
|
||||
isEarlyPaymentMerged: true,
|
||||
calculatedAmount: this.totalEarlyPayment - (this.selectedPolicy.deposit || 0),
|
||||
days: 0,
|
||||
dueDate: this.calculationStartDate.format('DD/MM/YYYY'),
|
||||
payment_note: 'Thanh toán sớm gộp',
|
||||
displayValue: '-',
|
||||
isFirstPlan: true
|
||||
});
|
||||
|
||||
for (let i = earlyPaymentCycles; i < this.plansToRender.length; i++) {
|
||||
const plan = this.plansToRender[i];
|
||||
let calculatedAmount = plan.type === 1
|
||||
? (basePrice * Number(plan.value)) / 100
|
||||
: Number(plan.value);
|
||||
const amountBeforeDeposit = calculatedAmount;
|
||||
|
||||
let dueDate = null;
|
||||
const daysDiff = plan.days || 0;
|
||||
if (daysDiff > 0) {
|
||||
const dueDateObj = this.calculationStartDate.add(daysDiff, 'day');
|
||||
dueDate = dueDateObj.format('DD/MM/YYYY');
|
||||
}
|
||||
|
||||
displayPlans.push({
|
||||
...plan,
|
||||
displayCycle: cycleCounter++,
|
||||
calculatedAmount: calculatedAmount,
|
||||
amountBeforeDeposit: amountBeforeDeposit,
|
||||
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value),
|
||||
dueDate: dueDate,
|
||||
isEarlyPaymentMerged: false,
|
||||
isFirstPlan: false
|
||||
});
|
||||
}
|
||||
|
||||
return displayPlans;
|
||||
},
|
||||
|
||||
calculatedPlans() {
|
||||
if (this.isPrecalculated && this.paymentPlans && this.paymentPlans.length > 0) {
|
||||
return this.paymentPlans.map((plan, index) => {
|
||||
const dueDate = plan.due_days ? this.calculationStartDate.add(plan.due_days, 'day').format('DD/MM/YYYY') : null;
|
||||
return {
|
||||
...plan,
|
||||
displayCycle: plan.cycle || (index + 1),
|
||||
calculatedAmount: plan.amount,
|
||||
displayValue: plan.is_early_merged ? 'Gộp sớm' : '-',
|
||||
dueDate: dueDate,
|
||||
days: plan.due_days,
|
||||
payment_note: plan.note || '-',
|
||||
isEarlyPaymentMerged: plan.is_early_merged,
|
||||
mergedCycles: plan.merged_cycles,
|
||||
mergedRawAmount: plan.raw_amount,
|
||||
isFirstPlan: index === 0,
|
||||
amountBeforeDeposit: plan.raw_amount
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.displayPaymentPlans;
|
||||
},
|
||||
|
||||
totalRealPayment() {
|
||||
let total = this.allocatedPrice;
|
||||
if (this.hasEarlyPaymentDiscount) {
|
||||
total -= this.totalEarlyDiscount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
calculatedPlans: {
|
||||
handler(newVal) {
|
||||
this.$emit('calculated-plans-change', newVal);
|
||||
},
|
||||
immediate: true,
|
||||
deep: true
|
||||
},
|
||||
allocatedPrice: {
|
||||
handler() {
|
||||
if (this.enableEarlyPayment) {
|
||||
this.updateEarlyPaymentPlans();
|
||||
}
|
||||
}
|
||||
},
|
||||
policyId: {
|
||||
immediate: true,
|
||||
handler(newPolicyId) {
|
||||
if (newPolicyId && !this.paymentPlans.length && this.selectedPolicy) {
|
||||
this.loadPaymentPlans(newPolicyId);
|
||||
}
|
||||
}
|
||||
},
|
||||
selectedPolicy: {
|
||||
immediate: true,
|
||||
handler(newPolicy) {
|
||||
if (newPolicy && this.paymentPlans.length === 0 && this.localPaymentPlans.length === 0) {
|
||||
const plans = this.allPaymentPlans.filter(p => p.policy === newPolicy.id);
|
||||
if (plans.length > 0) {
|
||||
plans.sort((a, b) => a.cycle - b.cycle);
|
||||
this.localPaymentPlans = plans;
|
||||
}
|
||||
}
|
||||
this.enableEarlyPayment = false;
|
||||
this.earlyPaymentCycles = 0;
|
||||
this.earlyPaymentDetails = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
doTick() {
|
||||
console.log('Hello')
|
||||
this.enableEarlyPayment = ! this.enableEarlyPayment
|
||||
return this.enableEarlyPayment
|
||||
},
|
||||
formatDate(date) {
|
||||
return dayjs(date).format('DD/MM/YYYY');
|
||||
},
|
||||
|
||||
handleEarlyPaymentToggle() {
|
||||
if (this.enableEarlyPayment) {
|
||||
this.earlyPaymentCycles = Math.min(2, this.plansToRender.length);
|
||||
this.updateEarlyPaymentPlans();
|
||||
} else {
|
||||
this.earlyPaymentCycles = 0;
|
||||
this.earlyPaymentDetails = [];
|
||||
this.$emit('early-payment-change', null);
|
||||
}
|
||||
},
|
||||
|
||||
updateEarlyPaymentPlans() {
|
||||
if (!this.enableEarlyPayment || this.earlyPaymentCycles === 0) {
|
||||
this.earlyPaymentDetails = [];
|
||||
this.$emit('early-payment-change', null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.earlyPaymentCycles = Math.max(2, Math.min(this.earlyPaymentCycles, this.plansToRender.length));
|
||||
|
||||
this.earlyPaymentDetails = this.plansToRender
|
||||
.filter(plan => plan.cycle <= this.earlyPaymentCycles)
|
||||
.map((plan) => {
|
||||
const rawAmount = plan.type === 1
|
||||
? (this.allocatedPrice * plan.value) / 100
|
||||
: plan.value;
|
||||
const days = plan.days || 0;
|
||||
const discountAmount = (rawAmount * days * EARLY_PAYMENT_DAILY_RATE) / 100;
|
||||
|
||||
return {
|
||||
cycle: plan.cycle,
|
||||
type: plan.type,
|
||||
value: plan.value,
|
||||
days: days,
|
||||
rawAmount: rawAmount,
|
||||
discountAmount: discountAmount,
|
||||
netAmount: rawAmount - discountAmount
|
||||
};
|
||||
});
|
||||
|
||||
this.$emit('early-payment-change', {
|
||||
cycles: this.earlyPaymentCycles,
|
||||
details: this.earlyPaymentDetails
|
||||
});
|
||||
},
|
||||
|
||||
async loadPaymentPlans(policyId) {
|
||||
if (!policyId || this.isLoadingPlans) return;
|
||||
this.isLoadingPlans = true;
|
||||
this.localPaymentPlans = [];
|
||||
try {
|
||||
const plans = await this.$getdata("paymentplan", { policy: policyId, policy__enable: "True" }, undefined, false);
|
||||
if (plans) {
|
||||
plans.sort((a, b) => a.cycle - b.cycle);
|
||||
}
|
||||
this.localPaymentPlans = plans || [];
|
||||
this.$emit('plans-loaded', this.localPaymentPlans);
|
||||
} catch (error) {
|
||||
console.error('Error loading payment plans:', error);
|
||||
this.localPaymentPlans = [];
|
||||
} finally {
|
||||
this.isLoadingPlans = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-container.schedule-container thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid #dbdbdb !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid #dbdbdb;
|
||||
}
|
||||
|
||||
li.is-active a,
|
||||
li a:hover {
|
||||
color: white !important;
|
||||
background-color: #204853 !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.schedule-container {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.table-container.schedule-container thead th {
|
||||
position: static;
|
||||
}
|
||||
|
||||
#ignore-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
504
app/components/application/PaymentSchedulePresentation.vue
Normal file
504
app/components/application/PaymentSchedulePresentation.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<div v-if="productData" class="grid px-3">
|
||||
<div class="cell is-col-span-12">
|
||||
<div id="schedule-content">
|
||||
<div v-if="selectedPolicy" id="print-area" :class="{ 'is-loading': isLoading }">
|
||||
<!-- Header -->
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<h3 class="title is-4 has-text-primary mb-1">
|
||||
{{ selectedPolicy.name }}
|
||||
</h3>
|
||||
<div>
|
||||
<span class="button is-white">
|
||||
<span class="has-text-weight-semibold">Đơn vị: VNĐ</span>
|
||||
</span>
|
||||
<button class="button is-light" @click="$emit('print')" id="ignore-print">
|
||||
<span class="is-size-6">In</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4" style="background-color: var(--bulma-background)"></hr>
|
||||
|
||||
<!-- Summary Information -->
|
||||
<div class="fixed-grid has-4-cols-mobile has-7-cols-desktop">
|
||||
<div class="grid">
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Sản phẩm</p>
|
||||
<p class="has-text-primary has-text-weight-medium">{{ productData.trade_code || productData.code }} <a
|
||||
class="ml-4" id="ignore" @click="$copyToClipboard(productData.trade_code)">
|
||||
<SvgIcon name="copy.svg" type="primary" :size="18" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Giá niêm yết</p>
|
||||
<p class="has-text-primary">{{ $numtoString(calculatorData.originPrice) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Tổng chiết khấu</p>
|
||||
<p class="has-text-danger has-text-weight-bold">{{ $numtoString(calculatorData.totalDiscount) }}</p>
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Giá sau chiết khấu</p>
|
||||
<p class="has-text-black has-text-weight-bold">{{ $numtoString(calculatorData.salePrice) }}</p>
|
||||
</div>
|
||||
<div v-if="selectedPolicy.contract_allocation_percentage < 100"
|
||||
class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Giá trị bảo đảm</p>
|
||||
<p class="has-text-primary">{{ $numtoString(calculatorData.allocatedPrice) }}</p>
|
||||
</div>
|
||||
<div v-if="totalPaid === 0" class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Đặt cọc</p>
|
||||
<p class="has-text-primary">{{ $numtoString(selectedPolicy.deposit) }}</p>
|
||||
</div>
|
||||
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
|
||||
<p class="is-size-6 has-text-weight-bold mb-1">Khách hàng</p>
|
||||
<p v-if="selectedCustomer" class="has-text-primary has-text-weight-medium">
|
||||
{{ selectedCustomer.code }} - {{ selectedCustomer.fullname }}
|
||||
</p>
|
||||
<p v-else class="has-text-grey is-italic is-size-6">Chưa chọn</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4" style="background-color: var(--bulma-background)"></hr>
|
||||
|
||||
<!-- Detailed Discounts -->
|
||||
<div v-if="calculatorData.detailedDiscounts && calculatorData.detailedDiscounts.length > 0" class="mt-4 mb-4">
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
CHI TIẾT CHIẾT KHẤU:
|
||||
</p>
|
||||
<table class="table is-fullwidth is-hoverable is-size-6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;"
|
||||
colspan="2">Diễn giải chiết khấu</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;" width="15%">Giá trị</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;" width="20%">Thành tiền</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;" width="20%">Còn lại</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom: 1px solid #f5f5f5;" class="has-text-grey-light">
|
||||
<td colspan="4" class="has-text-right pt-1 pb-1">Giá gốc</td>
|
||||
<td class="has-text-right has-text-weight-bold pt-1 pb-1">{{
|
||||
$numtoString(calculatorData.originPrice) }}</td>
|
||||
</tr>
|
||||
<tr v-for="(item, idx) in calculatorData.detailedDiscounts" :key="`discount-${idx}`"
|
||||
style="border-bottom: 1px solid #f5f5f5;">
|
||||
<td width="5%" class="has-text-centered">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<span class="has-text-weight-semibold">{{ item.name }}</span>
|
||||
<span class="tag is-primary has-text-white is-rounded border ml-1">{{ item.code }}</span>
|
||||
</td>
|
||||
<td class="has-text-right">{{ item.customType === 1 ? item.customValue + '%' :
|
||||
$numtoString(item.customValue) }}</td>
|
||||
<td class="has-text-right has-text-danger">-{{ $numtoString(item.amount) }}</td>
|
||||
<td class="has-text-right has-text-primary">{{ $numtoString(item.remaining) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Early Payment Details -->
|
||||
<div v-if="isEarlyPaymentActive" class="mt-4 mb-4">
|
||||
<!-- Original Schedule -->
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
LỊCH THANH TOÁN GỐC (THEO CHÍNH SÁCH)
|
||||
</p>
|
||||
<div class="table-container schedule-container mb-4">
|
||||
<table class="table is-fullwidth is-hoverable is-size-6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Đợt
|
||||
</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Tỷ lệ</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Số tiền (VND)</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
|
||||
bắt đầu</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
|
||||
đến hạn</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Số ngày</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(plan, index) in calculatorData.originalPaymentSchedule" :key="`orig-plan-${index}`"
|
||||
style="border-bottom: 1px solid #f5f5f5;">
|
||||
<td class="has-text-weight-semibold">Đợt {{ plan.cycle }}</td>
|
||||
<td class="has-text-right">{{ plan.type === 1 ? `${plan.value}%` : '-' }}</td>
|
||||
<td class="has-text-right">{{ $numtoString(plan.amount) }}</td>
|
||||
<td>{{ formatDate(plan.from_date) }}</td>
|
||||
<td>{{ formatDate(plan.to_date) }}</td>
|
||||
<td class="has-text-right">{{ plan.days }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Early Discount Calculation Details -->
|
||||
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
|
||||
DIỄN GIẢI CHIẾT KHẤU THANH TOÁN SỚM
|
||||
</p>
|
||||
<div class="table-container schedule-container">
|
||||
<table class="table is-fullwidth is-hoverable is-size-6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Đợt
|
||||
</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Hạn
|
||||
TT Gốc</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
|
||||
TT Thực Tế</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Số tiền gốc</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Số ngày TT sớm</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Tỷ lệ CK (%/ngày)</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Tiền chiết khấu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in calculatorData.earlyDiscountDetails" :key="`early-discount-${idx}`"
|
||||
style="border-bottom: 1px solid #f5f5f5;">
|
||||
<td>Đợt {{ item.cycle }}</td>
|
||||
<td>{{ formatDate(item.original_payment_date) }}</td>
|
||||
<td>{{ formatDate(item.actual_payment_date) }}</td>
|
||||
<td class="has-text-right">{{ $numtoString(item.original_amount) }}</td>
|
||||
<td class="has-text-right">{{ item.early_days }}</td>
|
||||
<td class="has-text-right">{{ item.discount_rate }}</td>
|
||||
<td class="has-text-right has-text-danger">-{{ $numtoString(item.discount_amount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="has-background-light">
|
||||
<th colspan="6" class="has-text-right has-text-weight-bold">Tổng chiết khấu thanh toán sớm</th>
|
||||
<th class="has-text-right has-text-weight-bold has-text-danger">-{{
|
||||
$numtoString(totalEarlyDiscount) }}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Schedule Table -->
|
||||
<div v-if="displaySchedule.length > 0" class="mt-4">
|
||||
<div class="level m-0 mb-2 is-mobile">
|
||||
<div class="level-left">
|
||||
<p class="has-text-weight-bold is-size-5 has-text-primary is-underlined">
|
||||
<span v-if="isEarlyPaymentActive">LỊCH THANH TOÁN CUỐI CÙNG</span>
|
||||
<span v-else>LỊCH THANH TOÁN</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="level-right" id="ignore-print">
|
||||
<div class="buttons are-small has-addons">
|
||||
<button class="button" @click="viewMode = 'table'"
|
||||
:class="viewMode === 'table' ? 'is-link is-selected' : 'is-light'">
|
||||
<span class="is-size-6">Bảng</span>
|
||||
</button>
|
||||
<button class="button" @click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'is-link is-selected' : 'is-light'">
|
||||
<span class="is-size-6">Thẻ</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div v-if="viewMode === 'table'" class="table-container schedule-container">
|
||||
<table class="table is-fullwidth is-hoverable is-size-6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Đợt
|
||||
thanh toán</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Số tiền (VND)</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Đã thanh toán</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
|
||||
style="border: none;">Còn phải TT</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
|
||||
bắt đầu</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
|
||||
đến hạn</th>
|
||||
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Trạng
|
||||
thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(plan, index) in displaySchedule" :key="`plan-${index}`"
|
||||
style="border-bottom: 1px solid #f5f5f5;"
|
||||
:class="plan.is_merged ? 'has-background-warning-light' : ''">
|
||||
<td class="has-text-weight-semibold" :class="plan.is_merged ? 'has-text-warning' : ''">
|
||||
Đợt {{ plan.cycle }}
|
||||
<span v-if="plan.is_merged" class="tag is-warning is-light ml-1 is-size-7">GỘP SỚM</span>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<div v-if="plan.is_merged" class="has-text-right">
|
||||
<p class="has-text-grey" title="Tổng các đợt gốc">{{ $numtoString(totalOriginalEarlyAmount) }}
|
||||
</p>
|
||||
<p class="has-text-danger" title="Chiết khấu thanh toán sớm">- {{
|
||||
$numtoString(totalEarlyDiscount) }}</p>
|
||||
<hr class="my-1"
|
||||
style="background: hsla(0, 0%, 0%, 0.2); margin-left: auto; width: 50%;">
|
||||
<p class="has-text-weight-bold">{{ $numtoString(plan.amount) }}</p>
|
||||
</div>
|
||||
<span v-else>{{ $numtoString(plan.amount) }}</span>
|
||||
</td>
|
||||
<td class="has-text-right has-text-success">{{ $numtoString(plan.paid_amount) }}</td>
|
||||
<td class="has-text-right has-text-danger">{{ $numtoString(plan.remain_amount) }}</td>
|
||||
<td>{{ formatDate(plan.from_date) }}</td>
|
||||
<td>{{ formatDate(plan.to_date) }}</td>
|
||||
<td>
|
||||
<span v-if="plan.status === 2" class="tag is-success">Đã thanh toán</span>
|
||||
<span v-else class="tag is-warning">Chờ thanh toán</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="has-background-light">
|
||||
<th class="has-text-right has-text-weight-bold">Tổng cộng</th>
|
||||
<th class="has-text-right has-text-weight-bold">{{ $numtoString(totalAmount) }}</th>
|
||||
<th class="has-text-right has-text-weight-bold has-text-success">{{ $numtoString(totalPaid) }}
|
||||
</th>
|
||||
<th class="has-text-right has-text-weight-bold has-text-danger">{{
|
||||
$numtoString(calculatorData.totalRemaining) }}</th>
|
||||
<th colspan="3"></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- List View (Card) -->
|
||||
<div v-else-if="viewMode === 'list'" class="schedule-container">
|
||||
<div v-for="(plan, index) in displaySchedule" :key="`card-${index}`" class="card mb-4"
|
||||
:class="plan.is_merged ? 'has-background-warning-light' : ''">
|
||||
<div class="card-content">
|
||||
<div class="level is-mobile mb-5">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<span class="tag is-primary" :class="plan.is_merged ? 'is-warning' : ''">Đợt {{ plan.cycle
|
||||
}}</span>
|
||||
<span v-if="plan.is_merged" class="tag is-warning is-light ml-1 is-size-7">GỘP SỚM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item has-text-weight-bold">
|
||||
<div v-if="plan.is_merged" class="has-text-right">
|
||||
<p class="has-text-grey" title="Tổng các đợt gốc">{{ $numtoString(totalOriginalEarlyAmount)
|
||||
}}</p>
|
||||
<p class="has-text-danger" title="Chiết khấu thanh toán sớm">- {{
|
||||
$numtoString(totalEarlyDiscount) }}</p>
|
||||
<hr class="my-1"
|
||||
style="background: hsla(0, 0%, 0%, 0.2); margin-left: auto">
|
||||
<p class="has-text-weight-bold">{{ $numtoString(plan.amount) }}</p>
|
||||
</div>
|
||||
<span v-else>{{ $numtoString(plan.amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level is-mobile mb-1">
|
||||
<div class="level-left">Đã thanh toán:</div>
|
||||
<div class="level-right has-text-success">{{ $numtoString(plan.paid_amount) }}</div>
|
||||
</div>
|
||||
<div class="level is-mobile mb-1">
|
||||
<div class="level-left">Còn phải TT:</div>
|
||||
<div class="level-right has-text-danger">{{ $numtoString(plan.remain_amount) }}</div>
|
||||
</div>
|
||||
<div class="level is-mobile mb-1">
|
||||
<div class="level-left">Từ ngày:</div>
|
||||
<div class="level-right">{{ formatDate(plan.from_date) }}</div>
|
||||
</div>
|
||||
<div class="level is-mobile mb-1">
|
||||
<div class="level-left">Đến hạn:</div>
|
||||
<div class="level-right">{{ formatDate(plan.to_date) }}</div>
|
||||
</div>
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">Trạng thái:</div>
|
||||
<div class="level-right">
|
||||
<span v-if="plan.status === 2" class="tag is-success">Đã thanh toán</span>
|
||||
<span v-else class="tag is-warning">Chờ thanh toán</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Footer -->
|
||||
<div class="" style="border-top: 1px solid #eee;">
|
||||
<div class="level is-mobile is-size-6 my-4">
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<span class="is-uppercase is-size-4 has-text-weight-semibold">Tổng cộng: </span>
|
||||
<span class="has-text-success has-text-weight-bold is-size-4">
|
||||
{{ $numtoString(calculatorData.allocatedPrice) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Props - CHỈ NHẬN DỮ LIỆU ĐÃ TÍNH TOÁN
|
||||
const props = defineProps({
|
||||
productData: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
selectedPolicy: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
selectedCustomer: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
calculatorData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
// Cấu trúc:
|
||||
// {
|
||||
// originPrice: number,
|
||||
// totalDiscount: number,
|
||||
// salePrice: number,
|
||||
// allocatedPrice: number,
|
||||
// originalPaymentSchedule: array,
|
||||
// finalPaymentSchedule: array,
|
||||
// earlyDiscountDetails: array,
|
||||
// totalRemaining: number,
|
||||
// detailedDiscounts: array,
|
||||
// baseDate: Date
|
||||
// }
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['print']);
|
||||
|
||||
// Local state
|
||||
const viewMode = ref('table');
|
||||
|
||||
// Computed - CHỈ HIỂN THỊ, KHÔNG TÍNH TOÁN
|
||||
const displaySchedule = computed(() => {
|
||||
return props.calculatorData?.finalPaymentSchedule || [];
|
||||
});
|
||||
|
||||
const isEarlyPaymentActive = computed(() => {
|
||||
return props.calculatorData.earlyDiscountDetails && props.calculatorData.earlyDiscountDetails.length > 0;
|
||||
});
|
||||
|
||||
const totalEarlyDiscount = computed(() => {
|
||||
return props.calculatorData.earlyDiscountDetails?.reduce((sum, item) => sum + item.discount_amount, 0) || 0;
|
||||
});
|
||||
|
||||
const totalOriginalEarlyAmount = computed(() => {
|
||||
return props.calculatorData.earlyDiscountDetails?.reduce((sum, item) => sum + item.original_amount, 0) || 0;
|
||||
});
|
||||
|
||||
const totalAmount = computed(() => {
|
||||
return displaySchedule.value.reduce((sum, plan) => sum + plan.amount, 0);
|
||||
});
|
||||
|
||||
const totalPaid = computed(() => {
|
||||
return displaySchedule.value.reduce((sum, plan) => sum + plan.paid_amount, 0);
|
||||
});
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-';
|
||||
return dayjs(date).format('DD/MM/YYYY');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-container.schedule-container thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid #dbdbdb !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid #dbdbdb;
|
||||
}
|
||||
|
||||
li.is-active a,
|
||||
li a:hover {
|
||||
color: white !important;
|
||||
background-color: #204853 !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
tr,
|
||||
td,
|
||||
th,
|
||||
.card {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.schedule-container {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.table-container.schedule-container thead th {
|
||||
position: static;
|
||||
}
|
||||
|
||||
#ignore-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1240
app/components/application/PhaseAdvance.vue
Normal file
1240
app/components/application/PhaseAdvance.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user