jws-frontend/src/pages/05_quotation/PaymentForm.vue
net 5c867a496d
Some checks failed
Spell Check / Spell Check with Typos (push) Failing after 6s
fix: #242
2025-10-14 14:45:41 +07:00

935 lines
30 KiB
Vue

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { QFile, QMenu } from 'quasar';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useQuotationPayment } from 'src/stores/quotations';
import {
PaymentPayload,
Quotation,
QuotationFull,
QuotationPaymentData,
} from 'src/stores/quotations/types';
import { dateFormatJS } from 'src/utils/datetime';
import { DebitNote } from 'src/stores/debit-note';
import { baseUrl, dialog, formatNumberDecimal } from 'stores/utils';
import { useConfigStore } from 'stores/config';
import useBranchStore from 'src/stores/branch';
import useOptionStore from 'src/stores/options';
import UploadFileCard from 'src/components/upload-file/UploadFileCard.vue';
import SelectInput from 'src/components/shared/SelectInput.vue';
import { SaveButton, EditButton, UndoButton } from 'components/button';
const { t } = useI18n();
const { fetchListBankByBranch } = useBranchStore();
const { mapOption } = useOptionStore();
const configStore = useConfigStore();
const quotationPayment = useQuotationPayment();
const { data: config } = storeToRefs(configStore);
const prop = defineProps<{
branchId: string;
data?: Quotation | QuotationFull | DebitNote;
readonly?: boolean;
isDebitNote?: boolean;
}>();
const firstCodePayment = defineModel<string>('firstCodePayment');
const refQFile = ref<InstanceType<typeof QFile>[]>([]);
const refQMenu = ref<InstanceType<typeof QMenu>[]>([]);
const paymentData = ref<QuotationPaymentData[]>([]);
const accountOpt = ref<{ label: string; value: string }[]>([]);
const formPaymentMethod = ref<
{
id: string;
channel: string | null;
reference: string | null;
account: string | null;
isEdit?: boolean;
}[]
>([]);
const paymentStatusOpts = [
{
status: 'PaymentInProcess',
icon: 'mdi-credit-card-clock-outline',
color: 'danger',
name: 'quotation.receiptDialog.PaymentInProcess',
},
{
status: 'PaymentRetry',
icon: 'mdi-information-outline',
color: 'negative',
name: 'quotation.receiptDialog.PaymentRetry',
},
{
status: 'PaymentSuccess',
icon: 'mdi-check-decagram-outline',
color: 'positive',
name: 'quotation.receiptDialog.PaymentSuccess',
},
];
const slipFile = ref<
{
paymentId: string;
data: { name: string; progress: number; loaded: number; total: number }[];
file?: File;
}[]
>([]);
const state = reactive({
waitExpansion: true,
payExpansion: [] as boolean[],
});
const emit = defineEmits<{
(e: 'view', id: string): void;
(e: 'fetchStatus'): void;
}>();
function monthDisplay(date: Date | string) {
return dateFormatJS({ date, monthStyle: 'long', locale: 'th-Th' });
}
function pickFile(index: number) {
refQFile.value[index].pickFiles();
}
async function getSlipList(payment: QuotationPaymentData, index: number) {
const slipList = await quotationPayment.listAttachment({
parentId: payment.id,
});
if (slipList && slipList.length > 0) {
slipFile.value[index].data = slipFile.value[index].data.filter((item) =>
slipList.includes(item.name),
);
slipList.forEach((slip) => {
const exists = slipFile.value[index].data.some(
(item) => item.name === slip,
);
if (!exists) {
slipFile.value[index].data.push({
name: slip,
progress: 1,
loaded: 0,
total: 0,
});
}
});
} else slipFile.value[index].data = [];
}
async function triggerUpload(
payment: QuotationPaymentData,
index: number,
file?: File,
) {
if (!file) return;
slipFile.value[index].data.push({
name: file.name,
progress: 0,
loaded: 0,
total: 0,
});
const ret = await quotationPayment.putAttachment({
parentId: payment.id,
name: file.name,
file: file,
onUploadProgress: (e) => {
slipFile.value[index].data[slipFile.value[index].data.length - 1] = {
name: file.name,
progress: e.progress || 0,
loaded: e.loaded,
total: e.total || 0,
};
},
});
if (ret) await getSlipList(payment, index);
slipFile.value[index].file = undefined;
}
async function triggerDelete(
payment: QuotationPaymentData,
name: string,
index: number,
) {
await quotationPayment.delAttachment({ parentId: payment.id, name });
await getSlipList(payment, index);
}
function triggerViewSlip(payment: QuotationPaymentData, name: string) {
const url = `${baseUrl}/payment/${payment.id}/attachment/${name}`;
window.open(url, '_blank');
}
async function selectStatus(
payment: QuotationPaymentData,
status: string,
index: number,
) {
dialog({
color: 'warning',
icon: 'mdi-alert',
title: t('dialog.title.confirmChangeStatus'),
actionText: t('general.confirm'),
persistent: true,
message: t('dialog.message.confirmChangeStatus'),
action: async () => {
payment.paymentStatus = status;
const payload = {
paymentStatus: payment.paymentStatus,
date: new Date(),
amount: payment.amount,
};
const res = await quotationPayment.updateQuotationPayment(
payment.id,
payload,
);
if (res) emit('fetchStatus');
},
cancel: () => {},
});
refQMenu.value[index].hide();
}
async function triggerSubmit(id: string) {
const index = paymentData.value.findIndex((p) => p.id === id);
if (index === -1) return;
const p = paymentData.value[index];
const payload: PaymentPayload = {
paymentStatus: p.paymentStatus,
date: new Date(p.date),
amount: p.amount,
account: formPaymentMethod.value[index].account,
channel: formPaymentMethod.value[index].channel,
reference: formPaymentMethod.value[index].reference,
};
await quotationPayment.updateQuotationPayment(p.id, payload);
formPaymentMethod.value[index].isEdit = false;
setTimeout(async () => {
await fetchData();
await getSlipList(paymentData.value[index], index);
}, 300);
}
async function fetchData() {
if (!prop.data) return;
const ret = await quotationPayment.getQuotationPayment({
quotationId: prop.isDebitNote === true ? undefined : prop.data.id,
debitNoteId: prop.isDebitNote === true ? prop.data.id : undefined,
quotationOnly: !!prop.isDebitNote ? false : true,
debitNoteOnly: !!prop.isDebitNote ? true : false,
});
if (ret) {
paymentData.value = ret.result;
formPaymentMethod.value = ret.result.map((p, i) => ({
id: p.id,
channel: p.channel,
reference: p.reference,
account: p.account,
isEdit: formPaymentMethod.value[i]?.isEdit || false,
}));
slipFile.value = paymentData.value.map((v, i) => {
if (i === 0) {
firstCodePayment.value = v.code;
}
return {
paymentId: v.id,
data: [],
};
});
}
}
async function fetchBankOption() {
const bankOption = await fetchListBankByBranch(prop.branchId);
accountOpt.value = bankOption
.map((b) => {
const name =
`${b.accountName} ${mapOption(b.bankName)} ${mapOption(b.accountType)} ${b.accountNumber}`.trim();
if (!name) return;
return {
label: name,
value: name,
};
})
.filter((i) => !!i);
}
onMounted(async () => {
await fetchData();
await fetchBankOption();
});
</script>
<template>
<div class="column no-wrap">
<!-- PRICE DETAIL -->
<section class="bordered rounded surface-1" style="overflow: hidden">
<q-expansion-item
hide-expand-icon
v-model="state.waitExpansion"
header-style="padding:0px"
>
<template v-slot:header>
<div class="column full-width bordered-b">
<header class="bg-color-orange text-bold text-body1 q-pa-sm">
<q-avatar class="surface-1 q-mr-sm" size="10px" />
{{ $t('quotation.receiptDialog.paymentWait') }}
</header>
<span class="q-pa-md row items-center">
<q-img
src="/images/quotation-avatar.png"
width="3rem"
class="q-mr-lg"
/>
<div class="column col">
<span class="text-bold text-body1">
{{ data.workName }}
</span>
<span
class="row items-center"
:class="data.urgent ? 'urgent' : 'app-text-muted'"
>
{{ data.code }}
<q-icon
v-if="data.urgent"
class="q-pl-sm"
name="mdi-fire"
size="xs"
/>
<span class="q-ml-auto" style="color: var(--foreground)">
฿ {{ formatNumberDecimal(data.finalPrice, 2) || '0.00' }}
</span>
</span>
</div>
</span>
</div>
</template>
<span class="app-text-muted-2 q-px-md q-py-sm column">
<span class="row">
{{ $t('general.total') }}
<span class="q-ml-auto" style="color: var(--foreground)">
฿
{{ formatNumberDecimal(data.totalPrice, 2) || '0.00' }}
</span>
</span>
<span class="row">
{{ $t('quotation.discountList') }}
<span class="q-ml-auto" style="color: var(--foreground)">
฿
{{ formatNumberDecimal(data.totalDiscount, 2) || '0.00' }}
</span>
</span>
<span class="row">
{{ $t('general.totalAfterDiscount') }}
<span class="q-ml-auto" style="color: var(--foreground)">
฿
{{
formatNumberDecimal(data.totalPrice - data.totalDiscount, 2) ||
'0.00'
}}
</span>
</span>
<span class="row">
{{ $t('general.totalVatExcluded') }}
<span class="q-ml-auto" style="color: var(--foreground)">
฿ {{ formatNumberDecimal(data.vatExcluded, 2) || '0.00' }}
</span>
</span>
<span class="row">
{{
$t('general.vat', {
msg: `${config && Math.round(config.vat * 100)}%`,
})
}}
<span class="q-ml-auto" style="color: var(--foreground)">
฿ {{ formatNumberDecimal(data.vat, 2) || '0.00' }}
</span>
</span>
<span class="row">
{{ $t('general.totalVatIncluded') }}
<span class="q-ml-auto" style="color: var(--foreground)">
฿
{{
formatNumberDecimal(
data.totalPrice - data.totalDiscount + data.vat,
2,
) || '0.00'
}}
</span>
</span>
<span class="row">
{{ $t('general.discountAfterVat') }}
<span class="q-ml-auto" style="color: var(--foreground)">
฿ {{ formatNumberDecimal(data.discount, 2) || '0.00' }}
</span>
</span>
</span>
</q-expansion-item>
<q-separator inset v-if="state.waitExpansion" />
<div
class="q-py-sm q-px-md row items-center justify-end app-text-muted-2"
>
{{ $t('quotation.totalPriceBaht') }}:
<span class="q-px-sm" style="color: var(--foreground)">
{{ formatNumberDecimal(data.finalPrice, 2) || '0.00' }}
</span>
<q-btn
dense
flat
rounded
padding="0"
:icon="`mdi-chevron-${state.waitExpansion ? 'down' : 'up'}`"
@click.stop="state.waitExpansion = !state.waitExpansion"
/>
</div>
</section>
<!-- PAYMENT DETAIL -->
<section
class="surface-1 rounded bordered column q-mt-md q-pa-md col scroll no-wrap"
>
<span class="text-weight-bold text-body1">
{{ $t('general.payment') }}
</span>
<div class="row items-center q-pb-md">
<span class="app-text-muted-2">{{ $t('quotation.payType') }}</span>
<div
class="badge-card q-ml-auto rounded"
:class="`badge-card__${data.payCondition}`"
>
{{ $t(`quotation.type.${data.payCondition}`) }}
</div>
</div>
<!-- split summary -->
<div
v-if="
data.payCondition === 'BillSplit' || data.payCondition === 'Split'
"
class="row items-center q-pb-sm"
>
<span class="app-text-muted-2 col-12 col-md">
{{ $t('quotation.paySplitCount') }}
</span>
<span>
{{ $t('quotation.receiptDialog.total') }}
</span>
<span class="bordered rounded surface-2 number-box q-mx-sm">
{{ data.paySplitCount }}
</span>
{{ $t('quotation.receiptDialog.installments') }}
{{ $i18n.locale === 'eng' ? ',' : '' }}
{{ $t('quotation.receiptDialog.paid') }}
<span class="bordered rounded surface-2 number-box q-mx-sm">
{{
paymentData.reduce(
(c, i) => (i.paymentStatus === 'PaymentSuccess' ? c + 1 : c),
0,
)
}}
</span>
{{
$i18n.locale === 'tha'
? $t('quotation.receiptDialog.installments')
: ','
}}
{{ $t('quotation.receiptDialog.remain') }}
<span class="bordered rounded surface-2 number-box q-mx-sm">
{{
paymentData.reduce(
(c, i) => (i.paymentStatus !== 'PaymentSuccess' ? c + 1 : c),
0,
)
}}
</span>
{{
$i18n.locale === 'tha'
? $t('quotation.receiptDialog.installments')
: ''
}}
</div>
<!-- summary total, paid, remain -->
<div class="row items-center">
<span
class="row col rounded q-px-sm q-py-md justify-end"
style="border: 1px solid hsl(var(--info-bg))"
>
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('quotation.receiptDialog.totalAmount') }}
</span>
{{ formatNumberDecimal(data.finalPrice, 2) }}
</span>
<span
class="row col rounded q-px-sm q-py-md q-mx-md justify-end"
style="border: 1px solid hsl(var(--positive-bg))"
>
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('quotation.receiptDialog.paid') }}
</span>
{{
formatNumberDecimal(
paymentData.reduce(
(c, i) =>
i.paymentStatus === 'PaymentSuccess' ? c + i.amount : c,
0,
),
2,
)
}}
</span>
<span
class="row col rounded q-px-sm q-py-md justify-end"
style="border: 1px solid hsl(var(--warning-bg))"
>
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('quotation.receiptDialog.remain') }}
</span>
{{
formatNumberDecimal(
paymentData.reduce(
(c, i) =>
i.paymentStatus !== 'PaymentSuccess' ? c + i.amount : c,
0,
),
2,
)
}}
</span>
</div>
<!-- bill -->
<span class="app-text-muted-2 q-pt-md" v-if="paymentData.length > 0">
{{ $t('quotation.receiptDialog.billOfPayment') }}
</span>
<!-- payment item -->
<section class="row">
<div
v-for="(payment, i) in paymentData"
:key="i"
class="bordered rounded surface-1 q-mb-md col-12"
style="overflow: hidden"
>
<q-expansion-item
hide-expand-icon
v-model="state.payExpansion[i]"
header-style="padding:0px"
@before-show="getSlipList(payment, i)"
>
<template v-slot:header>
<div class="column full-width">
<section
class="row full-width items-center surface-2 bordered-b q-px-md q-py-sm"
>
<span class="text-weight-medium column">
{{ $t('quotation.periodNo') }} {{ i + 1 }}
{{ monthDisplay(payment.createdAt) }}
<!-- {{ monthDisplay(p.date) }} -->
<!-- <span -->
<!-- class="text-caption app-text-muted-2" -->
<!-- v-if="data.payCondition !== 'Full'" -->
<!-- > -->
<!-- {{ $t('quotation.receiptDialog.paymentDueDate') }} -->
<!-- {{ -->
<!-- data.payCondition !== 'BillFull' -->
<!-- ? dateFormat(p.date, true) -->
<!-- : dateFormat(data.payBillDate) -->
<!-- }} -->
<!-- </span> -->
</span>
<div class="q-ml-auto row" style="gap: 10px">
<q-btn
:disable="readonly"
id="btn-payment"
@click.stop
unelevated
padding="4px 8px"
class="rounded text-capitalize text-weight-regular payment-wait row items-center"
:class="{
'payment-pending':
payment.paymentStatus === 'PaymentWait',
'payment-process':
payment.paymentStatus === 'PaymentInProcess',
'payment-retry':
payment.paymentStatus === 'PaymentRetry',
'payment-success':
payment.paymentStatus === 'PaymentSuccess',
}"
>
<q-icon
size="xs"
:name="
paymentStatusOpts.find(
(s) => s.status === payment.paymentStatus,
)?.icon || 'mdi-hand-coin-outline'
"
class="q-pr-sm"
/>
<span>
{{
$t(`quotation.receiptDialog.${payment.paymentStatus}`)
}}
</span>
<q-icon
v-if="!readonly"
name="mdi-chevron-down"
class="q-pl-xs"
/>
<q-menu
ref="refQMenu"
fit
:offset="[0, 8]"
class="rounded"
>
<q-list dense>
<template
v-for="opts in paymentStatusOpts"
:key="opts.status"
>
<q-item
:id="`btn-payment-${opts.status}`"
v-if="
(payment.paymentStatus === 'PaymentWait' &&
opts.status !== 'PaymentRetry') ||
(payment.paymentStatus === 'PaymentInProcess' &&
opts.status !== 'PaymentInProcess') ||
(payment.paymentStatus === 'PaymentRetry' &&
opts.status !== 'PaymentRetry')
"
clickable
class="row items-center"
@click="selectStatus(payment, opts.status, i)"
>
<q-icon
:name="opts.icon"
:color="opts.color"
class="q-pr-sm"
size="xs"
/>
{{ $t(opts.name) }}
</q-item>
</template>
</q-list>
</q-menu>
</q-btn>
</div>
</section>
<section class="row items-center q-px-md q-py-sm">
{{ $t('quotation.receiptDialog.amountToBePaid') }}
<span
class="q-px-sm q-ml-auto"
style="color: var(--foreground)"
>
{{ formatNumberDecimal(payment.amount, 2) }}
</span>
<q-btn
dense
flat
rounded
padding="0"
id="btn-show-file"
:icon="`mdi-chevron-${state.payExpansion[i] ? 'down' : 'up'}`"
@click.stop="state.payExpansion[i] = !state.payExpansion[i]"
/>
</section>
</div>
</template>
<!-- วิธีการรับชำระ -->
<template v-if="payment.paymentStatus === 'PaymentSuccess'">
<section
class="q-px-md q-py-xs text-weight-medium row items-center"
style="background-color: hsla(var(--info-bg) / 0.1)"
>
{{ $t('quotation.receiptDialog.paymentMethod') }}
</section>
<q-form
class="column full-height"
@submit.prevent
@submit="triggerSubmit(payment.id)"
>
<div
class="surface-2 q-px-md q-py-sm row q-col-gutter-sm items-center"
>
<SelectInput
id="input-payment-channel"
for="input-payment-channel"
:readonly="
readonly ||
(!formPaymentMethod[i].isEdit && !!payment.channel)
"
v-model="formPaymentMethod[i].channel"
class="col-md-2 col-6"
:rules="[
(val: string) => !!val || $t('form.error.required'),
]"
:option="[
{
label: $t('creditNote.label.Cash'),
value: 'Cash',
},
{
label: $t('creditNote.label.BankTransfer'),
value: 'BankTransfer',
},
]"
:label="$t('quotation.receiptDialog.paymentMethod')"
@update:model-value="
() => {
if (formPaymentMethod[i].channel === 'Cash') {
formPaymentMethod[i].reference = null;
formPaymentMethod[i].account = null;
}
}
"
/>
<q-input
v-if="formPaymentMethod[i].channel === 'BankTransfer'"
dense
outlined
class="col-md-3 col-6"
v-model="formPaymentMethod[i].reference"
:readonly="
readonly ||
(!formPaymentMethod[i].isEdit && !!payment.channel)
"
:label="$t('quotation.refNo')"
:rules="[
(val: string) => !!val || $t('form.error.required'),
]"
hide-bottom-space
/>
<SelectInput
v-if="formPaymentMethod[i].channel === 'BankTransfer'"
id="select-payment-account"
for="select-payment-account"
:readonly="
readonly ||
(!formPaymentMethod[i].isEdit && !!payment.channel)
"
v-model="formPaymentMethod[i].account"
class="col"
:option="accountOpt"
:label="$t('quotation.bankAccount')"
:rules="[
(val: string) => !!val || $t('form.error.required'),
]"
/>
<div class="q-ml-auto">
<UndoButton
v-if="formPaymentMethod[i].isEdit"
icon-only
@click="
() => {
formPaymentMethod[i].isEdit = false;
formPaymentMethod[i].channel = payment.channel;
formPaymentMethod[i].reference = payment.reference;
formPaymentMethod[i].account = payment.account;
}
"
/>
<SaveButton
v-if="!payment.channel || formPaymentMethod[i].isEdit"
icon-only
type="submit"
/>
<EditButton
v-if="
payment.channel && formPaymentMethod[i].isEdit === false
"
icon-only
@click="formPaymentMethod[i].isEdit = true"
/>
</div>
</div>
</q-form>
</template>
<!-- อัปโหลดใบเสร็จ -->
<section
class="q-px-md q-py-xs text-weight-medium row items-center"
style="background-color: hsla(var(--info-bg) / 0.1)"
>
{{
$t('general.upload', {
msg: $t('quotation.receiptDialog.slip'),
})
}}
</section>
<div class="surface-2 q-pa-md">
<div
class="upload-section column rounded q-py-md full-height items-center justify-center no-wrap"
>
<q-img src="/images/upload.png" width="150px" />
{{ $t('general.upload', { msg: ' E-slip' }) }}
{{
$t('general.or', {
msg: $t('general.upload', {
msg: $t('quotation.receiptDialog.paymentDocs'),
}),
})
}}
<q-btn
v-if="!readonly"
unelevated
id="btn-upload-file"
:label="$t('general.upload')"
rounded
class="app-bg-info q-mt-sm"
@click.stop="() => pickFile(i)"
/>
<q-file
ref="refQFile"
v-show="false"
v-model="slipFile[i].file"
@update:model-value="
triggerUpload(payment, i, slipFile[i].file)
"
/>
</div>
</div>
<section class="surface-2 row q-px-md">
<!-- upload card -->
<div
v-for="(d, j) in slipFile[i].data"
:key="j"
class="col-12"
:class="{
'q-pb-md': j === slipFile[i].data.length - 1,
'q-pb-sm': j < slipFile[i].data.length,
}"
>
<UploadFileCard
:name="d.name"
:progress="d.progress"
:uploading="{ loaded: d.loaded, total: d.total }"
:url="`/payment/${payment.id}/attachment/${d.name}`"
icon="mdi-file-image-outline"
color="hsl(var(--text-mute))"
clickable
@click="triggerViewSlip(payment, d.name)"
@close="triggerDelete(payment, d.name, i)"
/>
</div>
</section>
</q-expansion-item>
</div>
</section>
</section>
</div>
</template>
<style scoped>
.bg-color-orange {
--_color: var(--yellow-7-hsl);
color: white;
background: hsla(var(--_color));
}
.dark .bg-color-orange {
--_color: var(--orange-6-hsl);
}
.bg-color-orange-light {
--_color: var(--yellow-7-hsl);
background: hsla(var(--_color) / 0.2);
}
.dark .bg-color-orange {
--_color: var(--orange-6-hsl / 0.2);
}
.payment-pending {
color: hsl(var(--warning-bg));
background: hsla(var(--warning-bg) / 0.15);
}
.payment-process {
color: hsl(var(--danger-bg));
background: hsla(var(--danger-bg) / 0.15);
}
.payment-retry {
color: hsl(var(--negative-bg));
background: hsla(var(--negative-bg) / 0.15);
}
.payment-success {
color: hsl(var(--positive-bg));
background: hsla(var(--positive-bg) / 0.1);
}
.urgent {
color: hsl(var(--red-6-hsl));
}
.number-box {
text-align: center;
width: 25px;
height: 25px;
}
.badge-card {
padding: 4px 8px;
color: hsla(var(--gray-0-hsl) / 1);
background: hsla(var(--_color) / 1);
}
.badge-card__Full {
--_color: var(--red-6-hsl);
}
.badge-card__Split {
--_color: var(--blue-6-hsl);
}
.badge-card__BillFull {
--_color: var(--jungle-8-hsl);
}
.badge-card__BillSplit {
--_color: var(--purple-7-hsl);
}
.dark .badge-card__Split {
--_color: var(--blue-10-hsl);
}
.upload-section {
border: 3px solid var(--border-color);
border-style: dashed;
}
:deep(
.q-expansion-item
.q-item.q-item-type.row.no-wrap.q-item--clickable.q-link.cursor-pointer.q-focusable.q-hoverable
.q-focus-helper
) {
visibility: hidden;
}
</style>