jws-frontend/src/pages/11_credit-note/FormPage.vue
2025-09-18 09:59:59 +07:00

978 lines
27 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue';
import { api } from 'src/boot/axios';
import { useRoute, useRouter } from 'vue-router';
import { dateFormatJS } from 'src/utils/datetime';
import { initLang, initTheme } from 'src/utils/ui';
import { QuotationFull, useQuotationStore } from 'src/stores/quotations';
import {
CreditNote,
CreditNotePaybackStatus,
CreditNotePayload,
CreditNoteStatus,
useCreditNote,
} from 'src/stores/credit-note';
import { useConfigStore } from 'src/stores/config';
import DocumentExpansion from './expansion/DocumentExpansion.vue';
import RemarkExpansion from '../09_task-order/expansion/RemarkExpansion.vue';
import AdditionalFileExpansion from '../09_task-order/expansion/AdditionalFileExpansion.vue';
import PaymentExpansion from './expansion/PaymentExpansion.vue';
import CreditNoteExpansion from './expansion/CreditNoteExpansion.vue';
import StateButton from 'src/components/button/StateButton.vue';
import ProductExpansion from './expansion/ProductExpansion.vue';
import SelectReadyRequestWork from '../09_task-order/SelectReadyRequestWork.vue';
import RefundInformation from './RefundInformation.vue';
import QuotationFormReceipt from '../05_quotation/QuotationFormReceipt.vue';
import DialogViewFile from 'src/components/dialog/DialogViewFile.vue';
import {
MainButton,
SaveButton,
CancelButton,
EditButton,
UndoButton,
} from 'src/components/button';
import { precisionRound } from 'src/utils/arithmetic';
import { DebitNote, useDebitNote } from 'src/stores/debit-note';
import { RequestWork } from 'src/stores/request-list/types';
import { storeToRefs } from 'pinia';
import useOptionStore from 'src/stores/options';
import { dialogWarningClose, canAccess, isRoleInclude } from 'src/stores/utils';
import { useI18n } from 'vue-i18n';
import { QForm } from 'quasar';
import { getName } from 'src/services/keycloak';
import { RequestWorkStatus } from 'src/stores/request-list/types';
const route = useRoute();
const router = useRouter();
const creditNote = useCreditNote();
const debitNote = useDebitNote();
const quotation = useQuotationStore();
const configStore = useConfigStore();
const { data: config } = storeToRefs(configStore);
const { t } = useI18n();
const agentPrice = ref<boolean>(false);
const refForm = ref<InstanceType<typeof QForm>>();
const creditNoteData = ref<CreditNote>();
const quotationData = ref<DebitNote | QuotationFull>();
const view = ref<CreditNoteStatus | null>(null);
const fileList = ref<FileList>();
const attachmentList = ref<FileList>();
const fileData = ref<
{
name: string;
progress: number;
loaded: number;
total: number;
url?: string;
}[]
>([]);
const attachmentData = ref<
{
name: string;
progress: number;
loaded: number;
total: number;
url?: string;
placeholder?: boolean;
}[]
>([]);
const statusTabForm = ref<
{
title: string;
status: 'done' | 'doing' | 'waiting';
handler: () => void;
active?: () => boolean;
}[]
>([]);
// const readonly = computed(
// () =>
// creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Pending ||
// creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Success,
// );
const pageState = reactive({
productDialog: false,
fileDialog: false,
mode: 'view' as 'view' | 'edit' | 'info',
});
const defaultRemark = '';
const formData = ref<CreditNotePayload>({
quotationId: '',
requestWorkId: [],
reason: '',
detail: '',
paybackType: 'Cash',
paybackBank: '',
paybackAccount: '',
paybackAccountName: '',
remark: defaultRemark,
});
const formTaskList = ref<
{
requestWorkId: string;
requestWorkStep?: { requestWork: RequestWork };
}[]
>([]);
let taskListGroup = computed(() => {
const cacheData = formTaskList.value.reduce<
{
product: RequestWork['productService'];
list: RequestWork[];
}[]
>((acc, curr) => {
const task = curr.requestWorkStep;
if (!task) return acc;
if (task.requestWork) {
let exist = acc.find(
(item) => task.requestWork.productService.productId == item.product.id,
);
const record = Object.assign(task.requestWork);
if (exist) {
exist.list.push(task.requestWork);
} else {
acc.push({
product: task.requestWork.productService,
list: [record],
});
}
}
return acc;
}, []);
return cacheData;
});
const summaryPrice = computed(() => getPrice(taskListGroup.value));
async function initStatus() {
if (creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Pending)
view.value = CreditNoteStatus.Pending;
if (creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Success)
view.value = CreditNoteStatus.Success;
statusTabForm.value = [
{
title: 'title',
status: creditNoteData.value?.id !== undefined ? 'done' : 'doing',
active: () => view.value === null,
handler: () => {
view.value = null;
},
},
{
title: 'Pending',
status: creditNoteData.value?.id
? creditNoteData.value.creditNoteStatus === CreditNoteStatus.Waiting
? 'waiting'
: creditNoteData.value.creditNoteStatus === CreditNoteStatus.Success
? 'done'
: 'doing'
: 'waiting',
active: () => view.value === CreditNoteStatus.Pending,
handler: async () => {
view.value = CreditNoteStatus.Pending;
creditNoteData.value &&
(await getFileList(creditNoteData.value.id, true));
},
},
{
title: 'Success',
status:
creditNoteData.value?.id &&
creditNoteData.value.creditNoteStatus === CreditNoteStatus.Success
? 'done'
: 'waiting',
active: () => view.value === CreditNoteStatus.Success,
handler: () => {
view.value = CreditNoteStatus.Success;
},
},
];
}
function getPrice(
list: {
product: RequestWork['productService'];
list: RequestWork[];
}[],
) {
return list.reduce(
(a, c) => {
const value = creditNote.getfinalPriceCredit(c.list || []);
a.totalPrice = precisionRound(a.totalPrice + value.totalPrice);
a.totalDiscount = precisionRound(a.totalDiscount + value.totalDiscount);
a.vat = precisionRound(a.vat + value.vat);
a.vatIncluded = precisionRound(a.vatIncluded + value.vatIncluded);
a.vatExcluded = precisionRound(a.vatExcluded + value.vatExcluded);
a.finalPrice = precisionRound(a.finalPrice + value.finalPrice);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatIncluded: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
}
function openProductDialog() {
pageState.productDialog = true;
}
async function getCreditNote() {
if (typeof route.params['id'] !== 'string') return;
const ret = await creditNote.getCreditNote(route.params['id']);
if (!ret) return;
creditNoteData.value = ret;
assignFormData();
}
function assignFormData() {
if (!creditNoteData.value) return;
const current = creditNoteData.value;
formData.value = {
quotationId: creditNoteData.value.quotationId,
requestWorkId: creditNoteData.value.requestWork.map((v) => v.id || ''),
reason: creditNoteData.value.reason,
remark: creditNoteData.value.remark,
detail: creditNoteData.value.detail,
paybackType: creditNoteData.value.paybackType,
paybackBank: creditNoteData.value.paybackBank,
paybackAccount: creditNoteData.value.paybackAccount,
paybackAccountName: creditNoteData.value.paybackAccountName,
};
formTaskList.value = creditNoteData.value.requestWork.map((v) => ({
requestWorkId: v.id || '',
requestWorkStep: {
requestWork: {
...v,
stepStatus: v.stepStatus || [],
request: { ...v.request, quotation: current.quotation },
} as RequestWork,
},
}));
}
function openSlipDialog() {
pageState.fileDialog = true;
}
async function getQuotation() {
if (creditNoteData.value) {
quotationData.value = creditNoteData.value.quotation;
return;
}
if (
route.name !== 'CreditNoteNew' ||
typeof route.query['quotationId'] !== 'string'
) {
return;
}
const isDebitNote = route.query['isDebitNote'] === 'true';
const ret = isDebitNote
? await debitNote.getDebitNote(route.query['quotationId'])
: await quotation.getQuotation(route.query['quotationId']);
if (!ret) return;
quotationData.value = ret;
agentPrice.value = quotationData.value.agentPrice;
}
async function submit() {
const payload = formData.value;
payload.requestWorkId = formTaskList.value.map((v) => v.requestWorkId);
payload.quotationId =
(pageState.mode === 'edit'
? creditNoteData.value?.quotationId
: typeof route.query['quotationId'] === 'string'
? route.query['quotationId']
: '') || '';
const res =
pageState.mode === 'edit'
? await creditNote.updateCreditNote(
creditNoteData.value?.id || '',
payload,
)
: creditNoteData.value
? await creditNote.acceptCreditNote(creditNoteData.value.id)
: await creditNote.createCreditNote(payload);
if (res) {
await router.push(`/credit-note/${res.id}`);
await getCreditNote();
if (attachmentList.value) {
await uploadFile(res.id, attachmentList.value, false);
}
initStatus();
pageState.mode = 'info';
}
}
function goToQuotation() {
if (!quotationData.value) return;
const quotation = quotationData.value;
const url = new URL('/quotation/view', window.location.origin);
localStorage.setItem(
'new-quotation',
JSON.stringify({
customerBranchId: quotation.customerBranchId,
agentPrice: quotation.agentPrice,
statusDialog: 'info',
quotationId: quotation.id,
}),
);
window.open(url.toString(), '_blank');
}
async function changePaybackStatus(status: CreditNotePaybackStatus) {
if (!creditNoteData.value) return;
const res = await creditNote.action.updatePaybackStatus(
creditNoteData.value.id,
status,
);
if (res) {
creditNoteData.value.paybackStatus = status;
if (status === CreditNotePaybackStatus.Done) {
creditNoteData.value.creditNoteStatus = CreditNoteStatus.Success;
initStatus();
}
}
}
async function uploadFile(
creditNoteId: string,
list: FileList,
slip?: boolean,
) {
const promises: ReturnType<
typeof creditNote.putAttachment | typeof creditNote.putFile
>[] = [];
if (!slip) {
attachmentData.value = attachmentData.value.filter((v) => !v.placeholder);
}
for (let i = 0; i < list.length; i++) {
const data = {
name: list[i].name,
progress: 1,
loaded: 0,
total: 0,
url: `/credit-note/${creditNoteId}/${slip ? 'file-slip' : 'attachment'}/${list[i].name}`,
};
promises.push(
slip
? creditNote.putFile({
group: 'slip',
parentId: creditNoteId,
fileId: list[i].name,
file: list[i],
uploadUrl: true,
onUploadProgress: (e) => {
const exists = fileData?.value.find((v) => v.name === data.name);
if (!exists) return fileData?.value.push(data);
exists.total = e.total || 0;
exists.progress = e.progress || 0;
exists.loaded = e.loaded;
},
})
: creditNote.putAttachment({
parentId: creditNoteId,
name: list[i].name,
file: list[i],
onUploadProgress: (e) => {
const exists = attachmentData?.value.find(
(v) => v.name === data.name,
);
if (!exists) return attachmentData?.value.push(data);
exists.total = e.total || 0;
exists.progress = e.progress || 0;
exists.loaded = e.loaded;
},
}),
);
slip ? fileData?.value.push(data) : attachmentData?.value.push(data);
}
fileList.value = undefined;
attachmentList.value = undefined;
const beforeUnloadHandler = (e: Event) => {
e.preventDefault();
};
window.addEventListener('beforeunload', beforeUnloadHandler);
return await Promise.all(promises).then((v) => {
window.removeEventListener('beforeunload', beforeUnloadHandler);
return v;
});
}
async function remove(creditNoteId: string, n: string, slip?: boolean) {
dialogWarningClose(t, {
message: t('dialog.message.confirmDelete'),
actionText: t('dialog.action.ok'),
action: async () => {
const res = slip
? await creditNote.delFile({
group: 'slip',
parentId: creditNoteId,
fileId: n,
})
: await creditNote.delAttachment({
parentId: creditNoteId,
name: n,
});
if (res) {
getFileList(creditNoteId, slip);
}
},
cancel: () => {},
});
}
async function getFileList(creditNoteId: string, slip?: boolean) {
const list = slip
? await creditNote.listFile({
group: 'slip',
parentId: creditNoteId,
})
: await creditNote.listAttachment({
parentId: creditNoteId,
});
if (list)
slip
? (fileData.value = await Promise.all(
list.map(async (v) => {
const rse = await creditNote.headFile({
group: 'slip',
parentId: creditNoteId,
fileId: v,
});
let contentLength = 0;
if (rse) contentLength = Number(rse['content-length']);
return {
name: v,
progress: 1,
loaded: contentLength,
total: contentLength,
url: `/credit-note/${creditNoteId}/file-slip/${v}`,
};
}),
))
: (attachmentData.value = await Promise.all(
list.map(async (v) => {
const rse = await creditNote.headAttachment({
parentId: creditNoteId,
fileId: v,
});
let contentLength = 0;
if (rse) contentLength = Number(rse['content-length']);
return {
name: v,
progress: 1,
loaded: contentLength,
total: contentLength,
url: `/credit-note/${creditNoteId}/attachment/${v}`,
};
}),
));
}
function fileToUrl(file: File) {
return URL.createObjectURL(file);
}
function storeDataLocal() {
localStorage.setItem(
'credit-note-preview',
JSON.stringify({
data: {
...formData.value,
id:
route.name === 'CreditNoteNew' ? undefined : creditNoteData.value?.id,
customerBranchId: quotationData.value?.customerBranchId,
registeredBranchId: quotationData.value?.registeredBranchId,
},
meta: {
source: {
code:
route.name === 'CreditNoteNew' ? '-' : creditNoteData.value?.code,
createAt:
route.name === 'CreditNoteNew'
? Date.now()
: creditNoteData.value?.createdAt,
createBy: creditNoteData.value?.createdBy,
contactName: quotationData.value?.contactName,
contactTel: quotationData.value?.contactTel,
workName: quotationData.value?.workName,
},
summaryPrice: summaryPrice.value,
taskListGroup: { ...taskListGroup.value },
agentPrice: quotationData.value?.agentPrice,
createdBy: getName(),
},
}),
);
const url = new URL('/credit-note/document-view', window.location.origin);
window.open(url, '_blank');
}
function closeTab() {
dialogWarningClose(t, {
message: t('dialog.message.close'),
action: () => {
window.close();
},
cancel: () => {},
});
}
function undo() {
assignFormData();
pageState.mode = 'info';
}
onMounted(async () => {
initTheme();
initLang();
await useConfigStore().getConfig();
await getCreditNote();
await getQuotation();
if (creditNoteData.value) {
pageState.mode = 'info';
await getFileList(creditNoteData.value.id, true);
}
initStatus();
});
</script>
<template>
<div class="column surface-0 fullscreen">
<div class="color-bar" :class="{ ['dark']: $q.dark.isActive }">
<div :class="{ ['indigo-segment']: true }"></div>
<div :class="{ ['light-indigo-segment']: true }"></div>
<div class="white-segment"></div>
</div>
<!-- SEC: Header -->
<header
class="row q-px-md q-py-sm items-center justify-between relative-position"
>
<section class="banner" :class="{ dark: $q.dark.isActive }"></section>
<div style="flex: 1" class="row items-center">
<RouterLink to="/credit-note">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('creditNote.title') }}
<!-- {{ code || '' }} -->
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg: dateFormatJS({
date: creditNoteData?.createdAt || new Date(Date.now()),
monthStyle: 'long',
}),
})
}}
</span>
</span>
</div>
</header>
<!-- SEC: Body -->
<article
class="col full-width q-pa-md"
style="flex-grow: 1; overflow-y: auto"
>
<section class="col-sm col-12">
<div class="col q-gutter-y-md">
<nav
class="surface-1 q-pa-sm row no-wrap full-width scroll rounded"
style="gap: 10px"
>
<StateButton
v-for="i in statusTabForm"
:key="i.title"
:label="
$t(
`creditNote${i.title === 'title' ? '' : '.status'}.${i.title}`,
)
"
:status-active="i.active?.()"
:status-done="i.status === 'done'"
:status-waiting="i.status === 'waiting'"
@click="i.handler()"
/>
</nav>
<DocumentExpansion
:noLink="
route.query['isDebitNote'] === 'true' ||
quotationData?.code.startsWith('DN')
"
readonly
:registered-branch-id="quotationData?.registeredBranchId"
:customer-id="quotationData?.customerBranchId"
:quotation-code="quotationData?.code || '-'"
:quotation-work-name="quotationData?.workName || '-'"
:quotation-contact-name="quotationData?.contactName || '-'"
:quotation-contact-tel="quotationData?.contactTel || '-'"
@goto-quotation="goToQuotation"
/>
<q-form
ref="refForm"
greedy
@submit.prevent
@validation-success="submit"
>
<CreditNoteExpansion
v-if="view === null"
:readonly="pageState.mode === 'info'"
v-model:reason="formData.reason"
v-model:detail="formData.detail"
/>
</q-form>
<ProductExpansion
v-if="view === null"
creditNote
:readonly="pageState.mode === 'info'"
:agentPrice="quotationData?.agentPrice"
:task-list="taskListGroup"
@add-product="openProductDialog"
/>
<PaymentExpansion
v-if="view === null"
:readonly="pageState.mode === 'info'"
:total-price="summaryPrice.finalPrice"
v-model:payback-type="formData.paybackType"
v-model:payback-bank="formData.paybackBank"
v-model:payback-account="formData.paybackAccount"
v-model:payback-account-name="formData.paybackAccountName"
/>
<RefundInformation
v-if="view === CreditNoteStatus.Pending"
:readonly="!canAccess('related', 'edit')"
:total="creditNoteData?.value"
:paid="
creditNoteData?.paybackStatus === CreditNotePaybackStatus.Done
? creditNoteData?.value
: 0
"
:remain="
creditNoteData?.paybackStatus === CreditNotePaybackStatus.Pending
? creditNoteData?.value
: 0
"
:payback-status="creditNoteData?.paybackStatus"
v-model:payback-type="formData.paybackType"
v-model:payback-bank="formData.paybackBank"
v-model:payback-account="formData.paybackAccount"
v-model:payback-account-name="formData.paybackAccountName"
v-model:file-data="fileData"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
@change-status="changePaybackStatus"
@upload="
async (f) => {
if (!creditNoteData) return;
fileList = f;
await uploadFile(creditNoteData.id, f, true);
}
"
@remove="
async (n) => {
if (!creditNoteData) return;
await remove(creditNoteData.id, n, true);
}
"
/>
<AdditionalFileExpansion
v-if="view !== CreditNoteStatus.Success"
:readonly="isRoleInclude(['sale', 'head_of_sale'])"
v-model:file-data="attachmentData"
:transform-url="
async (url: string) => {
if (!creditNoteData?.id) {
return url;
} else {
const result = await api.get<string>(url);
return result.data;
}
}
"
@fetch-file-list="
() => {
if (!creditNoteData) return;
getFileList(creditNoteData.id);
}
"
@upload="
async (f) => {
attachmentList = f;
if (!creditNoteData) {
attachmentData = [];
Array.from(f).forEach((el) => {
attachmentData.push({
name: el.name,
progress: 1,
loaded: 0,
total: el.size,
placeholder: true,
url: fileToUrl(el),
});
});
} else {
await uploadFile(creditNoteData.id, f);
}
}
"
@remove="
async (n) => {
if (!creditNoteData) {
const attIndex = attachmentData.findIndex(
(v) => v.name === n,
);
attachmentData.splice(attIndex, 1);
} else {
await remove(creditNoteData.id, n);
}
}
"
/>
<RemarkExpansion
v-if="view !== CreditNoteStatus.Success"
v-model:remark="formData.remark"
:default-remark="defaultRemark"
:items="[]"
:readonly="pageState.mode === 'info'"
></RemarkExpansion>
<QuotationFormReceipt
v-if="creditNoteData && view === CreditNoteStatus.Success"
hide-example-btn
:title="$t('creditNote.label.refund')"
:amount="creditNoteData.value"
:success-label="$t('creditNote.label.refundSuccess')"
:date="
creditNoteData.paybackDate
? new Date(creditNoteData.paybackDate)
: undefined
"
@view="openSlipDialog"
>
<template #payType>
<span v-if="creditNoteData.paybackType === 'BankTransfer'">
<q-img
:src="`/img/bank/${creditNoteData?.paybackBank}.png`"
class="bordered q-mr-xs"
style="border-radius: 50%; width: 20px"
/>
{{ useOptionStore().mapOption(creditNoteData?.paybackBank) }}
{{ creditNoteData?.paybackAccount }}
{{
`${$t('creditNote.label.accountName')} ${creditNoteData?.paybackAccountName}`
}}
</span>
<span v-else>{{ $t('creditNote.label.Cash') }}</span>
</template>
</QuotationFormReceipt>
</div>
</section>
</article>
<!-- SEC: footer -->
<footer class="surface-1 q-pa-md full-width">
<nav class="row justify-end" style="gap: var(--size-2)">
<!-- TODO: view example -->
<MainButton
class="q-mr-auto"
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
@click="storeDataLocal()"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<UndoButton v-if="pageState.mode === 'edit'" outlined @click="undo()" />
<CancelButton
v-if="pageState.mode !== 'edit'"
@click="closeTab()"
:label="$t('dialog.action.close')"
outlined
/>
<SaveButton
v-if="pageState.mode === 'edit'"
:disabled="taskListGroup.length === 0"
@click="(e) => refForm?.submit(e)"
solid
/>
<EditButton
v-if="
pageState.mode === 'info' &&
creditNoteData?.creditNoteStatus === CreditNoteStatus.Waiting
"
class="no-print"
@click="pageState.mode = 'edit'"
solid
/>
<SaveButton
v-if="
!creditNoteData ||
(creditNoteData?.creditNoteStatus === CreditNoteStatus.Waiting &&
canAccess('related', 'edit'))
"
:disabled="taskListGroup.length === 0 || pageState.mode === 'edit'"
type="submit"
@click.stop="(e) => refForm?.submit(e)"
:label="
$t(
`creditNote.label.${creditNoteData?.creditNoteStatus ? 'submit' : 'request'}`,
)
"
icon="mdi-account-multiple-check-outline"
solid
/>
</nav>
</footer>
</div>
<!-- SEC: Dialog -->
<SelectReadyRequestWork
v-if="quotationData"
creditNote
:task-list-group="taskListGroup"
:fetch-params="{ cancelOnly: true, quotationId: quotationData.id }"
v-model:open="pageState.productDialog"
v-model:task-list="formTaskList"
/>
<DialogViewFile
hide-tab
download
v-model="pageState.fileDialog"
:url="fileData[0]?.url"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
/>
</template>
<style scoped>
.color-bar {
width: 100%;
height: 1vh;
background: linear-gradient(
90deg,
rgb(47, 68, 173) 0%,
rgba(255, 255, 255, 1) 77%,
rgba(204, 204, 204, 1) 100%
);
display: flex;
overflow: hidden;
}
.color-bar.dark {
opacity: 0.7;
}
.indigo-segment {
background-color: var(--indigo-10);
flex-grow: 4;
}
.light-indigo-segment {
background-color: hsla(var(--indigo-10-hsl) / 0.2);
flex-grow: 0.5;
}
.white-segment {
background-color: #ffffff;
flex-grow: 1;
}
.indigo-segment,
.light-indigo-segment,
.white-segment {
transform: skewX(-60deg);
}
.banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url('/images/building-banner.png');
background-repeat: no-repeat;
background-size: cover;
z-index: -1;
&.dark {
filter: invert(100%);
}
}
</style>