Initial commit
This commit is contained in:
79
app/components/accounting/AccountView.vue
Normal file
79
app/components/accounting/AccountView.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<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('DD/MM/YYYY')}` }}
|
||||
</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>
|
||||
143
app/components/accounting/ConfirmDeleteEntry.vue
Normal file
143
app/components/accounting/ConfirmDeleteEntry.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<!-- components/dialog/ConfirmDeleteEntry.vue -->
|
||||
<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 -->
|
||||
<p 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" />
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<!-- 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>
|
||||
73
app/components/accounting/DebtCustomer.vue
Normal file
73
app/components/accounting/DebtCustomer.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
74
app/components/accounting/DebtProduct.vue
Normal file
74
app/components/accounting/DebtProduct.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<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>
|
||||
581
app/components/accounting/DebtView.vue
Normal file
581
app/components/accounting/DebtView.vue
Normal file
@@ -0,0 +1,581 @@
|
||||
<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('DD/MM/YYYY') : '—',
|
||||
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('DD/MM/YYYY') : '—',
|
||||
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>
|
||||
31
app/components/accounting/InternalAccount.vue
Normal file
31
app/components/accounting/InternalAccount.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<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>
|
||||
228
app/components/accounting/InternalDeposit.vue
Normal file
228
app/components/accounting/InternalDeposit.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<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>
|
||||
247
app/components/accounting/InternalEntry.vue
Normal file
247
app/components/accounting/InternalEntry.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<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('DD/MM/YYYY') }}
|
||||
</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('DD/MM/YYYY')}` }}
|
||||
</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('DD/MM/YYYY 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>
|
||||
104
app/components/accounting/InternalTransfer.vue
Normal file
104
app/components/accounting/InternalTransfer.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<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>
|
||||
290
app/components/accounting/TransactionInvoice.vue
Normal file
290
app/components/accounting/TransactionInvoice.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user