changes
This commit is contained in:
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<div v-if="record">
|
||||
<div
|
||||
class="columns is-multiline mx-0 mt-1"
|
||||
id="printable"
|
||||
>
|
||||
<div class="column is-5">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("code") }}:</label>
|
||||
<div class="control">
|
||||
{{ `${record.code}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-7">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("account-type") }}:</label>
|
||||
<div class="control">
|
||||
{{ `${record.type__code} / ${record.type__name}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("currency") }}:</label>
|
||||
<div class="control">
|
||||
{{ `${record.currency__code} / ${record.currency__name}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-7">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("balance") }}:</label>
|
||||
<div class="control">
|
||||
{{ $numtoString(record.balance) }}
|
||||
</div>
|
||||
<!--<p class="help is-findata">{{$vnmoney($formatNumber(record.balance))}}</p>-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("open-date") }}:</label>
|
||||
<div class="control">
|
||||
{{ `${$dayjs(record.create_time).format("L")}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--<div class="column is-7">
|
||||
<div class="field">
|
||||
<label class="label">Chi nhánh:</label>
|
||||
<div class="control">
|
||||
{{ `${record.branch__code} / ${record.branch__name}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="border-bottom"></div>
|
||||
<div
|
||||
class="mt-5"
|
||||
id="ignore"
|
||||
>
|
||||
<button
|
||||
class="button is-primary has-text-white"
|
||||
@click="$exportpdf('printable', record.code)"
|
||||
>
|
||||
{{ $lang("print") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["row"],
|
||||
data() {
|
||||
return {
|
||||
errors: {},
|
||||
record: undefined,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
this.record = await this.$getdata("internalaccount", { id: this.row.account || this.row.id }, undefined, true);
|
||||
},
|
||||
methods: {
|
||||
selected(attr, obj) {
|
||||
this.record[attr] = obj;
|
||||
if (attr === "_type") this.category = obj.category__code;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,148 +0,0 @@
|
||||
<template>
|
||||
<div class="has-text-centered">
|
||||
<div class="mb-3 p-3">
|
||||
<p class="is-size-5 has-text-weight-semibold mb-4">Bạn có chắc chắn muốn xóa bút toán này?</p>
|
||||
<p class="mt-3 has-text-danger has-text-weight-semibold">
|
||||
Hành động này <strong>không thể hoàn tác</strong>.<br />
|
||||
Dữ liệu liên quan (nếu có) sẽ bị xóa vĩnh viễn.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field is-grouped is-justify-content-center">
|
||||
<!-- Captcha addon group - shown only when captcha is not confirmed -->
|
||||
<div
|
||||
class="control"
|
||||
v-if="!isConfirmed"
|
||||
>
|
||||
<div class="field has-addons">
|
||||
<p class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Nhập mã xác nhận"
|
||||
v-model="userInputCaptcha"
|
||||
@keyup.enter="isConfirmed && confirmDelete()"
|
||||
/>
|
||||
</p>
|
||||
<p class="control">
|
||||
<a
|
||||
class="button is-static has-text-weight-bold has-background-grey-lighter"
|
||||
style="font-family: "Courier New", monospace; letter-spacing: 2px"
|
||||
>
|
||||
{{ captchaCode }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button
|
||||
class="button"
|
||||
@click="generateCaptcha"
|
||||
title="Tạo mã mới"
|
||||
>
|
||||
<span class="icon">
|
||||
<SvgIcon
|
||||
name="refresh.svg"
|
||||
type="primary"
|
||||
:size="23"
|
||||
></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<!-- Confirm button - shown only when captcha IS confirmed -->
|
||||
<p
|
||||
class="control"
|
||||
v-if="isConfirmed"
|
||||
>
|
||||
<button
|
||||
class="button is-danger"
|
||||
:class="{ 'is-loading': isDeleting }"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Xác nhận xóa
|
||||
</button>
|
||||
</p>
|
||||
<!-- Cancel button - always shown -->
|
||||
<p class="control">
|
||||
<button
|
||||
class="button"
|
||||
:disabled="isDeleting"
|
||||
@click="cancel"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useNuxtApp } from "#app";
|
||||
|
||||
const props = defineProps({
|
||||
entryId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "deleted"]);
|
||||
|
||||
const { $snackbar, $insertapi } = useNuxtApp();
|
||||
const isDeleting = ref(false);
|
||||
const captchaCode = ref("");
|
||||
const userInputCaptcha = ref("");
|
||||
|
||||
const isConfirmed = computed(() => {
|
||||
return userInputCaptcha.value.toLowerCase() === captchaCode.value.toLowerCase() && userInputCaptcha.value !== "";
|
||||
});
|
||||
|
||||
const generateCaptcha = () => {
|
||||
captchaCode.value = Math.random().toString(36).substring(2, 7).toUpperCase();
|
||||
userInputCaptcha.value = "";
|
||||
};
|
||||
|
||||
// Initial generation
|
||||
generateCaptcha();
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (isDeleting.value || !isConfirmed.value) return;
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
// Gọi API xóa theo đúng endpoint delete-entry/{id}
|
||||
const result = await $insertapi("deleteentry", { id: props.entryId });
|
||||
|
||||
if (result === "error" || !result) {
|
||||
throw new Error("API xóa trả về lỗi");
|
||||
}
|
||||
|
||||
$snackbar(`Đã xóa bút toán ID ${props.entryId} thành công`, "Thành công", "Success");
|
||||
|
||||
emit("deleted", props.entryId);
|
||||
emit("close");
|
||||
} catch (err) {
|
||||
console.error("Xóa bút toán thất bại:", err);
|
||||
|
||||
let errorMsg = "Không thể xóa bút toán. Vui lòng thử lại.";
|
||||
|
||||
// Nếu backend trả về thông báo cụ thể
|
||||
if (err?.response?.data?.detail) {
|
||||
errorMsg = err.response.data.detail;
|
||||
} else if (err?.response?.data?.non_field_errors) {
|
||||
errorMsg = err.response.data.non_field_errors.join(" ");
|
||||
}
|
||||
|
||||
$snackbar(errorMsg, "Lỗi", "Danger");
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Từ ngày<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
v-bind="{ record, attr: 'fdate', maxdate: new Date() }"
|
||||
@date="selected('fdate', $event)"
|
||||
></Datepicker>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.issued_date"
|
||||
>
|
||||
{{ errors.issued_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Đến ngày<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
v-bind="{ record, attr: 'tdate', maxdate: new Date() }"
|
||||
@date="selected('tdate', $event)"
|
||||
></Datepicker>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.issued_date"
|
||||
>
|
||||
{{ errors.issued_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataView
|
||||
v-bind="vbind"
|
||||
v-if="vbind"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
const { $dayjs, $id } = useNuxtApp();
|
||||
const fdate = ref($dayjs().format("YYYY-MM-DD"));
|
||||
const tdate = ref($dayjs().format("YYYY-MM-DD"));
|
||||
const record = ref({ fdate: fdate.value, tdate: tdate.value });
|
||||
const errors = ref({});
|
||||
const vbind = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
function selected(attr, value) {
|
||||
if (attr === "fdate") fdate.value = value;
|
||||
else tdate.value = value;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
vbind.value = undefined;
|
||||
setTimeout(() => {
|
||||
vbind.value = {
|
||||
pagename: `debt-customer-${$id()}`,
|
||||
setting: "debt-customer",
|
||||
api: "internalentry",
|
||||
params: {
|
||||
values:
|
||||
"customer__code,customer__fullname,customer__type__name,customer__legal_type__name,customer__legal_code",
|
||||
distinct_values: {
|
||||
sum_sale_price: {
|
||||
type: "Sum",
|
||||
field: "product__prdbk__transaction__sale_price",
|
||||
},
|
||||
sum_received: {
|
||||
type: "Sum",
|
||||
field: "product__prdbk__transaction__amount_received",
|
||||
},
|
||||
sum_remain: {
|
||||
type: "Sum",
|
||||
field: "product__prdbk__transaction__amount_remain",
|
||||
},
|
||||
},
|
||||
summary: "annotate",
|
||||
filter: {
|
||||
date__gte: fdate.value,
|
||||
date__lte: tdate.value,
|
||||
},
|
||||
sort: "-sum_remain",
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Từ ngày<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
v-bind="{ record, attr: 'fdate', maxdate: new Date() }"
|
||||
@date="selected('fdate', $event)"
|
||||
></Datepicker>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.issued_date"
|
||||
>
|
||||
{{ errors.issued_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Đến ngày<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
v-bind="{ record, attr: 'tdate', maxdate: new Date() }"
|
||||
@date="selected('tdate', $event)"
|
||||
></Datepicker>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.issued_date"
|
||||
>
|
||||
{{ errors.issued_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataView
|
||||
v-bind="vbind"
|
||||
v-if="vbind"
|
||||
/>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
fdate: this.$dayjs().format("YYYY-MM-DD"),
|
||||
tdate: this.$dayjs().format("YYYY-MM-DD"),
|
||||
record: {},
|
||||
errors: {},
|
||||
vbind: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.record = { fdate: this.fdate, tdate: this.tdate };
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
selected(attr, value) {
|
||||
console.log("===date===", attr, value, this.fdate, this.tdate);
|
||||
if (attr === "fdate") this.fdate = value;
|
||||
else this.tdate = value;
|
||||
this.loadData();
|
||||
},
|
||||
loadData() {
|
||||
this.vbind = undefined;
|
||||
setTimeout(
|
||||
() =>
|
||||
(this.vbind = {
|
||||
setting: "debt-product-1",
|
||||
api: "internalentry",
|
||||
params: {
|
||||
values:
|
||||
"product,product__prdbk__transaction__amount_received,product__trade_code,product__prdbk__transaction__sale_price,product__zone_type__name,customer,customer__code,customer__fullname",
|
||||
distinct_values: {
|
||||
sumCR: {
|
||||
type: "Sum",
|
||||
filter: { type__code: "CR" },
|
||||
field: "amount",
|
||||
},
|
||||
sumDR: {
|
||||
type: "Sum",
|
||||
filter: { type__code: "DR" },
|
||||
field: "amount",
|
||||
},
|
||||
},
|
||||
summary: "annotate",
|
||||
filter: { date__gte: this.fdate, date__lte: this.tdate },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,643 +0,0 @@
|
||||
<template>
|
||||
<div class="p-3">
|
||||
<!-- TimeOption Filter -->
|
||||
<TimeOption
|
||||
v-bind="{
|
||||
pagename: 'debt_report',
|
||||
api: 'transaction',
|
||||
timeopt: { time: 36000, disable: ['add'] },
|
||||
filter: { phase: 3 },
|
||||
}"
|
||||
@option="handleTimeOption"
|
||||
@excel="exportExcel"
|
||||
@refresh-data="loadData"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="has-text-centered py-6"
|
||||
>
|
||||
<p class="has-text-grey mb-3">Đang tải dữ liệu...</p>
|
||||
<progress
|
||||
class="progress is-small is-primary"
|
||||
max="100"
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=""
|
||||
v-else
|
||||
>
|
||||
<!-- Table -->
|
||||
<div
|
||||
v-if="filteredRows.length > 0"
|
||||
class="table-container"
|
||||
style="overflow-x: auto; max-width: 100%"
|
||||
>
|
||||
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth debt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Fixed columns (sticky left) -->
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white has-text-centered"
|
||||
>
|
||||
STT
|
||||
</th>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white"
|
||||
>
|
||||
Mã KH
|
||||
</th>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white"
|
||||
>
|
||||
Mã Căn
|
||||
</th>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white"
|
||||
>
|
||||
Ngày ký HĐ
|
||||
</th>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white"
|
||||
>
|
||||
Giá trị HĐMB
|
||||
</th>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white"
|
||||
>
|
||||
Tiền nộp theo HĐV/TTTHNV
|
||||
</th>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="fixed-col has-background-primary has-text-white"
|
||||
>
|
||||
Tỷ lệ
|
||||
</th>
|
||||
|
||||
<!-- Scrollable columns -->
|
||||
<th
|
||||
v-for="(sch, si) in scheduleHeaders"
|
||||
:key="si"
|
||||
:colspan="6"
|
||||
class="has-text-centered has-background-primary has-text-white"
|
||||
>
|
||||
{{ sch.label }}
|
||||
</th>
|
||||
|
||||
<th
|
||||
rowspan="2"
|
||||
class="has-background-primary has-text-white"
|
||||
>
|
||||
Số tiền quá hạn
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<template
|
||||
v-for="(sch, si) in scheduleHeaders"
|
||||
:key="si"
|
||||
>
|
||||
<th class="has-background-primary has-text-white sub-header">Ngày</th>
|
||||
<th class="has-background-primary has-text-white sub-header">Số tiền</th>
|
||||
<th class="has-background-primary has-text-white sub-header">Lũy kế sang đợt</th>
|
||||
<th class="has-background-primary has-text-white sub-header">Số tiền đã thực thanh toán</th>
|
||||
<th class="has-background-primary has-text-white sub-header">Tỷ lệ</th>
|
||||
<th class="has-background-primary has-text-white sub-header-remain">Dư nợ</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, ri) in filteredRows"
|
||||
:key="ri"
|
||||
>
|
||||
<!-- Fixed columns -->
|
||||
<td class="fixed-col has-text-centered">{{ ri + 1 }}</td>
|
||||
<td class="fixed-col">{{ row.customer_code }}</td>
|
||||
<td class="fixed-col has-text-weight-semibold has-text-primary">
|
||||
{{ row.trade_code }}
|
||||
</td>
|
||||
<td class="fixed-col">{{ row.contract_date }}</td>
|
||||
<td class="fixed-col has-text-right">
|
||||
{{ fmt(row.sale_price) }}
|
||||
</td>
|
||||
<td class="fixed-col has-text-right has-text-weight-semibold has-background-warning-light">
|
||||
{{ fmt(row.ttthnv_paid) }}
|
||||
</td>
|
||||
<td class="fixed-col has-text-right has-background-success-light">
|
||||
{{ pct(row.ttthnv_paid, row.sale_price) }}
|
||||
</td>
|
||||
|
||||
<!-- Scrollable columns -->
|
||||
<template
|
||||
v-for="(sch, si) in scheduleHeaders"
|
||||
:key="si"
|
||||
>
|
||||
<template v-if="row.schedules[si]">
|
||||
<td>{{ row.schedules[si].to_date }}</td>
|
||||
<td class="has-text-right">
|
||||
{{ fmt(row.schedules[si].amount) }}
|
||||
</td>
|
||||
<td class="has-text-right has-text-info">
|
||||
{{ fmt(row.schedules[si].luy_ke_sang_dot) }}
|
||||
</td>
|
||||
<td class="has-text-right has-text-weight-semibold has-text-success">
|
||||
{{ fmt(row.schedules[si].thuc_thanh_toan) }}
|
||||
</td>
|
||||
<td
|
||||
class="has-text-right"
|
||||
:class="pctClass(row.schedules[si].thuc_thanh_toan, row.schedules[si].amount)"
|
||||
>
|
||||
{{ pct(row.schedules[si].thuc_thanh_toan, row.schedules[si].amount) }}
|
||||
</td>
|
||||
<td
|
||||
class="has-text-right has-text-weight-semibold"
|
||||
:class="Number(row.schedules[si].amount_remain) > 0 ? 'has-text-danger' : 'has-text-success'"
|
||||
>
|
||||
{{ fmt(row.schedules[si].amount_remain) }}
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td
|
||||
colspan="6"
|
||||
class="has-text-centered has-text-grey-light"
|
||||
style="font-style: italic"
|
||||
>
|
||||
—
|
||||
</td>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<td
|
||||
class="has-text-right has-text-weight-bold"
|
||||
:class="Number(row.overdue) > 0 ? 'has-background-danger-light' : ''"
|
||||
>
|
||||
{{ Number(row.overdue) > 0 ? fmt(row.overdue) : "—" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr class="has-background-light">
|
||||
<td
|
||||
:colspan="7"
|
||||
class="fixed-col has-text-right has-text-weight-bold"
|
||||
>
|
||||
TỔNG CỘNG:
|
||||
</td>
|
||||
|
||||
<template
|
||||
v-for="(sch, si) in scheduleHeaders"
|
||||
:key="si"
|
||||
>
|
||||
<td></td>
|
||||
<td class="has-text-right has-text-weight-semibold">
|
||||
{{ fmt(colSum(si, "amount")) }}
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="has-text-right has-text-weight-semibold has-text-success">
|
||||
{{ fmt(colSum(si, "thuc_thanh_toan")) }}
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="has-text-right has-text-weight-semibold has-text-danger">
|
||||
{{ fmt(colSum(si, "amount_remain")) }}
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<td class="has-text-right has-text-weight-bold has-text-danger">
|
||||
{{ fmt(totalOverdue) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else
|
||||
class="has-text-centered py-6"
|
||||
>
|
||||
<p class="has-text-grey">Không có dữ liệu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import TimeOption from "~/components/datatable/TimeOption";
|
||||
const { $findapi, $getapi, $dayjs, $copy } = useNuxtApp();
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref([]);
|
||||
const filteredRows = ref([]);
|
||||
const scheduleHeaders = ref([]);
|
||||
|
||||
const currentFilter = ref(null);
|
||||
const currentSearch = ref(null);
|
||||
|
||||
function handleTimeOption(option) {
|
||||
if (!option) {
|
||||
currentFilter.value = null;
|
||||
currentSearch.value = null;
|
||||
applyFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.filter) {
|
||||
currentFilter.value = option.filter;
|
||||
currentSearch.value = null;
|
||||
applyFilters();
|
||||
} else if (option.filter_or) {
|
||||
currentFilter.value = null;
|
||||
currentSearch.value = option.filter_or;
|
||||
applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
let filtered = [...rows.value];
|
||||
|
||||
if (currentFilter.value && currentFilter.value.create_time__date__gte) {
|
||||
const filterDate = new Date(currentFilter.value.create_time__date__gte);
|
||||
filtered = filtered.filter((row) => {
|
||||
const contractDate = row.contract_date_raw ? new Date(row.contract_date_raw) : null;
|
||||
return contractDate && contractDate >= filterDate;
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSearch.value) {
|
||||
const searchTerms = Object.values(currentSearch.value).map((v) =>
|
||||
String(v).toLowerCase().replace("__icontains", ""),
|
||||
);
|
||||
filtered = filtered.filter((row) => {
|
||||
const searchableText = [
|
||||
row.customer_code,
|
||||
row.customer_name,
|
||||
row.trade_code,
|
||||
row.contract_date,
|
||||
String(row.sale_price),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchTerms.some((term) => searchableText.includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
filteredRows.value = filtered;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
rows.value = [];
|
||||
filteredRows.value = [];
|
||||
scheduleHeaders.value = [];
|
||||
|
||||
try {
|
||||
const txnConn = $copy($findapi("transaction"));
|
||||
txnConn.params = {
|
||||
filter: { phase: 3 },
|
||||
values:
|
||||
"id,code,date,customer,customer__code,customer__fullname,sale_price,amount_received,amount_remain,product,product__trade_code,phase",
|
||||
sort: "id",
|
||||
};
|
||||
|
||||
const detailConn = $copy($findapi("reservation"));
|
||||
detailConn.params = {
|
||||
filter: { transaction__phase: 3, phase: 3 },
|
||||
values: "id,transaction,phase,amount,amount_received,amount_remaining,status",
|
||||
sort: "transaction",
|
||||
};
|
||||
|
||||
const schConn = $copy($findapi("payment_schedule"));
|
||||
schConn.params = {
|
||||
filter: { txn_detail__phase: 3 },
|
||||
values:
|
||||
"id,code,cycle,to_date,from_date,amount,paid_amount,amount_remain,remain_amount,status,status__name,txn_detail,txn_detail__transaction,txn_detail__phase,txn_detail__amount_received",
|
||||
sort: "txn_detail__transaction,cycle",
|
||||
};
|
||||
|
||||
const ttthnvConn = $copy($findapi("reservation"));
|
||||
ttthnvConn.params = {
|
||||
filter: { transaction__phase: 3, phase: 4 },
|
||||
values: "id,transaction,phase,amount,amount_received,amount_remaining,status",
|
||||
sort: "transaction",
|
||||
};
|
||||
|
||||
const [txnRs, detailRs, schRs, ttthnvRs] = await $getapi([txnConn, detailConn, schConn, ttthnvConn]);
|
||||
|
||||
const transactions = txnRs?.data?.rows || [];
|
||||
const details = detailRs?.data?.rows || [];
|
||||
const schedules = schRs?.data?.rows || [];
|
||||
const ttthnvList = ttthnvRs?.data?.rows || [];
|
||||
|
||||
if (!transactions.length) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// TTTHNV map
|
||||
const ttthnvMap = {};
|
||||
ttthnvList.forEach((t) => {
|
||||
const tid = t.transaction;
|
||||
ttthnvMap[tid] = (ttthnvMap[tid] || 0) + Number(t.amount_received || 0);
|
||||
});
|
||||
|
||||
// Group schedules by transaction
|
||||
const schByTxn = {};
|
||||
schedules.forEach((s) => {
|
||||
const tid = s.txn_detail__transaction;
|
||||
if (!schByTxn[tid]) schByTxn[tid] = [];
|
||||
schByTxn[tid].push(s);
|
||||
});
|
||||
|
||||
// Tìm số đợt tối đa
|
||||
let maxCycles = 0;
|
||||
Object.values(schByTxn).forEach((list) => {
|
||||
const paymentList = list.filter((s) => Number(s.cycle) > 0);
|
||||
if (paymentList.length > maxCycles) maxCycles = paymentList.length;
|
||||
});
|
||||
|
||||
scheduleHeaders.value = Array.from({ length: maxCycles }, (_, i) => ({
|
||||
label: `L0${i + 1}`,
|
||||
index: i,
|
||||
}));
|
||||
|
||||
rows.value = transactions.map((txn) => {
|
||||
const txnSchedules = (schByTxn[txn.id] || [])
|
||||
.filter((s) => Number(s.cycle) > 0)
|
||||
.sort((a, b) => Number(a.cycle) - Number(b.cycle));
|
||||
|
||||
const ttthnvPaid = ttthnvMap[txn.id] || 0;
|
||||
const salePriceNum = Number(txn.sale_price || 0);
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// QUAN TRỌNG: Theo yêu cầu mới nhất của bạn
|
||||
// Lũy kế HĐCN = TTTHNV
|
||||
const luyKe = ttthnvPaid;
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
// Phân bổ TTTHNV dần vào từng đợt → tính lũy kế sang đợt
|
||||
let remainingTTTHNV = ttthnvPaid;
|
||||
|
||||
const schedulesWithCalc = txnSchedules.map((sch) => {
|
||||
const scheduleAmount = Number(sch.amount || 0);
|
||||
|
||||
// Lũy kế sang đợt = min(remaining TTTHNV, số tiền đợt)
|
||||
const luyKeSangDot = Math.min(remainingTTTHNV, scheduleAmount);
|
||||
|
||||
// Số tiền đã thực thanh toán = paid_amount - lũy kế sang đợt
|
||||
const paidAmountFromSchedule = Number(sch.paid_amount || 0);
|
||||
const thucThanhToan = Math.max(0, paidAmountFromSchedule - luyKeSangDot);
|
||||
|
||||
// Dư nợ = số tiền đợt - lũy kế sang đợt - thực thanh toán
|
||||
const amountRemain = Math.max(0, scheduleAmount - luyKeSangDot - thucThanhToan);
|
||||
|
||||
remainingTTTHNV -= luyKeSangDot;
|
||||
remainingTTTHNV = Math.max(0, remainingTTTHNV);
|
||||
|
||||
return {
|
||||
to_date: sch.to_date ? $dayjs(sch.to_date).format("L") : "—",
|
||||
amount: scheduleAmount,
|
||||
luy_ke_sang_dot: luyKeSangDot,
|
||||
thuc_thanh_toan: thucThanhToan,
|
||||
amount_remain: amountRemain,
|
||||
status: sch.status,
|
||||
};
|
||||
});
|
||||
|
||||
// Tính quá hạn
|
||||
const todayDate = new Date();
|
||||
const overdue = txnSchedules.reduce((sum, sch, idx) => {
|
||||
const toDate = sch.to_date ? new Date(sch.to_date) : null;
|
||||
const remain = schedulesWithCalc[idx]?.amount_remain || 0;
|
||||
if (toDate && toDate < todayDate && remain > 0) {
|
||||
return sum + remain;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
const paddedSchedules = Array.from({ length: maxCycles }, (_, i) => schedulesWithCalc[i] || null);
|
||||
|
||||
return {
|
||||
customer_code: txn.customer__code || "",
|
||||
customer_name: txn.customer__fullname || "",
|
||||
trade_code: txn.product__trade_code || txn.code || "",
|
||||
contract_date: txn.date ? $dayjs(txn.date).format("L") : "—",
|
||||
contract_date_raw: txn.date,
|
||||
sale_price: salePriceNum,
|
||||
ttthnv_paid: ttthnvPaid,
|
||||
luy_ke: luyKe, // ← chính là TTTHNV
|
||||
schedules: paddedSchedules,
|
||||
overdue: overdue,
|
||||
};
|
||||
});
|
||||
|
||||
filteredRows.value = rows.value;
|
||||
} catch (e) {
|
||||
console.error("BaoCaoCongNo error:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(val) {
|
||||
const n = Number(val);
|
||||
if (isNaN(n) || (!n && n !== 0)) return "—";
|
||||
return n.toLocaleString("vi-VN");
|
||||
}
|
||||
|
||||
function pct(num, denom) {
|
||||
const n = Number(num);
|
||||
const d = Number(denom);
|
||||
if (!d || isNaN(n)) return "—";
|
||||
return ((n / d) * 100).toFixed(1) + "%";
|
||||
}
|
||||
|
||||
function pctClass(paid, amount) {
|
||||
const p = Number(paid);
|
||||
const a = Number(amount);
|
||||
if (isNaN(p) || isNaN(a) || !a) return "";
|
||||
const ratio = p / a;
|
||||
if (ratio >= 1) return "has-text-success";
|
||||
if (ratio >= 0.5) return "has-text-info";
|
||||
return "has-text-danger";
|
||||
}
|
||||
|
||||
function colSum(scheduleIndex, field) {
|
||||
return filteredRows.value.reduce((sum, row) => {
|
||||
const sch = row.schedules[scheduleIndex];
|
||||
return sum + (sch ? Number(sch[field] || 0) : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const totalOverdue = computed(() => filteredRows.value.reduce((s, r) => s + Number(r.overdue || 0), 0));
|
||||
|
||||
function exportExcel() {
|
||||
const headers = [
|
||||
"STT",
|
||||
"Mã KH",
|
||||
"Mã Căn",
|
||||
"Ngày ký HĐ",
|
||||
"Giá trị HĐMB",
|
||||
"Tiền nộp TTTHNV",
|
||||
"Lũy kế tiền về HĐCN",
|
||||
"Tỷ lệ HĐCN",
|
||||
];
|
||||
|
||||
scheduleHeaders.value.forEach((h) => {
|
||||
headers.push(
|
||||
`${h.label} - Ngày`,
|
||||
`${h.label} - Số tiền`,
|
||||
`${h.label} - Lũy kế sang`,
|
||||
`${h.label} - Số tiền đã thực thanh toán`,
|
||||
`${h.label} - Tỷ lệ`,
|
||||
`${h.label} - Dư nợ`,
|
||||
);
|
||||
});
|
||||
|
||||
headers.push("Số tiền quá hạn");
|
||||
|
||||
const data = filteredRows.value.map((row, i) => {
|
||||
const base = [
|
||||
i + 1,
|
||||
row.customer_code,
|
||||
row.trade_code,
|
||||
row.contract_date,
|
||||
fmt(row.sale_price),
|
||||
fmt(row.ttthnv_paid),
|
||||
fmt(row.luy_ke),
|
||||
pct(row.luy_ke, row.sale_price),
|
||||
];
|
||||
|
||||
scheduleHeaders.value.forEach((_, si) => {
|
||||
const sch = row.schedules[si];
|
||||
if (sch) {
|
||||
base.push(
|
||||
sch.to_date,
|
||||
fmt(sch.amount),
|
||||
fmt(sch.luy_ke_sang_dot),
|
||||
fmt(sch.thuc_thanh_toan),
|
||||
pct(sch.thuc_thanh_toan, sch.amount),
|
||||
fmt(sch.amount_remain),
|
||||
);
|
||||
} else {
|
||||
base.push("", "", "", "", "", "");
|
||||
}
|
||||
});
|
||||
|
||||
base.push(fmt(row.overdue));
|
||||
return base;
|
||||
});
|
||||
|
||||
const csvRows = [headers, ...data];
|
||||
const csv = csvRows.map((r) => r.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(",")).join("\n");
|
||||
|
||||
const BOM = "\uFEFF";
|
||||
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `bao-cao-cong-no-${$dayjs().format("YYYYMMDD")}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
onMounted(() => loadData());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fixed columns style */
|
||||
.fixed-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
background-color: white; /* nền trắng cho body */
|
||||
min-width: 80px; /* điều chỉnh theo nhu cầu */
|
||||
}
|
||||
|
||||
/* Cột STT thường hẹp hơn */
|
||||
.fixed-col:nth-child(1) {
|
||||
left: 0;
|
||||
min-width: 50px;
|
||||
z-index: 4; /* cao hơn để đè lên */
|
||||
}
|
||||
|
||||
/* Cột Mã KH */
|
||||
.fixed-col:nth-child(2) {
|
||||
left: 50px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Cột Mã Căn */
|
||||
.fixed-col:nth-child(3) {
|
||||
left: 150px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Cột Ngày ký HĐ */
|
||||
.fixed-col:nth-child(4) {
|
||||
left: 270px;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
/* Cột Giá trị HĐMB */
|
||||
.fixed-col:nth-child(5) {
|
||||
left: 380px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Cột Tiền nộp TTTHNV */
|
||||
.fixed-col:nth-child(6) {
|
||||
left: 520px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* Cột Lũy kế HĐCN */
|
||||
.fixed-col:nth-child(7) {
|
||||
left: 680px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* Cột Tỷ lệ */
|
||||
.fixed-col:nth-child(8) {
|
||||
left: 840px;
|
||||
min-width: 90px;
|
||||
border-right: 2px solid #dee2e6 !important; /* tạo đường phân cách rõ ràng */
|
||||
}
|
||||
|
||||
/* Header fixed */
|
||||
.debt-table thead .fixed-col {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background-color: #204853 !important; /* primary color */
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Footer fixed columns */
|
||||
.debt-table tfoot .fixed-col {
|
||||
background-color: #f8f9fa !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Đảm bảo tfoot cũng có z-index */
|
||||
.debt-table tfoot tr {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Optional: bóng nhẹ khi scroll để dễ nhận biết fixed */
|
||||
.fixed-col {
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<DataView
|
||||
v-bind="{
|
||||
api: 'internalaccount',
|
||||
setting: store.lang === 'en' ? 'internal-account-en' : 'internal-account',
|
||||
pagename: pagename,
|
||||
modal: {
|
||||
title: 'Tài khoản',
|
||||
component: 'accounting/AccountView',
|
||||
width: '50%',
|
||||
height: '300px',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<Modal
|
||||
@close="showmodal = undefined"
|
||||
v-bind="showmodal"
|
||||
v-if="showmodal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { useStore } from "~/stores/index";
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showmodal: undefined,
|
||||
pagename: "pagedata32",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
deposit() {
|
||||
this.showmodal = {
|
||||
component: "accounting/InternalDeposit",
|
||||
title: "Nộp tiền tài khoản nội bộ",
|
||||
width: "40%",
|
||||
height: "300px",
|
||||
vbind: { pagename: this.pagename },
|
||||
};
|
||||
},
|
||||
doClick() {
|
||||
this.$approvalcode();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,348 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-8">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("account") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'internalaccount',
|
||||
field: 'label',
|
||||
column: ['label'],
|
||||
first: true,
|
||||
optionid: record.account,
|
||||
}"
|
||||
:disabled="record.account"
|
||||
@option="selected('_account', $event)"
|
||||
v-if="!record.id"
|
||||
></SearchBox>
|
||||
<span v-else>{{ record.account__code }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors._account"
|
||||
>
|
||||
{{ errors._account }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Ngày hạch toán<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
v-bind="{ record: record, attr: 'date', maxdate: new Date() }"
|
||||
@date="selected('date', $event)"
|
||||
></Datepicker>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.issued_date"
|
||||
>
|
||||
{{ errors.issued_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Thu / chi<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'entrytype',
|
||||
field: 'name',
|
||||
column: ['name'],
|
||||
first: true,
|
||||
optionid: record.type,
|
||||
}"
|
||||
:disabled="record.type"
|
||||
@option="selected('_type', $event)"
|
||||
v-if="!record.id"
|
||||
></SearchBox>
|
||||
<span v-else>{{ record.type__name }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors._type"
|
||||
>
|
||||
{{ errors._type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("amount-only") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<InputNumber
|
||||
v-bind="{ record: record, attr: 'amount', placeholder: '' }"
|
||||
@number="selected('amount', $event)"
|
||||
></InputNumber>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.amount"
|
||||
>
|
||||
{{ errors.amount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Phương thức thanh toán<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'entrycategory',
|
||||
field: 'name',
|
||||
column: ['name'],
|
||||
first: true,
|
||||
optionid: record.type,
|
||||
}"
|
||||
:disabled="record.type"
|
||||
@option="selected('_category', $event)"
|
||||
v-if="!record.id"
|
||||
></SearchBox>
|
||||
<span v-else>{{ record.type__name }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors._type"
|
||||
>
|
||||
{{ errors._type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label">Sản phẩm<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<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,
|
||||
}"
|
||||
@option="selected('product', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors._type"
|
||||
>
|
||||
{{ errors._type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label">Khách hàng<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'customer',
|
||||
field: 'label',
|
||||
searchfield: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
column: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
first: true,
|
||||
}"
|
||||
@option="selected('customer', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors._type"
|
||||
>
|
||||
{{ errors._type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("content") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<textarea
|
||||
class="textarea"
|
||||
rows="2"
|
||||
v-model="record.content"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.content"
|
||||
>
|
||||
{{ errors.content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Mã tham chiếu</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input has-text-black"
|
||||
type="text"
|
||||
placeholder="Tối đa 30 ký tự"
|
||||
v-model="record.ref"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="column is-12"
|
||||
v-if="entry"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">Chứng từ đi kèm (nếu có)</label>
|
||||
<div class="control">
|
||||
<FileGallery
|
||||
v-bind="{
|
||||
row: entry,
|
||||
pagename: pagename,
|
||||
api: 'entryfile',
|
||||
info: false,
|
||||
}"
|
||||
></FileGallery>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 ml-3"
|
||||
v-if="!entry"
|
||||
>
|
||||
<button
|
||||
:class="['button is-primary has-text-white mr-2', isUpdating && 'is-loading']"
|
||||
@click="confirm()"
|
||||
>
|
||||
{{ $lang("confirm") }}
|
||||
</button>
|
||||
</div>
|
||||
<Modal
|
||||
@close="showContractModal = undefined"
|
||||
v-bind="showContractModal"
|
||||
v-if="showContractModal"
|
||||
></Modal>
|
||||
<Modal
|
||||
@close="showmodal = undefined"
|
||||
v-bind="showmodal"
|
||||
@confirm="update()"
|
||||
v-if="showmodal"
|
||||
></Modal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { useStore } from "~/stores/index";
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
props: ["pagename", "row", "option"],
|
||||
data() {
|
||||
return {
|
||||
record: { date: this.$dayjs().format("YYYY-MM-DD") },
|
||||
errors: {},
|
||||
isUpdating: false,
|
||||
showmodal: undefined,
|
||||
showContractModal: undefined,
|
||||
entry: undefined,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
if (!this.option) return;
|
||||
this.record.account = this.option.account;
|
||||
this.record.type = this.option.type;
|
||||
},
|
||||
methods: {
|
||||
selected(attr, obj) {
|
||||
this.record[attr] = obj;
|
||||
this.record = this.$copy(this.record);
|
||||
},
|
||||
checkError() {
|
||||
this.errors = {};
|
||||
if (this.$empty(this.record._account)) this.errors._account = "Chưa chọn tài khoản";
|
||||
if (this.$empty(this.record._type)) this.errors._type = "Chưa chọn loại hạch toán";
|
||||
if (this.$empty(this.record.amount)) this.errors.amount = "Chưa nhập số tiền";
|
||||
else if (this.$formatNumber(this.record.amount) <= 0) this.errors.amount = "Số tiền phải > 0";
|
||||
if (this.$empty(this.record.content)) this.errors.content = "Chưa nhập nội dung";
|
||||
if (Object.keys(this.errors).length > 0) return true;
|
||||
if (this.record._type.code === "DR" && this.record._account.balance < this.$formatNumber(this.record.amount)) {
|
||||
this.errors._account = "Số dư tài khoản không đủ để trích nợ";
|
||||
}
|
||||
return Object.keys(this.errors).length > 0;
|
||||
},
|
||||
confirm() {
|
||||
if (this.checkError()) return;
|
||||
this.showmodal = {
|
||||
component: `dialog/Confirm`,
|
||||
vbind: { content: this.$lang("confirm-action"), duration: 10 },
|
||||
title: this.$lang("confirm"),
|
||||
width: "500px",
|
||||
height: "100px",
|
||||
};
|
||||
},
|
||||
async update() {
|
||||
this.isUpdating = true;
|
||||
let obj1 = {
|
||||
code: this.record._account.code,
|
||||
amount: this.record.amount,
|
||||
content: this.record.content,
|
||||
type: this.record._type.code,
|
||||
category: this.record._category ? this.record._category.id : 1,
|
||||
user: this.store.login.id,
|
||||
ref: this.row ? this.row.code : !this.$empty(this.record.ref) ? this.record.ref.trim() : null,
|
||||
customer: this.record.customer ? this.record.customer.id : null,
|
||||
product: this.record.product ? this.record.product.id : null,
|
||||
date: this.$empty(this.record.date) ? null : this.record.date,
|
||||
};
|
||||
let rs1 = await this.$insertapi("accountentry", obj1, undefined, false);
|
||||
if (rs1 === "error") return;
|
||||
|
||||
if (this.record._category.id === 2) {
|
||||
const genDoc = await this.$generateDocument({
|
||||
doc_code: "PHIEU_THU_TIEN_MAT",
|
||||
entry_id: rs1.id,
|
||||
output_filename: `PHIEU_THU_TIEN_MAT-${rs1.code}`,
|
||||
});
|
||||
|
||||
await this.$insertapi("file", {
|
||||
name: genDoc.data.pdf,
|
||||
user: this.store.login.id,
|
||||
type: 4,
|
||||
size: 1000,
|
||||
file: genDoc.data.pdf, // or genDoc.data.pdf
|
||||
});
|
||||
|
||||
this.showContractModal = {
|
||||
component: "application/Contract",
|
||||
title: "Phiếu thu tiền mặt",
|
||||
width: "95%",
|
||||
height: "95vh",
|
||||
vbind: {
|
||||
directDocument: genDoc.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.entry = rs1;
|
||||
if (this.pagename) {
|
||||
let data = await this.$getdata("internalaccount", {
|
||||
code__in: [this.record._account.code],
|
||||
});
|
||||
this.$updatepage(this.pagename, data);
|
||||
}
|
||||
this.isUpdating = false;
|
||||
this.$emit("modalevent", { name: "entry", data: rs1 });
|
||||
this.$dialog(
|
||||
`Hạch toán <b>${this.record._type.name}</b> số tiền <b>${this.$numtoString(this.record.amount)}</b> vào tài khoản <b>${this.record._account.code}</b> thành công.`,
|
||||
"Thành công",
|
||||
"Success",
|
||||
10,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,326 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="record"
|
||||
id="printable"
|
||||
>
|
||||
<Caption v-bind="{ title: $lang('info') }" />
|
||||
<div class="columns is-multiline is-2 m-0">
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("code") }}:</label>
|
||||
<div class="control">
|
||||
<span>{{ record.code }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.type"
|
||||
>
|
||||
{{ errors.type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Tài khoản:</label>
|
||||
<div class="control">
|
||||
<span>{{ record.account__code }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.type"
|
||||
>
|
||||
{{ errors.type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Ngày hạch toán:</label>
|
||||
<div class="control">
|
||||
{{ $dayjs(record.date).format("L") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("amount-only") }}:</label>
|
||||
<div class="control">
|
||||
{{ $numtoString(record.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Thu / chi:</label>
|
||||
<div class="control">
|
||||
{{ record.type__name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Dư trước:</label>
|
||||
<div class="control">
|
||||
{{ $numtoString(record.balance_before) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Dư sau:</label>
|
||||
<div class="control">
|
||||
{{ $numtoString(record.balance_after) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Mã sản phẩm:</label>
|
||||
<div class="control">
|
||||
{{ record.product__trade_code }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Mã khách hàng:</label>
|
||||
<div class="control">
|
||||
{{ record.customer__code }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Người hạch toán:</label>
|
||||
<div class="control">
|
||||
{{ `${record.inputer__fullname}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("time") }}:</label>
|
||||
<div class="control">
|
||||
{{ `${$dayjs(record.create_time).format("L")}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Ref:</label>
|
||||
<div class="control">
|
||||
{{ `${record.ref || "/"}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-8">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("content") }}:</label>
|
||||
<div class="control">
|
||||
{{ `${record.content}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHẦN THÔNG TIN PHÂN BỔ -->
|
||||
<Caption v-bind="{ title: 'Thông tin phân bổ' }" />
|
||||
<!-- BẢNG CHI TIẾT PHÂN BỔ -->
|
||||
<div
|
||||
v-if="record.allocation_detail && record.allocation_detail.length > 0"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-striped is-hoverable is-bordered">
|
||||
<thead>
|
||||
<tr class="">
|
||||
<th class="has-background-primary has-text-white has-text-centered">STT</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Mã lịch</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Loại</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Tổng phân bổ</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Gốc</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Phạt</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Miễn lãi</th>
|
||||
<th class="has-background-primary has-text-white has-text-centered">Ngày phân bổ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in record.allocation_detail"
|
||||
:key="index"
|
||||
>
|
||||
<td class="has-text-centered">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<span class="tag is-link is-light">{{ item.schedule_code || item.schedule_id }}</span>
|
||||
</td>
|
||||
<td class="has-text-centered">
|
||||
<span
|
||||
v-if="item.type === 'REDUCTION'"
|
||||
class="tag is-warning"
|
||||
>Miễn lãi</span
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="tag is-success"
|
||||
>Thanh toán</span
|
||||
>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<strong>{{ $numtoString(item.amount) }}</strong>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<span
|
||||
v-if="item.principal"
|
||||
class="has-text-info has-text-weight-semibold"
|
||||
>
|
||||
{{ $numtoString(item.principal) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="has-text-grey-light"
|
||||
>-</span
|
||||
>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<span
|
||||
v-if="item.penalty"
|
||||
class="has-text-danger has-text-weight-semibold"
|
||||
>
|
||||
{{ $numtoString(item.penalty) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="has-text-grey-light"
|
||||
>-</span
|
||||
>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<span
|
||||
v-if="item.penalty"
|
||||
class="has-text-danger has-text-weight-semibold"
|
||||
>
|
||||
{{ $numtoString(item.penalty_reduce) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="has-text-grey-light"
|
||||
>-</span
|
||||
>
|
||||
</td>
|
||||
<td class="has-text-centered">
|
||||
{{ $dayjs(item.date).format("L HH:mm:ss") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="has-background-light">
|
||||
<td
|
||||
colspan="3"
|
||||
class="has-text-right has-text-weight-bold"
|
||||
>
|
||||
Tổng cộng:
|
||||
</td>
|
||||
<td class="has-text-right has-text-weight-bold">
|
||||
{{ $numtoString(totalAllocated) }}
|
||||
</td>
|
||||
<td class="has-text-right has-text-weight-bold has-text-info">
|
||||
{{ $numtoString(totalPrincipal) }}
|
||||
</td>
|
||||
<td class="has-text-right has-text-weight-bold has-text-danger">
|
||||
{{ $numtoString(totalPenalty) }}
|
||||
</td>
|
||||
<td class="has-text-right has-text-weight-bold has-text-danger">
|
||||
{{ $numtoString(totalPenaltyReduce) }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="notification is-info is-light mt-4"
|
||||
>
|
||||
<p class="has-text-centered">Chưa có dữ liệu phân bổ cho bút toán này.</p>
|
||||
</div>
|
||||
|
||||
<Caption
|
||||
class="mt-5"
|
||||
v-bind="{ title: 'Chứng từ' }"
|
||||
></Caption>
|
||||
<FileGallery v-bind="{ row: record, api: 'entryfile' }"></FileGallery>
|
||||
<div
|
||||
class="mt-5"
|
||||
id="ignore"
|
||||
>
|
||||
<button
|
||||
class="button is-primary has-text-white mr-2"
|
||||
@click="$exportpdf('printable', record.code, 'a4', 'landscape')"
|
||||
>
|
||||
{{ $lang("print") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="record.category === 2"
|
||||
class="button is-light"
|
||||
@click="viewPhieuThuTienMat"
|
||||
>
|
||||
Xem phiếu thu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ["row"],
|
||||
data() {
|
||||
return {
|
||||
errors: {},
|
||||
record: undefined,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
this.record = await this.$getdata("internalentry", { code: this.row.code }, undefined, true);
|
||||
},
|
||||
computed: {
|
||||
// Tính tổng số tiền đã phân bổ
|
||||
totalAllocated() {
|
||||
if (!this.record || !this.record.allocation_detail) return 0;
|
||||
return this.record.allocation_detail.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
},
|
||||
// Tính tổng gốc
|
||||
totalPrincipal() {
|
||||
if (!this.record || !this.record.allocation_detail) return 0;
|
||||
return this.record.allocation_detail.reduce((sum, item) => sum + (item.principal || 0), 0);
|
||||
},
|
||||
// Tính tổng phạt
|
||||
totalPenalty() {
|
||||
if (!this.record || !this.record.allocation_detail) return 0;
|
||||
return this.record.allocation_detail.reduce((sum, item) => sum + (item.penalty || 0), 0);
|
||||
},
|
||||
// Tính tổng phạt đã giảm
|
||||
totalPenaltyReduce() {
|
||||
if (!this.record || !this.record.allocation_detail) return 0;
|
||||
return this.record.allocation_detail.reduce((sum, item) => sum + (item.penalty_reduce || 0), 0);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selected(attr, obj) {
|
||||
this.record[attr] = obj;
|
||||
this.record = this.$copy(this.record);
|
||||
if (attr === "_type") this.category = obj.category__code;
|
||||
},
|
||||
viewPhieuThuTienMat() {
|
||||
const url = `${this.$getpath()}static/contract/PHIEU_THU_TIEN_MAT-${this.record.code}.pdf`;
|
||||
window.open(url, "_blank");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.column {
|
||||
padding-inline: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,191 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("source-account") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'internalaccount',
|
||||
field: 'label',
|
||||
column: ['label'],
|
||||
first: true,
|
||||
optionid: row.id,
|
||||
}"
|
||||
@option="selected('_source', $event)"
|
||||
v-if="!record.id"
|
||||
></SearchBox>
|
||||
<span v-else>{{ record.account__code }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.source"
|
||||
>
|
||||
{{ errors.source }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("dest-account") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="vbind"
|
||||
@option="selected('_target', $event)"
|
||||
v-if="vbind"
|
||||
></SearchBox>
|
||||
<span v-else>{{ record.account__code }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.target"
|
||||
>
|
||||
{{ errors.target }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("amount-only") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<InputNumber
|
||||
v-bind="{ record: record, attr: 'amount', placeholder: '' }"
|
||||
@number="selected('amount', $event)"
|
||||
></InputNumber>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.amount"
|
||||
>
|
||||
{{ errors.amount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label">{{ $lang("content") }}<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<textarea
|
||||
class="textarea"
|
||||
rows="2"
|
||||
v-model="record.content"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.content"
|
||||
>
|
||||
{{ errors.content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<button
|
||||
class="button is-primary has-text-white"
|
||||
@click="confirm()"
|
||||
>
|
||||
{{ $lang("confirm") }}
|
||||
</button>
|
||||
</div>
|
||||
<Modal
|
||||
@close="showmodal = undefined"
|
||||
v-bind="showmodal"
|
||||
@confirm="update()"
|
||||
v-if="showmodal"
|
||||
></Modal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["pagename", "row"],
|
||||
data() {
|
||||
return {
|
||||
record: {},
|
||||
errors: {},
|
||||
showmodal: undefined,
|
||||
vbind: undefined,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
selected(attr, obj) {
|
||||
this.record[attr] = obj;
|
||||
this.record = this.$copy(this.record);
|
||||
if (attr === "_source") {
|
||||
let currency = obj ? obj.currency : undefined;
|
||||
this.vbind = undefined;
|
||||
setTimeout(
|
||||
() =>
|
||||
(this.vbind = {
|
||||
api: "internalaccount",
|
||||
field: "label",
|
||||
column: ["label"],
|
||||
first: true,
|
||||
filter: { currency: currency },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
checkError() {
|
||||
this.errors = {};
|
||||
if (this.$empty(this.record._source)) this.errors.source = "Chưa chọn tài khoản nguồn";
|
||||
if (this.$empty(this.record._target)) this.errors.target = "Chưa chọn tài khoản đích";
|
||||
if (Object.keys(this.errors).length === 0) {
|
||||
if (this.record._source.id === this.record._target.id)
|
||||
this.errors.target = "Tài khoản nguồn phải khác tài khoản đích";
|
||||
}
|
||||
if (this.$empty(this.record.amount)) this.errors.amount = "Chưa nhập số tiền";
|
||||
else if (this.$formatNumber(this.record.amount) <= 0) this.errors.amount = "Số tiền phải > 0";
|
||||
else if (this.record._source.balance < this.$formatNumber(this.record.amount))
|
||||
this.errors.source = "Tài khoản nguồn không đủ số dư để điều chuyển";
|
||||
if (this.$empty(this.record.content)) this.errors.content = "Chưa nhập nội dung";
|
||||
return Object.keys(this.errors).length > 0;
|
||||
},
|
||||
confirm() {
|
||||
if (this.checkError()) return;
|
||||
this.showmodal = {
|
||||
component: `dialog/Confirm`,
|
||||
vbind: { content: this.$lang("confirm-action"), duration: 10 },
|
||||
title: this.$lang("confirm"),
|
||||
width: "500px",
|
||||
height: "100px",
|
||||
};
|
||||
},
|
||||
async update() {
|
||||
let content = `${this.record.content} (${this.record._source.code} -> ${this.record._target.code})`;
|
||||
let obj1 = {
|
||||
code: this.record._source.code,
|
||||
amount: this.record.amount,
|
||||
content: content,
|
||||
type: "DR",
|
||||
category: 2,
|
||||
user: this.$store.login.id,
|
||||
};
|
||||
let rs1 = await this.$insertapi("accountentry", obj1, undefined, false);
|
||||
if (rs1 === "error") return;
|
||||
let obj2 = {
|
||||
code: this.record._target.code,
|
||||
amount: this.record.amount,
|
||||
content: content,
|
||||
type: "CR",
|
||||
category: 2,
|
||||
user: this.$store.login.id,
|
||||
};
|
||||
let rs2 = await this.$insertapi("accountentry", obj2, undefined, false);
|
||||
if (rs2 === "error") return;
|
||||
let data = await this.$getdata("internalaccount", {
|
||||
code__in: [this.record._source.code, this.record._target.code],
|
||||
});
|
||||
this.$updatepage(this.pagename, data);
|
||||
this.$dialog(
|
||||
`Điều chuyển vốn <b>${this.$numtoString(this.record.amount)}</b> từ <b>${this.record._source.code}</b> tới <b>${this.record._target.code}</b> thành công.`,
|
||||
"Thành công",
|
||||
"Success",
|
||||
10,
|
||||
);
|
||||
this.$emit("close");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,344 +0,0 @@
|
||||
<template>
|
||||
<div class="content-transaction-invoice">
|
||||
<div class="container is-fluid px-4">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<label class="label">Link<b class="ml-1 has-text-danger">*</b></label>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label">Mã tra cứu<b class="ml-1 has-text-danger">*</b></label>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label">Số tiền<b class="ml-1 has-text-danger">*</b></label>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label">Loại tiền<b class="ml-1 has-text-danger">*</b></label>
|
||||
</div>
|
||||
<div class="column is-1"></div>
|
||||
</div>
|
||||
<div
|
||||
class="columns"
|
||||
v-for="(invoice, index) in invoices"
|
||||
>
|
||||
<div class="column">
|
||||
<input
|
||||
class="input has-text-centered has-text-weight-bold has-text-left"
|
||||
type="text"
|
||||
placeholder="Nhập link tra cứu"
|
||||
v-model="invoice.link"
|
||||
@blur="
|
||||
validateField({
|
||||
value: invoice.link,
|
||||
type: 'link',
|
||||
index,
|
||||
field: 'errorLink',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="invoice.errorLink"
|
||||
class="help is-danger"
|
||||
>
|
||||
Link phải bắt đầu bằng https
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<input
|
||||
class="input has-text-centered has-text-weight-bold has-text-left"
|
||||
type="text"
|
||||
placeholder="Nhập mã tra cứu"
|
||||
v-model="invoice.ref_code"
|
||||
@blur="
|
||||
validateField({
|
||||
value: invoice.ref_code,
|
||||
type: 'text',
|
||||
index,
|
||||
field: 'errorCode',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="invoice.errorCode"
|
||||
class="help is-danger"
|
||||
>
|
||||
Mã tra cứu không được bỏ trống
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<input
|
||||
class="input has-text-centered has-text-weight-bold has-text-right"
|
||||
type="number"
|
||||
placeholder="Số tiền"
|
||||
v-model="invoice.amount"
|
||||
@blur="
|
||||
validateField({
|
||||
value: invoice.amount,
|
||||
type: 'text',
|
||||
index,
|
||||
field: 'errorAmount',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="invoice.errorAmount"
|
||||
class="help is-danger"
|
||||
>
|
||||
Số tiền không được bỏ trống
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<select
|
||||
v-model="invoice.note"
|
||||
style="width: 100%; height: var(--bulma-control-height)"
|
||||
@blur="
|
||||
validateField({
|
||||
value: invoice.note,
|
||||
type: 'text',
|
||||
index,
|
||||
field: 'errorType',
|
||||
})
|
||||
"
|
||||
>
|
||||
<option value="principal">Tiền gốc</option>
|
||||
<option value="interest">Tiền lãi</option>
|
||||
</select>
|
||||
<p
|
||||
v-if="invoice.errorType"
|
||||
class="help is-danger"
|
||||
>
|
||||
Loại tiền không được bỏ trống
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-narrow is-1">
|
||||
<label
|
||||
class="label"
|
||||
v-if="i === 0"
|
||||
> </label
|
||||
>
|
||||
<div
|
||||
class="buttons is-gap-0.5 is-flex-wrap-nowrap are-small"
|
||||
style="height: 40px"
|
||||
>
|
||||
<button
|
||||
class="button is-dark"
|
||||
@click="handlerRemove(index)"
|
||||
>
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 20 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-dark"
|
||||
@click="add()"
|
||||
>
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 20 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<a
|
||||
class="button is-dark"
|
||||
:href="invoice.link"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'view.svg', type: 'white', size: 20 }"></SvgIcon>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 buttons is-right">
|
||||
<button
|
||||
class="button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ isVietnamese ? "Hủy" : "Cancel" }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary"
|
||||
@click="handlerUpdate"
|
||||
>
|
||||
{{ isVietnamese ? "Lưu lại" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { $snackbar, $getdata, $insertapi, $store, $updateapi, $deleteapi, $formatNumber } = useNuxtApp();
|
||||
|
||||
const isVietnamese = computed(() => $store.lang.toLowerCase() === "vi");
|
||||
|
||||
const invoices = ref([{}]);
|
||||
const delInvoices = ref([]);
|
||||
const emit = defineEmits(["close"]);
|
||||
const props = defineProps({
|
||||
row: Object,
|
||||
});
|
||||
const resInvoice = await $getdata("invoice", { payment: props.row.id }, undefined, false);
|
||||
|
||||
const validateField = ({ value, type = "text", index, field }) => {
|
||||
if (index < 0 || index >= invoices.value.length) return false;
|
||||
|
||||
const val = value?.toString().trim();
|
||||
let isInvalid = false;
|
||||
|
||||
// 1. Không được bỏ trống (áp dụng cho tất cả)
|
||||
if (!val) {
|
||||
isInvalid = true;
|
||||
}
|
||||
|
||||
// 2. Validate theo type
|
||||
if (!isInvalid && type === "link") {
|
||||
isInvalid = !/^https:\/\//.test(val);
|
||||
}
|
||||
|
||||
// set lỗi
|
||||
invoices.value[index][field] = isInvalid;
|
||||
|
||||
return !isInvalid;
|
||||
};
|
||||
|
||||
if (resInvoice.length) {
|
||||
const error = {
|
||||
errorLink: false,
|
||||
errorCode: false,
|
||||
errorAmount: false,
|
||||
errorType: false,
|
||||
};
|
||||
const formatData = resInvoice.map((invoice) => ({
|
||||
...invoice,
|
||||
amount: $formatNumber(invoice.amount),
|
||||
...error,
|
||||
}));
|
||||
|
||||
invoices.value = formatData;
|
||||
}
|
||||
const add = () => invoices.value.push({});
|
||||
|
||||
const validateAll = () => {
|
||||
const errors = [];
|
||||
|
||||
invoices.value.forEach((inv, index) => {
|
||||
const checks = [
|
||||
{
|
||||
value: inv.link,
|
||||
type: "link",
|
||||
field: "errorLink",
|
||||
label: "Link",
|
||||
},
|
||||
{
|
||||
value: inv.ref_code,
|
||||
type: "number",
|
||||
field: "errorCode",
|
||||
label: "Mã tham chiếu",
|
||||
},
|
||||
{
|
||||
value: inv.amount,
|
||||
type: "number",
|
||||
field: "errorAmount",
|
||||
label: "Số tiền",
|
||||
},
|
||||
{
|
||||
value: inv.note,
|
||||
type: "number",
|
||||
field: "errorType",
|
||||
label: "Số tiền",
|
||||
},
|
||||
];
|
||||
|
||||
checks.forEach(({ value, type, field, label }) => {
|
||||
const isValid = validateField({
|
||||
value,
|
||||
type,
|
||||
index,
|
||||
field,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
errors.push({
|
||||
index,
|
||||
field,
|
||||
label,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const handlerUpdate = async () => {
|
||||
try {
|
||||
// 1. Insert / Update
|
||||
if (!validateAll()?.valid) {
|
||||
$snackbar("Dữ liệu chưa hợp lệ");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const invoice of invoices.value) {
|
||||
let res;
|
||||
|
||||
if (invoice.id) {
|
||||
res = await $updateapi("invoice", invoice, undefined, false);
|
||||
} else {
|
||||
const dataSend = {
|
||||
...invoice,
|
||||
payment: props.row.id,
|
||||
};
|
||||
res = await $insertapi("invoice", dataSend, undefined, false);
|
||||
}
|
||||
|
||||
if (!res || res === "error") {
|
||||
throw new Error("Save invoice failed");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete
|
||||
for (const id of delInvoices.value) {
|
||||
const res = await $deleteapi("invoice", id);
|
||||
if (!res || res === "error") {
|
||||
throw new Error("Delete invoice failed");
|
||||
}
|
||||
}
|
||||
|
||||
$snackbar("Lưu hóa đơn thành công");
|
||||
emit("close");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
$snackbar("Có lỗi khi lưu hóa đơn");
|
||||
}
|
||||
};
|
||||
|
||||
const handlerRemove = (index) => {
|
||||
if (index < 0 || index >= invoices.value.length) return;
|
||||
|
||||
const [removed] = invoices.value.splice(index, 1);
|
||||
|
||||
if (removed?.id && !delInvoices.value.includes(removed.id)) {
|
||||
delInvoices.value.push(removed.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content-transaction-invoice input,
|
||||
select {
|
||||
border-radius: 5px;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
.content-transaction-invoice input[type="number"]::-webkit-inner-spin-button,
|
||||
.content-transaction-invoice input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-transaction-invoice input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,161 +0,0 @@
|
||||
<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>
|
||||
@@ -1,286 +0,0 @@
|
||||
<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>
|
||||
@@ -1,556 +0,0 @@
|
||||
<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("L 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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,798 +0,0 @@
|
||||
<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)"
|
||||
/>
|
||||
|
||||
<!-- 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)"
|
||||
/>
|
||||
|
||||
<!-- 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("L");
|
||||
};
|
||||
</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
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="row.count_note || $getEditRights()"
|
||||
class="dot-primary"
|
||||
@click="doClick()"
|
||||
>{{ row.count_note || "+" }}</span
|
||||
>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["row", "api", "pagename"],
|
||||
methods: {
|
||||
doClick() {
|
||||
let obj = {
|
||||
component: "common/NoteInfo",
|
||||
title: "Ghi chú",
|
||||
width: "50%",
|
||||
vbind: { row: this.row, api: this.api, pagename: this.pagename },
|
||||
};
|
||||
this.$emit("open", { name: "dataevent", data: { modal: obj } });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
class="dot-primary"
|
||||
@click="onClick()"
|
||||
>{{ row.count_product }}</span
|
||||
>
|
||||
</template>
|
||||
<script>
|
||||
// use in Khách hàng -> Giao dịch (<DataView :setting='customer-all-transaction'/>)
|
||||
export default {
|
||||
props: ["row", "api", "pagename"],
|
||||
methods: {
|
||||
onClick() {
|
||||
const obj = {
|
||||
component: "common/ProductInfo",
|
||||
title: "Sản phẩm",
|
||||
width: "60%",
|
||||
height: "400px",
|
||||
vbind: {
|
||||
row: this.row,
|
||||
pagename: this.pagename,
|
||||
},
|
||||
};
|
||||
this.$emit("open", { name: "dataevent", data: { modal: obj } });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,363 +0,0 @@
|
||||
<script setup>
|
||||
import Template1 from "@/lib/email/templates/Template1.vue";
|
||||
import { render } from "@vue-email/render";
|
||||
import { forEachAsync, isEqual } from "es-toolkit";
|
||||
|
||||
const {
|
||||
$dayjs,
|
||||
$getdata,
|
||||
$insertapi,
|
||||
$numtoString,
|
||||
$numberToVietnamese,
|
||||
$numberToVietnameseCurrency,
|
||||
$formatDateVN,
|
||||
$getFirstAndLastName,
|
||||
$snackbar,
|
||||
$store,
|
||||
} = useNuxtApp();
|
||||
|
||||
const payables = ref(null);
|
||||
const defaultFilter = {
|
||||
status: 1,
|
||||
to_date__gte: $dayjs().format("YYYY-MM-DD"),
|
||||
to_date__lte: undefined,
|
||||
};
|
||||
const filter = ref(defaultFilter);
|
||||
const activeDateFilter = ref(null);
|
||||
const key = ref(0);
|
||||
|
||||
function setDateFilter(detail) {
|
||||
activeDateFilter.value = isEqual(activeDateFilter.value, detail) ? null : detail;
|
||||
}
|
||||
|
||||
function resetDateFilter() {
|
||||
activeDateFilter.value = null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const payablesData = await $getdata("bizsetting", undefined, {
|
||||
filter: { classify: "duepayables" },
|
||||
sort: "index",
|
||||
});
|
||||
payables.value = payablesData;
|
||||
});
|
||||
|
||||
watch(
|
||||
activeDateFilter,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
filter.value = defaultFilter;
|
||||
contents.value = null;
|
||||
} else {
|
||||
const cutoffDate = $dayjs().add(val.time, "day").format("YYYY-MM-DD");
|
||||
const filterField = `to_date__${val.lookup}`;
|
||||
filter.value = {
|
||||
...defaultFilter,
|
||||
[filterField]: cutoffDate,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const contents = ref(null);
|
||||
const isSending = ref(false);
|
||||
|
||||
function sanitizeContentPayment(text, maxLength = 80) {
|
||||
if (!text) return "";
|
||||
|
||||
return text
|
||||
.normalize("NFD") // bỏ dấu tiếng Việt
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "") // bỏ ký tự lạ
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const buildQrHtml = (url) => `
|
||||
<div style="text-align: center; margin-top: 16px">
|
||||
<img
|
||||
src="${url}"
|
||||
alt="VietQR"
|
||||
width="500"
|
||||
style="display: inline-block"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const buildContentPayment = (data) => {
|
||||
const {
|
||||
txn_detail__transaction__customer__type__code: customerType,
|
||||
txn_detail__transaction__customer__code: customerCode,
|
||||
txn_detail__transaction__customer__fullname: customerName,
|
||||
txn_detail__transaction__product__trade_code: productCode,
|
||||
cycle,
|
||||
} = data;
|
||||
|
||||
if (customerType.toLowerCase() === "cn") {
|
||||
if (customerName.length < 14) {
|
||||
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
|
||||
} else {
|
||||
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
|
||||
}
|
||||
} else {
|
||||
return `${productCode} ${customerCode} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
|
||||
}
|
||||
};
|
||||
|
||||
function replaceTemplateVars(html, paymentScheduleItem) {
|
||||
const {
|
||||
txn_detail__transaction__product__trade_code,
|
||||
txn_detail__transaction__customer__code,
|
||||
txn_detail__transaction__customer__fullname,
|
||||
txn_detail__transaction__customer__legal_code,
|
||||
txn_detail__transaction__customer__type__code,
|
||||
txn_detail__transaction__customer__contact_address,
|
||||
txn_detail__transaction__customer__address,
|
||||
txn_detail__transaction__customer__phone,
|
||||
from_date,
|
||||
to_date,
|
||||
remain_amount,
|
||||
cycle,
|
||||
} = paymentScheduleItem;
|
||||
return html
|
||||
.replace(/\[day]/g, String(new Date().getDate()).padStart(2, "0") || "")
|
||||
.replace(/\[month]/g, String(new Date().getMonth() + 1).padStart(2, "0") || "")
|
||||
.replace(/\[year]/g, new Date().getFullYear() || "")
|
||||
.replace(/\[product\.trade_code\]/g, txn_detail__transaction__product__trade_code)
|
||||
.replace(/\[product\.trade_code_payment\]/g, sanitizeContentPayment(txn_detail__transaction__product__trade_code))
|
||||
.replace(/\[customer\.fullname\]/g, txn_detail__transaction__customer__fullname)
|
||||
.replace(
|
||||
/\[customer\.name\]/g,
|
||||
`${txn_detail__transaction__customer__type__code.toLowerCase() == "cn" ? (txn_detail__transaction__customer__fullname.length < 14 ? txn_detail__transaction__customer__fullname : $getFirstAndLastName(txn_detail__transaction__customer__fullname)) : ""}` ||
|
||||
"",
|
||||
)
|
||||
.replace(/\[customer\.code\]/g, txn_detail__transaction__customer__code || "")
|
||||
.replace(/\[customer\.legal_code\]/g, txn_detail__transaction__customer__legal_code || "")
|
||||
.replace(
|
||||
/\[customer\.contact_address\]/g,
|
||||
txn_detail__transaction__customer__contact_address || txn_detail__transaction__customer__address || "",
|
||||
)
|
||||
.replace(/\[customer\.phone\]/g, txn_detail__transaction__customer__phone || "")
|
||||
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(from_date) || "")
|
||||
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(to_date) || "")
|
||||
.replace(/\[payment_schedule\.amount\]/g, $numtoString(remain_amount) || "")
|
||||
.replace(/\[payment_schedule\.amount_in_word\]/g, $numberToVietnameseCurrency(remain_amount) || "")
|
||||
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(cycle) || "")
|
||||
.replace(
|
||||
/\[payment_schedule\.cycle-in-words\]/g,
|
||||
`${cycle == 0 ? "đặt cọc" : `đợt thứ ${$numberToVietnamese(cycle).toLowerCase()}`}` || "",
|
||||
)
|
||||
.replace(/\[payment_schedule\.note\]/g, `${cycle == 0 ? "Dat coc" : `Dot ${cycle}`}` || "");
|
||||
}
|
||||
|
||||
function quillToEmailHtml(html) {
|
||||
return (
|
||||
html
|
||||
// ALIGN
|
||||
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
|
||||
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
|
||||
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
|
||||
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
|
||||
|
||||
// FONT SIZE
|
||||
.replace(/ql-size-small/g, "")
|
||||
.replace(/ql-size-large/g, "")
|
||||
.replace(/ql-size-huge/g, "")
|
||||
|
||||
// REMOVE EMPTY CLASS
|
||||
.replace(/class=""/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
const showmodal = ref(null);
|
||||
|
||||
function openConfirmModal() {
|
||||
showmodal.value = {
|
||||
component: "dialog/Confirm",
|
||||
title: "Xác nhận",
|
||||
width: "500px",
|
||||
height: "100px",
|
||||
vbind: {
|
||||
content: "Bạn có đồng ý gửi thông báo hàng loạt không?",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendEmails() {
|
||||
isSending.value = true;
|
||||
$snackbar("Hệ thống đang xử lý ngầm yêu cầu gửi email hàng loạt...");
|
||||
|
||||
const paymentScheduleData = await $getdata("payment_schedule", undefined, {
|
||||
filter: filter.value,
|
||||
sort: "to_date",
|
||||
values:
|
||||
"penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__legal_code,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__phone,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry",
|
||||
});
|
||||
|
||||
const emailTemplate = await $getdata("emailtemplate", { id: activeDateFilter.value.emailTemplate }, undefined, true);
|
||||
|
||||
let message = emailTemplate.content.content;
|
||||
|
||||
contents.value = paymentScheduleData.map((paymentSchedule) => {
|
||||
message = message.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, "");
|
||||
const transfer = {
|
||||
bank: {
|
||||
code: "MB",
|
||||
name: "MB Bank",
|
||||
},
|
||||
account: {
|
||||
number: "146768686868",
|
||||
name: "CONG TY CO PHAN BAT DONG SAN UTOPIA",
|
||||
},
|
||||
content: "Thanh toán đơn #xyz",
|
||||
};
|
||||
|
||||
transfer.content = buildContentPayment(paymentSchedule);
|
||||
const params = new URLSearchParams({
|
||||
addInfo: transfer.content,
|
||||
accountName: transfer.account.name,
|
||||
});
|
||||
const qrImageUrl = `https://img.vietqr.io/image/${transfer.bank.code}-${transfer.account.number}-print.png?${params.toString()}`;
|
||||
|
||||
message = `
|
||||
${message.trim()}
|
||||
|
||||
${buildQrHtml(qrImageUrl)}
|
||||
`;
|
||||
return {
|
||||
...emailTemplate.content,
|
||||
content: undefined,
|
||||
message: replaceTemplateVars(message, paymentSchedule),
|
||||
};
|
||||
});
|
||||
|
||||
await forEachAsync(contents.value, async (bigContent, i) => {
|
||||
const { imageUrl, keyword, message, subject } = bigContent;
|
||||
const tempEm = {
|
||||
content: toRaw(bigContent),
|
||||
previewMode: true,
|
||||
};
|
||||
|
||||
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
|
||||
tempEm.content.message = quillToEmailHtml(message);
|
||||
let emailHtml = await render(Template1, tempEm);
|
||||
|
||||
// If no image URL provided, remove image section from HTML
|
||||
if ((imageUrl ?? "").trim() === "") {
|
||||
emailHtml = emailHtml
|
||||
.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, "")
|
||||
.replace(/\n\s*\n\s*\n/g, "\n\n");
|
||||
}
|
||||
|
||||
// Replace keywords in HTML
|
||||
let finalEmailHtml = emailHtml;
|
||||
if (keyword && keyword.length > 0) {
|
||||
keyword.forEach(({ keyword, value }) => {
|
||||
if (keyword && value) {
|
||||
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, "g");
|
||||
finalEmailHtml = finalEmailHtml.replace(regex, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await $insertapi(
|
||||
"sendemail",
|
||||
{
|
||||
to: paymentScheduleData[i].txn_detail__transaction__customer__email,
|
||||
content: finalEmailHtml,
|
||||
subject: replaceTemplateVars(subject, paymentScheduleData[i]) || "Thông báo từ Utopia Villas & Resort",
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
if (response !== null) {
|
||||
await $insertapi(
|
||||
"productnote",
|
||||
{
|
||||
ref: paymentScheduleData[i].txn_detail__transaction__product,
|
||||
user: $store.login.id,
|
||||
detail: `Đã gửi email thông báo nhắc nợ cho sản phẩm ${paymentScheduleData[i].txn_detail__transaction__product__trade_code} vào lúc ${$dayjs().format("HH:mm ngày L")}.`,
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
$snackbar("Thông báo đã được gửi thành công đến các khách hàng.");
|
||||
isSending.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
watch(
|
||||
filter,
|
||||
() => {
|
||||
key.value += 1;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="is-flex is-justify-content-space-between is-align-content-center mb-4">
|
||||
<div class="buttons m-0">
|
||||
<p>Đến hạn:</p>
|
||||
<button
|
||||
v-for="payable in payables"
|
||||
:key="payable.id"
|
||||
@click="setDateFilter(payable.detail)"
|
||||
:class="['button', { 'is-primary': isEqual(activeDateFilter, payable.detail) }]"
|
||||
>
|
||||
{{ payable.detail.lookup === "lte" ? "≤" : ">" }}
|
||||
{{ payable.detail.time }} ngày
|
||||
</button>
|
||||
<button
|
||||
v-if="activeDateFilter"
|
||||
@click="resetDateFilter()"
|
||||
class="button is-white"
|
||||
>
|
||||
Xoá lọc
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="activeDateFilter"
|
||||
@click="openConfirmModal()"
|
||||
:class="['button', 'is-light', { 'is-loading': isSending }]"
|
||||
>
|
||||
Gửi thông báo
|
||||
</button>
|
||||
</div>
|
||||
<DataView
|
||||
:key="key"
|
||||
v-bind="{
|
||||
pagename: 'payment-schedule-due',
|
||||
api: 'payment_schedule',
|
||||
setting: 'payment-schedule-debt-due',
|
||||
realtime: { time: '5', update: 'true' },
|
||||
params: {
|
||||
filter,
|
||||
sort: 'to_date',
|
||||
values:
|
||||
'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,ovd_days,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<Modal
|
||||
v-if="showmodal"
|
||||
v-bind="showmodal"
|
||||
@confirm="sendEmails()"
|
||||
@close="showmodal = undefined"
|
||||
/>
|
||||
<!-- <div class="is-flex is-gap-1">
|
||||
// debug
|
||||
<Template1
|
||||
v-if="contents"
|
||||
v-for="content in contents"
|
||||
:content="content"
|
||||
previewMode
|
||||
/>
|
||||
</div> -->
|
||||
</template>
|
||||
@@ -1,362 +0,0 @@
|
||||
<script setup>
|
||||
import Template1 from "@/lib/email/templates/Template1.vue";
|
||||
import { render } from "@vue-email/render";
|
||||
import { forEachAsync, isEqual } from "es-toolkit";
|
||||
|
||||
const {
|
||||
$dayjs,
|
||||
$getdata,
|
||||
$insertapi,
|
||||
$numtoString,
|
||||
$numberToVietnamese,
|
||||
$numberToVietnameseCurrency,
|
||||
$formatDateVN,
|
||||
$getFirstAndLastName,
|
||||
$snackbar,
|
||||
$store,
|
||||
} = useNuxtApp();
|
||||
|
||||
const payables = ref(null);
|
||||
const defaultFilter = {
|
||||
status: 1,
|
||||
to_date__lt: $dayjs().format("YYYY-MM-DD"),
|
||||
};
|
||||
const filter = ref(defaultFilter);
|
||||
const activeDateFilter = ref(null);
|
||||
const key = ref(0);
|
||||
|
||||
function setDateFilter(detail) {
|
||||
activeDateFilter.value = isEqual(activeDateFilter.value, detail) ? null : detail;
|
||||
}
|
||||
|
||||
function resetDateFilter() {
|
||||
activeDateFilter.value = null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const payablesData = await $getdata("bizsetting", undefined, {
|
||||
filter: { classify: "overduepayables" },
|
||||
sort: "index",
|
||||
});
|
||||
payables.value = payablesData;
|
||||
});
|
||||
|
||||
watch(
|
||||
activeDateFilter,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
filter.value = defaultFilter;
|
||||
contents.value = null;
|
||||
} else {
|
||||
const cutoffDate = $dayjs().subtract(val.time, "day").format("YYYY-MM-DD");
|
||||
const filterField = `to_date__${val.lookup === "lte" ? "gt" : "lte"}`;
|
||||
filter.value = {
|
||||
...defaultFilter,
|
||||
[filterField]: cutoffDate,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const contents = ref(null);
|
||||
const isSending = ref(false);
|
||||
|
||||
function sanitizeContentPayment(text, maxLength = 80) {
|
||||
if (!text) return "";
|
||||
|
||||
return text
|
||||
.normalize("NFD") // bỏ dấu tiếng Việt
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "") // bỏ ký tự lạ
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const buildQrHtml = (url) => `
|
||||
<div style="text-align: center; margin-top: 16px">
|
||||
<img
|
||||
src="${url}"
|
||||
alt="VietQR"
|
||||
width="500"
|
||||
style="display: inline-block"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const buildContentPayment = (data) => {
|
||||
const {
|
||||
txn_detail__transaction__customer__type__code: customerType,
|
||||
txn_detail__transaction__customer__code: customerCode,
|
||||
txn_detail__transaction__customer__fullname: customerName,
|
||||
txn_detail__transaction__product__trade_code: productCode,
|
||||
cycle,
|
||||
} = data;
|
||||
|
||||
if (customerType.toLowerCase() === "cn") {
|
||||
if (customerName.length < 14) {
|
||||
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
|
||||
} else {
|
||||
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
|
||||
}
|
||||
} else {
|
||||
return `${productCode} ${customerCode} TT ${cycle == 0 ? "Dat Coc" : `Dot ${cycle}`}`;
|
||||
}
|
||||
};
|
||||
|
||||
function replaceTemplateVars(html, paymentScheduleItem) {
|
||||
const {
|
||||
txn_detail__transaction__product__trade_code,
|
||||
txn_detail__transaction__customer__code,
|
||||
txn_detail__transaction__customer__fullname,
|
||||
txn_detail__transaction__customer__legal_code,
|
||||
txn_detail__transaction__customer__type__code,
|
||||
txn_detail__transaction__customer__contact_address,
|
||||
txn_detail__transaction__customer__address,
|
||||
txn_detail__transaction__customer__phone,
|
||||
from_date,
|
||||
to_date,
|
||||
remain_amount,
|
||||
cycle,
|
||||
} = paymentScheduleItem;
|
||||
return html
|
||||
.replace(/\[day]/g, String(new Date().getDate()).padStart(2, "0") || "")
|
||||
.replace(/\[month]/g, String(new Date().getMonth() + 1).padStart(2, "0") || "")
|
||||
.replace(/\[year]/g, new Date().getFullYear() || "")
|
||||
.replace(/\[product\.trade_code\]/g, txn_detail__transaction__product__trade_code)
|
||||
.replace(/\[product\.trade_code_payment\]/g, sanitizeContentPayment(txn_detail__transaction__product__trade_code))
|
||||
.replace(/\[customer\.fullname\]/g, txn_detail__transaction__customer__fullname)
|
||||
.replace(
|
||||
/\[customer\.name\]/g,
|
||||
`${txn_detail__transaction__customer__type__code.toLowerCase() == "cn" ? (txn_detail__transaction__customer__fullname.length < 14 ? txn_detail__transaction__customer__fullname : $getFirstAndLastName(txn_detail__transaction__customer__fullname)) : ""}` ||
|
||||
"",
|
||||
)
|
||||
.replace(/\[customer\.code\]/g, txn_detail__transaction__customer__code || "")
|
||||
.replace(/\[customer\.legal_code\]/g, txn_detail__transaction__customer__legal_code || "")
|
||||
.replace(
|
||||
/\[customer\.contact_address\]/g,
|
||||
txn_detail__transaction__customer__contact_address || txn_detail__transaction__customer__address || "",
|
||||
)
|
||||
.replace(/\[customer\.phone\]/g, txn_detail__transaction__customer__phone || "")
|
||||
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(from_date) || "")
|
||||
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(to_date) || "")
|
||||
.replace(/\[payment_schedule\.amount\]/g, $numtoString(remain_amount) || "")
|
||||
.replace(/\[payment_schedule\.amount_in_word\]/g, $numberToVietnameseCurrency(remain_amount) || "")
|
||||
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(cycle) || "")
|
||||
.replace(
|
||||
/\[payment_schedule\.cycle-in-words\]/g,
|
||||
`${cycle == 0 ? "đặt cọc" : `đợt thứ ${$numberToVietnamese(cycle).toLowerCase()}`}` || "",
|
||||
)
|
||||
.replace(/\[payment_schedule\.note\]/g, `${cycle == 0 ? "Dat coc" : `Dot ${cycle}`}` || "");
|
||||
}
|
||||
|
||||
function quillToEmailHtml(html) {
|
||||
return (
|
||||
html
|
||||
// ALIGN
|
||||
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
|
||||
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
|
||||
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
|
||||
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
|
||||
|
||||
// FONT SIZE
|
||||
.replace(/ql-size-small/g, "")
|
||||
.replace(/ql-size-large/g, "")
|
||||
.replace(/ql-size-huge/g, "")
|
||||
|
||||
// REMOVE EMPTY CLASS
|
||||
.replace(/class=""/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
const showmodal = ref(null);
|
||||
|
||||
function openConfirmModal() {
|
||||
showmodal.value = {
|
||||
component: "dialog/Confirm",
|
||||
title: "Xác nhận",
|
||||
width: "500px",
|
||||
height: "100px",
|
||||
vbind: {
|
||||
content: "Bạn có đồng ý gửi thông báo hàng loạt không?",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendEmails() {
|
||||
isSending.value = true;
|
||||
$snackbar("Hệ thống đang xử lý ngầm yêu cầu gửi email hàng loạt...");
|
||||
|
||||
const paymentScheduleData = await $getdata("payment_schedule", undefined, {
|
||||
filter: filter.value,
|
||||
sort: "to_date",
|
||||
values:
|
||||
"penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__legal_code,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__phone,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry",
|
||||
});
|
||||
|
||||
const emailTemplate = await $getdata("emailtemplate", { id: activeDateFilter.value.emailTemplate }, undefined, true);
|
||||
|
||||
let message = emailTemplate.content.content;
|
||||
|
||||
contents.value = paymentScheduleData.map((paymentSchedule) => {
|
||||
message = message.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, "");
|
||||
const transfer = {
|
||||
bank: {
|
||||
code: "MB",
|
||||
name: "MB Bank",
|
||||
},
|
||||
account: {
|
||||
number: "146768686868",
|
||||
name: "CONG TY CO PHAN BAT DONG SAN UTOPIA",
|
||||
},
|
||||
content: "Thanh toán đơn #xyz",
|
||||
};
|
||||
|
||||
transfer.content = buildContentPayment(paymentSchedule);
|
||||
const params = new URLSearchParams({
|
||||
addInfo: transfer.content,
|
||||
accountName: transfer.account.name,
|
||||
});
|
||||
const qrImageUrl = `https://img.vietqr.io/image/${transfer.bank.code}-${transfer.account.number}-print.png?${params.toString()}`;
|
||||
|
||||
message = `
|
||||
${message.trim()}
|
||||
|
||||
${buildQrHtml(qrImageUrl)}
|
||||
`;
|
||||
return {
|
||||
...emailTemplate.content,
|
||||
content: undefined,
|
||||
message: replaceTemplateVars(message, paymentSchedule),
|
||||
};
|
||||
});
|
||||
|
||||
await forEachAsync(contents.value, async (bigContent, i) => {
|
||||
const { imageUrl, keyword, message, subject } = bigContent;
|
||||
const tempEm = {
|
||||
content: toRaw(bigContent),
|
||||
previewMode: true,
|
||||
};
|
||||
|
||||
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
|
||||
tempEm.content.message = quillToEmailHtml(message);
|
||||
let emailHtml = await render(Template1, tempEm);
|
||||
|
||||
// If no image URL provided, remove image section from HTML
|
||||
if ((imageUrl ?? "").trim() === "") {
|
||||
emailHtml = emailHtml
|
||||
.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, "")
|
||||
.replace(/\n\s*\n\s*\n/g, "\n\n");
|
||||
}
|
||||
|
||||
// Replace keywords in HTML
|
||||
let finalEmailHtml = emailHtml;
|
||||
if (keyword && keyword.length > 0) {
|
||||
keyword.forEach(({ keyword, value }) => {
|
||||
if (keyword && value) {
|
||||
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, "g");
|
||||
finalEmailHtml = finalEmailHtml.replace(regex, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await $insertapi(
|
||||
"sendemail",
|
||||
{
|
||||
to: paymentScheduleData[i].txn_detail__transaction__customer__email,
|
||||
content: finalEmailHtml,
|
||||
subject: replaceTemplateVars(subject, paymentScheduleData[i]) || "Thông báo từ Utopia Villas & Resort",
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
if (response !== null) {
|
||||
await $insertapi(
|
||||
"productnote",
|
||||
{
|
||||
ref: paymentScheduleData[i].txn_detail__transaction__product,
|
||||
user: $store.login.id,
|
||||
detail: `Đã gửi email thông báo quá hạn cho sản phẩm ${paymentScheduleData[i].txn_detail__transaction__product__trade_code} vào lúc ${$dayjs().format("HH:mm ngày L")}.`,
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
$snackbar("Thông báo đã được gửi thành công đến các khách hàng.");
|
||||
isSending.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
watch(
|
||||
filter,
|
||||
() => {
|
||||
key.value += 1;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="is-flex is-justify-content-space-between is-align-content-center mb-4">
|
||||
<div class="buttons m-0">
|
||||
<p>Quá hạn:</p>
|
||||
<button
|
||||
v-for="payable in payables"
|
||||
:key="payable.id"
|
||||
@click="setDateFilter(payable.detail)"
|
||||
:class="['button', { 'is-primary': isEqual(activeDateFilter, payable.detail) }]"
|
||||
>
|
||||
{{ payable.detail.lookup === "lte" ? "≤" : ">" }}
|
||||
{{ payable.detail.time }} ngày
|
||||
</button>
|
||||
<button
|
||||
v-if="activeDateFilter"
|
||||
@click="resetDateFilter()"
|
||||
class="button is-white"
|
||||
>
|
||||
Xoá lọc
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="activeDateFilter"
|
||||
@click="openConfirmModal()"
|
||||
:class="['button', 'is-light', { 'is-loading': isSending }]"
|
||||
>
|
||||
Gửi thông báo
|
||||
</button>
|
||||
</div>
|
||||
<DataView
|
||||
:key="key"
|
||||
v-bind="{
|
||||
pagename: 'payment-schedule-overdue',
|
||||
api: 'payment_schedule',
|
||||
setting: 'payment-schedule-debt-overdue',
|
||||
realtime: { time: '5', update: 'true' },
|
||||
params: {
|
||||
filter,
|
||||
sort: 'to_date',
|
||||
values:
|
||||
'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,ovd_days,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
<Modal
|
||||
v-if="showmodal"
|
||||
v-bind="showmodal"
|
||||
@confirm="sendEmails()"
|
||||
@close="showmodal = undefined"
|
||||
/>
|
||||
<!-- <div class="is-flex is-gap-1">
|
||||
// debug
|
||||
<Template1
|
||||
v-if="contents"
|
||||
v-for="content in contents"
|
||||
:content="content"
|
||||
previewMode
|
||||
/>
|
||||
</div> -->
|
||||
</template>
|
||||
@@ -1,362 +0,0 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<p class="has-text-centered title is-4 has-text-danger mb-4">
|
||||
Vui lòng kiểm tra kỹ thông tin trước khi thay đổi ngày đến hạn
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="loadingData"
|
||||
class="has-text-centered py-5"
|
||||
>
|
||||
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
|
||||
<p class="mt-2">
|
||||
{{ isVietnamese ? "Đang tải thông tin công nợ..." : "Loading payment schedule information..." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="paymentScheduleData">
|
||||
<div class="content">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Mã:" : "Schedule Code:" }}</strong>
|
||||
<p>{{ paymentScheduleData.code || "-" }}</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Trạng thái:" : "Status:" }}</strong>
|
||||
<p
|
||||
:class="{
|
||||
'has-text-success': paymentScheduleData.status__name === 'Đã xác nhận',
|
||||
'has-text-warning': paymentScheduleData.status__name === 'Chưa xác nhận',
|
||||
}"
|
||||
>
|
||||
{{ paymentScheduleData.status__name || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Loại thanh toán:" : "Payment Type:" }}</strong>
|
||||
<p>{{ paymentScheduleData.type__name || "-" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Tiền gốc theo kỳ thanh toán:" : "Amount:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-primary">
|
||||
{{ $numtoString(paymentScheduleData.amount) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Ngày đến hạn hiện tại:" : "Current Due Date:" }}</strong>
|
||||
<p class="has-text-weight-bold">
|
||||
{{ formatDate(paymentScheduleData.to_date) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Ngày tính lãi:" : "Penalty Date:" }}</strong>
|
||||
<p :class="paymentScheduleData.batch_date ? 'has-text-danger' : ''">
|
||||
{{ formatDate(paymentScheduleData.batch_date) || "Chưa tính lãi" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="title is-6">
|
||||
{{ isVietnamese ? "Thông tin Giao dịch liên quan" : "Related Transaction Information" }}
|
||||
</p>
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Mã giao dịch:" : "Transaction Code:" }}</strong>
|
||||
<p>
|
||||
{{ paymentScheduleData.txn_detail__transaction__code || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Khách hàng:" : "Customer:" }}</strong>
|
||||
<p>
|
||||
{{ paymentScheduleData.txn_detail__transaction__customer__fullname || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<strong>{{ isVietnamese ? "Chính sách:" : "Policy:" }}</strong>
|
||||
<p>
|
||||
{{ paymentScheduleData.txn_detail__transaction__policy__code || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div v-if="canEditDueDate">
|
||||
<Caption
|
||||
class="mb-4"
|
||||
v-bind="{ title: 'Thay đổi ngày đến hạn', size: 20 }"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label class="label has-text-weight-bold">{{ isVietnamese ? "Ngày đến hạn mới" : "New Due Date" }}</label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
:record="dateRecord"
|
||||
attr="newDueDate"
|
||||
@date="updateDueDate"
|
||||
position="is-bottom-left"
|
||||
:mindate="minDate"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="dateError"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ dateError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field mt-5">
|
||||
<label class="label has-text-weight-bold">{{ isVietnamese ? "Mã xác nhận" : "Confirmation Code" }}</label>
|
||||
<div class="control">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="isVietnamese ? 'Nhập mã xác nhận' : 'Enter confirmation code'"
|
||||
v-model="userInputCaptcha"
|
||||
@keydown.enter="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a
|
||||
class="button is-static has-text-weight-bold has-background-grey-lighter"
|
||||
style="font-family: "Courier New", monospace; letter-spacing: 2px"
|
||||
>
|
||||
{{ captchaCode }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-info is-light"
|
||||
@click="generateCaptcha"
|
||||
:title="isVietnamese ? 'Tạo mã mới' : 'Generate new code'"
|
||||
>
|
||||
<SvgIcon v-bind="{ name: 'refresh.svg', type: 'primary', size: 23 }"></SvgIcon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="!isCaptchaValid && userInputCaptcha.length > 0"
|
||||
class="help is-danger"
|
||||
>
|
||||
Mã xác nhận không đúng.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="notification is-warning"
|
||||
>
|
||||
<p class="has-text-weight-bold">
|
||||
{{ isVietnamese ? "Không thể thay đổi ngày đến hạn" : "Cannot change due date" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped is-grouped-left mt-5">
|
||||
<p class="control">
|
||||
<button
|
||||
v-if="canEditDueDate"
|
||||
class="button is-success has-text-white"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="handleUpdate"
|
||||
:disabled="!isUpdateValid || isLoading"
|
||||
>
|
||||
<span>{{ isVietnamese ? "Cập nhật ngày đến hạn" : "Update Due Date" }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span>{{ isVietnamese ? "Đóng" : "Close" }}</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useNuxtApp } from "#app";
|
||||
import { useStore } from "@/stores/index";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const props = defineProps({
|
||||
scheduleItemId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "updated"]);
|
||||
|
||||
const store = useStore();
|
||||
const { $getdata, $patchapi, $snackbar } = useNuxtApp();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const loadingData = ref(true);
|
||||
const paymentScheduleData = ref(null);
|
||||
const dateRecord = ref({ newDueDate: null });
|
||||
const captchaCode = ref("");
|
||||
const userInputCaptcha = ref("");
|
||||
|
||||
const isVietnamese = computed(() => store.lang === "vi");
|
||||
|
||||
// Kiểm tra xem có thể sửa ngày đến hạn không (chưa tính lãi và chưa thanh toán)
|
||||
const canEditDueDate = computed(() => {
|
||||
const hasNoPenaltyCalculated =
|
||||
paymentScheduleData.value?.batch_date === null || paymentScheduleData.value?.batch_date === undefined;
|
||||
const isNotPaid = paymentScheduleData.value?.status !== 2;
|
||||
return hasNoPenaltyCalculated && isNotPaid;
|
||||
});
|
||||
|
||||
// Ngày tối thiểu (phải lớn hơn ngày hiện tại)
|
||||
const minDate = computed(() => {
|
||||
const currentDate = paymentScheduleData.value?.to_date;
|
||||
if (currentDate) {
|
||||
return dayjs(currentDate).add(1, "day").format("YYYY-MM-DD");
|
||||
}
|
||||
return dayjs().add(1, "day").format("YYYY-MM-DD");
|
||||
});
|
||||
|
||||
// Kiểm tra lỗi ngày
|
||||
const dateError = computed(() => {
|
||||
if (!dateRecord.value.newDueDate) return "";
|
||||
|
||||
const selectedDate = dayjs(dateRecord.value.newDueDate);
|
||||
const currentDueDate = dayjs(paymentScheduleData.value?.to_date);
|
||||
|
||||
if (selectedDate.isBefore(currentDueDate) || selectedDate.isSame(currentDueDate)) {
|
||||
return isVietnamese.value
|
||||
? "Ngày đến hạn mới phải lớn hơn ngày đến hạn hiện tại"
|
||||
: "New due date must be after current due date";
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
const updateDueDate = (date) => {
|
||||
dateRecord.value.newDueDate = date;
|
||||
};
|
||||
|
||||
const generateCaptcha = () => {
|
||||
captchaCode.value = Math.random().toString(36).substring(2, 7).toUpperCase();
|
||||
userInputCaptcha.value = "";
|
||||
};
|
||||
|
||||
const isCaptchaValid = computed(() => {
|
||||
return userInputCaptcha.value.toLowerCase() === captchaCode.value.toLowerCase();
|
||||
});
|
||||
|
||||
const isUpdateValid = computed(() => {
|
||||
return (
|
||||
canEditDueDate.value &&
|
||||
dateRecord.value.newDueDate &&
|
||||
!dateError.value &&
|
||||
isCaptchaValid.value &&
|
||||
userInputCaptcha.value.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "-";
|
||||
return dayjs(dateString).format("L");
|
||||
};
|
||||
|
||||
const fetchPaymentScheduleData = async () => {
|
||||
loadingData.value = true;
|
||||
try {
|
||||
const data = await $getdata("payment_schedule", { id: props.scheduleItemId }, undefined, true);
|
||||
paymentScheduleData.value = data;
|
||||
|
||||
// Set initial value for new due date
|
||||
if (data?.to_date) {
|
||||
dateRecord.value.newDueDate = data.to_date;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error fetching payment schedule data:", e);
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Không thể tải thông tin công nợ." : "Failed to load payment schedule information.",
|
||||
"Lỗi",
|
||||
"Error",
|
||||
);
|
||||
} finally {
|
||||
loadingData.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!isUpdateValid.value) {
|
||||
if (!isCaptchaValid.value) {
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Vui lòng nhập đúng mã xác nhận." : "Please enter the correct confirmation code.",
|
||||
"Cảnh báo",
|
||||
"Warning",
|
||||
);
|
||||
} else if (dateError.value) {
|
||||
$snackbar(dateError.value, "Cảnh báo", "Warning");
|
||||
} else if (!canEditDueDate.value) {
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Không thể thay đổi ngày đến hạn." : "Cannot change due date.",
|
||||
"Cảnh báo",
|
||||
"Warning",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await $patchapi("payment_schedule", {
|
||||
id: props.scheduleItemId,
|
||||
to_date: dateRecord.value.newDueDate,
|
||||
});
|
||||
|
||||
if (response !== "error") {
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Cập nhật ngày đến hạn thành công!" : "Due date updated successfully!",
|
||||
"Thành công",
|
||||
"Success",
|
||||
);
|
||||
emit("updated");
|
||||
emit("close");
|
||||
} else {
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Có lỗi xảy ra khi cập nhật ngày đến hạn." : "An error occurred while updating due date.",
|
||||
"Lỗi",
|
||||
"Error",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error updating due date:", e);
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Có lỗi xảy ra khi cập nhật ngày đến hạn." : "An error occurred while updating due date.",
|
||||
"Lỗi",
|
||||
"Error",
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaymentScheduleData();
|
||||
generateCaptcha();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,608 +0,0 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div
|
||||
v-if="loadingData"
|
||||
class="has-text-centered py-5"
|
||||
>
|
||||
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
|
||||
<p class="mt-2">
|
||||
{{ isVietnamese ? "Đang tải thông tin công nợ..." : "Loading payment schedule information..." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="paymentScheduleData">
|
||||
<div class="content">
|
||||
<!-- Thông tin cơ bản (giữ nguyên) -->
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Mã:" : "Schedule Code:" }}</strong>
|
||||
<p>{{ paymentScheduleData.code || "-" }}</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Trạng thái:" : "Status:" }}</strong>
|
||||
<p
|
||||
:class="{
|
||||
'has-text-success':
|
||||
paymentScheduleData.status__name === 'Đã xác nhận' || paymentScheduleData.status__name === 'Paid',
|
||||
'has-text-warning':
|
||||
paymentScheduleData.status__name === 'Chưa xác nhận' ||
|
||||
paymentScheduleData.status__name === 'Pending',
|
||||
'has-text-danger':
|
||||
paymentScheduleData.status__name === 'Quá hạn' || paymentScheduleData.status__name === 'Overdue',
|
||||
}"
|
||||
>
|
||||
{{ paymentScheduleData.status__name || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Loại thanh toán:" : "Payment Type:" }}</strong>
|
||||
<p>{{ paymentScheduleData.type__name || "-" }}</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Đợt thanh toán:" : "Cycle:" }}</strong>
|
||||
<p>{{ paymentScheduleData.cycle_days || 0 }} ngày</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Tiền gốc theo kỳ thanh toán:" : "Amount:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-primary">
|
||||
{{ $numtoString(paymentScheduleData.amount) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Số tiền gốc đã thanh toán:" : "Paid Amount:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-primary">
|
||||
{{ $numtoString(paymentScheduleData.paid_amount) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Số tiền gốc còn lại:" : "Remaining Principal:" }}</strong>
|
||||
<p
|
||||
class="has-text-weight-bold"
|
||||
:class="paymentScheduleData.amount_remain > 0 ? 'has-text-danger' : 'has-text-success'"
|
||||
>
|
||||
{{ $numtoString(paymentScheduleData.amount_remain) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Ngày đến hạn:" : "Due Date:" }}</strong>
|
||||
<p
|
||||
:class="{
|
||||
'has-text-danger': isOverdue(paymentScheduleData.to_date && paymentScheduleData.ovd_days > 0),
|
||||
}"
|
||||
>
|
||||
{{ formatDate(paymentScheduleData.to_date) }}
|
||||
<span
|
||||
v-if="isOverdue(paymentScheduleData.to_date && paymentScheduleData.ovd_days > 0)"
|
||||
class="has-text-weight-bold"
|
||||
>
|
||||
(Quá hạn {{ paymentScheduleData.ovd_days }} ngày)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Tổng lãi phải thu:" : "Total Penalty Amount:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-danger">
|
||||
{{ paymentScheduleData.penalty_amount > 0 ? $numtoString(paymentScheduleData.penalty_amount) : "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Lãi phạt đã thanh toán:" : "Penalty Paid:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-success">
|
||||
{{ paymentScheduleData.penalty_paid > 0 ? $numtoString(paymentScheduleData.penalty_paid) : "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Lãi phạt còn lại:" : "Penalty Remaining:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-danger">
|
||||
{{ paymentScheduleData.penalty_remain > 0 ? $numtoString(paymentScheduleData.penalty_remain) : "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Miễn giảm lãi phạt:" : "Penalty Reduced:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-primary">
|
||||
{{ paymentScheduleData.penalty_reduce > 0 ? $numtoString(paymentScheduleData.penalty_reduce) : "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Tổng tiền phải thanh toán:" : "Total Remaining:" }}</strong>
|
||||
<p class="has-text-weight-bold has-text-primary">
|
||||
{{ $numtoString(paymentScheduleData.remain_amount) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<strong>{{ isVietnamese ? "Ghi chú:" : "Note:" }}</strong>
|
||||
<p class="is-size-6">
|
||||
{{ paymentScheduleData.detail?.note || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Timeline lịch sử -->
|
||||
|
||||
<div
|
||||
v-if="processedEntries.length > 0"
|
||||
class="is-flex is-flex-direction-column is-gap-5"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in processedEntries"
|
||||
:key="index"
|
||||
class="is-flex is-align-items-start is-gap-4"
|
||||
>
|
||||
<div style="min-width: 3rem">
|
||||
<p class="is-size-5 has-text-weight-bold has-text-primary">
|
||||
{{ formatDate(item.entry.date) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="item.entry.code"
|
||||
class="is-size-6 has-text-grey"
|
||||
>
|
||||
{{ item.entry.code }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="is-flex-grow-1">
|
||||
<p class="is-size-5 has-text-weight-bold has-text-dark mb-2">
|
||||
{{ getEntryTypeLabel(item.entry.type) }}
|
||||
<span
|
||||
v-if="item.entry.code"
|
||||
class="tag is-link is-light ml-2"
|
||||
>
|
||||
{{ item.entry.code }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="box is-shadowless p-4"
|
||||
style="border-left: 5px solid #204853"
|
||||
>
|
||||
<div class="columns is-mobile is-multiline is-gap-3">
|
||||
<div class="column is-6-mobile">
|
||||
<span class="has-text-grey-light">Gốc trả:</span><br />
|
||||
<span
|
||||
v-if="item.entry.principal > 0"
|
||||
class="has-text-success has-text-weight-semibold is-size-5"
|
||||
>
|
||||
{{ $numtoString(item.entry.principal) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="has-text-grey-light"
|
||||
>-</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-mobile">
|
||||
<span class="has-text-grey-light">Lãi trả:</span><br />
|
||||
<span
|
||||
v-if="item.entry.penalty > 0"
|
||||
class="has-text-danger has-text-weight-semibold is-size-5"
|
||||
>
|
||||
{{ $numtoString(item.entry.penalty) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="has-text-grey-light"
|
||||
>-</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-mobile">
|
||||
<span class="has-text-grey-light">Gốc còn lại :</span><br />
|
||||
<strong
|
||||
:class="item.principalRemain > 0 ? 'has-text-danger' : 'has-text-success'"
|
||||
class="is-size-5"
|
||||
>
|
||||
{{ $numtoString(item.principalRemain) }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chi tiết lãi phát sinh -->
|
||||
<div class="">
|
||||
<!-- Diễn giải chi tiết cách tính lãi kỳ này -->
|
||||
<p
|
||||
v-if="item.penaltyThisPeriod > 0"
|
||||
class="is-size-6 has-text-grey"
|
||||
>
|
||||
Dư nợ gốc còn lại:
|
||||
{{ $numtoString(item.principalBefore) }} × {{ item.penaltyDetail.days }} ngày (từ
|
||||
{{ item.penaltyDetail.from }} đến {{ item.penaltyDetail.to }}) × {{ item.rate }}%/ngày =
|
||||
{{ $numtoString(item.penaltyThisPeriod) }}
|
||||
</p>
|
||||
|
||||
<p class="is-size-6 mt-3">
|
||||
<strong>Tổng lãi tích lũy đến {{ formatDate(item.entry.date) }}: </strong>
|
||||
<span :class="item.penaltyAccumulated > 0 ? 'has-text-danger' : 'has-text-grey'">
|
||||
{{ $numtoString(item.penaltyAccumulated) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tiền lãi hiện tại (nếu chưa hết nợ) -->
|
||||
<div
|
||||
v-if="
|
||||
hasUnpaidDebt &&
|
||||
latestPenaltyToThisEntry != paymentScheduleData.penalty_amount &&
|
||||
processedEntries.length > 0 &&
|
||||
latestPenaltyToThisEntry > 0
|
||||
"
|
||||
class="mt-5 box has-background-warning-light"
|
||||
>
|
||||
<p class="is-size-5">
|
||||
<strong>Lãi đến ngày thanh toán gần nhất ({{ latestEntryDate }}) :</strong>
|
||||
{{ $numtoString(latestPenaltyToThisEntry) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="latestAdditionalPenalty > 0"
|
||||
class="is-size-5 mt-2"
|
||||
>
|
||||
<strong>Lãi phát sinh từ ngày {{ latestEntryDate }} đến nay:</strong>
|
||||
{{ $numtoString(latestAdditionalPenalty) }}
|
||||
</p>
|
||||
<p class="is-size-5 has-text-weight-bold mt-3">
|
||||
Tổng lãi hiện tại : {{ $numtoString(latestPenaltyToThisEntry) }} +
|
||||
{{ $numtoString(latestAdditionalPenalty) }} =
|
||||
{{ $numtoString(paymentScheduleData.penalty_amount) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Tóm tắt cuối cùng -->
|
||||
<div class="columns is-mobile is-multiline">
|
||||
<div class="column is-3-tablet is-6-mobile">
|
||||
<p class="heading">Gốc đã trả</p>
|
||||
<p class="title is-5 has-text-success">
|
||||
{{ $numtoString(paymentScheduleData.paid_amount) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3-tablet is-6-mobile">
|
||||
<p class="heading">Gốc còn lại</p>
|
||||
<p
|
||||
class="title is-5"
|
||||
:class="paymentScheduleData.amount_remain > 0 ? 'has-text-danger' : 'has-text-success'"
|
||||
>
|
||||
{{ $numtoString(paymentScheduleData.amount_remain) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3-tablet is-6-mobile">
|
||||
<p class="heading">Lãi đã trả</p>
|
||||
<p class="title is-5 has-text-success">
|
||||
{{ $numtoString(paymentScheduleData.penalty_paid) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3-tablet is-6-mobile">
|
||||
<p class="heading">Lãi còn lại</p>
|
||||
<p
|
||||
class="title is-5"
|
||||
:class="paymentScheduleData.penalty_remain > 0 ? 'has-text-danger' : 'has-text-success'"
|
||||
>
|
||||
{{ $numtoString(paymentScheduleData.penalty_remain) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-3-tablet is-6-mobile">
|
||||
<p class="heading">Tổng tiền phải thanh toán</p>
|
||||
<p class="title is-5 has-text-primary">
|
||||
{{ $numtoString(paymentScheduleData.remain_amount) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isConfirmAllowed && $getEditRights()"
|
||||
class="field is-grouped is-grouped-left"
|
||||
>
|
||||
<p class="control">
|
||||
<button
|
||||
class="button is-info mx-3 has-text-white"
|
||||
@click="handleViewEmail"
|
||||
>
|
||||
<span>Gửi thông báo</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="paymentScheduleData.batch_date == null"
|
||||
class="button is-danger has-text-white"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="getPenalty"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span>Tính lãi</span>
|
||||
</button>
|
||||
</p>
|
||||
<Modal
|
||||
@close="showModalViewEmail = undefined"
|
||||
v-bind="showModalViewEmail"
|
||||
v-if="showModalViewEmail"
|
||||
></Modal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useNuxtApp } from "#app";
|
||||
import { useStore } from "@/stores/index";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const props = defineProps({
|
||||
scheduleItemId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "confirmed"]);
|
||||
|
||||
const store = useStore();
|
||||
const { $insertapi, $snackbar, $getEditRights, $getdata, $patchapi, $numtoString } = useNuxtApp();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const loadingData = ref(true);
|
||||
const paymentScheduleData = ref(null);
|
||||
const rule = ref(null);
|
||||
const invoiceData = ref({
|
||||
link: "",
|
||||
ref_code: "",
|
||||
});
|
||||
const isUpdatingInvoice = ref(false);
|
||||
const showModal = ref(null);
|
||||
const showModalViewEmail = ref(null);
|
||||
|
||||
const isVietnamese = computed(() => store.lang === "vi");
|
||||
|
||||
const isOverdue = (dueDate) => {
|
||||
if (!dueDate) return false;
|
||||
return dayjs(dueDate).isBefore(dayjs(), "day");
|
||||
};
|
||||
|
||||
const isConfirmAllowed = computed(() => paymentScheduleData.value?.status === 1);
|
||||
const isInvoiceAllowed = computed(() => paymentScheduleData.value?.status === 2);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "-";
|
||||
return dayjs(dateString).format("L");
|
||||
};
|
||||
|
||||
const getEntryTypeLabel = (type) => {
|
||||
const labels = {
|
||||
PAYMENT: "Thanh toán",
|
||||
REDUCTION: "Miễn giảm",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// Xử lý timeline
|
||||
const processedEntries = computed(() => {
|
||||
if (!paymentScheduleData.value?.entry) return [];
|
||||
|
||||
const relevantEntries = paymentScheduleData.value.entry.filter(
|
||||
(e) => e.type === "PAYMENT" && e.penalty_added_to_entry !== undefined,
|
||||
);
|
||||
|
||||
relevantEntries.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
let currentPrincipal = Number(paymentScheduleData.value.amount || 0);
|
||||
let totalPenaltyAccumulated = 0;
|
||||
let totalPenaltyPaid = Number(paymentScheduleData.value.penalty_paid || 0);
|
||||
let lastDate = paymentScheduleData.value.to_date;
|
||||
let principalBeforeThisPeriod = currentPrincipal;
|
||||
|
||||
const result = [];
|
||||
|
||||
relevantEntries.forEach((entry, index) => {
|
||||
const entryDate = dayjs(entry.date);
|
||||
const lastEventDate = dayjs(lastDate);
|
||||
const days = entryDate.diff(lastEventDate, "day");
|
||||
|
||||
let penaltyThisPeriod = Number(entry.penalty_added_to_entry || 0);
|
||||
|
||||
totalPenaltyAccumulated += penaltyThisPeriod;
|
||||
|
||||
if (entry.type === "PAYMENT") {
|
||||
const principalPaid = Number(entry.principal || 0);
|
||||
const penaltyPaid = Number(entry.penalty || 0);
|
||||
|
||||
currentPrincipal -= principalPaid;
|
||||
if (currentPrincipal < 0) currentPrincipal = 0;
|
||||
|
||||
totalPenaltyPaid += penaltyPaid;
|
||||
principalBeforeThisPeriod = currentPrincipal;
|
||||
}
|
||||
|
||||
lastDate = entry.date;
|
||||
|
||||
const penaltyRemain = totalPenaltyAccumulated - totalPenaltyPaid;
|
||||
|
||||
result.push({
|
||||
entry,
|
||||
principalRemain: Number(entry.amount_remain_after_allocation || 0), // Gốc còn lại sau bút toán này
|
||||
principalBefore: Number(entry.amount_remain_after_allocation || 0) + Number(entry.principal || 0), // Dư nợ trước khi trừ principal của bút toán này
|
||||
penaltyAccumulated: totalPenaltyAccumulated,
|
||||
penaltyThisPeriod,
|
||||
penaltyRemain,
|
||||
rate: Number(entry.DAILY_PENALTY_RATE || 0) * 100,
|
||||
totalDebt: Number(entry.amount_remain_after_allocation || 0) + penaltyThisPeriod,
|
||||
penaltyDetail: {
|
||||
from: lastEventDate.format("L"),
|
||||
to: entryDate.format("L"),
|
||||
days,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Tiền lãi hiện tại (từ entry gần nhất)
|
||||
const latestEntry = computed(() => {
|
||||
if (!paymentScheduleData.value?.entry?.length) return null;
|
||||
const paymentEntries = paymentScheduleData.value.entry.filter(
|
||||
(e) => e.type === "PAYMENT" && e.penalty_to_this_entry !== undefined,
|
||||
);
|
||||
if (paymentEntries.length === 0) return null;
|
||||
return paymentEntries[paymentEntries.length - 1];
|
||||
});
|
||||
|
||||
const hasUnpaidDebt = computed(() => {
|
||||
return paymentScheduleData.value?.amount_remain > 0 || paymentScheduleData.value?.penalty_remain > 0;
|
||||
});
|
||||
|
||||
const latestPenaltyToThisEntry = computed(() => {
|
||||
return latestEntry.value?.penalty_to_this_entry || 0;
|
||||
});
|
||||
|
||||
const latestAdditionalPenalty = computed(() => {
|
||||
return paymentScheduleData.value?.penalty_amount - latestEntry.value?.penalty_to_this_entry || 0;
|
||||
});
|
||||
|
||||
const latestTotalPenalty = computed(() => {
|
||||
return latestPenaltyToThisEntry.value + latestAdditionalPenalty.value;
|
||||
});
|
||||
|
||||
const latestEntryCode = computed(() => {
|
||||
return latestEntry.value?.code || "-";
|
||||
});
|
||||
|
||||
const latestEntryDate = computed(() => {
|
||||
return latestEntry.value?.date ? formatDate(latestEntry.value.date) : "-";
|
||||
});
|
||||
|
||||
const fetchPaymentScheduleData = async () => {
|
||||
loadingData.value = true;
|
||||
try {
|
||||
const data = await $getdata("payment_schedule", { id: props.scheduleItemId }, undefined, true);
|
||||
const ruleData = await $getdata("bizsetting", { code: "rule" }, undefined, true);
|
||||
|
||||
rule.value =
|
||||
ruleData?.detail === "fee-principal"
|
||||
? "Lãi phạt quá hạn trước - Gốc sau"
|
||||
: "Tiền gốc trước - Lãi phạt quá hạn sau";
|
||||
|
||||
paymentScheduleData.value = data;
|
||||
|
||||
if (data?.link) invoiceData.value.link = data.link;
|
||||
if (data?.ref_code) invoiceData.value.ref_code = data.ref_code;
|
||||
} catch (e) {
|
||||
console.error("Error fetching payment schedule data:", e);
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Không thể tải thông tin công nợ." : "Failed to load payment schedule information.",
|
||||
"Lỗi",
|
||||
"Error",
|
||||
);
|
||||
} finally {
|
||||
loadingData.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getPenalty = async () => {
|
||||
isLoading.value = true;
|
||||
const target_item = paymentScheduleData.value;
|
||||
const workflowPayload = {
|
||||
workflow_code: "CALCULATE_LATE_PAYMENT_PENALTY",
|
||||
trigger: "create",
|
||||
target_item,
|
||||
};
|
||||
try {
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
if (response === "error" || !response?.success) throw new Error("Calculate penalty failed");
|
||||
$snackbar("Tính lãi thành công!", "Thành công", "Success");
|
||||
await fetchPaymentScheduleData();
|
||||
} catch (e) {
|
||||
$snackbar("Có lỗi xảy ra khi tính lãi.", "Lỗi", "Error");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateInvoice = async () => {
|
||||
if (!invoiceData.value.link || !invoiceData.value.ref_code) {
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Vui lòng nhập đầy đủ link và mã xác thực" : "Please enter both link and reference code",
|
||||
"Cảnh báo",
|
||||
"Warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingInvoice.value = true;
|
||||
try {
|
||||
const response = await $patchapi(
|
||||
"payment_schedule",
|
||||
{
|
||||
id: props.scheduleItemId,
|
||||
link: invoiceData.value.link,
|
||||
ref_code: invoiceData.value.ref_code,
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
if (response === "error" || !response) throw new Error("Update failed");
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Cập nhật hóa đơn thành công!" : "Invoice updated successfully!",
|
||||
"Thành công",
|
||||
"Success",
|
||||
);
|
||||
await fetchPaymentScheduleData();
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice:", error);
|
||||
$snackbar(isVietnamese.value ? "Có lỗi xảy ra khi cập nhật hóa đơn" : "Error updating invoice", "Lỗi", "Error");
|
||||
} finally {
|
||||
isUpdatingInvoice.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetInvoiceData = () => {
|
||||
invoiceData.value = { link: "", ref_code: "" };
|
||||
};
|
||||
|
||||
function openEntryDetailModal(entry) {
|
||||
if (!entry.code) return;
|
||||
showModal.value = {
|
||||
component: "accounting/InternalEntry",
|
||||
title: `Chi tiết bút toán: ${entry.code}`,
|
||||
height: "500px",
|
||||
width: "80%",
|
||||
vbind: {
|
||||
row: { code: entry.code },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleViewEmail() {
|
||||
const emailTemplate = await $getdata(
|
||||
"emailtemplate",
|
||||
{ name: "Mail Thông báo đến hạn thanh toán" },
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
showModalViewEmail.value = {
|
||||
component: "marketing/email/viewEmail/ViewEmail",
|
||||
title: "Xem trước nội dung nhắc thanh toán",
|
||||
width: "60%",
|
||||
vbind: {
|
||||
idEmailTemplate: emailTemplate[0]?.id || null,
|
||||
scheduleItemId: props.scheduleItemId,
|
||||
},
|
||||
onConfirm: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
showModal.value = null;
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
showModal.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaymentScheduleData();
|
||||
});
|
||||
</script>
|
||||
@@ -1,267 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header với nút thêm và upload -->
|
||||
<div class="columns mb-0">
|
||||
<div class="column is-10">
|
||||
<!-- Cart selection list -->
|
||||
<div v-if="showCartList">
|
||||
<div class="tags">
|
||||
<div
|
||||
v-for="(cart, index) in carts"
|
||||
:key="cart.id"
|
||||
class="tag is-medium has-addons"
|
||||
:class="{ 'is-primary': selectedCarts.includes(cart.id) }"
|
||||
@click="handleCartClick(cart.id, index, $event)"
|
||||
style="cursor: pointer; user-select: none"
|
||||
>
|
||||
<span>{{ cart.code }} - {{ cart.name }}</span>
|
||||
<span
|
||||
v-if="selectedCarts.includes(cart.id) && $getEditRights()"
|
||||
class="ml-2 tag is-delete"
|
||||
@click.stop="removeCart(cart.id)"
|
||||
></span>
|
||||
<span
|
||||
v-if="selectedCarts.includes(cart.id) && $getEditRights()"
|
||||
class="tag ml-2"
|
||||
@click.stop="editCart(cart.id)"
|
||||
title="Chỉnh sửa giỏ hàng"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<SvgIcon v-bind="{ name: 'pen1.svg', type: 'dark', size: 16 }" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$getEditRights()"
|
||||
class="column is-2"
|
||||
>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<a
|
||||
class="mr-3"
|
||||
@click="upload()"
|
||||
>
|
||||
<span class="icon-text">
|
||||
<SvgIcon v-bind="{ name: 'upload.svg', type: 'primary', size: 25 }" />
|
||||
<span class="ml-1 fsb-17">Phân bổ</span>
|
||||
</span>
|
||||
</a>
|
||||
<a @click="addNew()">
|
||||
<span class="icon-text">
|
||||
<SvgIcon v-bind="{ name: 'add1.png', type: 'primary', size: 23 }" />
|
||||
<span class="ml-1 fsb-17">Thêm mới</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data View với product được filter -->
|
||||
<div
|
||||
class="m-0"
|
||||
v-if="selectedCarts.length > 0"
|
||||
>
|
||||
<div class="level mb-3">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<h5 class="title is-5">Sản phẩm trong giỏ</h5>
|
||||
<button
|
||||
class="button is-small is-text"
|
||||
@click="clearSelection"
|
||||
>
|
||||
Bỏ chọn tất cả
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DataView với key động để force reload khi filter thay đổi -->
|
||||
<DataView
|
||||
v-bind="dataViewConfig"
|
||||
:key="selectedCartsKey"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modal Components -->
|
||||
<Modal
|
||||
v-if="showmodal"
|
||||
@close="showmodal = undefined"
|
||||
@dataevent="dataevent"
|
||||
v-bind="showmodal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useStore } from "~/stores/index";
|
||||
import DataView from "~/components/datatable/DataView.vue";
|
||||
import Modal from "~/components/Modal.vue";
|
||||
import SvgIcon from "~/components/SvgIcon.vue";
|
||||
|
||||
const { $getapi, $findapi, $copy, $find } = useNuxtApp();
|
||||
const store = useStore();
|
||||
|
||||
// State
|
||||
const showmodal = ref();
|
||||
const showCartList = ref(true);
|
||||
const selectedCarts = ref([]);
|
||||
const carts = ref([]);
|
||||
const lastSelectedIndex = ref(null);
|
||||
|
||||
// Key động để force reload DataView khi selectedCarts thay đổi
|
||||
const selectedCartsKey = computed(() => {
|
||||
const sorted = [...selectedCarts.value].sort((a, b) => a - b);
|
||||
return "dataview-cart-" + sorted.join("-");
|
||||
});
|
||||
|
||||
// DataView config
|
||||
const dataViewConfig = computed(() => {
|
||||
const config = {
|
||||
api: "product",
|
||||
setting: "product-list-cart",
|
||||
pagename: "pagedata1",
|
||||
modal: { component: "parameter/ProductForm", title: "Sản phẩm" },
|
||||
timeopt: { time: 36000, disable: "add" },
|
||||
realtime: { time: 2, update: "false" },
|
||||
};
|
||||
|
||||
if (selectedCarts.value.length > 0) {
|
||||
config.filter = { cart__in: selectedCarts.value };
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// Modal configs
|
||||
const newAddon = {
|
||||
component: "parameter/NewCart",
|
||||
title: "Giỏ hàng",
|
||||
width: "900px",
|
||||
height: "400px",
|
||||
};
|
||||
|
||||
// Methods
|
||||
function handleCartClick(cartId, index, event) {
|
||||
if (event.ctrlKey) {
|
||||
// Ctrl + Click: Chọn/bỏ chọn từng cái
|
||||
toggleCart(cartId);
|
||||
lastSelectedIndex.value = index;
|
||||
} else if (event.shiftKey) {
|
||||
// Shift + Click: Chọn range từ last đến hiện tại
|
||||
if (lastSelectedIndex.value !== null) {
|
||||
selectRange(lastSelectedIndex.value, index);
|
||||
} else {
|
||||
toggleCart(cartId);
|
||||
lastSelectedIndex.value = index;
|
||||
}
|
||||
} else {
|
||||
// Click bình thường: Chọn chỉ cái này
|
||||
selectedCarts.value = [cartId];
|
||||
lastSelectedIndex.value = index;
|
||||
}
|
||||
}
|
||||
|
||||
function selectRange(startIndex, endIndex) {
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const cartId = carts.value[i].id;
|
||||
if (!selectedCarts.value.includes(cartId)) {
|
||||
selectedCarts.value.push(cartId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCart(cartId) {
|
||||
const index = selectedCarts.value.indexOf(cartId);
|
||||
if (index > -1) {
|
||||
selectedCarts.value.splice(index, 1);
|
||||
} else {
|
||||
selectedCarts.value.push(cartId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCart(cartId) {
|
||||
const index = selectedCarts.value.indexOf(cartId);
|
||||
if (index > -1) {
|
||||
selectedCarts.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getCartName(cartId) {
|
||||
const cart = carts.value.find((c) => c.id === cartId);
|
||||
return cart ? `${cart.code} - ${cart.name}` : "";
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
if (selectedCarts.value.length === 0) {
|
||||
alert("Vui lòng chọn ít nhất một giỏ hàng");
|
||||
return;
|
||||
}
|
||||
showCartList.value = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedCarts.value = [];
|
||||
showCartList.value = true;
|
||||
}
|
||||
|
||||
function upload() {
|
||||
showmodal.value = {
|
||||
component: "parameter/ImportData",
|
||||
title: "Phân bổ giỏ hàng",
|
||||
width: "80%",
|
||||
height: "400px",
|
||||
vbind: { code: "product-cart" },
|
||||
};
|
||||
}
|
||||
|
||||
function addNew() {
|
||||
showmodal.value = {
|
||||
component: "parameter/NewCart",
|
||||
title: "Thêm giỏ hàng mới",
|
||||
width: "900px",
|
||||
height: "500px",
|
||||
};
|
||||
}
|
||||
|
||||
function editCart(cartId) {
|
||||
showmodal.value = {
|
||||
component: "parameter/NewCart",
|
||||
title: "Chỉnh sửa giỏ hàng",
|
||||
width: "900px",
|
||||
height: "500px",
|
||||
vbind: {
|
||||
id: cartId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dataevent() {
|
||||
loadCarts();
|
||||
}
|
||||
|
||||
async function loadCarts() {
|
||||
try {
|
||||
const conn = $findapi("cart");
|
||||
const rs = await $getapi([conn]);
|
||||
const obj = $find(rs, { name: "cart" });
|
||||
if (obj) {
|
||||
carts.value = $copy(obj.data.rows || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi fetch giỏ hàng:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
onMounted(() => {
|
||||
loadCarts();
|
||||
});
|
||||
</script>
|
||||
@@ -1,405 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline mx-1">
|
||||
<div :class="`column px-0 is-full ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<div class="field">
|
||||
<label class="label">Sản phẩm</label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'product',
|
||||
field: 'trade_code',
|
||||
column: ['trade_code'],
|
||||
optionid: props.row.trade_code,
|
||||
first: true,
|
||||
disabled: true,
|
||||
viewaddon: productViewAddon,
|
||||
}"
|
||||
@option="selected('product', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="false"
|
||||
>
|
||||
error
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`column px-0 is-full ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<div class="field">
|
||||
<label class="label"> Giao dịch<b class="ml-1 has-text-danger">*</b> </label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'transactionphase',
|
||||
field: 'name',
|
||||
column: ['name'],
|
||||
optionid: transaction?.phase,
|
||||
first: true,
|
||||
viewaddon: transactionTypeViewAddon,
|
||||
}"
|
||||
@option="selected('phase', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.phase"
|
||||
>
|
||||
{{ errors.phase }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`column px-0 is-full ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<div class="field">
|
||||
<label class="label"> Khách hàng<b class="ml-1 has-text-danger">*</b> </label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'customer',
|
||||
field: 'label',
|
||||
column: ['fullname', 'phone'],
|
||||
first: true,
|
||||
optionid: transaction?.customer,
|
||||
addon: customerAddon,
|
||||
viewaddon: customerViewAddon,
|
||||
}"
|
||||
@option="selected('customer', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.customer"
|
||||
>
|
||||
{{ errors.customer }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`column px-0 is-6 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<div class="field">
|
||||
<label class="label"> Số tiền đặt cọc<b class="ml-1 has-text-danger">*</b> </label>
|
||||
<div class="control">
|
||||
<InputNumber
|
||||
v-bind="{
|
||||
record: formData,
|
||||
attr: 'amount',
|
||||
defaultValue: true,
|
||||
}"
|
||||
@number="selected('amount', $event)"
|
||||
></InputNumber>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.amount"
|
||||
>
|
||||
{{ errors.amount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`column is-6 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<div class="field">
|
||||
<label class="label">Hạn thanh toán<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<Datepicker
|
||||
v-bind="{ record: formData, attr: 'due_date' }"
|
||||
@date="selected('due_date', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.due_date"
|
||||
>
|
||||
{{ errors.due_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`column px-0 is-full ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<div class="field">
|
||||
<label class="label">{{ dataLang && findFieldName("note")[lang] }}</label>
|
||||
<div class="control">
|
||||
<textarea
|
||||
v-model="formData.note"
|
||||
class="textarea"
|
||||
name="note"
|
||||
placeholder=""
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
class="help is-danger"
|
||||
v-if="errors.note"
|
||||
>
|
||||
{{ errors.note }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`column is-full px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
|
||||
<button
|
||||
:class="`button is-primary has-text-white ${isSubmitting ? 'is-loading' : ''}`"
|
||||
@click="handleSubmitData"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
{{ isVietnamese ? "Tạo giao dịch" : "Create Transaction" }}
|
||||
</button>
|
||||
|
||||
<!-- Nút xem hợp đồng - chỉ hiện khi có contract -->
|
||||
<button
|
||||
v-if="contractData"
|
||||
class="button is-info ml-3"
|
||||
@click="openContractModal"
|
||||
>
|
||||
<span>{{ isVietnamese ? "Xem hợp đồng" : "View Contract" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal hiển thị hợp đồng -->
|
||||
<Modal
|
||||
v-if="showContractModal"
|
||||
@close="showContractModal = false"
|
||||
@dataevent="handleContractUpdated"
|
||||
v-bind="contractModalConfig"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { isEqual, pick } from "es-toolkit";
|
||||
import dayjs from "dayjs";
|
||||
import { useStore } from "~/stores/index";
|
||||
|
||||
const props = defineProps({
|
||||
row: Object,
|
||||
api: String,
|
||||
});
|
||||
|
||||
const { $updatepage, $getdata, $insertapi, $updateapi, $empty, $snackbar, $generateDocument } = useNuxtApp();
|
||||
const transaction = await $getdata("transaction", { product: props.row.id }, undefined, true);
|
||||
const reservation = transaction
|
||||
? await $getdata("reservation", { transaction: transaction.id }, undefined, true)
|
||||
: undefined;
|
||||
|
||||
const viewport = 5;
|
||||
const store = useStore();
|
||||
const lang = computed(() => store.lang);
|
||||
const isVietnamese = computed(() => lang.value === "vi");
|
||||
const dataLang = ref(store.common);
|
||||
const isSubmitting = ref(false);
|
||||
const contractData = ref(null);
|
||||
const showContractModal = ref(false);
|
||||
|
||||
// Load contract nếu đã có
|
||||
if (transaction) {
|
||||
contractData.value = await $getdata("contract", { transaction: transaction.id }, undefined, true);
|
||||
}
|
||||
|
||||
const initFormData = {
|
||||
product: props.row.id,
|
||||
phase: transaction?.phase,
|
||||
customer: transaction?.customer,
|
||||
amount: reservation?.amount,
|
||||
due_date: reservation?.due_date,
|
||||
note: props.row.note,
|
||||
};
|
||||
|
||||
const formData = ref({ ...initFormData });
|
||||
watch(formData, (val) => console.log(toRaw(val)), { deep: true });
|
||||
|
||||
const errors = ref({});
|
||||
|
||||
const productViewAddon = {
|
||||
component: "product/ProductView",
|
||||
width: "55%",
|
||||
height: "600px",
|
||||
title: lang === "en" ? "Product" : "Sản phẩm",
|
||||
};
|
||||
|
||||
const transactionTypeViewAddon = {
|
||||
component: "transaction/TransactionTypeView",
|
||||
width: "40%",
|
||||
height: "100px",
|
||||
title: lang === "en" ? "Transaction Type" : "Loại giao dịch",
|
||||
};
|
||||
|
||||
const customerViewAddon = {
|
||||
component: "customer/CustomerView",
|
||||
width: "75%",
|
||||
height: "600px",
|
||||
title: lang === "en" ? "Customer" : "Khách hàng",
|
||||
};
|
||||
|
||||
const customerAddon = {
|
||||
component: "customer/Customer",
|
||||
width: "75%",
|
||||
height: "400px",
|
||||
title: isVietnamese.value ? "Tạo khách hàng" : "Add customer",
|
||||
};
|
||||
|
||||
// Config cho modal hợp đồng
|
||||
const contractModalConfig = computed(() => ({
|
||||
component: "application/Contract",
|
||||
title: isVietnamese.value ? "Hợp đồng" : "Contract",
|
||||
width: "90%",
|
||||
height: "90vh",
|
||||
vbind: {
|
||||
row: {
|
||||
id: contractData.value?.transaction,
|
||||
...contractData.value,
|
||||
},
|
||||
api: "transaction",
|
||||
},
|
||||
event: "contractUpdated",
|
||||
eventname: "dataevent",
|
||||
}));
|
||||
|
||||
const findFieldName = (code) => {
|
||||
let field = dataLang.value.find((v) => v.code === code);
|
||||
return field;
|
||||
};
|
||||
|
||||
const selected = (fieldName, value) => {
|
||||
const finalValue =
|
||||
value !== null && typeof value === "object" ? value.id || value.index || value.code || value.label : value;
|
||||
formData.value[fieldName] = finalValue;
|
||||
};
|
||||
|
||||
const checkErrors = (fields) => {
|
||||
const { phase, customer, amount, due_date } = fields;
|
||||
errors.value = {};
|
||||
|
||||
if ($empty(phase)) {
|
||||
errors.value.phase = isVietnamese.value
|
||||
? "Giai đoạn giao dịch không được để trống"
|
||||
: "Transaction phase is required";
|
||||
}
|
||||
|
||||
if ($empty(customer)) {
|
||||
errors.value.customer = isVietnamese.value ? "Khách hàng không được để trống" : "Customer is required";
|
||||
}
|
||||
|
||||
if ($empty(amount)) {
|
||||
errors.value.amount = isVietnamese.value ? "Số tiền đặt cọc không được để trống" : "Deposit amount is required";
|
||||
}
|
||||
|
||||
if ($empty(due_date)) {
|
||||
errors.value.due_date = isVietnamese.value ? "Hạn thanh toán không được để trống" : "Due date is required";
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length > 0;
|
||||
};
|
||||
|
||||
async function handleSubmitData() {
|
||||
try {
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
if (isEqual(formData.value, initFormData)) {
|
||||
$snackbar(isVietnamese.value ? "Form không thay đổi" : "Form is unchanged");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidationErrors = checkErrors(formData.value);
|
||||
if (hasValidationErrors) {
|
||||
$snackbar(isVietnamese.value ? "Vui lòng kiểm tra lại dữ liệu." : "Please check the data again.");
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
// 1. Tạo transaction
|
||||
const transactionPayload = {
|
||||
...pick(formData.value, ["product", "phase", "customer"]),
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
};
|
||||
|
||||
const transactionRs = await $insertapi("transaction", transactionPayload, undefined, false);
|
||||
const { date, id: transactionId, code: transactionCode } = transactionRs;
|
||||
|
||||
// 2. Tạo reservation
|
||||
const reservationPayload = {
|
||||
amount: Number(formData.value.amount),
|
||||
due_date: formData.value.due_date,
|
||||
creator: 1,
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
transaction: transactionId,
|
||||
};
|
||||
|
||||
const reservationnRs = await $insertapi("reservation", reservationPayload, undefined, false);
|
||||
const reservationId = reservationnRs.id;
|
||||
|
||||
// 3. Generate document
|
||||
const documents = [];
|
||||
|
||||
try {
|
||||
const docResult = await $generateDocument({
|
||||
doc_code: "PXLTT1",
|
||||
customer_id: formData.value.customer,
|
||||
investor_id: 1,
|
||||
product_id: formData.value.product,
|
||||
reservation: reservationId,
|
||||
output_filename: `hop_dong_${transactionCode}`,
|
||||
});
|
||||
|
||||
if (docResult.success && docResult.data) {
|
||||
documents.push({
|
||||
code: docResult.data.code,
|
||||
name: docResult.data.name,
|
||||
en: docResult.data.name,
|
||||
file: docResult.data.file,
|
||||
pdf: docResult.data.pdf,
|
||||
});
|
||||
} else {
|
||||
console.error("Generate document failed:", docResult.error);
|
||||
}
|
||||
} catch (docError) {
|
||||
console.error("Lỗi khi tạo tài liệu:", docError);
|
||||
}
|
||||
|
||||
// 4. Tạo contract record
|
||||
if (documents.length > 0) {
|
||||
const contractPayload = {
|
||||
transaction: transactionId,
|
||||
document: documents,
|
||||
link: crypto.randomUUID(),
|
||||
signature: null,
|
||||
status: 1,
|
||||
user: null,
|
||||
};
|
||||
|
||||
const contractResult = await $insertapi("contract", contractPayload, undefined, false);
|
||||
contractData.value = contractResult;
|
||||
}
|
||||
|
||||
$snackbar(
|
||||
isVietnamese.value ? "Tạo giao dịch và hợp đồng thành công!" : "Transaction and contract created successfully!",
|
||||
);
|
||||
|
||||
// Tự động mở modal xem hợp đồng
|
||||
if (contractData.value) {
|
||||
showContractModal.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Create transaction failed:", error);
|
||||
$snackbar(isVietnamese.value ? "Tạo giao dịch thất bại" : "Create transaction failed");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openContractModal() {
|
||||
showContractModal.value = true;
|
||||
}
|
||||
|
||||
function handleContractUpdated(eventData) {
|
||||
if (eventData?.data) {
|
||||
contractData.value = { ...contractData.value, ...eventData.data };
|
||||
$snackbar(isVietnamese.value ? "Hợp đồng đã được cập nhật" : "Contract has been updated");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
<script setup>
|
||||
import FileUpload from "@/components/media/FileUpload.vue";
|
||||
|
||||
const { $buildFileUrl, $formatFileSize, $getdata, $insertapi, $patchapi, $snackbar } = useNuxtApp();
|
||||
|
||||
const project = await $getdata("project", undefined, undefined, true);
|
||||
const projectDocuments = ref([]);
|
||||
|
||||
async function loadProjectDocuments() {
|
||||
projectDocuments.value = await $getdata("projectfile", undefined, {
|
||||
filter: { project: project.id, file__type: 1 },
|
||||
values:
|
||||
"id,project,file,file__id,file__code,file__type,file__name,file__file,file__size,file__caption,file__user,file__user__fullname,create_time",
|
||||
});
|
||||
}
|
||||
|
||||
loadProjectDocuments();
|
||||
|
||||
async function attachFilesToProject(files) {
|
||||
const payload = files.filter((file) => file && file.id).map((file) => ({ project: project.id, file: file.id }));
|
||||
if (payload.length === 0) return 0;
|
||||
const result = await $insertapi("projectfile", payload, undefined, false);
|
||||
if (result === "error") {
|
||||
throw new Error("Không thể liên kết tệp với dự án");
|
||||
}
|
||||
return payload.length;
|
||||
}
|
||||
|
||||
async function onUploadedProjectDocs(payload) {
|
||||
const uploadedFiles = Array.isArray(payload) ? payload : null;
|
||||
if (uploadedFiles && uploadedFiles.length > 0) {
|
||||
try {
|
||||
const linked = await attachFilesToProject(uploadedFiles);
|
||||
if (linked > 0) {
|
||||
await loadProjectDocuments();
|
||||
const message =
|
||||
linked === 1 ? "Đã thêm tài liệu dự án thành công" : `Đã thêm ${linked} tài liệu dự án thành công`;
|
||||
$snackbar(message, "Thành công", "Success");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error attaching project documents:", error);
|
||||
$snackbar("Không thể thêm tài liệu dự án, vui lòng thử lại", "Lỗi", "Error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const event = payload?.target ? payload : null;
|
||||
if (!event) return;
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const totalFiles = files.length;
|
||||
Array.from(files).forEach((file) => {
|
||||
projectDocuments.value.push({
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
size: $formatFileSize(file.size),
|
||||
uploaded_by: store.login?.fullname || "Admin",
|
||||
});
|
||||
});
|
||||
const message =
|
||||
totalFiles === 1 ? "Đã thêm tài liệu dự án thành công" : `Đã thêm ${totalFiles} tài liệu dự án thành công`;
|
||||
$snackbar(message, "Thành công", "Success");
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
const editingDocument = ref(null);
|
||||
const editingDocumentName = ref("");
|
||||
const editingDocumentCaption = ref("");
|
||||
|
||||
// Sửa tài liệu
|
||||
function editDocument(doc) {
|
||||
editingDocument.value = doc;
|
||||
editingDocumentName.value = doc.file__name;
|
||||
editingDocumentCaption.value = doc.file__caption || "";
|
||||
}
|
||||
|
||||
async function saveEditDocument() {
|
||||
if (!editingDocument.value) return;
|
||||
const index = projectDocuments.value.findIndex((doc) => doc.id === editingDocument.value.id);
|
||||
if (index !== -1) {
|
||||
projectDocuments.value[index] = {
|
||||
...projectDocuments.value[index],
|
||||
name: editingDocumentName.value,
|
||||
caption: editingDocumentCaption.value,
|
||||
};
|
||||
const fileId = editingDocument.value.fileId || editingDocument.value.file;
|
||||
if (fileId) {
|
||||
try {
|
||||
await $patchapi(
|
||||
"file",
|
||||
{
|
||||
id: fileId,
|
||||
name: editingDocumentName.value,
|
||||
caption: editingDocumentCaption.value?.trim() || null,
|
||||
},
|
||||
{},
|
||||
false,
|
||||
);
|
||||
$snackbar("Đã cập nhật tài liệu thành công", "Thành công", "Success");
|
||||
} catch (error) {
|
||||
console.error("Error updating document metadata:", error);
|
||||
$snackbar("Cập nhật tài liệu thất bại", "Lỗi", "Error");
|
||||
}
|
||||
}
|
||||
}
|
||||
cancelEditDocument();
|
||||
}
|
||||
|
||||
function cancelEditDocument() {
|
||||
editingDocument.value = null;
|
||||
editingDocumentName.value = "";
|
||||
editingDocumentCaption.value = "";
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<FileUpload
|
||||
v-if="$getEditRights()"
|
||||
position="right"
|
||||
:type="['pdf', 'file']"
|
||||
@files="onUploadedProjectDocs"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Mô tả</th>
|
||||
<th>Người tải lên</th>
|
||||
<th>Chức năng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template
|
||||
v-for="doc in projectDocuments"
|
||||
:key="doc.id"
|
||||
>
|
||||
<tr v-if="editingDocument?.id === doc.id">
|
||||
<td colspan="4">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Tên tài liệu</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="editingDocumentName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Mô tả</label>
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="editingDocumentCaption"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label"> </label>
|
||||
<div class="control">
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="button has-text-white is-primary"
|
||||
@click="saveEditDocument"
|
||||
>
|
||||
Lưu
|
||||
</button>
|
||||
<button
|
||||
class="button is-danger has-text-white"
|
||||
@click="cancelEditDocument"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td>
|
||||
<div class="is-flex is-align-items-center">
|
||||
<div>
|
||||
<a
|
||||
:href="$buildFileUrl(doc.file__file)"
|
||||
target="_blank"
|
||||
class="has-text-weight-semibold"
|
||||
>
|
||||
{{ doc.file__name }}
|
||||
</a>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ $formatFileSize(doc.file__size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="is-size-7">{{ doc.file__caption || "-" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
{{ doc.file__user__fullname }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ doc.create_time ? new Date(doc.create_time).toLocaleString("vi-VN") : "-" }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<a
|
||||
v-if="$getEditRights()"
|
||||
@click="editDocument(doc)"
|
||||
title="Sửa"
|
||||
>
|
||||
<span class="icon"
|
||||
><SvgIcon
|
||||
v-bind="{
|
||||
name: 'edit.svg',
|
||||
type: 'primary',
|
||||
size: 18,
|
||||
}"
|
||||
></SvgIcon
|
||||
></span>
|
||||
</a>
|
||||
<a
|
||||
:href="$buildFileUrl(doc.file__file)"
|
||||
target="_blank"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<span class="icon"
|
||||
><SvgIcon
|
||||
v-bind="{
|
||||
name: 'download.svg',
|
||||
type: 'success',
|
||||
size: 18,
|
||||
}"
|
||||
></SvgIcon
|
||||
></span>
|
||||
</a>
|
||||
<a
|
||||
v-if="$getEditRights()"
|
||||
@click="deleteDocument(doc)"
|
||||
title="Xóa"
|
||||
>
|
||||
<span class="icon"
|
||||
><SvgIcon
|
||||
v-bind="{
|
||||
name: 'bin1.svg',
|
||||
type: 'primary',
|
||||
size: 18,
|
||||
}"
|
||||
></SvgIcon
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="projectDocuments.length === 0">
|
||||
<td
|
||||
colspan="4"
|
||||
class="has-text-centered py-4"
|
||||
>
|
||||
<p class="has-text-grey">Chưa có tài liệu nào được đính kèm.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,230 +0,0 @@
|
||||
<script setup>
|
||||
import { useStore } from "~/stores/index";
|
||||
import { useAdvancedWorkflow } from "~/composables/useAdvancedWorkflow";
|
||||
import Contract from "~/components/application/Contract.vue";
|
||||
import Datepicker from "~/components/datepicker/Datepicker.vue";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const props = defineProps({
|
||||
transactionId: Number,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "modalevent"]);
|
||||
|
||||
const { $getdata, $snackbar } = useNuxtApp();
|
||||
const store = useStore();
|
||||
const { updateTransactionCustomer, isLoading } = useAdvancedWorkflow();
|
||||
|
||||
const transaction = ref(null);
|
||||
const currentCustomer = ref(null);
|
||||
const newCustomer = ref(null);
|
||||
const workflowResult = ref(null);
|
||||
const showContract = ref(false);
|
||||
const contractId = ref(null);
|
||||
|
||||
// Contract Date
|
||||
const initialContractDate = dayjs().format("YYYY-MM-DD");
|
||||
const contractDate = ref(initialContractDate);
|
||||
const dateRecord = ref({ contractDate: initialContractDate });
|
||||
|
||||
const customerViewAddon = {
|
||||
component: "customer/CustomerView",
|
||||
width: "70%",
|
||||
height: "600px",
|
||||
title: "Chi tiết khách hàng",
|
||||
};
|
||||
|
||||
const customerViewAdd = {
|
||||
component: "customer/CustomerInfo2",
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
title: "Chỉnh sửa khách hàng",
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.transactionId) {
|
||||
transaction.value = await $getdata("transaction", { id: props.transactionId }, undefined, true);
|
||||
if (transaction.value && transaction.value.customer) {
|
||||
currentCustomer.value = await $getdata("customer", { id: transaction.value.customer }, undefined, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateContractDate(newDate) {
|
||||
contractDate.value = newDate;
|
||||
dateRecord.value.contractDate = newDate;
|
||||
}
|
||||
|
||||
function updateTransactionCustomerDisplay(result) {
|
||||
if (result?.success) {
|
||||
const contract = result?.contract;
|
||||
if (contract && contract.id) {
|
||||
$snackbar(`Đổi khách hàng thành công. Hợp đồng mới được tạo với ID: ${contract.id}`, { type: "is-success" });
|
||||
contractId.value = contract.id;
|
||||
showContract.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmChangeCustomer() {
|
||||
if (!newCustomer.value) {
|
||||
$snackbar("Vui lòng chọn khách hàng mới.", { type: "is-warning" });
|
||||
return;
|
||||
}
|
||||
|
||||
workflowResult.value = null;
|
||||
|
||||
try {
|
||||
const result = await updateTransactionCustomer(props.transactionId, newCustomer.value.id, contractDate.value);
|
||||
workflowResult.value = result;
|
||||
updateTransactionCustomerDisplay(result);
|
||||
} catch (error) {
|
||||
console.error("Workflow execution error:", error);
|
||||
$snackbar("Có lỗi xảy ra khi thực thi workflow.", { type: "is-danger" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!showContract">
|
||||
<div v-if="transaction && currentCustomer">
|
||||
<h3 class="fsb-18 mb-3">Mã giao dịch: {{ transaction.code }}</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="is-size-6 has-text-weight-bold mb-2">Khách hàng hiện tại</h4>
|
||||
<div class="is-flex is-flex-wrap-wrap is-gap-4">
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Mã</p>
|
||||
<p class="is-size-6">{{ currentCustomer.code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Tên</p>
|
||||
<p class="is-size-6">
|
||||
{{ currentCustomer.name || currentCustomer.fullname }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Loại</p>
|
||||
<p class="is-size-6">
|
||||
<span
|
||||
class="tag"
|
||||
:class="currentCustomer.type === 1 ? 'is-info' : 'is-warning'"
|
||||
>{{ currentCustomer.type === 1 ? "Cá nhân" : "Tổ chức" }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Điện thoại</p>
|
||||
<p class="is-size-6">{{ currentCustomer.phone }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Email</p>
|
||||
<p class="is-size-6">{{ currentCustomer.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-3">
|
||||
<label class="label">Chọn khách hàng mới<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div class="control">
|
||||
<SearchBox
|
||||
v-bind="{
|
||||
api: 'customer',
|
||||
field: 'label',
|
||||
searchfield: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
column: ['code', 'fullname', 'phone', 'legal_code'],
|
||||
first: true,
|
||||
viewaddon: customerViewAddon,
|
||||
addon: customerViewAdd,
|
||||
placeholder: 'Tìm khách hàng theo mã, tên, số điện thoại hoặc CMND',
|
||||
}"
|
||||
@option="
|
||||
(customer) => {
|
||||
newCustomer = customer;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newCustomer"
|
||||
class="mb-4"
|
||||
>
|
||||
<h4 class="is-size-6 has-text-weight-bold mb-2">Khách hàng mới được chọn</h4>
|
||||
<div class="is-flex is-flex-wrap-wrap is-gap-4">
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Mã</p>
|
||||
<p class="is-size-6">{{ newCustomer.code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Tên</p>
|
||||
<p class="is-size-6">
|
||||
{{ newCustomer.name || newCustomer.fullname }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Loại</p>
|
||||
<p class="is-size-6">
|
||||
<span
|
||||
class="tag"
|
||||
:class="newCustomer.type === 1 ? 'is-info' : 'is-warning'"
|
||||
>{{ newCustomer.type === 1 ? "Cá nhân" : "Tổ chức" }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Điện thoại</p>
|
||||
<p class="is-size-6">{{ newCustomer.phone }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="has-text-grey is-size-7">Email</p>
|
||||
<p class="is-size-6">{{ newCustomer.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field mb-4">
|
||||
<label class="label">Ngày ký hợp đồng mới<b class="ml-1 has-text-danger">*</b></label>
|
||||
<div
|
||||
class="control"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<Datepicker
|
||||
:record="dateRecord"
|
||||
attr="contractDate"
|
||||
@date="updateContractDate"
|
||||
position="is-top-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="is-flex is-justify-content-flex-end is-gap-2">
|
||||
<button
|
||||
class="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary"
|
||||
:disabled="!newCustomer"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="confirmChangeCustomer"
|
||||
>
|
||||
Xác nhận
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="is-flex is-justify-content-center is-align-items-center"
|
||||
style="min-height: 200px"
|
||||
>
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Contract :contractId="contractId" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,444 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="content p-0">
|
||||
<div class="columns">
|
||||
<!-- Left Column: Transaction & Customer Info -->
|
||||
<div class="column is-half">
|
||||
<div class="mb-5">
|
||||
<p class="title is-5 mb-3">Thông tin Giao dịch</p>
|
||||
<p><strong>Loại giao dịch:</strong> {{ phaseInfo.name || "-" }}</p>
|
||||
<p>
|
||||
<strong>Chính sách tài chính:</strong>
|
||||
{{ selectedPolicy.name || "-" }}
|
||||
</p>
|
||||
<p><strong>Giá gốc:</strong> {{ $numtoString(originPrice) }}</p>
|
||||
<p>
|
||||
<strong>Số tiền đặt cọc:</strong>
|
||||
<span class="has-text-primary has-text-weight-bold">{{ $numtoString(depositAmount) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Ngày ký hợp đồng:</strong>
|
||||
<span class="has-text-primary has-text-weight-bold">{{ formatDate(initialContractDate) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Ngày hết hạn GD:</strong>
|
||||
<span class="has-text-primary has-text-weight-bold">{{ formatDate(editableDueDate) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<p class="title is-5 mb-3">Thông tin Khách hàng</p>
|
||||
<div class="columns">
|
||||
<!-- Original Customer Column -->
|
||||
<div class="column">
|
||||
<p>
|
||||
<strong>{{ isIndividual ? "Khách hàng" : "Tổ chức" }}</strong>
|
||||
</p>
|
||||
<p><strong>Mã KH:</strong> {{ selectedCustomer.code || "-" }}</p>
|
||||
<p>
|
||||
<strong>{{ isIndividual ? "Họ và tên:" : "Tên tổ chức:" }}</strong>
|
||||
{{ selectedCustomer.fullname || "-" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Số điện thoại:</strong>
|
||||
{{ selectedCustomer.phone || "-" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ isIndividual ? "CCCD:" : "GPKD/Mã số thuế:" }}</strong>
|
||||
{{ selectedCustomer.legal_code || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Co-owner Column -->
|
||||
<div
|
||||
class="column"
|
||||
v-if="coOwner && isIndividual"
|
||||
>
|
||||
<p class="is-flex is-justify-content-space-between">
|
||||
<strong>Đồng sở hữu</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="delete is-small"
|
||||
@click.stop="clearCoOwner()"
|
||||
></button>
|
||||
</p>
|
||||
<p><strong>Mã:</strong> {{ coOwner.people__code || "-" }}</p>
|
||||
<p>
|
||||
<strong>Họ và tên:</strong>
|
||||
{{ coOwner.people__fullname || "-" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Số điện thoại:</strong>
|
||||
{{ coOwner.people__phone || "-" }}
|
||||
</p>
|
||||
<p><strong>CCCD:</strong> {{ coOwner.people__legal_code || "-" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Co-owner SearchBox -->
|
||||
<div
|
||||
v-if="isIndividual && relatedPeople.length > 0"
|
||||
class="mt-2"
|
||||
>
|
||||
<label class="label">Đồng sở hữu</label>
|
||||
<SearchBox
|
||||
:key="coOwner ? coOwner.id : 'empty'"
|
||||
v-bind="{
|
||||
vdata: relatedPeople,
|
||||
field: 'people__fullname',
|
||||
column: ['people__code', 'people__fullname'],
|
||||
first: true,
|
||||
optionid: coOwner ? coOwner.id : null,
|
||||
}"
|
||||
@option="selectCoOwnerByPerson"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isIndividual && relatedPeople.length === 0"
|
||||
class="mt-2"
|
||||
>
|
||||
<p class="has-text-grey-light is-size-7">Không có người liên quan nào được thêm</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Product Info -->
|
||||
<div
|
||||
class="column is-half"
|
||||
style="border-left: 1px solid #dbdbdb; padding-left: 2rem"
|
||||
>
|
||||
<div class="mb-5">
|
||||
<p class="title is-5 mb-3">Thông tin Sản phẩm</p>
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-half">
|
||||
<strong>Mã thương mại:</strong>
|
||||
{{ productData.trade_code || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Số tờ thửa:</strong>
|
||||
{{ productData.land_lot_code || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Mã quy hoạch:</strong>
|
||||
{{ productData.zone_code || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Phân khu:</strong>
|
||||
{{ productData.zone_type__name || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Loại sản phẩm:</strong>
|
||||
{{ productData.type__name || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Hướng cửa:</strong>
|
||||
{{ productData.direction__name || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Kích thước lô:</strong>
|
||||
{{ productData.land_lot_size || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Diện tích đất:</strong>
|
||||
{{ productData.lot_area ? $numtoString(productData.lot_area) + " m²" : "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>DT xây dựng:</strong>
|
||||
{{ productData.building_area ? $numtoString(productData.building_area) + " m²" : "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>DT sàn:</strong>
|
||||
{{ productData.total_built_area ? $numtoString(productData.total_built_area) + " m²" : "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Tầng cao:</strong>
|
||||
{{ productData.number_of_floors || "-" }}
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<strong>Trạng thái:</strong>
|
||||
{{ productData.status__name || "-" }}
|
||||
</div>
|
||||
<div class="column is-half"><strong>Dự án:</strong> {{ productData.project__name || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Số tiền thanh toán</label>
|
||||
<div class="control">
|
||||
<InputNumber
|
||||
:record="paymentRecord"
|
||||
attr="paymentAmount"
|
||||
placeholder="Nhập số tiền"
|
||||
:disabled="isPaymentInputDisabled"
|
||||
@number="updatePaymentAmount"
|
||||
:class="{ 'is-danger': !isPaymentAmountValid }"
|
||||
></InputNumber>
|
||||
</div>
|
||||
<p
|
||||
v-if="!isPaymentAmountValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ paymentAmountError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Ngày ký hợp đồng</label>
|
||||
<p class="control has-text-weight-bold pt-2">
|
||||
{{ formatDate(initialContractDate) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Ngày hết hạn giao dịch</label>
|
||||
<Datepicker
|
||||
:record="dateRecord"
|
||||
attr="dueDate"
|
||||
@date="updateDueDate"
|
||||
position="is-top-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Mã xác nhận</label>
|
||||
<div class="control">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Nhập mã"
|
||||
v-model="userInputCaptcha"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a
|
||||
class="button is-static has-text-weight-bold has-background-grey-lighter"
|
||||
style="font-family: "Courier New", monospace; letter-spacing: 2px"
|
||||
>
|
||||
{{ captchaCode }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button
|
||||
class="button"
|
||||
@click="generateCaptcha"
|
||||
:title="'Tạo mã mới'"
|
||||
>
|
||||
<span class="icon">
|
||||
<SvgIcon
|
||||
v-bind="{
|
||||
name: 'refresh.svg',
|
||||
type: 'primary',
|
||||
size: 23,
|
||||
}"
|
||||
></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="is-flex is-justify-content-flex-end mt-4">
|
||||
<button
|
||||
class="button has-text-white is-success mr-2"
|
||||
:disabled="!isConfirmed"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<span>Tạo giao dịch</span>
|
||||
</button>
|
||||
<button
|
||||
class="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
import dayjs from "dayjs";
|
||||
import Datepicker from "~/components/datepicker/Datepicker.vue";
|
||||
import InputNumber from "~/components/common/InputNumber.vue";
|
||||
import SearchBox from "~/components/SearchBox";
|
||||
|
||||
export default {
|
||||
components: { Datepicker, InputNumber, SearchBox },
|
||||
props: {
|
||||
productData: { type: Object, required: true },
|
||||
phaseInfo: { type: Object, required: true },
|
||||
selectedPolicy: { type: Object, required: true },
|
||||
originPrice: { type: Number, required: true },
|
||||
discountValueDisplay: { type: Number, required: true },
|
||||
selectedCustomer: { type: Object, required: true },
|
||||
initialContractDate: { type: String, required: true },
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
emits: ["close", "modalevent"],
|
||||
data() {
|
||||
const initialDueDate = dayjs(this.initialContractDate).add(0, "day").format("YYYY-MM-DD");
|
||||
|
||||
return {
|
||||
editableDueDate: initialDueDate,
|
||||
dateRecord: { dueDate: initialDueDate },
|
||||
captchaCode: "",
|
||||
userInputCaptcha: "",
|
||||
paymentRecord: { paymentAmount: null },
|
||||
coOwner: null,
|
||||
relatedPeople: [],
|
||||
peopleAddon: {
|
||||
component: "people/People",
|
||||
width: "65%",
|
||||
height: "600px",
|
||||
title: "Người liên quan",
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
depositAmount: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (this.phaseInfo.id === "p0" && newVal === 0) {
|
||||
this.paymentRecord.paymentAmount = null;
|
||||
} else {
|
||||
this.paymentRecord.paymentAmount = newVal;
|
||||
}
|
||||
},
|
||||
},
|
||||
selectedCustomer: {
|
||||
immediate: true,
|
||||
async handler(newVal) {
|
||||
if (newVal && newVal.id) {
|
||||
await this.fetchRelatedPeople();
|
||||
} else {
|
||||
this.relatedPeople = [];
|
||||
this.coOwner = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isIndividual() {
|
||||
return this.selectedCustomer?.type === 1;
|
||||
},
|
||||
paymentAmount() {
|
||||
return this.paymentRecord.paymentAmount;
|
||||
},
|
||||
isPaymentInputDisabled() {
|
||||
return this.phaseInfo.id !== "p0";
|
||||
},
|
||||
isPaymentAmountValid() {
|
||||
return this.paymentAmount > 0;
|
||||
},
|
||||
paymentAmountError() {
|
||||
if (this.isPaymentAmountValid) return "";
|
||||
return "Số tiền thanh toán phải lớn hơn 0.";
|
||||
},
|
||||
isConfirmed() {
|
||||
return (
|
||||
this.userInputCaptcha.toLowerCase() === this.captchaCode.toLowerCase() &&
|
||||
this.userInputCaptcha !== "" &&
|
||||
this.isPaymentAmountValid
|
||||
);
|
||||
},
|
||||
depositAmount() {
|
||||
return this.selectedPolicy?.deposit || 0;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.generateCaptcha();
|
||||
},
|
||||
methods: {
|
||||
async fetchRelatedPeople() {
|
||||
try {
|
||||
const { $getdata } = useNuxtApp();
|
||||
const apiName = this.isIndividual ? "customerpeople" : "legalrep";
|
||||
const filterKey = this.isIndividual ? "customer" : "organization";
|
||||
|
||||
let customerId = this.selectedCustomer.id;
|
||||
|
||||
// Nếu là organization, cần lấy organization ID
|
||||
if (!this.isIndividual) {
|
||||
const org = await $getdata("organization", { customer: customerId }, undefined, true);
|
||||
if (org) {
|
||||
customerId = org.id;
|
||||
} else {
|
||||
this.relatedPeople = [];
|
||||
this.coOwner = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const people = await $getdata(apiName, { [filterKey]: customerId });
|
||||
this.relatedPeople = Array.isArray(people) ? people : people ? [people] : [];
|
||||
|
||||
// Auto-select người đầu tiên nếu có
|
||||
if (this.relatedPeople.length > 0) {
|
||||
this.coOwner = this.relatedPeople[0];
|
||||
} else {
|
||||
this.coOwner = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching related people:", error);
|
||||
this.relatedPeople = [];
|
||||
this.coOwner = null;
|
||||
}
|
||||
},
|
||||
clearCoOwner() {
|
||||
this.coOwner = null;
|
||||
},
|
||||
selectCoOwnerByPerson(selectedPerson) {
|
||||
// selectedPerson là một record từ customerpeople
|
||||
this.coOwner = selectedPerson || null;
|
||||
},
|
||||
formatDate(date) {
|
||||
return date ? dayjs(date).format("L") : "-";
|
||||
},
|
||||
updateDueDate(newDate) {
|
||||
this.editableDueDate = newDate;
|
||||
this.dateRecord.dueDate = newDate;
|
||||
},
|
||||
updatePaymentAmount(value) {
|
||||
this.paymentRecord.paymentAmount = value;
|
||||
},
|
||||
generateCaptcha() {
|
||||
this.captchaCode = Math.random().toString(36).substring(2, 7).toUpperCase();
|
||||
this.userInputCaptcha = "";
|
||||
},
|
||||
handleConfirm() {
|
||||
if (this.isConfirmed) {
|
||||
this.$emit("modalevent", {
|
||||
name: "confirm",
|
||||
data: {
|
||||
currentDate: this.initialContractDate,
|
||||
dueDate: this.editableDueDate,
|
||||
paymentAmount: this.depositAmount,
|
||||
depositReceived: this.paymentAmount,
|
||||
people: this.isIndividual && this.coOwner ? this.coOwner.people : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,277 +0,0 @@
|
||||
<script setup>
|
||||
import { isNotNil } from "es-toolkit";
|
||||
|
||||
// receive either txndetail or txndetailCode
|
||||
// pass index to show as timeline item
|
||||
const props = defineProps({
|
||||
txndetail: Object,
|
||||
index: Number,
|
||||
txndetailCode: Number,
|
||||
});
|
||||
|
||||
const { $getdata, $insertapi, $snackbar } = useNuxtApp();
|
||||
const store = useStore();
|
||||
const txndetail = ref(props.txndetail);
|
||||
|
||||
const showModal = ref(null);
|
||||
const paymentSchedules = ref(null);
|
||||
const transaction = ref(null);
|
||||
const isUploadingFile = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!props.txndetail) {
|
||||
const txndetailData = await $getdata(
|
||||
"reservation",
|
||||
undefined,
|
||||
{
|
||||
filter: { code: props.txndetailCode },
|
||||
values:
|
||||
"id,code,date,amount,amount_remaining,amount_received,due_date,transaction,customer_old__fullname,customer_new__fullname,transaction__code,phase,phase__name,creator,creator__fullname,status,status__name,approver,approver__fullname,approve_time,create_time,update_time",
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
txndetail.value = txndetailData;
|
||||
}
|
||||
|
||||
const schedules = await $getdata("payment_schedule", undefined, {
|
||||
filter: { txn_detail: txndetail.value.id },
|
||||
values:
|
||||
"id,code,txn_detail,txn_detail__transaction__policy__code,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry",
|
||||
});
|
||||
paymentSchedules.value = schedules;
|
||||
|
||||
// Fetch transaction info
|
||||
if (txndetail.value.transaction) {
|
||||
transaction.value = await $getdata("transaction", { id: txndetail.value.transaction }, undefined, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
paymentSchedules.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
function openModal() {
|
||||
showModal.value = {
|
||||
title: "Hợp đồng",
|
||||
height: "40vh",
|
||||
width: "50%",
|
||||
component: "transaction/TransactionFiles",
|
||||
vbind: { txndetail: txndetail.value.id },
|
||||
};
|
||||
}
|
||||
|
||||
async function handleFileUpload(uploadedFiles) {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) return;
|
||||
|
||||
isUploadingFile.value = true;
|
||||
try {
|
||||
for (const fileRecord of uploadedFiles) {
|
||||
const payload = {
|
||||
txn_detail: txndetail.value.id,
|
||||
file: fileRecord.id,
|
||||
phase: txndetail.value.phase,
|
||||
};
|
||||
|
||||
const result = await $insertapi("transactionfile", payload);
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error || "Lưu file không thành công.");
|
||||
}
|
||||
}
|
||||
|
||||
$snackbar("File đã được upload thành công!", { type: "is-success" });
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
$snackbar(error.message || "Có lỗi khi upload file.", {
|
||||
type: "is-danger",
|
||||
});
|
||||
} finally {
|
||||
isUploadingFile.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="txndetail"
|
||||
class="is-flex is-gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="props.index !== undefined"
|
||||
class="is-flex is-flex-direction-column is-align-items-center is-gap-1"
|
||||
>
|
||||
<p
|
||||
class="is-size-5 has-text-weight-semibold is-flex is-justify-content-center is-align-items-center"
|
||||
style="border: 4px solid rgb(32, 72, 83); border-radius: 9999px; width: 2.5rem; height: 2.5rem"
|
||||
>
|
||||
{{ props.index + 1 }}
|
||||
</p>
|
||||
<div style="border: 3px solid #dddddd; border-radius: 9999px; flex-grow: 1; width: min-content"></div>
|
||||
</div>
|
||||
<div class="is-flex is-flex-direction-column is-align-items-baseline is-gap-2 is-flex-grow-1">
|
||||
<div
|
||||
class="columns is-flex-wrap-wrap is-1.5 fs-15"
|
||||
style="width: 100%"
|
||||
>
|
||||
<div class="column is-12 my-0 columns">
|
||||
<p class="column is-3">
|
||||
<span>{{ txndetail.phase__name }} - </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.code }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="phase != 7"
|
||||
class="column is-3 p-0"
|
||||
>
|
||||
<p>
|
||||
<span>Số tiền: </span>
|
||||
<span class="has-text-weight-semibold">{{ $numtoString(txndetail.amount) }}</span>
|
||||
</p>
|
||||
<p
|
||||
class="fs-14 has-text-grey"
|
||||
v-if="isNotNil(txndetail.amount_received) && isNotNil(txndetail.amount_remaining)"
|
||||
>
|
||||
<span>(</span>
|
||||
<span
|
||||
title="Đã thu"
|
||||
class="has-text-weight-semibold"
|
||||
style="color: hsl(120, 70%, 40%)"
|
||||
>
|
||||
{{ $numtoString(txndetail.amount_received) }}
|
||||
</span>
|
||||
<span> • </span>
|
||||
<span
|
||||
title="Còn lại"
|
||||
class="has-text-weight-semibold"
|
||||
style="color: hsl(0, 50%, 50%)"
|
||||
>
|
||||
{{ $numtoString(txndetail.amount_remaining) }}
|
||||
</span>
|
||||
<span>)</span>
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="phase != 7"
|
||||
class="column is-3"
|
||||
>
|
||||
<span>Trạng thái: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.status__name }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="phase === 7"
|
||||
class="column is-3"
|
||||
>
|
||||
<span>Khách hàng mới: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.customer_new__fullname }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="phase === 7"
|
||||
class="column is-3"
|
||||
>
|
||||
<span>Người chuyển nhượng: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.creator__fullname }}</span>
|
||||
</p>
|
||||
<div class="column is-3 p-0 is-flex is-align-items-center is-gap-3">
|
||||
<a
|
||||
class="fsb-15 has-text-primary"
|
||||
@click.prevent="openModal"
|
||||
>Hợp đồng</a
|
||||
>
|
||||
<FileUpload
|
||||
v-if="
|
||||
txndetail.phase === 7 &&
|
||||
$getEditRights('edit', {
|
||||
code: 'transaction',
|
||||
category: 'topmenu',
|
||||
})
|
||||
"
|
||||
:type="['image', 'pdf']"
|
||||
@files="handleFileUpload"
|
||||
position="right"
|
||||
class="file-upload-inline"
|
||||
style="display: inline-block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="column is-12 my-0 has-background-grey" />
|
||||
<p
|
||||
v-if="phase === 7"
|
||||
class="column is-12 mt-1 ml-1 columns"
|
||||
>
|
||||
<span
|
||||
>Khách hàng
|
||||
<span class="has-text-weight-semibold">{{ txndetail.customer_old__fullname }} </span>
|
||||
chuyển nhượng hợp đồng này cho khách hàng
|
||||
<span class="has-text-weight-semibold">{{ txndetail.customer_new__fullname }} </span></span
|
||||
>
|
||||
</p>
|
||||
<div
|
||||
v-if="phase != 7"
|
||||
class="column is-12 mt-0 columns"
|
||||
>
|
||||
<p class="column is-3">
|
||||
<span>Từ ngày: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.date ? $dayjs(txndetail.date).format("L") : "" }}</span>
|
||||
</p>
|
||||
|
||||
<p class="column is-3">
|
||||
<span>Đến ngày: </span>
|
||||
<span class="has-text-weight-semibold">{{
|
||||
txndetail.due_date ? $dayjs(txndetail.due_date).format("L") : ""
|
||||
}}</span>
|
||||
</p>
|
||||
<p class="column is-3">
|
||||
<span>Người tạo: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.creator__fullname }}</span>
|
||||
</p>
|
||||
<p class="column is-3">
|
||||
<span>Người duyệt: </span>
|
||||
<span class="has-text-weight-semibold">{{ txndetail.approver__fullname }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 100%">
|
||||
<DataView
|
||||
v-if="paymentSchedules && paymentSchedules.length > 0"
|
||||
v-bind="{
|
||||
pagename: `txndetail-${txndetail.id}`,
|
||||
api: 'payment_schedule',
|
||||
params: {
|
||||
filter: { txn_detail: txndetail.id },
|
||||
sort: 'cycle',
|
||||
values:
|
||||
'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,remain_amount,paid_amount,remain_amount,code,txn_detail,txn_detail__transaction__policy__code,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
|
||||
},
|
||||
setting: 'payment_schedule_list_timeline',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>Đã có lỗi khi lấy thông tin chi tiết giao dịch.</div>
|
||||
<Modal
|
||||
v-if="showModal"
|
||||
v-bind="showModal"
|
||||
@close="showModal = undefined"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.file-upload-inline) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:deep(.file-upload-inline .button) {
|
||||
height: 1.5em;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.875rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3273dc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.file-upload-inline .button:hover) {
|
||||
background: none;
|
||||
color: #1a5490;
|
||||
}
|
||||
</style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th style="text-align: center">Tải xuống</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="txnfile in txnfiles"
|
||||
:key="txnfile.id"
|
||||
>
|
||||
<td>
|
||||
<div class="is-flex is-align-items-center">
|
||||
<span class="icon is-medium has-text-primary mr-2">
|
||||
<SvgIcon
|
||||
v-bind="{
|
||||
name: 'attach-file.svg',
|
||||
type: 'primary',
|
||||
size: 20,
|
||||
}"
|
||||
></SvgIcon>
|
||||
</span>
|
||||
<div
|
||||
class="is-clickable"
|
||||
@click="open(txnfile)"
|
||||
>
|
||||
<p class="has-text-weight-semibold has-text-primary">
|
||||
{{ txnfile.file__name }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ $formatFileSize(txnfile.file__size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="buttons is-justify-content-center">
|
||||
<a
|
||||
class="button is-small is-info is-outlined"
|
||||
@click="download(txnfile)"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<span class="icon is-small"
|
||||
><SvgIcon
|
||||
v-bind="{
|
||||
name: 'download.svg',
|
||||
type: 'primary',
|
||||
size: 14,
|
||||
}"
|
||||
></SvgIcon
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="txnfiles.length === 0">
|
||||
<td
|
||||
colspan="4"
|
||||
class="has-text-centered py-4"
|
||||
>
|
||||
<p class="has-text-grey">Chưa có tài liệu nào được đính kèm.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Modal
|
||||
@close="showmodal = undefined"
|
||||
v-bind="showmodal"
|
||||
v-if="showmodal"
|
||||
></Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
const { $formatFileSize, $getdata, $getpath } = useNuxtApp();
|
||||
|
||||
const props = defineProps({
|
||||
txndetail: [String, Number],
|
||||
});
|
||||
|
||||
const showmodal = ref(undefined);
|
||||
const txnfiles = ref([]);
|
||||
|
||||
async function fetchTxnFiles() {
|
||||
if (props.txndetail) {
|
||||
const data = await $getdata("transactionfile", {
|
||||
txn_detail: props.txndetail,
|
||||
});
|
||||
txnfiles.value = Array.isArray(data) ? data : data ? [data] : [];
|
||||
}
|
||||
}
|
||||
|
||||
function open(v) {
|
||||
const fileName = v.file__name || "";
|
||||
const filePath = v.file__file || v.file;
|
||||
|
||||
if (!filePath) return;
|
||||
|
||||
if (fileName.indexOf(".png") >= 0 || fileName.indexOf(".jpg") >= 0 || fileName.indexOf(".jpeg") >= 0) {
|
||||
showmodal.value = {
|
||||
title: fileName,
|
||||
component: "media/ChipImage",
|
||||
vbind: {
|
||||
extend: false,
|
||||
file: v,
|
||||
image: `${$getpath()}static/files/${filePath}`,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
window.open(`${$getpath()}static/files/${filePath}`);
|
||||
}
|
||||
|
||||
function download(v) {
|
||||
const filePath = v.file__file || v.file;
|
||||
if (!filePath) return;
|
||||
|
||||
window.open(`${$getpath()}static/files/${filePath}`, v.file__name || "_blank");
|
||||
}
|
||||
|
||||
fetchTxnFiles();
|
||||
|
||||
watch(
|
||||
() => props.txndetail,
|
||||
() => {
|
||||
fetchTxnFiles();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script setup>
|
||||
import TransactionDetail from "@/components/transaction/TransactionDetail.vue";
|
||||
|
||||
const props = defineProps({
|
||||
transaction__code: String,
|
||||
});
|
||||
|
||||
const { $exportpdf, $getdata, $snackbar } = useNuxtApp();
|
||||
const txn = ref(null);
|
||||
const txndetails = ref(null);
|
||||
const showModal = ref(null);
|
||||
|
||||
function openChangeCustomerModal() {
|
||||
showModal.value = {
|
||||
title: "Đổi khách hàng",
|
||||
height: "40vh",
|
||||
width: "50%",
|
||||
component: "transaction/ChangeCustomerModal",
|
||||
vbind: { transactionId: txn.value.id },
|
||||
};
|
||||
}
|
||||
|
||||
function print() {
|
||||
const fileName = `Giao-dich-${props.transaction__code}`;
|
||||
const docId = "print-area";
|
||||
$exportpdf(docId, fileName, "a4", "landscape");
|
||||
$snackbar("Đang xuất PDF...", { type: "is-info" });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const fetchedTxn = await $getdata("transaction", { code: props.transaction__code }, undefined, true);
|
||||
txn.value = fetchedTxn;
|
||||
|
||||
const fetchedTxndetails = await $getdata("reservation", undefined, {
|
||||
filter: {
|
||||
transaction__code: props.transaction__code,
|
||||
},
|
||||
sort: "create_time",
|
||||
values:
|
||||
"id,code,date,amount,amount_remaining,amount_received,due_date,transaction,customer_old__fullname,customer_new__fullname,transaction__code,phase,phase__name,creator,creator__fullname,status,status__name,approver,approver__fullname,approve_time,create_time,update_time",
|
||||
});
|
||||
txndetails.value = fetchedTxndetails;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div id="print-area">
|
||||
<!-- mt-1 because bug: print empty first page -->
|
||||
<div class="columns mx-0 mt-1 mb-4">
|
||||
<div class="column is-narrow is-flex is-align-items-center">
|
||||
<p class="has-text-weight-semibold">{{ transaction__code }}</p>
|
||||
</div>
|
||||
<div class="column is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Giá hợp đồng: </span>
|
||||
<span class="has-text-weight-semibold">{{ $numtoString(txn?.sale_price) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Đã thu: </span>
|
||||
<span
|
||||
class="has-text-weight-semibold"
|
||||
style="color: hsl(120, 70%, 40%)"
|
||||
>
|
||||
{{ $numtoString(txn?.amount_received) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-flex is-align-items-center">
|
||||
<p>
|
||||
<span>Còn lại: </span>
|
||||
<span
|
||||
class="has-text-weight-semibold"
|
||||
style="color: hsl(0, 50%, 50%)"
|
||||
>
|
||||
{{ $numtoString(txn?.amount_remain) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="ignore"
|
||||
class="column is-narrow is-flex is-align-items-center is-gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="txn?.phase === 4 && $getEditRights('edit', { code: 'transaction', category: 'topmenu' })"
|
||||
@click="openChangeCustomerModal"
|
||||
class="button is-link"
|
||||
>
|
||||
Đổi khách hàng
|
||||
</button>
|
||||
<button
|
||||
@click="print"
|
||||
class="button is-light"
|
||||
>
|
||||
In thông tin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-flex is-flex-direction-column is-gap-4">
|
||||
<TransactionDetail
|
||||
v-for="(txndetail, i) in txndetails"
|
||||
:key="txndetail.id"
|
||||
:index="i"
|
||||
:txndetail="txndetail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
v-if="showModal"
|
||||
v-bind="showModal"
|
||||
@close="showModal = undefined"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,137 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<label
|
||||
v-for="item in filteredPhases"
|
||||
:key="item.id"
|
||||
class="py-3 is-clickable is-block"
|
||||
:class="{ 'has-background-success-light': selectedPhaseId === item.id }"
|
||||
style="border-bottom: 1px solid #dbdbdb"
|
||||
>
|
||||
<div class="columns px-3 is-mobile is-vcentered is-gapless mb-0">
|
||||
<div class="column">
|
||||
<span class="is-size-6">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
:stroke="selectedPhaseId === item.id ? '#48c78e' : '#dbdbdb'"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
v-if="selectedPhaseId === item.id"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="6"
|
||||
fill="#48c78e"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="radio"
|
||||
:value="item.id"
|
||||
v-model="selectedPhaseId"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-success has-text-white is-fullwidth"
|
||||
@click="submit"
|
||||
:disabled="!selectedPhaseId"
|
||||
>
|
||||
{{ isVietnamese ? "Xác nhận" : "Confirm" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
emits: ["modalevent", "close"],
|
||||
props: {
|
||||
filterPhases: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedPhaseId: null,
|
||||
phases: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Giữ chỗ",
|
||||
code: "reserved",
|
||||
phase: 1,
|
||||
status: 3,
|
||||
transstatus: 1,
|
||||
},
|
||||
{
|
||||
id: "p2a",
|
||||
name: "Thỏa thuận thực hiện nguyện vọng",
|
||||
code: "fulfillwish",
|
||||
phase: 4,
|
||||
status: 5,
|
||||
transstatus: 1,
|
||||
},
|
||||
{
|
||||
id: "p2b",
|
||||
name: "Đặt cọc",
|
||||
code: "deposit",
|
||||
phase: 2,
|
||||
status: 4,
|
||||
transstatus: 1,
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
name: "Mua bán",
|
||||
code: "pertrade",
|
||||
phase: 3,
|
||||
status: 6,
|
||||
transstatus: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isVietnamese() {
|
||||
return this.store.lang === "vi";
|
||||
},
|
||||
filteredPhases() {
|
||||
if (this.filterPhases.length === 0) {
|
||||
return this.phases; // If no filter is provided, show all phases
|
||||
}
|
||||
return this.phases.filter((phase) => this.filterPhases.includes(phase.code));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (this.selectedPhaseId) {
|
||||
const selected = this.phases.find((p) => p.id === this.selectedPhaseId);
|
||||
this.$emit("modalevent", { name: "phaseSelected", data: selected });
|
||||
this.$emit("close");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:id="docid"
|
||||
v-if="record"
|
||||
>
|
||||
<div :id="docid1">
|
||||
<div class="columns is-multiline mx-0">
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.data && findFieldName("id")[this.lang] }}</label>
|
||||
<div class="control">
|
||||
<span
|
||||
class="hyperlink"
|
||||
@click="$copyToClipboard(record.id)"
|
||||
>{{ record.id }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.data && findFieldName("code")[this.lang] }}</label>
|
||||
<div class="control">
|
||||
<span
|
||||
class="hyperlink"
|
||||
@click="$copyToClipboard(record.code)"
|
||||
>{{ record.code }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.lang === "vi" ? "Tên" : "Name" }}</label>
|
||||
<div class="control">
|
||||
{{ record.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3 pb-1 px-0">
|
||||
<div class="field">
|
||||
<label class="label">{{ this.lang === "vi" ? "Chi tiết" : "Detail" }}</label>
|
||||
<div class="control">
|
||||
<span>{{ record.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { useStore } from "@/stores/index";
|
||||
export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
return { store };
|
||||
},
|
||||
props: ["row", "pagename"],
|
||||
data() {
|
||||
return {
|
||||
record: undefined,
|
||||
errors: {},
|
||||
showmodal: undefined,
|
||||
docid: this.$id(),
|
||||
docid1: this.$id(),
|
||||
data: this.store.common,
|
||||
isEditMode: this.isEditMode,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
lang() {
|
||||
return this.store.lang;
|
||||
},
|
||||
isVietnamese() {
|
||||
return this.store.lang === "vi";
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
this.record = await this.$getdata("transactiontype", { id: this.row.id }, undefined, true);
|
||||
},
|
||||
methods: {
|
||||
findFieldName(code) {
|
||||
let field = this.data.find((v) => v.code === code);
|
||||
return field;
|
||||
},
|
||||
copy(value) {
|
||||
this.$copyToClipboard(value);
|
||||
this.$snackbar("Đã copy vào clipboard.", "Copy", "Success");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,398 +0,0 @@
|
||||
import { ref } from "vue";
|
||||
import { useNuxtApp } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
// Interface cho các tham số từ UI
|
||||
interface TransactionParams {
|
||||
product: any;
|
||||
customer: any;
|
||||
policy: any;
|
||||
phaseInfo: any;
|
||||
priceAfterDiscount: number;
|
||||
discountValue: number;
|
||||
detailedDiscounts: any[];
|
||||
paymentPlans: any[];
|
||||
currentDate: string;
|
||||
reservationDueDate: string;
|
||||
reservationAmount: number;
|
||||
depositReceived: number;
|
||||
people?: number | null;
|
||||
earlyDiscountAmount?: number;
|
||||
gifts?: Array<{ id: number }>; // Thêm trường gifts
|
||||
}
|
||||
|
||||
export function useAdvancedWorkflow() {
|
||||
const { $insertapi, $snackbar, $store } = useNuxtApp();
|
||||
const isLoading = ref(false);
|
||||
const error = ref<any>(null);
|
||||
|
||||
/**
|
||||
* Helper: Tìm kết quả của một step cụ thể trong mảng 'result' của backend trả về
|
||||
*/
|
||||
const findStepResult = (steps: any[], stepCode: string, filter?: string | ((res: any) => boolean)) => {
|
||||
if (!Array.isArray(steps)) return null;
|
||||
|
||||
// 1. Tìm đúng step đang thực thi
|
||||
const step = steps.find((s) => (s.step === stepCode || s.step?.startsWith(stepCode)) && s.executed);
|
||||
if (!step || !Array.isArray(step.results) || step.results.length === 0) return null;
|
||||
|
||||
// 2. Nếu filter là một hàm (Callback)
|
||||
if (typeof filter === "function") {
|
||||
const matched = step.results.find(filter);
|
||||
return matched ? matched.result : null;
|
||||
}
|
||||
|
||||
// 3. Nếu filter là một chuỗi (Action Name)
|
||||
if (typeof filter === "string") {
|
||||
const matched = step.results.find((r) => r.action === filter);
|
||||
return matched ? matched.result : null;
|
||||
}
|
||||
|
||||
// 4. Mặc định: Trả về result của phần tử đầu tiên
|
||||
return step.results[0]?.result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Chuyển đổi danh sách chiết khấu sang format API
|
||||
*/
|
||||
const mapDiscountsList = (detailedDiscounts: any[]) => {
|
||||
if (!detailedDiscounts) return [];
|
||||
return detailedDiscounts
|
||||
.filter((d) => d.id)
|
||||
.map((d) => ({
|
||||
discount: d.id,
|
||||
value: d.customValue,
|
||||
type: d.customType,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tạo lịch thanh toán cọc (Installments)
|
||||
*/
|
||||
const createInstallments = (depositReceived: number, reservationAmount: number) => {
|
||||
const installments = [];
|
||||
if (depositReceived > 0) {
|
||||
installments.push({
|
||||
amount: depositReceived,
|
||||
due_days: 0,
|
||||
description: "Thanh toán tiền đặt cọc đã nhận",
|
||||
detail: { note: "Thanh toán tiền đặt cọc" },
|
||||
});
|
||||
}
|
||||
|
||||
const remaining = reservationAmount - depositReceived;
|
||||
if (remaining > 0) {
|
||||
installments.push({
|
||||
amount: remaining,
|
||||
due_days: 2,
|
||||
description: "Phần cọc còn lại",
|
||||
detail: { note: "Thanh toán phần cọc còn lại" },
|
||||
});
|
||||
}
|
||||
return installments;
|
||||
};
|
||||
|
||||
const createWorkflowTransaction = async (params: TransactionParams) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const userId = $store?.login?.id;
|
||||
if (!userId) {
|
||||
throw new Error("Không tìm thấy thông tin người dùng. Vui lòng đăng nhập lại.");
|
||||
}
|
||||
|
||||
// 1. Cấu hình Body theo đúng workflow
|
||||
const workflowPayload = {
|
||||
workflow_code: "FULL_CONTRACT_CREATION",
|
||||
trigger: "create",
|
||||
data: {
|
||||
phase_code: params.phaseInfo.code,
|
||||
current_date: params.currentDate,
|
||||
due_date: params.reservationDueDate,
|
||||
customer_id: params.customer.id,
|
||||
product_id: params.product.id,
|
||||
policy_id: params.policy.id,
|
||||
user_id: userId,
|
||||
sale_price: params.product.origin_price,
|
||||
origin_price: params.product.origin_price,
|
||||
deposit_amount: params.reservationAmount,
|
||||
discount_amount: 0,
|
||||
amount_received: 0,
|
||||
payment_plan: params.paymentPlans,
|
||||
installments: createInstallments(params.depositReceived, params.reservationAmount),
|
||||
people: params.people || null,
|
||||
early_discount_amount: Math.trunc(params.earlyDiscountAmount || 0),
|
||||
gifts: params.gifts || [], // Thêm danh sách quà tặng
|
||||
},
|
||||
};
|
||||
|
||||
// 2. Gọi API Workflow
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
|
||||
// Kiểm tra thành công
|
||||
if (response === "error" || !response?.success) {
|
||||
throw new Error(response?.message || "Thực thi Workflow thất bại.");
|
||||
}
|
||||
|
||||
// 3. Bóc tách dữ liệu từ mảng result trả về
|
||||
const steps = response.result;
|
||||
|
||||
// Lấy Transaction từ step "create_transaction" với action "API_CALL"
|
||||
const txnData = findStepResult(steps, "create_transaction", "API_CALL");
|
||||
|
||||
console.log("Transaction Data:", txnData);
|
||||
|
||||
$snackbar("Giao dịch đã được khởi tạo thành công!", "Thành công", "Success");
|
||||
|
||||
// 4. Return transaction data
|
||||
return {
|
||||
transaction: txnData ? { id: txnData.id, code: txnData.code } : null,
|
||||
};
|
||||
} catch (e: any) {
|
||||
error.value = e;
|
||||
console.error("Workflow Error:", e);
|
||||
$snackbar(e.message || "Có lỗi xảy ra khi thực thi workflow.", "Lỗi", "Error");
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DUYỆT CÔNG NỢ (KẾ TOÁN) - SỬ DỤNG WORKFLOW
|
||||
*/
|
||||
const confirmPaymentSchedule = async (paymentId: number, payload?: object): Promise<boolean> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const userId = $store?.login?.id;
|
||||
if (!userId) {
|
||||
throw new Error("Không tìm thấy thông tin người dùng. Vui lòng đăng nhập lại.");
|
||||
}
|
||||
|
||||
const workflowPayload = {
|
||||
workflow_id: 9,
|
||||
workflow_code: "APPROVE_PAYMENT",
|
||||
trigger: "create",
|
||||
data: {
|
||||
payment_id: paymentId,
|
||||
user_id: userId,
|
||||
...payload,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
|
||||
if (response === "error" || !response?.success) {
|
||||
throw new Error(response?.message || "Không thể xác nhận công nợ qua workflow.");
|
||||
}
|
||||
|
||||
$snackbar("Công nợ đã được xác nhận thành công!", "Thành công", "Success");
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
error.value = e;
|
||||
console.error("Workflow Error (confirmPaymentSchedule):", e);
|
||||
$snackbar(e.message || "Có lỗi xảy ra khi xác nhận công nợ.", "Lỗi", "Error");
|
||||
return false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackPayment = async (paymentId: number, entryCode: string, entryAmount: number) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const userId = $store?.login?.id;
|
||||
if (!userId) {
|
||||
throw new Error("Không tìm thấy thông tin người dùng. Vui lòng đăng nhập lại.");
|
||||
}
|
||||
|
||||
const workflowPayload = {
|
||||
workflow_code: "ROLLBACK_PAYMENT",
|
||||
trigger: "create",
|
||||
data: {
|
||||
payment_id: paymentId,
|
||||
entry_code: entryCode,
|
||||
amount: entryAmount,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
|
||||
if (response === "error" || !response?.success) {
|
||||
throw new Error(response?.message || "Không thể hủy bút toán qua workflow.");
|
||||
}
|
||||
|
||||
$snackbar("Công nợ đã được xác nhận thành công!", "Thành công", "Success");
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
error.value = e;
|
||||
console.error("Workflow Error (confirmPaymentSchedule):", e);
|
||||
$snackbar(e.message || "Có lỗi xảy ra khi xác nhận công nợ.", "Lỗi", "Error");
|
||||
return false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DUYỆT CHI TIẾT GIAO DỊCH (QUẢN LÝ) - SỬ DỤNG WORKFLOW
|
||||
*/
|
||||
const approveTransactionDetail = async (detailId: number, statusCode: string): Promise<any> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const userId = $store?.login?.id;
|
||||
if (!userId) {
|
||||
throw new Error("Không tìm thấy thông tin người dùng. Vui lòng đăng nhập lại.");
|
||||
}
|
||||
|
||||
const workflowPayload = {
|
||||
workflow_code: "APPROVE_TRANSACTION_DETAIL",
|
||||
trigger: "create",
|
||||
data: {
|
||||
detail_id: detailId,
|
||||
status_code: statusCode,
|
||||
user_id: userId,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
|
||||
if (response === "error" || !response?.success) {
|
||||
throw new Error(response?.message || "Không thể duyệt chi tiết giao dịch qua workflow.");
|
||||
}
|
||||
|
||||
$snackbar("Chi tiết giao dịch đã được duyệt thành công!", "Thành công", "Success");
|
||||
return response;
|
||||
} catch (e: any) {
|
||||
error.value = e;
|
||||
console.error("Workflow Error (approveTransactionDetail):", e);
|
||||
$snackbar(e.message || "Có lỗi xảy ra khi duyệt chi tiết giao dịch.", "Lỗi", "Error");
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* CHUYỂN GIAI ĐOẠN GIAO DỊCH - SỬ DỤNG WORKFLOW
|
||||
*/
|
||||
const advanceTransactionPhase = async (detailId: number): Promise<any> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const userId = $store?.login?.id;
|
||||
if (!userId) {
|
||||
throw new Error("Không tìm thấy thông tin người dùng. Vui lòng đăng nhập lại.");
|
||||
}
|
||||
|
||||
const workflowPayload = {
|
||||
workflow_code: "ADVANCE_TRANSACTION_PHASE",
|
||||
trigger: "create",
|
||||
data: {
|
||||
detail_id: detailId,
|
||||
user_id: userId,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
|
||||
if (response === "error" || !response?.success) {
|
||||
throw new Error(response?.message || "Không thể chuyển giai đoạn giao dịch qua workflow.");
|
||||
}
|
||||
|
||||
const steps = response.result;
|
||||
|
||||
// 1. Tìm contract thông qua step bắt đầu bằng 'adv_from_phase_' với action là 'GENERATE_DOCUMENT'
|
||||
const contract = findStepResult(steps, "adv_lookup_transaction", "LOOKUP_DATA");
|
||||
|
||||
console.log("contract", contract);
|
||||
|
||||
$snackbar("Chuyển giai đoạn giao dịch thành công!", contract, "Success");
|
||||
|
||||
return {
|
||||
...response,
|
||||
contract: contract,
|
||||
};
|
||||
} catch (e: any) {
|
||||
error.value = e;
|
||||
console.error("Workflow Error (advanceTransactionPhase):", e);
|
||||
$snackbar(e.message || "Có lỗi xảy ra khi chuyển giai đoạn.", "Lỗi", "Error");
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* CHUYỂN ĐỔI KHÁCH HÀNG - SỬ DỤNG WORKFLOW
|
||||
*/
|
||||
const updateTransactionCustomer = async (
|
||||
transactionId: number,
|
||||
newCustomerId: number,
|
||||
contractDate?: string,
|
||||
): Promise<any> => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const userId = $store?.login?.id;
|
||||
if (!userId) {
|
||||
throw new Error("Không tìm thấy thông tin người dùng. Vui lòng đăng nhập lại.");
|
||||
}
|
||||
|
||||
const workflowPayload = {
|
||||
workflow_code: "UPDATE_TRANS_AND_CO_OP",
|
||||
trigger: "create",
|
||||
data: {
|
||||
transaction: transactionId,
|
||||
new_cus: newCustomerId,
|
||||
user_id: userId,
|
||||
date: contractDate || dayjs().format("YYYY-MM-DD"),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await $insertapi("workflow", workflowPayload, undefined, false);
|
||||
|
||||
if (response === "error" || !response?.success) {
|
||||
throw new Error(response?.message || "Không thể chuyển đổi khách hàng qua workflow.");
|
||||
}
|
||||
|
||||
const steps = response.result;
|
||||
|
||||
// Find the contract from any executed step starting with 'GEN_'
|
||||
const contract = findStepResult(steps, "GEN_", "API_CALL");
|
||||
|
||||
$snackbar("Chuyển đổi khách hàng thành công!", "Thành công", "Success");
|
||||
|
||||
return {
|
||||
...response,
|
||||
contract: contract,
|
||||
};
|
||||
} catch (e: any) {
|
||||
error.value = e;
|
||||
console.error("Workflow Error (updateTransactionCustomer):", e);
|
||||
$snackbar(e.message || "Có lỗi xảy ra khi chuyển đổi khách hàng.", "Lỗi", "Error");
|
||||
return null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
createWorkflowTransaction,
|
||||
confirmPaymentSchedule,
|
||||
approveTransactionDetail,
|
||||
advanceTransactionPhase,
|
||||
updateTransactionCustomer,
|
||||
rollbackPayment,
|
||||
};
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// composables/usePaymentCalculator.js
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
export function usePaymentCalculator() {
|
||||
// Inputs cơ bản
|
||||
const originPrice = ref(0);
|
||||
const discounts = ref([]); // [{ id, name, code, type: 1 (percent) | 2 (fixed), value: number }]
|
||||
const paymentPlan = ref([]); // [{cycle, value, type: 1 (percent) | other (fixed), days, ...}]
|
||||
const contractAllocationPercentage = ref(100);
|
||||
const startDate = ref(new Date());
|
||||
|
||||
// Thanh toán sớm
|
||||
const earlyPaymentCycles = ref(0);
|
||||
const earlyDiscountRate = ref(0.0191780821918);
|
||||
|
||||
// Số tiền đã thanh toán thực tế
|
||||
const paidAmount = ref(0);
|
||||
|
||||
// Tiền gốc
|
||||
const originalPrice = computed(() => originPrice.value);
|
||||
|
||||
// Chi tiết chiết khấu
|
||||
const detailedDiscounts = computed(() => {
|
||||
const details = [];
|
||||
let currentBalance = originPrice.value || 0;
|
||||
|
||||
discounts.value.forEach((discountData) => {
|
||||
const d = discountData;
|
||||
let amount = 0;
|
||||
if (d.type === 1) {
|
||||
// percent
|
||||
amount = (currentBalance * Number(d.value)) / 100;
|
||||
} else {
|
||||
// fixed
|
||||
amount = Number(d.value);
|
||||
}
|
||||
if (currentBalance - amount < 0) {
|
||||
amount = currentBalance;
|
||||
}
|
||||
currentBalance -= amount;
|
||||
|
||||
details.push({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
customValue: d.value,
|
||||
customType: d.type,
|
||||
amount: amount,
|
||||
remaining: currentBalance,
|
||||
});
|
||||
});
|
||||
return details;
|
||||
});
|
||||
|
||||
// Tổng tiền giảm giá từ discounts
|
||||
const totalDiscount = computed(() => {
|
||||
return detailedDiscounts.value.reduce((sum, d) => sum + d.amount, 0);
|
||||
});
|
||||
|
||||
// Giá sau chiết khấu
|
||||
const salePrice = computed(() => {
|
||||
const lastDiscount = detailedDiscounts.value[detailedDiscounts.value.length - 1];
|
||||
return lastDiscount ? lastDiscount.remaining : originPrice.value;
|
||||
});
|
||||
|
||||
const allocatedAmount = computed(() => {
|
||||
const amount = salePrice.value * (contractAllocationPercentage.value / 100);
|
||||
return Math.round(amount / 1000) * 1000;
|
||||
});
|
||||
|
||||
const finalTotal = computed(() => allocatedAmount.value);
|
||||
|
||||
// Lịch thanh toán gốc (hỗ trợ cả percent và fixed cho paymentPlan.type)
|
||||
const originalPaymentSchedule = computed(() => {
|
||||
const schedule = [];
|
||||
let remaining = allocatedAmount.value;
|
||||
|
||||
const sortedPlan = [...paymentPlan.value].sort((a, b) => a.cycle - b.cycle);
|
||||
|
||||
let lastToDateForPeriodCalc = new Date(startDate.value); // Used to calculate the duration of each installment period.
|
||||
|
||||
sortedPlan.forEach((plan) => {
|
||||
let amount = 0;
|
||||
|
||||
// Xử lý linh hoạt theo type của Payment_Plan
|
||||
if (plan.type === 1) {
|
||||
// percent
|
||||
amount = allocatedAmount.value * (plan.value / 100);
|
||||
} else {
|
||||
// fixed (type 2 hoặc bất kỳ giá trị khác)
|
||||
amount = plan.value || 0;
|
||||
}
|
||||
|
||||
const adjustedAmount = Math.min(amount, remaining);
|
||||
remaining -= adjustedAmount;
|
||||
|
||||
const fromDate = new Date(startDate.value); // Per user request, all installments start from the same date.
|
||||
const toDate = new Date(startDate.value);
|
||||
toDate.setDate(toDate.getDate() + (plan.days || 0));
|
||||
|
||||
// The duration of the installment period is the difference between this installment's due date and the previous one.
|
||||
const daysInPeriod = Math.round((toDate.getTime() - lastToDateForPeriodCalc.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
schedule.push({
|
||||
cycle: plan.cycle,
|
||||
from_date: fromDate,
|
||||
to_date: toDate,
|
||||
amount: adjustedAmount,
|
||||
paid_amount: 0,
|
||||
remain_amount: adjustedAmount,
|
||||
days: daysInPeriod > 0 ? daysInPeriod : 0, // Use calculated period duration; it cannot be negative.
|
||||
status: 1,
|
||||
type: plan.type,
|
||||
value: plan.value,
|
||||
payment_note: `Thanh toán theo kế hoạch đợt số ${plan.cycle}`,
|
||||
});
|
||||
|
||||
lastToDateForPeriodCalc = new Date(toDate); // Update the last due date for the next period's duration calculation.
|
||||
});
|
||||
|
||||
// Cộng phần dư (nếu có) vào đợt cuối
|
||||
if (remaining > 0 && schedule.length > 0) {
|
||||
const last = schedule[schedule.length - 1];
|
||||
last.amount += remaining;
|
||||
last.remain_amount += remaining;
|
||||
}
|
||||
|
||||
return schedule;
|
||||
});
|
||||
|
||||
// Chiết khấu thanh toán sớm
|
||||
const earlyDiscounts = computed(() => {
|
||||
if (earlyPaymentCycles.value <= 0) return [];
|
||||
|
||||
const schedule = originalPaymentSchedule.value;
|
||||
const numEarly = Math.min(earlyPaymentCycles.value, schedule.length);
|
||||
const actualPaymentDate = new Date(startDate.value);
|
||||
const actualPaymentDateOnly = new Date(
|
||||
actualPaymentDate.getFullYear(),
|
||||
actualPaymentDate.getMonth(),
|
||||
actualPaymentDate.getDate(),
|
||||
);
|
||||
|
||||
return schedule.slice(0, numEarly).map((item) => {
|
||||
const originalDate = new Date(item.to_date);
|
||||
const originalDateOnly = new Date(originalDate.getFullYear(), originalDate.getMonth(), originalDate.getDate());
|
||||
const earlyDays = Math.round(
|
||||
(originalDateOnly.getTime() - actualPaymentDateOnly.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (earlyDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return item.amount * earlyDays * (earlyDiscountRate.value / 100);
|
||||
});
|
||||
});
|
||||
|
||||
const totalEarlyDiscount = computed(() => {
|
||||
const rawTotal = earlyDiscounts.value.reduce((sum, disc) => sum + disc, 0);
|
||||
return parseInt(rawTotal);
|
||||
});
|
||||
|
||||
// Chi tiết chiết khấu thanh toán sớm
|
||||
const earlyDiscountDetails = computed(() => {
|
||||
if (earlyPaymentCycles.value <= 0) return [];
|
||||
|
||||
const schedule = originalPaymentSchedule.value;
|
||||
const numEarly = Math.min(earlyPaymentCycles.value, schedule.length);
|
||||
const actualPaymentDate = new Date(startDate.value);
|
||||
const actualPaymentDateOnly = new Date(
|
||||
actualPaymentDate.getFullYear(),
|
||||
actualPaymentDate.getMonth(),
|
||||
actualPaymentDate.getDate(),
|
||||
);
|
||||
|
||||
return schedule.slice(0, numEarly).map((item, index) => {
|
||||
const originalDate = new Date(item.to_date);
|
||||
const originalDateOnly = new Date(originalDate.getFullYear(), originalDate.getMonth(), originalDate.getDate());
|
||||
const earlyDays = Math.round(
|
||||
(originalDateOnly.getTime() - actualPaymentDateOnly.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
return {
|
||||
cycle: item.cycle,
|
||||
original_payment_date: originalDate,
|
||||
actual_payment_date: actualPaymentDate,
|
||||
early_days: earlyDays > 0 ? earlyDays : 0,
|
||||
original_amount: item.amount,
|
||||
discount_rate: earlyDiscountRate.value,
|
||||
discount_amount: earlyDiscounts.value[index] || 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Lịch sau thanh toán sớm (gộp đợt)
|
||||
const scheduleAfterEarly = computed(() => {
|
||||
const base = originalPaymentSchedule.value;
|
||||
const numEarly = Math.min(earlyPaymentCycles.value, base.length);
|
||||
|
||||
if (numEarly <= 0) return base;
|
||||
|
||||
const earlyItems = base.slice(0, numEarly);
|
||||
const totalEarlyAmount = earlyItems.reduce((sum, i) => sum + i.amount, 0);
|
||||
const mergedAmount = totalEarlyAmount - totalEarlyDiscount.value;
|
||||
|
||||
const originalCycles = earlyItems.map((i) => i.cycle);
|
||||
|
||||
const mergedItem = {
|
||||
cycle: 1,
|
||||
from_date: earlyItems[0].from_date,
|
||||
to_date: earlyItems[0].from_date, // Due date is the start date for the merged installment
|
||||
amount: Math.max(0, mergedAmount),
|
||||
paid_amount: 0,
|
||||
remain_amount: Math.max(0, mergedAmount),
|
||||
payment_note: `Thanh toán gộp đợt ${originalCycles.join(", ")} (đã trừ số tiền chiết khấu thanh toán sớm là ${totalEarlyDiscount.value})`,
|
||||
days: 0,
|
||||
status: 1,
|
||||
is_merged: true,
|
||||
original_cycles: originalCycles,
|
||||
};
|
||||
|
||||
const remainingItems = base.slice(numEarly).map((item, idx) => ({
|
||||
...item,
|
||||
cycle: idx + 2,
|
||||
}));
|
||||
|
||||
return [mergedItem, ...remainingItems];
|
||||
});
|
||||
|
||||
// Lịch thanh toán cuối cùng
|
||||
const finalPaymentSchedule = computed(() => {
|
||||
const schedule = scheduleAfterEarly.value.map((item) => ({ ...item }));
|
||||
let remainingPaid = paidAmount.value || 0;
|
||||
|
||||
for (let i = 0; i < schedule.length && remainingPaid > 0; i++) {
|
||||
const item = schedule[i];
|
||||
const canPay = Math.min(remainingPaid, item.remain_amount);
|
||||
|
||||
item.paid_amount += canPay;
|
||||
item.remain_amount -= canPay;
|
||||
remainingPaid -= canPay;
|
||||
|
||||
if (item.remain_amount <= 0) {
|
||||
item.status = 2;
|
||||
item.remain_amount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return schedule;
|
||||
});
|
||||
|
||||
const totalRemaining = computed(() => finalPaymentSchedule.value.reduce((sum, item) => sum + item.remain_amount, 0));
|
||||
|
||||
return {
|
||||
originPrice,
|
||||
discounts,
|
||||
paymentPlan,
|
||||
contractAllocationPercentage,
|
||||
startDate,
|
||||
earlyPaymentCycles,
|
||||
earlyDiscountRate,
|
||||
paidAmount,
|
||||
|
||||
originalPrice,
|
||||
detailedDiscounts,
|
||||
totalDiscount,
|
||||
salePrice,
|
||||
allocatedAmount,
|
||||
finalTotal,
|
||||
finalPaymentSchedule,
|
||||
originalPaymentSchedule,
|
||||
earlyDiscountDetails,
|
||||
totalEarlyDiscount,
|
||||
totalRemaining,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const module = "application";
|
||||
const mode = "prod";
|
||||
const mode = "dev";
|
||||
const paths = [
|
||||
{ name: "dev", url: "https://erpapi.bigdatatech.vn/" },
|
||||
{ name: "local", url: "http://localhost:8000/" },
|
||||
@@ -590,10 +590,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
commit: "productdocument",
|
||||
url: "data/Product_Document/",
|
||||
url_detail: "data-detail/Product_Document/",
|
||||
params: {
|
||||
values:
|
||||
"id,product,product__code,product__type,product__type__code,product__type__name,product__type__en,product__category,product__category__code,product__category__name,document,document__code,document__name,document__en",
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
name: "productstatus",
|
||||
|
||||
@@ -45,8 +45,6 @@ import Customers from "@/components/report/Customers.vue";
|
||||
import Goods from "@/components/report/Goods.vue";
|
||||
import ReportCashBook from "@/components/report/CashBook.vue";
|
||||
import Finance from "@/components/report/Finance.vue";
|
||||
import Notebox from "@/components/common/Notebox.vue";
|
||||
import ProductCountbox from "@/components/common/ProductCountbox.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import DataView from "@/components/datatable/DataView.vue";
|
||||
import PivotDataView from "@/components/datatable/PivotDataView.vue";
|
||||
@@ -61,9 +59,7 @@ import Avatarbox from "@/components/common/Avatarbox.vue";
|
||||
import Email from "@/components/marketing/email/Email.vue";
|
||||
import ViewList from "@/components/common/ViewList.vue";
|
||||
import InternalEntry from "@/components/modal/InternalEntry.vue";
|
||||
|
||||
import Configuration from "@/components/maintab/Configuration.vue";
|
||||
import DebtView from "@/components/accounting/DebtView.vue";
|
||||
|
||||
// format
|
||||
import FormatNumber from "@/components/datatable/format/FormatNumber.vue";
|
||||
@@ -84,48 +80,25 @@ import MenuCollab from "@/components/menu/MenuCollab.vue";
|
||||
import MenuNote from "@/components/menu/MenuNote.vue";
|
||||
import MenuPayment from "@/components/menu/MenuPayment.vue";
|
||||
import ScrollBox from "@/components/datatable/ScrollBox.vue";
|
||||
import Product from "@/components/product/Product.vue";
|
||||
import Reservation from "@/components/modal/Reservation.vue";
|
||||
import UserMainTab from "@/components/modal/UserMainTab.vue";
|
||||
import TransactionFiles from "@/components/transaction/TransactionFiles.vue";
|
||||
import PaymentSchedule from "@/components/application/PaymentSchedule.vue";
|
||||
import TransactionView from "@/components/transaction/TransactionView.vue";
|
||||
import ContractPaymentUpload from "@/components/application/ContractPaymentUpload.vue";
|
||||
import CountWithAdd from "@/components/common/CountWithAdd.vue";
|
||||
import CalculationView from "@/components/application/CalculationView.vue";
|
||||
import InternalAccount from "@/components/accounting/InternalAccount.vue";
|
||||
import MenuAccount from "@/components/menu/MenuAccount.vue";
|
||||
import PhaseAdvance from "@/components/application/PhaseAdvance.vue";
|
||||
import ImageLayout from "@/components/media/ImageLayout.vue";
|
||||
import ProjectDocuments from "@/components/product/ProjectDocuments.vue";
|
||||
|
||||
import Cart from "@/components/product/Cart.vue";
|
||||
import CountdownTimer from "@/components/common/CountdownTimer.vue";
|
||||
import CustomerInfo2 from "@/components/customer/CustomerInfo2.vue";
|
||||
import MenuFile from "@/components/menu/MenuFile.vue";
|
||||
import DebtProduct from "@/components/accounting/DebtProduct.vue";
|
||||
import DebtCustomer from "@/components/accounting/DebtCustomer.vue";
|
||||
import Due from "@/components/debt/Due.vue";
|
||||
import Overdue from "@/components/debt/Overdue.vue";
|
||||
|
||||
const components = {
|
||||
DebtView,
|
||||
PivotDataView,
|
||||
PaymentSchedule,
|
||||
CustomerInfo2,
|
||||
CountdownTimer,
|
||||
PhaseAdvance,
|
||||
InternalEntry,
|
||||
ViewList,
|
||||
ColorText,
|
||||
CalculationView,
|
||||
CountWithAdd,
|
||||
ContractPaymentUpload,
|
||||
TransactionView,
|
||||
TransactionFiles,
|
||||
Reservation,
|
||||
Notebox,
|
||||
ProductCountbox,
|
||||
MenuAction,
|
||||
Email,
|
||||
SvgIcon,
|
||||
@@ -153,18 +126,10 @@ const components = {
|
||||
MenuAdd,
|
||||
MenuCollab,
|
||||
MenuNote,
|
||||
Product,
|
||||
UserMainTab,
|
||||
InternalAccount,
|
||||
MenuAccount,
|
||||
ImageLayout,
|
||||
ProjectDocuments,
|
||||
Cart,
|
||||
MenuFile,
|
||||
DebtProduct,
|
||||
DebtCustomer,
|
||||
Due,
|
||||
Overdue,
|
||||
Dashboard,
|
||||
Orders,
|
||||
Inventory,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useStore = defineStore("main", {
|
||||
export const useStore = defineStore("maindev", {
|
||||
state: () => ({
|
||||
viewport: undefined,
|
||||
login: undefined,
|
||||
|
||||
Reference in New Issue
Block a user