jws-frontend/src/pages/12_debit-note/FormPage.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

1543 lines
44 KiB
Vue

<script setup lang="ts">
import { useQuasar } from 'quasar';
import { ref, onMounted, reactive, computed } from 'vue';
import { api } from 'src/boot/axios';
import { useRoute, useRouter } from 'vue-router';
import { dateFormatJS, calculateAge } from 'src/utils/datetime';
import { initLang, initTheme, Lang } from 'src/utils/ui';
import { useQuotationStore } from 'src/stores/quotations';
import { useQuotationForm } from 'src/pages/05_quotation/form';
import { useDebitNoteForm } from './form';
import { useReceipt, usePayment } from 'stores/payment';
import useProductServiceStore from 'stores/product-service';
import {
DebitNote,
DebitNoteStatus,
DebitNotePayload,
useDebitNote,
} from 'src/stores/debit-note';
import {
QuotationStatus,
PayCondition,
QuotationPayload,
ProductRelation,
ProductServiceList,
} from 'src/stores/quotations/types';
import { useConfigStore } from 'src/stores/config';
import DocumentExpansion from './expansion/DocumentExpansion.vue';
import RemarkExpansion from '../12_debit-note/expansion/RemarkExpansion.vue';
import AdditionalFileExpansion from '../09_task-order/expansion/AdditionalFileExpansion.vue';
import PaymentExpansion from './expansion/PaymentExpansion.vue';
import DebitNoteExpansion from './expansion/DebitNoteExpansion.vue';
import StateButton from 'src/components/button/StateButton.vue';
import ProductExpansion from '../12_debit-note/expansion/ProductExpansion.vue';
import SelectReadyRequestWork from '../09_task-order/SelectReadyRequestWork.vue';
import QuotationFormReceipt from '../05_quotation/QuotationFormReceipt.vue';
import DialogViewFile from 'src/components/dialog/DialogViewFile.vue';
import WorkerItemExpansion from './expansion/WorkerItemExpansion.vue';
import PaymentForm from '../05_quotation/PaymentForm.vue';
import {
MainButton,
SaveButton,
UndoButton,
EditButton,
CancelButton,
} from 'src/components/button';
import { RequestWork } from 'src/stores/request-list/types';
import { storeToRefs } from 'pinia';
import useOptionStore from 'src/stores/options';
import {
canAccess,
deleteItem,
dialog,
dialogWarningClose,
isRoleInclude,
} from 'src/stores/utils';
import { useI18n } from 'vue-i18n';
import { Employee } from 'src/stores/employee/types';
import QuotationFormWorkerSelect from '../05_quotation/QuotationFormWorkerSelect.vue';
import { watch } from 'vue';
import { ProductTree, quotationProductTree } from '../05_quotation/utils';
import TableRequest from '../05_quotation/TableRequest.vue';
import { RequestData, RequestDataStatus } from 'src/stores/request-list/types';
import { useRequestList } from 'src/stores/request-list';
import { Receipt } from 'src/stores/payment/types';
import {
Product,
ProductGroup,
Service,
} from 'src/stores/product-service/types';
import { precisionRound } from 'src/utils/arithmetic';
import QuotationFormProductSelect from '../05_quotation/QuotationFormProductSelect.vue';
import { getName } from 'src/services/keycloak';
const debitNoteForm = useDebitNoteForm();
const route = useRoute();
const router = useRouter();
const debitNote = useDebitNote();
const configStore = useConfigStore();
const quotationStore = useQuotationStore();
const optionStore = useOptionStore();
const quotationForm = useQuotationForm();
const useReceiptStore = useReceipt();
const productServiceStore = useProductServiceStore();
const $q = useQuasar();
const paymentStore = usePayment();
const { data: config } = storeToRefs(configStore);
const { newWorkerList } = storeToRefs(quotationForm);
const { currentFormData } = storeToRefs(debitNoteForm);
const { t, locale } = useI18n();
const requestStore = useRequestList();
type ProductGroupId = string;
type Node = {
[key: string]: any;
opened?: boolean;
checked?: boolean;
bg?: string;
fg?: string;
icon?: string;
children?: Node[];
};
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const tempTableProduct = ref<ProductServiceList[]>([]);
const productList = ref<Partial<Record<ProductGroupId, Product[]>>>({});
const serviceList = ref<Partial<Record<ProductGroupId, Service[]>>>({});
const selectedProductGroup = ref<string>('');
const selectedGroupSub = ref<'product' | 'service' | null>(null);
const productGroup = ref<ProductGroup[]>([]);
const agentPrice = ref(false);
const debitNoteData = ref<DebitNote>();
const quotationData = ref<DebitNote['debitNoteQuotation']>();
const view = ref<QuotationStatus>(QuotationStatus.Issued);
const fileList = ref<FileList>();
const receiptList = ref<Receipt[]>([]);
let previousValue: DebitNotePayload | undefined = undefined;
const rowsRequestList = ref<RequestData[]>([]);
const productServiceList = ref<
Required<QuotationPayload['productServiceList'][number]>[]
>([]);
const productServiceNodes = ref<ProductTree>([]);
const tempPaySplitCount = ref(0);
const tempPaySplit = ref<
{ no: number; amount: number; name?: string; invoice?: boolean }[]
>([]);
const formTaskList = ref<
{
requestWorkId: string;
requestWorkStep?: { requestWork: RequestWork };
}[]
>([]);
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 productService = computed(() => {
return selectedInstallmentNo.value.length > 0
? productServiceList.value.filter((v) => {
return (
v.installmentNo &&
selectedInstallmentNo.value.includes(v.installmentNo)
);
})
: productServiceList.value;
});
const summaryPrice = computed(() => getPrice(productServiceList.value));
const readonly = computed(
() =>
debitNoteData.value?.quotationStatus === QuotationStatus.PaymentSuccess ||
debitNoteData.value?.quotationStatus === QuotationStatus.ProcessComplete ||
pageState.mode === 'info',
);
const pageState = reactive({
mode: 'create' as 'create' | 'edit' | 'info',
employeeModal: false,
productDialog: false,
fileDialog: false,
productServiceModal: false,
inputSearchRequest: '',
statusFilterRequest: 'All' as 'All' | RequestDataStatus,
imageDialog: false,
imageDialogTitle: '',
imageDialogUrl: '',
});
const selectedWorker = ref<
(Employee & {
attachment?: {
name?: string;
group?: string;
url?: string;
file?: File;
_meta?: Record<string, any>;
}[];
})[]
>([]);
const selectedWorkerItem = ref([]);
const selectedInstallmentNo = ref<number[]>([]);
const installmentAmount = ref<number>(0);
const QUOTATION_STATUS = [
'Accepted',
'PaymentPending',
'PaymentSuccess',
'ProcessComplete',
];
function covertToNode() {
productServiceNodes.value = quotationProductTree(
productServiceList.value,
agentPrice.value,
config.value?.vat,
);
}
watch(
() => productServiceList.value,
() => {
covertToNode();
},
);
function toggleDeleteProduct(index: number) {
// product display
productServiceList.value.splice(index, 1);
productServiceList.value = [...productServiceList.value];
// find split count
tempPaySplitCount.value = Math.max(
...productServiceList.value.map((v) => v.installmentNo || 0),
);
currentFormData.value.paySplitCount = tempPaySplitCount.value;
// make split.length === split count
tempPaySplit.value.splice(currentFormData.value.paySplitCount);
}
async function getAllProduct(
groupId: string,
opts?: { force?: boolean; page?: number; pageSize?: number; query?: string },
) {
selectedGroupSub.value = 'product';
if (!opts?.force && productList.value[groupId] !== undefined) return;
const ret = await productServiceStore.fetchListProduct({
page: opts?.page ?? 1,
pageSize: opts?.pageSize ?? 9999,
query: opts?.query,
productGroupId: groupId,
activeOnly: true,
});
if (ret) productList.value[groupId] = ret.result;
}
async function getAllService(
groupId: string,
opts?: { force?: boolean; page?: number; pageSize?: number; query?: string },
) {
selectedGroupSub.value = 'service';
if (!opts?.force && serviceList.value[groupId] !== undefined) return;
const ret = await productServiceStore.fetchListService({
page: opts?.page ?? 1,
pageSize: opts?.pageSize ?? 9999,
productGroupId: groupId,
query: opts?.query,
fullDetail: true,
});
if (ret) serviceList.value[groupId] = ret.result;
}
async function assignToProductServiceList() {
const ret = await productServiceStore.fetchProductGroup({
page: 1,
pageSize: 9999,
});
if (ret) {
productGroup.value = ret.result;
}
}
function undo() {
if (!previousValue) return;
currentFormData.value = structuredClone(previousValue);
assignProductServiceList();
assignSelectedWorker();
pageState.mode = 'info';
}
function assignProductServiceList() {
if (!debitNoteData.value) return;
productServiceList.value = debitNoteData.value.productServiceList.map(
(v, i) => ({
id: v.id!,
installmentNo: v.installmentNo || 0,
workerIndex: v.worker
.map((a) =>
debitNoteData.value!.worker.findIndex(
(b) => b.employeeId === a.employeeId,
),
)
.filter(
(index): index is number => index !== -1 && index !== undefined,
),
vat: v.vat || 0,
pricePerUnit: v.pricePerUnit || 0,
discount: v.discount || 0,
amount: v.amount || 0,
product: v.product,
work: v.work || null,
service: v.service || null,
}),
);
}
function assignSelectedWorker() {
if (debitNoteData.value)
selectedWorkerItem.value = debitNoteData.value.worker.map((e) => {
return {
id: e.employee.id,
foreignRefNo: e.employee.employeePassport
? e.employee.employeePassport[0]?.number || '-'
: '-',
employeeName:
locale.value === Lang.English
? `${e.employee.firstNameEN} ${e.employee.lastNameEN}`
: `${e.employee.firstName || e.employee.firstNameEN} ${e.employee.lastName || e.employee.lastNameEN}`,
birthDate: dateFormatJS({ date: e.employee.dateOfBirth }),
gender: e.employee.gender,
age: calculateAge(e.employee.dateOfBirth),
nationality: optionStore.mapOption(e.employee.nationality),
documentExpireDate:
e.employee.employeePassport !== undefined &&
e.employee.employeePassport[0]?.expireDate !== undefined
? dateFormatJS({ date: e.employee.employeePassport[0]?.expireDate })
: '-',
imgUrl: e.employee.selectedImage
? `${API_BASE_URL}/employee/${e.id}/image/${e.employee.selectedImage}`
: '',
status: e.employee.status,
workerNew: false,
lastNameEN: e.employee.lastNameEN,
lastName: e.employee.lastName,
middleNameEN: e.employee.middleNameEN,
middleName: e.employee.middleName,
firstNameEN: e.employee.firstNameEN,
firstName: e.employee.firstName,
namePrefix: e.employee.namePrefix,
};
});
}
async function assignFormData(id: string) {
const data = await debitNote.getDebitNote(id);
if (!data) return;
debitNoteData.value = data;
selectedProductGroup.value =
data.productServiceList[0]?.product.productGroup?.id || '';
(previousValue = {
id: data.id || undefined,
debitNoteQuotationId: data.debitNoteQuotationId || undefined,
productServiceList: structuredClone(
data.productServiceList.map((v) => ({
workerIndex: v.worker.map((a) =>
data.worker.findIndex((b) => b.employeeId === a.employeeId),
),
installmentNo: v.installmentNo,
discount: v.discount,
amount: v.amount,
workId: v.workId ?? '',
serviceId: v.serviceId ?? '',
productId: v.productId,
})),
),
worker: data.worker.map((v) => v.employee),
payBillDate: new Date(data.payBillDate),
paySplitCount: data.paySplitCount,
payCondition: data.payCondition,
dueDate: new Date(data.dueDate),
discount: data.discount,
agentPrice: data.agentPrice,
quotationId: data.debitNoteQuotationId,
remark: data.remark || undefined,
}),
(currentFormData.value = structuredClone(previousValue));
assignProductServiceList();
assignSelectedWorker();
await getQuotation(data.debitNoteQuotation?.id);
await assignToProductServiceList();
await fetchRequest();
await fetchReceipt();
await initStatus();
}
async function getQuotation(id?: string) {
const quotationId =
typeof route.query['quotationId'] === 'string'
? route.query['quotationId']
: id;
if (!!quotationId) {
const data = await quotationStore.getQuotation(quotationId);
if (!!data) {
quotationData.value = data;
agentPrice.value = quotationData.value.agentPrice;
}
}
}
function getStatus(
status: typeof currentFormData.value.quotationStatus,
doneIndex: number,
doingIndex: number,
) {
return QUOTATION_STATUS.findIndex((v) => v === status) >= doneIndex
? 'done'
: QUOTATION_STATUS.findIndex((v) => v === status) >= doingIndex
? 'doing'
: 'waiting';
}
async function initStatus() {
if (debitNoteData.value?.quotationStatus === QuotationStatus.Issued)
view.value = QuotationStatus.Issued;
//TODO แก้สถานะแสดงแยกต่างหาก
statusTabForm.value = [
{
title: 'title',
status: debitNoteData.value?.id !== undefined ? 'done' : 'doing',
active: () => view.value === QuotationStatus.Issued,
handler: () => {
pageState.mode = 'info';
view.value = QuotationStatus.Issued;
},
},
{
title: 'accepted',
status:
debitNoteData.value?.id !== undefined
? getStatus(debitNoteData.value.quotationStatus, 1, -1)
: 'waiting',
active: () => view.value === QuotationStatus.Accepted,
handler: () => {
pageState.mode = 'info';
view.value = QuotationStatus.Accepted;
},
},
{
title: 'payment',
status:
debitNoteData.value?.id !== undefined
? getStatus(debitNoteData.value.quotationStatus, 2, 1)
: 'waiting',
active: () => view.value === QuotationStatus.PaymentPending,
handler: async () => {
view.value = QuotationStatus.PaymentPending;
},
},
{
title: 'receipt',
status: getStatus(debitNoteData.value?.quotationStatus, 2, 2),
active: () => view.value === QuotationStatus.PaymentSuccess,
handler: () => {
pageState.mode = 'info';
view.value = QuotationStatus.PaymentSuccess;
},
},
{
title: 'processComplete',
status: getStatus(debitNoteData.value?.quotationStatus, 3, 2),
active: () => view.value === QuotationStatus.ProcessComplete,
handler: () => {
pageState.mode = 'info';
view.value = QuotationStatus.ProcessComplete;
},
},
];
}
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');
}
function getPrice(
list: typeof productServiceList.value,
filterHook?: (
item: (typeof productServiceList.value)[number],
index?: number,
) => boolean,
) {
if (filterHook) list = list.filter(filterHook);
return list.reduce(
(a, c) => {
if (
selectedInstallmentNo.value.length > 0 &&
c.installmentNo &&
!selectedInstallmentNo.value.includes(c.installmentNo)
) {
return a;
}
const calcVat =
c.product[agentPrice.value ? 'agentPriceCalcVat' : 'calcVat'];
const vatFactor = calcVat ? (config.value?.vat ?? 0.07) : 0;
const pricePerUnit = precisionRound(
(c.pricePerUnit * (1 + vatFactor)) / (1 + vatFactor),
);
const price = precisionRound(
(pricePerUnit * c.amount * (1 + vatFactor) - c.discount) /
(1 + vatFactor),
);
const vat = price * vatFactor;
a.totalPrice = precisionRound(a.totalPrice + price + c.discount);
a.totalDiscount = precisionRound(a.totalDiscount + c.discount);
a.vat = precisionRound(a.vat + vat);
a.vatExcluded = c.product.calcVat
? a.vatExcluded
: precisionRound(a.vatExcluded + price);
a.finalPrice = precisionRound(a.totalPrice - a.totalDiscount + a.vat);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatIncluded: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
}
async function fetchRequest() {
const res = await requestStore.getRequestDataList({
query: pageState.inputSearchRequest,
page: 1,
pageSize: 999999,
requestDataStatus:
pageState.statusFilterRequest === 'All'
? undefined
: pageState.statusFilterRequest,
quotationId: currentFormData.value.id,
});
if (res) {
rowsRequestList.value = res.result;
}
}
async function fetchReceipt() {
const res = await useReceiptStore.getReceiptList({
debitNoteId: debitNoteData.value?.id || '',
debitNoteOnly: true,
quotationOnly: false,
});
if (res) {
receiptList.value = res.result;
}
}
function viewProductFile(data: ProductRelation) {
const base64 = data.detail.match(/src="([^"]+)"/);
pageState.imageDialog = true;
pageState.imageDialogTitle = data.name;
pageState.imageDialogUrl = base64 ? base64[1] : '';
}
function convertToTable(nodes: Node[]) {
const _recursive = (v: Node): Node | Node[] => {
if (v.checked && v.children) return v.children.flatMap(_recursive);
if (v.checked) return v;
return [];
};
const list = nodes.flatMap(_recursive).map((v) => v.value);
currentFormData.value.paySplitCount = Math.max(
...list.map((v) => v.installmentNo || 0),
);
tempPaySplitCount.value = currentFormData.value.paySplitCount;
list.forEach((v) => {
v.amount = Math.max(selectedWorkerItem.value.length, 1);
if (!v.workerIndex) v.workerIndex = [];
for (let i = 0; i < selectedWorkerItem.value.length; i++) {
v.workerIndex.push(i);
}
if (!v.installmentNo) {
v.installmentNo = currentFormData.value.paySplitCount;
}
});
productServiceList.value = list;
tempTableProduct.value = JSON.parse(JSON.stringify(list));
currentFormData.value.paySplitCount = 0;
currentFormData.value.payCondition = PayCondition.Full;
tempPaySplitCount.value = currentFormData.value.paySplitCount;
pageState.productServiceModal = false;
}
async function getFileList(debitNoteId: string, slip?: boolean) {
const list = slip
? await debitNote.listFile({
group: 'slip',
parentId: debitNoteId,
})
: await debitNote.listAttachment({
parentId: debitNoteId,
});
if (list)
slip
? (fileData.value = await Promise.all(
list.map(async (v) => {
const rse = await debitNote.headFile({
group: 'slip',
parentId: debitNoteId,
fileId: v,
});
let contentLength = 0;
if (rse) contentLength = Number(rse['content-length']);
return {
name: v,
progress: 1,
loaded: contentLength,
total: contentLength,
url: `/debit-note/${debitNoteId}/file-slip/${v}`,
};
}),
))
: (attachmentData.value = await Promise.all(
list.map(async (v) => {
const rse = await debitNote.headAttachment({
parentId: debitNoteId,
fileId: v,
});
let contentLength = 0;
if (rse) contentLength = Number(rse['content-length']);
return {
name: v,
progress: 1,
loaded: contentLength,
total: contentLength,
url: `/debit-note/${debitNoteId}/attachment/${v}`,
};
}),
));
}
async function uploadFile(debitNoteId: string, list: FileList, slip?: boolean) {
const promises: ReturnType<
typeof debitNote.putAttachment | typeof debitNote.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: `/debit-note/${debitNoteId}/${slip ? 'file-slip' : 'attachment'}/${list[i].name}`,
};
promises.push(
slip
? debitNote.putFile({
group: 'slip',
parentId: debitNoteId,
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;
},
})
: debitNote.putAttachment({
parentId: debitNoteId,
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(debitNoteId: string, n: string, slip?: boolean) {
dialogWarningClose(t, {
message: t('dialog.message.confirmDelete'),
actionText: t('dialog.action.ok'),
action: async () => {
const res = slip
? await debitNote.delFile({
group: 'slip',
parentId: debitNoteId,
fileId: n,
})
: await debitNote.delAttachment({
parentId: debitNoteId,
name: n,
});
if (res) {
getFileList(debitNoteId, slip);
}
},
cancel: () => {},
});
}
async function submit() {
const payload: DebitNotePayload = {
id: currentFormData.value.id || undefined,
productServiceList: productServiceList.value.map((v) => ({
installmentNo: v.installmentNo,
workerIndex: v.workerIndex,
discount: v.discount,
amount: v.amount,
productId: v.product.id,
workId: v.work?.id || '',
serviceId: v.service?.id || '',
})),
worker: JSON.parse(
JSON.stringify([
...selectedWorkerItem.value.map((v) => {
{
return v.id;
}
}),
]),
),
dueDate: currentFormData.value.dueDate,
payCondition: currentFormData.value.payCondition,
discount: currentFormData.value.discount,
remark: currentFormData.value.remark || undefined,
agentPrice: agentPrice.value,
quotationId: quotationData.value?.id || '',
};
const res =
pageState.mode === 'edit'
? await debitNote.updateDebitNote(payload)
: await debitNote.createDebitNote(payload);
if (res) {
newWorkerList.value = [];
await router.push(`/debit-note/${res.id}/?mode=info`);
if (attachmentList.value && pageState.mode === 'create') {
await uploadFile(res.id, attachmentList.value, false);
}
pageState.mode = 'info';
assignFormData(res.id);
initStatus();
}
}
function toggleMode(mode: 'create' | 'edit' | 'info') {
const currentMode = mode || route.query.mode;
const newMode = currentMode; // เปลี่ยนค่า 'edit' เป็น 'view' และกลับกัน
router.push({
path: route.path,
query: { ...route.query, mode: newMode },
});
}
async function exampleReceipt(id: string) {
$q.loading.show();
const url = await paymentStore.getFlowAccount(id);
if (url) {
$q.loading.hide();
window.open(url.data.link, '_blank');
}
}
function storeDataLocal() {
// quotationFormData.value.productServiceList = productServiceList.value;
//
localStorage.setItem(
'debit-note-preview',
JSON.stringify({
data: {
...currentFormData.value,
customerBranchId: quotationData.value?.customerBranchId,
registeredBranchId: quotationData.value?.registeredBranchId,
agentPrice: quotationData.value.agentPrice,
},
meta: {
source: {
code: pageState.mode === 'create' ? '-' : debitNoteData.value?.code,
createAt:
pageState.mode === 'create'
? Date.now()
: debitNoteData.value?.createdAt,
createBy: debitNoteData.value?.createdBy,
payCondition: currentFormData.value.payCondition,
contactName: quotationData.value?.contactName,
contactTel: quotationData.value?.contactTel,
workName: quotationData.value?.workName,
dueDate: currentFormData.value.dueDate,
},
productServicelist: productService.value,
selectedWorker: selectedWorkerItem.value,
createdBy: getName(),
},
}),
);
const url = new URL('/debit-note/document-view', window.location.origin);
window.open(url, '_blank');
}
async function closeTab() {
dialogWarningClose(t, {
message: t('dialog.message.close'),
action: () => {
window.close();
},
cancel: () => {},
});
}
function closeAble() {
return window.opener !== null;
}
function combineWorker(newWorker: any, oldWorker: any) {
selectedWorkerItem.value = [
...oldWorker.map((e) => ({
id: e.id,
foreignRefNo: e.employeePassport
? e.employeePassport[0]?.number || '-'
: '-',
employeeName:
locale.value === Lang.English
? `${e.firstNameEN} ${e.lastNameEN}`
: `${e.firstName || e.firstNameEN} ${e.lastName || e.lastNameEN}`,
birthDate: dateFormatJS({ date: e.dateOfBirth }),
gender: e.gender,
age: calculateAge(e.dateOfBirth),
nationality: optionStore.mapOption(e.nationality),
documentExpireDate:
e.employeePassport !== undefined &&
e.employeePassport[0]?.expireDate !== undefined
? dateFormatJS({ date: e.employeePassport[0]?.expireDate })
: '-',
imgUrl: e.selectedImage
? `${API_BASE_URL}/employee/${e.id}/image/${e.selectedImage}`
: '',
status: e.status,
workerNew: false,
lastNameEN: e.lastNameEN,
lastName: e.lastName,
middleNameEN: e.middleNameEN,
middleName: e.middleName,
firstNameEN: e.firstNameEN,
firstName: e.firstName,
namePrefix: e.namePrefix,
})),
...newWorker.map((v: any) => ({
id: v.id,
foreignRefNo: v.passportNo || '-',
employeeName:
locale.value === Lang.English
? `${v.firstNameEN} ${v.lastNameEN}`
: `${v.firstName || v.firstNameEN} ${v.lastName || v.lastNameEN}`,
birthDate: dateFormatJS({ date: v.dateOfBirth }),
gender: v.gender,
age: calculateAge(v.dateOfBirth),
nationality: optionStore.mapOption(v.nationality),
documentExpireDate: '-',
imgUrl: '',
status: 'CREATED',
lastNameEN: v.lastNameEN,
lastName: v.lastName,
middleNameEN: v.middleNameEN,
middleName: v.middleName,
firstNameEN: v.firstNameEN,
firstName: v.firstName,
namePrefix: v.namePrefix,
dateOfBirth: v.dateOfBirth,
workerNew: true,
})),
];
}
watch(
() => pageState.mode,
() => toggleMode(pageState.mode),
);
watch(
[() => pageState.inputSearchRequest, () => pageState.statusFilterRequest],
() => {
fetchRequest();
},
);
onMounted(async () => {
initTheme();
initLang();
initStatus();
if (!!route.params['id'] && typeof route.params['id'] === 'string') {
await assignFormData(route.params['id']);
}
if (typeof route.query['quotationId'] === 'string') {
pageState.mode = 'create';
await getQuotation(route.query['quotationId']);
}
if (typeof route.query['mode'] === 'string') {
pageState.mode =
(route.query['mode'] as 'create' | 'edit' | 'info') || 'info';
}
if (typeof route.query['tab'] === 'string') {
view.value =
{
payment: QuotationStatus.PaymentPending,
receipt: QuotationStatus.PaymentSuccess,
}[route.query['tab']] || QuotationStatus.Issued;
}
if (
pageState.mode === 'edit' &&
debitNoteData.value?.quotationStatus !== QuotationStatus.Issued
) {
pageState.mode = 'info';
}
await useConfigStore().getConfig();
});
async function submitAccepted() {
dialog({
color: 'info',
icon: 'mdi-account-check',
title: t('dialog.title.confirmDebitNoteAccept'),
actionText: t('general.confirm'),
persistent: true,
message: t('dialog.message.quotationAccept'),
action: async () => {
if (!currentFormData.value.id) return;
const res = await debitNote.action.acceptDebitNote(
currentFormData.value.id,
);
if (res && typeof route.params['id'] === 'string') {
await assignFormData(route.params['id']);
}
},
cancel: () => {},
});
}
</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="/debit-note">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('debitNote.title') }}
<!-- {{ code || '' }} -->
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg: dateFormatJS({
date: debitNoteData?.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(
`debitNote${i.title === 'title' ? '' : '.viewMode'}.${i.title}`,
)
"
:status-active="i.active?.()"
:status-done="i.status === 'done'"
:status-waiting="i.status === 'waiting'"
@click="
() => {
if (pageState.mode !== 'create') i.handler();
}
"
/>
</nav>
<!-- #TODO add goToQuotation as @goto-quotation-->
<DocumentExpansion
v-if="
view === QuotationStatus.Issued ||
view === QuotationStatus.Accepted
"
: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 || '-'"
v-model:due-date="currentFormData.dueDate"
@goto-quotation="() => goToQuotation()"
/>
<DebitNoteExpansion
v-if="false"
:readonly
v-model:reason="currentFormData.reason"
v-model:detail="currentFormData.detail"
/>
<PaymentForm
v-if="view === QuotationStatus.PaymentPending"
is-debit-note
:branch-id="quotationData?.registeredBranchId"
:readonly="isRoleInclude(['sale', 'head_of_sale'])"
:data="debitNoteData"
@fetch-status="
() => {
if (!!debitNoteData) assignFormData(debitNoteData?.id);
}
"
/>
<WorkerItemExpansion
v-if="
view === QuotationStatus.Issued ||
view === QuotationStatus.Accepted
"
:readonly
:hide-btn-add-worker="
!readonly &&
(!currentFormData || currentFormData.quotationStatus === 'Issued')
"
:row-worker="selectedWorkerItem"
@add-worker="() => (pageState.employeeModal = true)"
@delete="(i) => deleteItem(selectedWorkerItem, i)"
/>
<!-- #TODO add openProductDialog at @add-product-->
<ProductExpansion
v-if="
view === QuotationStatus.Issued ||
view === QuotationStatus.Accepted
"
:readonly
:installment-input="currentFormData.payCondition === 'SplitCustom'"
:max-installment="currentFormData.paySplitCount"
:agent-price="agentPrice"
:employee-rows="selectedWorkerItem"
:rows="productService"
@add-product="
() => {
pageState.productServiceModal = true;
covertToNode();
}
"
@update-rows="
(v) => {
view === null && (productServiceList = v);
}
"
@delete="toggleDeleteProduct"
@view-file="viewProductFile"
/>
<PaymentExpansion
v-if="
view === QuotationStatus.Issued ||
view === QuotationStatus.Accepted
"
:total-price="summaryPrice.finalPrice"
:pay-split="[]"
class="q-mb-md"
v-model:pay-type="currentFormData.payCondition"
v-model:pay-split-count="currentFormData.paySplitCount"
v-model:final-discount="currentFormData.discount"
v-model:pay-bill-date="currentFormData.payBillDate"
v-model:summary-price="summaryPrice"
/>
<!-- TODO: bind additional file -->
<AdditionalFileExpansion
v-if="
view === QuotationStatus.Issued ||
view === QuotationStatus.Accepted ||
view === QuotationStatus.PaymentPending
"
:readonly="isRoleInclude(['sale', 'head_of_sale'])"
v-model:file-data="attachmentData"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
@fetch-file-list="
() => {
if (!debitNoteData) return;
getFileList(debitNoteData.id);
}
"
@upload="
async (f) => {
attachmentList = f;
attachmentData = [];
Array.from(f).forEach(({ name }) => {
attachmentData.push({
name: name,
progress: 1,
loaded: 0,
total: 0,
placeholder: true,
});
});
if (!debitNoteData) return;
await uploadFile(debitNoteData.id, f);
}
"
@remove="
async (n) => {
if (!debitNoteData) return;
await remove(debitNoteData.id, n);
}
"
/>
<RemarkExpansion
v-if="
view === QuotationStatus.Issued ||
view === QuotationStatus.Accepted ||
view === QuotationStatus.PaymentPending
"
:readonly="readonly"
:final-price="summaryPrice.finalPrice"
:selected-worker
v-model:remark="currentFormData.remark"
/>
<main
v-if="view === QuotationStatus.ProcessComplete"
class="surface-1 q-pa-md full-width q-gutter-y-md rounded"
>
<div class="row justify-between items-center">
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="col-12 col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearchRequest"
debounce="200"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
<q-select
v-model="pageState.statusFilterRequest"
outlined
dense
option-value="value"
option-label="label"
map-options
emit-value
:for="'field-select-status'"
:hide-dropdown-icon="$q.screen.lt.sm"
:options="[
{
label: $t('general.all'),
value: 'All',
},
{
label: $t('requestList.status.Pending'),
value: RequestDataStatus.Pending,
},
{
label: $t('requestList.status.InProgress'),
value: RequestDataStatus.InProgress,
},
{
label: $t('requestList.status.Completed'),
value: RequestDataStatus.Completed,
},
]"
></q-select>
</div>
<TableRequest :rows="rowsRequestList" />
</main>
<template v-if="view === QuotationStatus.PaymentSuccess">
<QuotationFormReceipt
hide-view-btn
v-for="(v, i) in receiptList"
:key="i"
:amount="v.amount"
:date="v.date"
:pay-type="quotationData?.payCondition"
:index="i"
:pay-split-count="quotationData?.paySplitCount || 0"
@view="
() => {
if (quotationData?.payCondition !== 'Full') {
selectedInstallmentNo = [v.invoice.installments[0].no];
installmentAmount = v.invoice.amount;
}
view = QuotationStatus.Issued;
}
"
@example="() => exampleReceipt(v.id)"
/>
</template>
</div>
</section>
</article>
<!-- SEC: footer -->
<footer class="surface-1 q-pa-md full-width">
<nav class="row justify-end">
<!-- 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>
<CancelButton
outlined
id="btn-close"
@click="closeTab()"
:label="$t('dialog.action.close')"
v-if="
(pageState.mode === 'info' || pageState.mode === 'create') &&
closeAble()
"
/>
<div class="row q-gutter-x-sm q-ml-xs">
<UndoButton
outlined
v-if="pageState.mode === 'edit'"
@click="undo()"
/>
<SaveButton
v-if="pageState.mode !== 'info'"
:disabled="
selectedWorkerItem.length === 0 && productService.length === 0
"
@click="submit"
solid
/>
<template v-if="debitNoteData">
<EditButton
v-if="
pageState.mode === 'info' &&
view === QuotationStatus.Issued &&
debitNoteData.quotationStatus === QuotationStatus.Issued
"
class="no-print"
solid
@click="pageState.mode = 'edit'"
/>
<MainButton
v-if="
view === QuotationStatus.Accepted &&
debitNoteData.quotationStatus === QuotationStatus.Issued
"
solid
icon="mdi-account-multiple-check-outline"
color="207 96% 32%"
id="btn-submit-accepted"
@click="
() => {
submitAccepted();
}
"
>
{{ $t('quotation.customerAcceptance') }}
</MainButton>
</template>
</div>
</nav>
</footer>
</div>
<!-- SEC: Dialog -->
<!-- add employee quotation -->
<QuotationFormWorkerSelect
:preselect-worker="selectedWorkerItem"
:customerBranchId="quotationData?.customerBranchId"
v-model:open="pageState.employeeModal"
@success="
(v) => {
combineWorker(v.newWorker, v.worker);
}
"
/>
<!-- add product service -->
<QuotationFormProductSelect
v-model="pageState.productServiceModal"
v-model:nodes="productServiceNodes"
v-model:product-group="productGroup"
v-model:product-list="productList"
v-model:service-list="serviceList"
v-model:selected-product-group="selectedProductGroup"
:agent-price="agentPrice"
@submit="convertToTable"
@select-group="
async (id) => {
await getAllService(id);
await getAllProduct(id);
}
"
@search="
(id, text, mode) => {
if (mode === 'service') {
getAllService(id, { force: true, query: text, pageSize: 50 });
}
if (mode === 'product') {
getAllProduct(id, { force: true, query: text, pageSize: 50 });
}
}
"
></QuotationFormProductSelect>
<SelectReadyRequestWork
v-if="quotationData"
creditNote
: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(--cyan-7);
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>