Initial commit

This commit is contained in:
Viet An
2026-03-02 09:45:33 +07:00
commit d17a9e2588
415 changed files with 92113 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
<template>
<div class="has-text-centered">
<p class="">
Bạn chắc chắn muốn chuyển sản phẩm này sang trạng thái<br />
<strong :class="newStatus === 2 ? 'has-text-success' : 'has-text-danger'">
{{ newStatus === 2 ? 'ĐANG BÁN' : 'KHÓA' }}
</strong>
không?
</p>
<hr class="my-3" />
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button " :class="newStatus === 2 ? 'is-success' : 'is-danger'" :disabled="isSaving"
@click="confirmChange">
<span v-if="isSaving">Đang xử lý...</span>
<span v-else>Đồng ý</span>
</button>
</div>
<div class="control">
<button class="button is-dark " :disabled="isSaving" @click="close">
Hủy bỏ
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useNuxtApp } from '#app'
const emit = defineEmits(['close', 'modalevent'])
const props = defineProps({
product: {
type: Object,
required: true
}
})
const { $patchapi, $snackbar } = useNuxtApp()
const isSaving = ref(false)
const currentStatus = computed(() => Number(props.product?.status) || null)
const newStatus = computed(() => (currentStatus.value === 15 ? 2 : 15))
async function confirmChange() {
if (!props.product?.id || newStatus.value === currentStatus.value) {
close()
return
}
isSaving.value = true
try {
const result = await $patchapi(
'product',
{
id: props.product.id,
status: newStatus.value
},
{},
false
)
if (result === 'error' || !result) {
$snackbar('Cập nhật thất bại', 'Lỗi', 'Error')
return
}
$snackbar('Cập nhật trạng thái thành công', 'Thành công', 'Success')
// Phát sự kiện để component cha (hoặc bảng) cập nhật lại dữ liệu
emit('modalevent', {
name: 'update',
data: {
id: props.product.id,
status: newStatus.value
}
})
close()
} catch (error) {
console.error('Lỗi đổi trạng thái:', error)
$snackbar('Có lỗi xảy ra', 'Lỗi', 'Error')
} finally {
isSaving.value = false
}
}
function close() {
emit('close')
}
</script>

View File

@@ -0,0 +1,311 @@
<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"> 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('DD/MM/YYYY');
};
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>

View File

@@ -0,0 +1,323 @@
<template>
<div class="container-fluid">
<div class="mt-0" v-if="loading">
<p>{{ isVietnamese ? 'Đang tải dữ liệu...' : 'Loading...' }}</p>
</div>
<div class="mt-0" v-else-if="entries.length === 0">
<p>{{ isVietnamese ? 'Không có dữ liệu' : 'No data' }}</p>
</div>
<div v-else>
<div v-for="(entry, index) in entries" :key="entry.code || index" class="entry-item mb-5">
<h3 class="title is-5 mb-3">
{{ isVietnamese ? 'Bút toán' : 'Entry' }} #{{ index + 1 }}
</h3>
<div class="columns is-multiline mx-0">
<!-- bút toán -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Mã bút toán' : 'Code' }}</label>
<div class="control">
<span class="hyperlink" @click="$copyToClipboard(entry.code)">{{ entry.code }}</span>
</div>
</div>
</div>
<!-- Ngày bút toán -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Ngày bút toán' : 'Date' }}</label>
<div class="control">
<span>{{ entry.date ? $dayjs(entry.date).format("DD/MM/YYYY") : "/" }}</span>
</div>
</div>
</div>
<!-- Amount -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Số tiền' : 'Amount' }}</label>
<div class="control">
<span>
{{ $numtoString(entry.amount) }}
</span>
</div>
</div>
</div>
<!-- quỹ -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Mã quỹ' : 'Account Code' }}</label>
<div class="control">
<span class="hyperlink" @click="$copyToClipboard(entry.account__code)">
{{ entry.account__code || "/" }}
</span>
</div>
</div>
</div>
<!-- Tên quỹ -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Tên quỹ' : 'Account Type' }}</label>
<div class="control">
<span>{{ entry.account__type__name || "/" }}</span>
</div>
</div>
</div>
<!-- Loại bút toán -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Loại bút toán' : 'Category' }}</label>
<div class="control">
<span>{{ entry.category__name || "/" }}</span>
</div>
</div>
</div>
<!-- Loại tiền -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Loại tiền' : 'Currency' }}</label>
<div class="control">
<span>{{ entry.account__currency__code || "/" }}</span>
</div>
</div>
</div>
<!-- trước -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Dư trước' : 'Balance Before' }}</label>
<div class="control">
<span>
{{ $numtoString(entry.balance_before) }}
</span>
</div>
</div>
</div>
<!-- Ghi -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Ghi có' : 'Credit' }}</label>
<div class="control">
<span v-if="entry.type===1">
{{ $numtoString(entry.amount) }}
</span>
</div>
</div>
</div>
<!-- Trích nợ (Ghi nợ) -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Ghi nợ' : 'Debit' }}</label>
<div class="control">
<span v-if="entry.type===2">
{{ $numtoString(entry.amount) }}
</span>
</div>
</div>
</div>
<!-- sau -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Dư sau' : 'Balance After' }}</label>
<div class="control">
<span>
{{ $numtoString(entry.balance_after) }}
</span>
</div>
</div>
</div>
<!-- Loại (Ghi /ghi nợ) -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Loại' : 'Type' }}</label>
<div class="control">
<span>{{ entry.type__name || "/" }}</span>
</div>
</div>
</div>
<!-- Người nhập -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Người nhập' : 'Inputer' }}</label>
<div class="control">
<span class="hyperlink" @click="openUser(entry.inputer)">
{{ entry.inputer__fullname || "/" }}
</span>
</div>
</div>
</div>
<!-- Người duyệt -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Người duyệt' : 'Approver' }}</label>
<div class="control">
<span class="hyperlink" @click="openUser(entry.approver)">
{{ entry.approver__fullname || "/" }}
</span>
</div>
</div>
</div>
<!-- Ref -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label"> tham chiếu</label>
<div class="control">
<span>{{ entry.ref || "/" }}</span>
</div>
</div>
</div>
<!-- Thời gian -->
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Thời gian' : 'Create Time' }}</label>
<div class="control">
<span>{{ entry.create_time ? $dayjs(entry.create_time).format("DD/MM/YYYY HH:mm") : "/" }}</span>
</div>
</div>
</div>
<!-- Nội dung -->
<div class="column is-full pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? 'Nội dung' : 'Content' }}</label>
<div class="control">
<span>{{ entry.content || "/" }}</span>
</div>
</div>
</div>
</div>
<hr v-if="index < entries.length - 1" class="my-4">
</div>
</div>
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
import { useStore } from "@/stores/index";
export default {
setup() {
const store = useStore();
return { store };
},
props: {
entryList: {
type: Array,
default: () => []
}
},
data() {
return {
entries: [],
loading: false,
showmodal: undefined,
};
},
computed: {
lang() {
return this.store.lang;
},
isVietnamese() {
return this.store.lang === "vi";
},
},
async created() {
await this.fetchEntries();
},
watch: {
entryList: {
handler() {
this.fetchEntries();
},
deep: true
}
},
methods: {
parseEntryItem(item) {
// Nếu item đã là object thì return luôn
if (typeof item === 'object' && item !== null) {
return item;
}
// Nếu item là string thì parse
if (typeof item === 'string') {
try {
// Thay thế single quotes bằng double quotes để parse JSON
const jsonString = item.replace(/'/g, '"');
return JSON.parse(jsonString);
} catch (error) {
console.error('Lỗi khi parse string thành JSON:', item, error);
return null;
}
}
return null;
},
async fetchEntries() {
const { $getdata } = useNuxtApp();
if (!this.entryList || this.entryList.length === 0) {
this.entries = [];
return;
}
this.loading = true;
this.entries = [];
try {
// Duyệt qua từng item trong entryList để fetch dữ liệu
for (const item of this.entryList) {
// Parse item từ string sang object nếu cần
const parsedItem = this.parseEntryItem(item);
if (parsedItem && parsedItem.code) {
try {
const entryData = await $getdata("internalentry", { code: parsedItem.code }, undefined, true);
if (entryData) {
this.entries.push(entryData);
}
} catch (error) {
console.error(`Lỗi khi load bút toán với code ${parsedItem.code}:`, error);
}
}
}
console.log(`Đã load ${this.entries.length} bút toán thành công`);
} catch (error) {
console.error("Lỗi khi load danh sách bút toán:", error);
} finally {
this.loading = false;
}
},
openUser(userId) {
if (!userId) return;
this.showmodal = {
component: "user/UserInfo",
width: "50%",
height: "200px",
title: "User",
vbind: { userId: userId },
};
},
},
};
</script>

View File

@@ -0,0 +1,525 @@
<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 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">
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("DD/MM/YYYY");
};
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("DD/MM/YYYY"),
to: entryDate.format("DD/MM/YYYY"),
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>

View File

@@ -0,0 +1,18 @@
<template>
<ViewList :vbind="{
api: 'payment_schedule',
params: {
filter: { txn_detail: scheduleDetailId },
values: 'link,ref_code,batch_date,amount_remain,penalty_remain,penalty_paid,penalty_amount,penalty_reduce,ovd_days,remain_amount,paid_amount,txn_detail__transaction__product__trade_code,txn_detail__status,txn_detail__transaction__product__code,txn_detail__phase__name,txn_detail,id,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__code,txn_detail__transaction__customer__legal_code,status__name,type__name,code,from_date,txn_detail__transaction__policy__code,to_date,amount,cycle,cycle_days,txn_detail__transaction,type,status,updater,entry,detail,txn_detail__transaction__code,txn_detail__code',
},
setting: 'payment_schedule_list_timeline',
}" />
</template>
<script>
export default {
props: {
scheduleDetailId: [Number, String],
}
};
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div class="columns is-multiline is-mobile mx-0">
<div class="column is-3">
<div class="field">
<label class="label has-text-left">ID<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input class="input" type="text" v-model="record.id" disabled>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label has-text-left"> giữ chỗ<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input class="input" type="text" v-model="record.code">
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label has-text-left"> giao dịch<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input class="input" type="text" v-model="record.transaction__code">
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label has-text-left">Người tạo<span class="has-text-danger ml-1">*</span></label>
<div class="control">
<input class="input" type="text" v-model="record.creator__fullname" disabled>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left">Ngày bắt đầu</label>
<div class="control">
<input class="input" type="date" v-model="record.date">
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left">Hạn thanh toán</label>
<div class="control">
<input class="input" type="date" v-model="record.due_date">
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left"> tiền đặt cọc</label>
<div class="control">
<input class="input" type="number" v-model="record.amount">
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label has-text-left">Trạng thái</label>
<div class="control">
<SearchBox
v-bind="{
api: 'status',
field: 'name',
column: ['name'],
optionid: record.approve_status,
first: true,
}"
@option="selectedStatus"
/>
</div>
</div>
</div>
<div class="column is-12 pt-5">
<a class="button is-primary has-text-white" @click="updateData()">Lưu lại</a>
<a class="button is-dark has-text-white ml-5" @click="updateData(true)" v-if="record.id">Tạo mới</a>
<a
v-if="contractData"
class="button is-info has-text-white ml-5"
@click="openContractModal"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'contract.svg', type: 'primary', size: 18 }" />
</span>
<span>Xem hợp đồng</span>
</a>
</div>
</div>
<Modal
@close="showmodal = undefined"
v-bind="showmodal"
@texteditor="updateText"
@update="updateAttr"
@dataevent="handleModalEvent"
v-if="showmodal"
/>
</template>
<script setup>
const emit = defineEmits([])
var props = defineProps({
pagename: String,
row: Object,
api: String
})
const { $copy, $resetNull, $insertrow, $updaterow, $getdata } = useNuxtApp()
var record = ref(props.row ? $copy(props.row) : {})
var showmodal = ref()
var vapi = props.api || 'reservation'
var contractData = ref(null)
const loadContract = async () => {
if (record.value.transaction__code) {
try {
contractData.value = await $getdata('contract', { transaction__code: record.value.transaction__code }, undefined, true)
} catch (error) {
console.error('Error loading contract:', error)
contractData.value = null
}
}
}
const contractModalConfig = computed(() => ({
component: 'application/Contract',
title: 'Hợp đồng',
width: '90%',
height: '90vh',
vbind: {
row: contractData.value,
api: 'transaction',
},
}))
const openContractModal = () => {
showmodal.value = contractModalConfig.value
}
const handleModalEvent = (eventData) => {
if (eventData?.data?.transaction) {
contractData.value = { ...contractData.value, ...eventData.data }
}
}
var selectedStatus = function(option) {
record.value.approve_status = option.id
}
var updateText = function (content) {
record.value.vi = content
}
var openEditor = function () {
showmodal.value = {
component: 'common/TextEditor',
vbind: { content: record.value.vi },
title: 'Text editor',
width: '40%',
height: '150px'
}
}
var editDetail = function () {
let detail = record.value.detail ? record.value.detail : {}
showmodal.value = {
component: 'datatable/FieldAttribute',
vbind: { field: detail, close: true },
title: 'Sửa thuộc tính',
width: '40%',
height: '150px'
}
}
var updateAttr = function (detail) {
record.value.detail = detail
}
var updateData = async function (isNew) {
let ele = record.value
if (ele.create_time === null) ele.create_time = new Date()
ele = $resetNull(ele)
if (isNew) delete ele.id
let result = ele.id
? await $updaterow(vapi, ele, undefined, props.pagename)
: await $insertrow(vapi, ele, undefined, props.pagename)
if (isNew) emit('close')
await loadContract()
}
onMounted(() => {
loadContract()
})
watch(() => record.value.transaction, () => {
loadContract()
})
</script>

View File

@@ -0,0 +1,189 @@
<template>
<div class="columns mx-0 px-0 py-2">
<!-- Tab Navigation -->
<div
:class="`column is-narrow p-0 pr-4 ${viewport < 2 ? 'px-0' : ''}`"
:style="`${viewport < 2 ? '' : 'border-right: 1px solid #B0B0B0;'}`"
>
<div
:class="['is-clickable p-3', i !== 0 && 'mt-2', getStyle(v)]"
style="width: 120px; border-radius: 4px;"
v-for="(v, i) in tabsArray"
:key="i"
@click="changeTab(v)"
>
{{ isVietnamese ? v.name : v.en }}
</div>
</div>
<!-- Tab Content -->
<div
:class="`column pl-4 ${viewport < 2 ? 'px-0' : 'pr-0 py-0'}`"
style="min-width: 0"
>
<div v-if="isLoading" class="has-text-centered">
<span class="icon is-large">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
</span>
</div>
<component
:is="getComponent(activeComponent.component)"
v-else-if="activeComponent && activeComponent.component"
v-bind="componentProps"
@update="handleUpdate"
@close="emit('close')"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, defineAsyncComponent, watch } from "vue";
import { useStore } from "@/stores/index";
import { useNuxtApp } from "#app";
const props = defineProps({
tabs: [Array, Object],
row: Object,
pagename: String,
application: Object,
});
const emit = defineEmits(["modalevent", "close"]);
const store = useStore();
const nuxtApp = useNuxtApp();
const { $dialog, $calculate, $findapi, $getapi, $copy } = nuxtApp;
const tabsArray = computed(() => {
if (Array.isArray(props.tabs)) {
return props.tabs;
}
if (typeof props.tabs === 'object' && props.tabs !== null) {
return Object.keys(props.tabs)
.sort()
.map(key => props.tabs[key]);
}
return [];
});
const modules = import.meta.glob("@/components/**/*.vue");
function getComponent(path) {
if (!path || typeof path !== "string" || !path.includes("/")) return null;
const moduleKey = Object.keys(modules).find((key) => key.endsWith(`${path}.vue`));
if (moduleKey) return defineAsyncComponent(modules[moduleKey]);
console.warn(`Component not found: ${path}`);
return null;
}
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const viewport = computed(() => store.viewport);
const record = ref(props.row || {});
const activeTab = ref(tabsArray.value.find(t => t.active === true || t.active === 'true')?.code || tabsArray.value[0]?.code || '');
const tabData = ref(null);
const isLoading = ref(false);
const activeComponent = computed(() => tabsArray.value.find(t => t.code === activeTab.value));
const componentProps = computed(() => {
const baseProps = {
row: record.value,
data: tabData.value,
pagename: props.pagename,
application: props.application,
};
const tabSpecificProps = activeComponent.value?.vbind || {};
const processedProps = {};
if (record.value) {
for (const key in tabSpecificProps) {
const value = tabSpecificProps[key];
if (typeof value === 'string' && value.startsWith('row.')) {
const recordKey = value.substring(4);
processedProps[key] = record.value[recordKey];
} else {
processedProps[key] = value;
}
}
}
return { ...baseProps, ...processedProps };
});
watch(activeTab, async (newTabCode) => {
const tabConfig = tabsArray.value.find(t => t.code === newTabCode);
if (tabConfig && tabConfig.api) {
await fetchTabData(tabConfig);
} else {
tabData.value = null;
}
}, { immediate: true });
async function fetchTabData(tabConfig) {
isLoading.value = true;
tabData.value = null;
try {
let conn = $findapi(tabConfig.api.name);
if (!conn) {
console.error(`API connection '${tabConfig.api.name}' not found.`);
return;
}
conn = $copy(conn);
if (tabConfig.api.params) {
let params = $copy(tabConfig.api.params);
for (const key in params.filter) {
const value = params.filter[key];
if (typeof value === 'string' && value.startsWith('record.')) {
const recordKey = value.substring(7);
params.filter[key] = record.value[recordKey];
}
}
conn.params = params;
}
const result = await $getapi([conn]);
const apiResult = result.find(r => r.name === tabConfig.api.name);
if (apiResult && apiResult.data) {
tabData.value = apiResult.data.rows;
}
} catch (error) {
console.error("Error fetching tab data:", error);
} finally {
isLoading.value = false;
}
}
function getStyle(tab) {
const canEnter = checkCondition(tab, false);
return tab.code === activeTab.value
? "has-background-primary has-text-white"
: `has-background-light ${canEnter ? "" : "has-text-grey-light"}`;
}
function checkCondition(tab, showAlert = true) {
if (!tab.condition) return true;
const context = { record: record.value };
const result = $calculate(context, [], tab.condition);
if (!result.success || !result.value) {
if (showAlert) {
$dialog("Vui lòng <b>lưu dữ liệu</b> hoặc hoàn tất các bước trước khi chuyển sang mục này.", "Thông báo");
}
return false;
}
return true;
}
function changeTab(tab) {
if (activeTab.value === tab.code) return;
if (!checkCondition(tab)) return;
activeTab.value = tab.code;
}
function handleUpdate(updatedRecord) {
record.value = { ...record.value, ...updatedRecord };
emit("modalevent", { name: "dataevent", data: record.value });
}
</script>