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

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

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

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

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

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

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

View 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"> trước:</label>
<div class="control">
{{ $numtoString(record.balance_before) }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"> sau:</label>
<div class="control">
{{ $numtoString(record.balance_after) }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"> 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"> 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"> 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 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>

View 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ố để đ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>

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