Initial commit

This commit is contained in:
Viet An
2026-04-06 13:47:10 +07:00
commit f423d9ab20
439 changed files with 97497 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<template>
<ImageGallery
v-bind="{ row, pagename, show, api: 'applicationfile' }"
@remove="emit('remove')"
@update="update"
></ImageGallery>
</template>
<script setup>
import ImageGallery from "../media/ImageGallery.vue";
const props = defineProps({
row: Object,
pagename: String,
api: String,
});
const emit = defineEmits(["remove"]);
</script>

File diff suppressed because it is too large Load Diff

View 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>

View 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 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>

View File

@@ -0,0 +1,509 @@
<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="is-flex is-align-items-center is-gap-3">
<div class="buttons has-addons mb-0">
<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>
<button v-if="$getEditRights() && currentReservation?.status === 5 && supplementNotes.length > 0"
class="button is-danger is-light" @click="showSupplementNotes">
<span>Thông tin thiếu ({{ supplementNotes.length }})</span>
</button>
<button v-if="$getEditRights() && currentReservation?.status === 5" class="button is-warning"
:class="{ 'is-loading': isSubmitting }" :disabled="isSubmitting" @click="confirmSubmitForApproval">
Xác nhận đã hoàn thành bổ sung thông tin
</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">
<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 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 h-full"
style="border-radius: 6px; overflow: hidden; 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)"
class="w-full h-full is-flex is-justify-content-center is-align-items-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 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 loại tài liệu được định nghĩa cho giai đoạn này.</p>
</div>
<!-- Supplement Notes Modal -->
<div v-if="showNotesModal" class="modal is-active">
<div class="modal-background" @click="showNotesModal = false"></div>
<div class="modal-card" style="width: 600px; max-width: 90vw;">
<header class="modal-card-head">
<p class="modal-card-title">Yêu cầu bổ sung thông tin</p>
<button class="delete" @click="showNotesModal = false"></button>
</header>
<section class="modal-card-body">
<div v-for="(note, idx) in supplementNotes" :key="idx" class="notification is-warning is-light mb-3">
<p class="is-size-7 has-text-grey mb-1">
{{ $dayjs(note.create_time).format('DD/MM/YYYY HH:mm') }}
</p>
<p>{{ note.detail }}</p>
</div>
</section>
<footer class="modal-card-foot">
<button class="button" @click="showNotesModal = false">Đóng</button>
</footer>
</div>
</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,
currentReservation: null,
productNotes: [],
files: [],
isLoading: false,
isSubmitting: false,
showmodal: undefined,
showNotesModal: false,
phasedoctypes: [],
viewMode: 'list',
};
},
computed: {
supplementNotes() {
return this.productNotes.filter(note =>
note.detail &&
note.detail.includes('Yêu cầu bổ sung thông tin cho giao dịch') &&
!note.detail.includes('(đã bổ sung)')
);
}
},
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?.txncurrent__detail) {
this.currentReservation = await $getdata('reservation', {
id: this.transaction.txncurrent__detail
}, undefined, true);
}
if (this.transaction?.product) {
await this.fetchProductNotes();
}
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 fetchProductNotes() {
const { $getdata } = useNuxtApp();
if (!this.transaction?.product) return;
try {
const notes = await $getdata('productnote', { ref: this.transaction.product }, undefined, false);
this.productNotes = Array.isArray(notes) ? notes : (notes ? [notes] : []);
} catch (error) {
console.error("Lỗi khi tải product notes:", error);
this.productNotes = [];
}
},
showSupplementNotes() {
this.showNotesModal = true;
},
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;
}
}
};
},
confirmSubmitForApproval() {
this.showmodal = {
component: 'dialog/Confirm',
title: 'Xác nhận hoàn thành bổ sung',
height: '10vh',
width: '40%',
vbind: {
content: 'Bạn có chắc chắn đã hoàn thành bổ sung thông tin và muốn nộp lại để chờ duyệt không?'
},
onConfirm: () => this.submitForApproval()
};
},
async submitForApproval() {
if (this.isSubmitting) return;
this.isSubmitting = true;
const { $patchapi, $getdata, $snackbar, $updateapi } = useNuxtApp();
try {
// Cập nhật tất cả note chưa có "(đã bổ sung)" → thêm vào cuối
const notesToUpdate = this.productNotes.filter(note =>
note.detail &&
note.detail.includes('Yêu cầu bổ sung thông tin cho giao dịch') &&
!note.detail.includes('(đã bổ sung)')
);
await Promise.all(notesToUpdate.map(note =>
$updateapi('productnote', {
...note,
detail: note.detail + ' (đã bổ sung)'
})
));
// Patch reservation status về 2
await $patchapi('reservation', {
id: this.currentReservation.id,
status: 2
});
// Refresh
this.currentReservation = await $getdata('reservation', {
id: this.transaction.txncurrent__detail
}, undefined, true);
await this.fetchProductNotes();
$snackbar('Xác nhận hoàn thành bổ sung thông tin thành công', 'Success', 'Success');
this.$emit('status-updated');
} catch (error) {
console.error('Lỗi khi cập nhật trạng thái:', error);
$snackbar('Lỗi khi cập nhật trạng thái', 'Error', 'Danger');
} finally {
this.isSubmitting = 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) {
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>

View 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 quyền xem thông tin sản phẩm này.</p>
</template>

View File

@@ -0,0 +1,880 @@
<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 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Ự
</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:&nbsp;</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 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() {
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>

View 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:&nbsp;</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>

File diff suppressed because it is too large Load Diff