jws-frontend/src/pages/05_quotation/QuotationForm.vue
Methapon2001 e0d24b49cb fix: price not match with original price by .01
This will calculate quantity later when no discount is set.
However when discount is set there may be some number that can introduce
+-0.01
2024-12-16 16:56:06 +07:00

2842 lines
88 KiB
Vue

<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useQuasar } from 'quasar';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import {
dialogCheckData,
dialogWarningClose,
formatNumberDecimal,
} from 'stores/utils';
import { ProductTree, quotationProductTree } from './utils';
// NOTE: Import stores
import { dateFormat, calculateAge } from 'src/utils/datetime';
import { useEmployeeForm } from 'src/pages/03_customer-management/form';
import { useQuotationStore } from 'src/stores/quotations';
import useProductServiceStore from 'stores/product-service';
import {
baseUrl,
waitAll,
calculateDaysUntilExpire,
dialog,
} from 'src/stores/utils';
import useEmployeeStore from 'stores/employee';
import { useInvoice, useReceipt } from 'stores/payment';
import useCustomerStore from 'stores/customer';
import useOptionStore from 'stores/options';
import { useQuotationForm } from './form';
import useOcrStore from 'stores/ocr';
import { deleteItem } from 'stores/utils';
import { runOcr, parseResultMRZ } from 'src/utils/ocr';
// NOTE Import Types
import { View } from './types.ts';
import {
PayCondition,
PaySplit,
ProductServiceList,
QuotationPayload,
} from 'src/stores/quotations/types';
import { EmployeeWorker } from 'src/stores/quotations/types';
import { Employee } from 'src/stores/employee/types';
import { Receipt } from 'src/stores/payment/types';
import {
ProductGroup,
Product,
Service,
} from 'src/stores/product-service/types';
// NOTE: Import Components
import SelectInput from 'components/shared/SelectInput.vue';
import SwitchItem from 'components/shared/SwitchItem.vue';
import FormEmployeePassport from 'components/03_customer-management/FormEmployeePassport.vue';
import FormEmployeeVisa from 'components/03_customer-management/FormEmployeeVisa.vue';
import FormReferDocument from 'src/components/05_quotation/FormReferDocument.vue';
import { UploadFileGroup, NoticeJobEmployment } from 'components/upload-file';
import FormPerson from 'components/02_personnel-management/FormPerson.vue';
import ProductItem from 'components/05_quotation/ProductItem.vue';
import WorkerItem from 'components/05_quotation/WorkerItem.vue';
import ToggleButton from 'components/button/ToggleButton.vue';
import FormAbout from 'components/05_quotation/FormAbout.vue';
import SelectZone from 'components/shared/SelectZone.vue';
import PersonCard from 'components/shared/PersonCard.vue';
import ImportWorker from './ImportWorker.vue';
import {
AddButton,
SaveButton,
EditButton,
UndoButton,
DeleteButton,
CloseButton,
MainButton,
} from 'components/button';
import QuotationFormReceipt from './QuotationFormReceipt.vue';
import QuotationFormProductSelect from './QuotationFormProductSelect.vue';
import QuotationFormInfo from './QuotationFormInfo.vue';
import ProfileBanner from 'components/ProfileBanner.vue';
import DialogForm from 'components/DialogForm.vue';
import {
uploadFileListEmployee,
columnsAttachment,
} from 'src/pages/03_customer-management/constant';
import UploadFileSection from 'src/components/upload-file/UploadFileSection.vue';
import { columnPaySplit } from './constants';
import { precisionRound } from 'src/utils/arithmetic';
import { useConfigStore } from 'src/stores/config';
import QuotationFormMetadata from './QuotationFormMetadata.vue';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import PaymentForm from './PaymentForm.vue';
import { api } from 'src/boot/axios';
import { RouterLink, useRoute } from 'vue-router';
import { initLang, initTheme } from 'src/utils/ui';
import { convertTemplate } from 'src/utils/string-template';
type Node = {
[key: string]: any;
opened?: boolean;
checked?: boolean;
bg?: string;
fg?: string;
icon?: string;
children?: Node[];
};
type ProductGroupId = string;
const employeeStore = useEmployeeStore();
const route = useRoute();
const useReceiptStore = useReceipt();
const configStore = useConfigStore();
const productServiceStore = useProductServiceStore();
const employeeFormStore = useEmployeeForm();
const customerStore = useCustomerStore();
const quotationForm = useQuotationForm();
const quotationStore = useQuotationStore();
const invoiceStore = useInvoice();
const optionStore = useOptionStore();
const { t, locale } = useI18n();
const ocrStore = useOcrStore();
const $q = useQuasar();
const {
currentFormData: quotationFormData,
currentFormState: quotationFormState,
invoicePayload: invoiceFormData,
newWorkerList,
fileItemNewWorker,
quotationFull,
} = storeToRefs(quotationForm);
const { data: config } = storeToRefs(configStore);
const receiptList = ref<Receipt[]>([]);
const templateForm = ref<string>('');
const templateFormOption = ref<{ label: string; value: string }[]>([]);
const refSelectZoneEmployee = ref<InstanceType<typeof SelectZone>>();
const mrz = ref<Awaited<ReturnType<typeof parseResultMRZ>>>();
const toggleWorker = ref(true);
const tempTableProduct = ref<ProductServiceList[]>([]);
const tempPaySplitCount = ref(0);
const tempPaySplit = ref<
{ no: number; amount: number; name?: string; invoice?: boolean }[]
>([]);
const currentQuotationId = ref<string | undefined>(undefined);
const date = ref();
const preSelectedWorker = ref<Employee[]>([]);
const readonly = computed(() => {
return !(
quotationFormState.value.mode === 'create' ||
quotationFormState.value.mode === 'edit'
);
});
const selectedWorker = ref<
(Employee & {
attachment?: {
name?: string;
group?: string;
url?: string;
file?: File;
_meta?: Record<string, any>;
}[];
})[]
>([]);
const workerList = ref<Employee[]>([]);
const selectedProductGroup = ref('');
const selectedInstallmentNo = ref<number[]>([]);
const installmentAmount = ref<number>(0);
const selectedInstallment = ref();
const agentPrice = ref(false);
const attachmentData = ref<
{
name: string;
progress: number;
loaded: number;
total: number;
url?: string;
}[]
>([]);
const getToolbarConfig = computed(() => {
const toolbar = [['left', 'center', 'justify'], ['toggle'], ['clip']];
if (readonly.value) {
return toolbar.filter((item) => !item.includes('toggle'));
}
return toolbar;
});
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 price = precisionRound(c.pricePerUnit * c.amount);
const vat =
precisionRound(
(c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) *
(config.value?.vat || 0.07),
) * (!c.discount ? c.amount : 1);
a.totalPrice = precisionRound(a.totalPrice + price);
a.totalDiscount = precisionRound(a.totalDiscount + Number(c.discount));
a.vat = c.product.calcVat ? precisionRound(a.vat + vat) : a.vat;
a.vatExcluded = c.product.calcVat
? a.vatExcluded
: precisionRound(a.vat + vat);
a.finalPrice = precisionRound(
a.totalPrice -
a.totalDiscount +
a.vat -
Number(quotationFormData.value.discount || 0),
);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
}
const summaryPrice = computed(() => getPrice(productServiceList.value));
const payBank = ref('');
const pageState = reactive({
hideStat: false,
inputSearch: '',
statusFilter: 'all',
fieldSelected: [],
gridView: false,
isLoaded: false,
importWorker: false,
currentTab: 'all',
addModal: false,
quotationModal: false,
employeeModal: false,
productServiceModal: false,
remarkWrite: true,
});
const productList = ref<Partial<Record<ProductGroupId, Product[]>>>({});
const serviceList = ref<Partial<Record<ProductGroupId, Service[]>>>({});
const productGroup = ref<ProductGroup[]>([]);
const { state: employeeFormState, currentFromDataEmployee } =
storeToRefs(employeeFormStore);
const selectedGroupSub = ref<'product' | 'service' | null>(null);
const formDataEmployee = ref<
EmployeeWorker & {
attachment?: {
name?: string;
group?: string;
url?: string;
file?: File;
_meta?: Record<string, any>;
}[];
}
>({
passportNo: '',
documentExpireDate: new Date(),
lastNameEN: '',
lastName: '',
middleNameEN: '',
middleName: '',
firstNameEN: '',
firstName: '',
namePrefix: '',
nationality: '',
gender: '',
dateOfBirth: new Date(),
attachment: [],
});
const productServiceList = ref<
Required<QuotationPayload['productServiceList'][number]>[]
>([]);
const productService = computed(() => {
return selectedInstallmentNo.value.length > 0
? productServiceList.value.filter((v) => {
return (
v.installmentNo &&
selectedInstallmentNo.value.includes(v.installmentNo)
);
})
: productServiceList.value;
});
function isIssueInvoice() {
return quotationFormData.value.paySplit.some(
(value) =>
selectedInstallmentNo.value.includes(value.no) &&
value.invoiceId !== undefined,
);
}
async function fetchStatus() {
statusQuotationForm.value = [
{
title: 'Issued',
status: getStatus(quotationFormData.value.quotationStatus, 0, -1),
active: () => view.value === View.Quotation,
handler: () => (
(view.value = View.Quotation),
(code.value = ''),
(selectedInstallmentNo.value = []),
(selectedInstallment.value = [])
),
},
{
title: 'Accepted',
status: getStatus(quotationFormData.value.quotationStatus, 1, 0),
active: () => view.value === View.Accepted,
handler: () => ((view.value = View.Accepted), (code.value = '')),
},
{
title: 'Invoice',
status:
quotationFormData.value.payCondition === 'Full'
? getStatus(quotationFormData.value.quotationStatus, 3, 1)
: getStatus(quotationFormData.value.quotationStatus, 4, 1),
active: () =>
view.value === View.Invoice || view.value === View.InvoicePre,
handler: () => {
selectedInstallmentNo.value = [];
selectedInstallment.value = [];
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Invoice
: View.InvoicePre;
if (
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
) {
getInvoiceCodeFullPay();
}
},
},
{
title: 'PaymentInProcess',
status: getStatus(quotationFormData.value.quotationStatus, 4, 1),
active: () =>
view.value === View.Payment || view.value === View.PaymentPre,
handler: () => {
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Payment
: View.PaymentPre;
},
},
{
title: 'Receipt',
status: getStatus(quotationFormData.value.quotationStatus, 4, 1),
active: () => view.value === View.Receipt,
handler: () => {
fetchReceipt();
view.value = View.Receipt;
code.value = '';
},
},
{
title: 'ProcessComplete',
status: getStatus(quotationFormData.value.quotationStatus, 5, 4),
active: () => view.value === View.Complete,
handler: () => {
view.value = View.Complete;
},
},
];
}
async function fetchQuotation() {
if (
(!!currentQuotationId.value || !!quotationFormData.value.id) &&
quotationFormState.value.mode &&
quotationFormState.value.mode !== 'create'
) {
const id = currentQuotationId.value || quotationFormData.value.id || '';
await quotationForm.assignFormData(id, quotationFormState.value.mode);
tempPaySplitCount.value = quotationFormData.value.paySplitCount || 0;
tempPaySplit.value = JSON.parse(
JSON.stringify(quotationFormData.value.paySplit),
);
}
await assignToProductServiceList();
await fetchStatus();
}
async function fetchReceipt() {
const res = await useReceiptStore.getReceiptList({
quotationId: quotationFormData.value.id,
});
if (res) {
receiptList.value = res.result;
}
}
async function closeTab() {
if (quotationFormState.value.mode === 'edit') {
quotationForm.resetForm();
await assignToProductServiceList();
} else {
dialogWarningClose(t, {
message: t('dialog.message.close'),
action: () => {
window.close();
},
cancel: () => {},
});
}
}
function closeAble() {
return window.opener !== null;
}
async function assignToProductServiceList() {
const ret = await productServiceStore.fetchProductGroup({
page: 1,
pageSize: 9999,
});
if (ret) {
productGroup.value = ret.result;
productServiceList.value = quotationFormData.value.productServiceList.map(
(v) => ({
installmentNo: v.installmentNo || 0,
workerIndex: v.workerIndex || [0],
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,
}),
);
selectedProductGroup.value =
quotationFormData.value.productServiceList[0]?.product.productGroup?.id ||
'';
}
}
async function submitAccepted() {
dialog({
color: 'info',
icon: 'mdi-account-check',
title: t('dialog.title.confirmQuotationAccept'),
actionText: t('general.confirm'),
persistent: true,
message: t('dialog.message.quotationAccept'),
action: async () => {
if (!quotationFormData.value.id) return;
const res = await quotationForm.accepted(quotationFormData.value.id);
if (res) {
await fetchQuotation();
}
},
cancel: () => {},
});
}
async function convertInvoiceToSubmit() {
if (quotationFormData.value.id) {
invoiceFormData.value = {
installmentNo: selectedInstallmentNo.value,
amount:
quotationFormData.value.payCondition === 'SplitCustom'
? installmentAmount.value
: summaryPrice.value.finalPrice,
quotationId: quotationFormData.value.id,
};
const res = await quotationForm.submitInvoice();
if (res) {
await fetchQuotation();
view.value = View.Payment;
}
}
}
async function convertDataToFormSubmit() {
quotationFormData.value.productServiceList = JSON.parse(
JSON.stringify(
productServiceList.value.map((v) => ({
installmentNo: v.installmentNo,
workerIndex: v.workerIndex,
discount: v.discount,
amount: v.amount,
product: v.product,
work: v.work,
service: v.service,
})),
),
);
selectedWorker.value.forEach((v, i) => {
if (v.attachment !== undefined) {
v.attachment.forEach((value) => {
fileItemNewWorker.value.push({
employeeIndex: i,
name: value.name,
group: value.group as 'passport' | 'visa' | 'in-country-notice',
url: value.url,
file: value.file,
_meta: value._meta,
});
});
}
});
quotationFormData.value.worker = JSON.parse(
JSON.stringify(
selectedWorker.value.map((v) => {
if (v.id === undefined) {
const { attachment, ...payload } = v;
return payload;
} else {
return v.id;
}
}),
),
);
quotationFormData.value.paySplit = JSON.parse(
JSON.stringify(
quotationFormData.value.paySplit.map((p) => ({ ...p, no: undefined })),
),
);
quotationFormData.value = {
id: quotationFormData.value.id,
workerMax: quotationFormData.value.workerMax,
productServiceList: quotationFormData.value.productServiceList,
urgent: quotationFormData.value.urgent,
customerBranchId: quotationFormData.value.customerBranchId,
registeredBranchId: quotationFormData.value.registeredBranchId,
worker: quotationFormData.value.worker,
payBillDate: quotationFormData.value.payBillDate,
paySplit: quotationFormData.value.paySplit,
paySplitCount: quotationFormData.value.paySplitCount,
payCondition: quotationFormData.value.payCondition,
dueDate: quotationFormData.value.dueDate,
contactTel: quotationFormData.value.contactTel,
contactName: quotationFormData.value.contactName,
workName: quotationFormData.value.workName,
_count: quotationFormData.value._count,
status: quotationFormData.value.status,
discount: quotationFormData.value.discount,
remark: quotationFormData.value.remark || '',
};
const res = await quotationForm.submitQuotation();
if (res === true) {
quotationFormState.value.mode = 'info';
localStorage.setItem(
'new-quotation',
JSON.stringify({
quotationId: quotationFormData.value.id,
customerBranchId: quotationFormData.value.customerBranchId,
branchId: quotationFormData.value.registeredBranchId,
agentPrice: agentPrice.value,
statusDialog: 'info',
}),
);
await fetchQuotation();
}
}
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,
});
if (ret) productList.value[groupId] = ret.result;
}
function setDefaultFormEmployee() {
formDataEmployee.value = {
passportNo: '',
documentExpireDate: new Date(),
lastNameEN: '',
lastName: '',
middleNameEN: '',
middleName: '',
firstNameEN: '',
firstName: '',
namePrefix: '',
nationality: '',
gender: '',
dateOfBirth: new Date(),
attachment: [],
};
employeeFormState.value.dialogModal = false;
}
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;
}
function triggerCreateEmployee() {
employeeFormStore.resetFormDataEmployee(true);
setDefaultFormEmployee();
employeeFormState.value.dialogType = 'create';
employeeFormState.value.dialogModal = true;
employeeFormState.value.isEmployeeEdit = true;
}
async function triggerSelectEmployeeDialog() {
pageState.employeeModal = true;
await nextTick();
refSelectZoneEmployee.value?.assignSelect(
preSelectedWorker.value,
selectedWorker.value,
);
}
function triggerProductServiceDialog() {
pageState.productServiceModal = true;
}
function handleChangePayType(type: PayCondition) {
if (
type === 'Split' &&
tempPaySplitCount.value &&
tempPaySplit.value &&
tempTableProduct.value
) {
quotationFormData.value.paySplitCount = tempPaySplitCount.value;
quotationFormData.value.paySplit = JSON.parse(
JSON.stringify(tempPaySplit.value),
);
productServiceList.value = productServiceList.value.map((item, index) => ({
...item,
installmentNo: tempTableProduct.value[index].installmentNo || 0,
}));
}
}
function handleUpdateProductTable(
data: QuotationPayload['productServiceList'][number],
opt?: {
newInstallmentNo: number;
},
) {
// calc price
const calc = (c: QuotationPayload['productServiceList'][number]) => {
const pricePerUnit = c.pricePerUnit || 0;
const discount = c.discount || 0;
return precisionRound(
pricePerUnit * c.amount -
discount +
(c.product.calcVat
? (pricePerUnit * c.amount - discount) * (config.value?.vat || 0.07)
: 0),
);
};
// installment
if (opt && opt.newInstallmentNo) {
const oldInstallmentNo = JSON.parse(JSON.stringify(data.installmentNo));
const oldPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === oldInstallmentNo,
);
const newPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === opt.newInstallmentNo,
);
if (oldPaySplit) oldPaySplit.amount = oldPaySplit.amount - calc(data);
if (newPaySplit) newPaySplit.amount = newPaySplit.amount + calc(data);
return;
}
// re calc price
const currTempPaySplit = tempPaySplit.value.find(
(v) => v.no === data.installmentNo,
);
const currPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === data.installmentNo,
);
const targetPaySplit = productService.value.filter(
(v) => v.installmentNo === data.installmentNo,
);
let targetPaySplitAmount: number[] = [];
targetPaySplitAmount = targetPaySplit.map((c) => {
return calc(c);
});
const totalSum = targetPaySplitAmount.reduce((sum, value) => sum + value, 0);
if (currTempPaySplit && currPaySplit) {
currTempPaySplit.amount = totalSum;
currPaySplit.amount = totalSum;
}
}
function toggleDeleteProduct(index: number) {
const currProduct = productServiceList.value[index];
let currPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === currProduct.installmentNo,
);
let currTempPaySplit = tempPaySplit.value.find(
(v) => v.no === currProduct.installmentNo,
);
// cal curr amount
if (currPaySplit && currTempPaySplit) {
const price = agentPrice.value
? currProduct.product.agentPrice
: currProduct.product.price;
const pricePerUnit = currProduct.product.vatIncluded
? precisionRound(price / (1 + (config.value?.vat || 0.07)))
: price;
const vat = precisionRound(
(pricePerUnit * currProduct.amount - currProduct.discount) *
(config.value?.vat || 0.07),
);
const finalPrice = precisionRound(
pricePerUnit * currProduct.amount +
vat -
Number(currProduct.discount || 0),
);
currTempPaySplit.amount = currPaySplit.amount - finalPrice;
currPaySplit.amount = currTempPaySplit.amount;
}
// 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),
);
quotationFormData.value.paySplitCount = tempPaySplitCount.value;
// make split.length === split count
tempPaySplit.value.splice(quotationFormData.value.paySplitCount);
quotationFormData.value.paySplit.splice(
quotationFormData.value.paySplitCount,
);
}
async function assignWorkerToSelectedWorker() {
selectedWorker.value = quotationFormData.value.worker;
}
function convertToTable(nodes: Node[]) {
// TODO:
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);
quotationFormData.value.paySplitCount = Math.max(
...list.map((v) => v.installmentNo || 0),
);
tempPaySplitCount.value = quotationFormData.value.paySplitCount;
list.forEach((v) => {
v.amount = Math.max(selectedWorker.value.length, 1);
if (!v.workerIndex) v.workerIndex = [];
for (let i = 0; i < selectedWorker.value.length; i++) {
v.workerIndex.push(i);
}
if (!v.installmentNo) {
v.installmentNo = quotationFormData.value.paySplitCount;
}
});
productServiceList.value = list;
tempTableProduct.value = JSON.parse(JSON.stringify(list));
quotationFormData.value.paySplit = Array.apply(
null,
new Array(quotationFormData.value.paySplitCount),
).map((_, i) => ({
no: i + 1,
amount: getPrice(list, (v) => {
return (
v.installmentNo === i + 1 ||
(i + 1 === quotationFormData.value.paySplitCount && !v.installmentNo)
);
}).finalPrice,
}));
tempPaySplit.value = JSON.parse(
JSON.stringify(quotationFormData.value.paySplit),
);
pageState.productServiceModal = false;
}
function convertEmployeeToTable() {
productServiceList.value.forEach((v) => {
if (selectedWorker.value.length === 0 && v.amount === 1) v.amount -= 1;
v.amount = Math.max(
v.amount + preSelectedWorker.value.length - selectedWorker.value.length,
1,
);
const oldWorkerId: string[] = [];
const newWorkerIndex: number[] = [];
selectedWorker.value.forEach((item, i) => {
if (v.workerIndex.includes(i)) oldWorkerId.push(item.id);
});
preSelectedWorker.value.forEach((item, i) => {
if (selectedWorker.value.find((n) => item.id === n.id)) return;
newWorkerIndex.push(i);
});
v.workerIndex = oldWorkerId
.map((id) => preSelectedWorker.value.findIndex((item) => item.id === id))
.filter((idx) => idx !== -1)
.concat(newWorkerIndex);
});
refSelectZoneEmployee.value?.assignSelect(
selectedWorker.value,
preSelectedWorker.value,
);
pageState.employeeModal = false;
quotationFormData.value.workerMax = Math.max(
quotationFormData.value.workerMax || 1,
selectedWorker.value.length,
);
}
async function triggerDelete(name: string) {
await quotationStore.delAttachment({
parentId: quotationFormData.value.id || '',
name,
});
await getAttachment();
}
async function getAttachment() {
const attachment = await quotationStore.listAttachment({
parentId: quotationFormData.value.id || '',
});
if (attachment && attachment.length > 0) {
attachmentData.value = attachmentData.value.filter((item) =>
attachment.includes(item.name),
);
attachment.forEach((v) => {
const exists = attachmentData.value.some((item) => item.name === v);
if (!exists) {
attachmentData.value.push({
name: v,
progress: 1,
loaded: 0,
total: 0,
url: `quotation/${quotationFormData.value.id || ''}/attachment/${v}`,
});
}
});
} else attachmentData.value = [];
}
async function uploadAttachment(file?: File) {
if (!file) return;
if (!quotationFormData.value) return;
attachmentData.value.push({
name: file.name,
progress: 0,
loaded: 0,
total: 0,
});
const ret = await quotationStore.putAttachment({
parentId: quotationFormData.value.id || '',
name: file.name,
file: file,
onUploadProgress: (e) => {
attachmentData.value[attachmentData.value.length - 1] = {
name: file.name,
progress: e.progress || 0,
loaded: e.loaded,
total: e.total || 0,
url: `quotation/${quotationFormData.value.id || ''}/attachment/${file.name}`,
};
},
});
if (ret) await getAttachment();
}
const sessionData = ref<Record<string, any>>();
onMounted(async () => {
initTheme();
initLang();
await configStore.getConfig();
sessionStorage.setItem(
'new-quotation',
localStorage.getItem('new-quotation') ||
sessionStorage.getItem('new-quotation') ||
'',
);
localStorage.removeItem('new-quotation');
const payload = sessionStorage.getItem('new-quotation');
if (!!payload) {
const parsed = JSON.parse(payload);
date.value = Date.now();
quotationFormState.value.mode = parsed.statusDialog;
quotationFormData.value.registeredBranchId = parsed.branchId;
quotationFormData.value.customerBranchId = parsed.customerBranchId;
currentQuotationId.value = parsed.quotationId;
agentPrice.value = parsed.agentPrice;
await fetchQuotation();
await assignWorkerToSelectedWorker();
sessionData.value = parsed;
}
await fetchStatus();
if (quotationFormState.value.mode !== 'create') {
await getAttachment();
}
pageState.isLoaded = true;
if (route.query['tab'] === 'invoice') {
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Invoice
: View.InvoicePre;
}
if (route.query['tab'] === 'receipt') {
view.value = View.Receipt;
}
});
watch(
() => quotationFormData.value.quotationStatus,
() => {
fetchStatus();
},
);
watch(
() => quotationFormData.value.customerBranchId,
async (v) => {
if (!v) return;
const retEmp = await customerStore.fetchBranchEmployee(v, {
passport: true,
});
if (retEmp) workerList.value = retEmp.data.result;
},
);
watch(
() => quotationFormData.value.registeredBranchId,
async (v) => {
if (!pageState.isLoaded) return;
const url = new URL(window.location.href);
url.searchParams.set('branchId', v);
history.pushState({}, '', url.toString());
},
);
const productServiceNodes = ref<ProductTree>([]);
watch(
() => productServiceList.value,
() => {
productServiceNodes.value = quotationProductTree(
productServiceList.value,
agentPrice.value,
config.value?.vat,
);
},
);
async function searchEmployee(text: string) {
let query: string | undefined = text;
let pageSize = 50;
if (!text) {
query = undefined;
pageSize = 9999;
}
const retEmp = await customerStore.fetchBranchEmployee(
quotationFormData.value.customerBranchId,
{
query: query,
pageSize: pageSize,
passport: true,
},
);
if (retEmp) workerList.value = retEmp.data.result;
}
function storeDataLocal() {
quotationFormData.value.productServiceList = productServiceList.value;
localStorage.setItem(
'quotation-preview',
JSON.stringify({
data: {
...quotationFormData.value,
productServiceList: productService.value,
},
meta: {
source: {
...quotationFormState.value.source,
code:
quotationFormState.value.mode === 'create'
? '-'
: quotationFormState.value?.source?.code,
createAt:
quotationFormState.value.mode === 'create'
? Date.now()
: quotationFormState.value?.source?.createdAt,
createBy: quotationFormState.value?.source?.createdBy,
payCondition: quotationFormData.value.payCondition,
contactName: quotationFormData.value.contactName,
contactTel: quotationFormData.value.contactTel,
workName: quotationFormData.value.workName,
dueDate: quotationFormData.value.dueDate,
},
selectedWorker: selectedWorker.value,
createdBy: quotationFormState.value.createdBy('tha'),
},
}),
);
const url = new URL('/quotation/document-view', window.location.origin);
url.searchParams.append('type', view.value);
window.open(url, '_blank');
}
const QUOTATION_STATUS = [
'Issued',
'Accepted',
'PaymentPending',
'PaymentInProcess',
'PaymentSuccess',
'ProcessComplete',
'Canceled',
];
function getStatus(
status: typeof quotationFormData.value.quotationStatus,
doneIndex: number,
doingIndex: number,
) {
return QUOTATION_STATUS.findIndex((v) => v === status) >= doneIndex
? 'done'
: QUOTATION_STATUS.findIndex((v) => v === status) >= doingIndex
? 'doing'
: 'waiting';
}
const statusQuotationForm = ref<
{
title: string;
status: 'done' | 'doing' | 'waiting';
handler: () => void;
active?: () => boolean;
}[]
>([]);
const view = ref<View>(View.Quotation);
const code = ref<string>('');
async function getInvoiceCode(invoiceId: string) {
const ret = await invoiceStore.getInvoice(invoiceId);
if (ret) code.value = ret.code;
}
async function getInvoiceCodeFullPay() {
const ret = await invoiceStore.getInvoiceList({
quotationId: quotationFormData.value.id,
});
if (ret) code.value = ret.result.at(0)?.code || '';
}
const importWorkerCriteria = ref<{
passport: string[] | null;
visa: string[] | null;
}>({
passport: [],
visa: [],
});
async function getWorkerFromCriteria(
payload: typeof importWorkerCriteria.value,
) {
const ret = await employeeStore.fetchList({
payload: {
passport: payload.passport || undefined,
},
pageSize: 99999, // must include all possible worker
page: 1,
passport: true,
customerBranchId: quotationFormData.value.customerBranchId,
});
if (!ret) return false; // error, do not close dialog
const deduplicate = ret.result.filter(
(a) => !selectedWorker.value.find((b) => a.id === b.id),
);
preSelectedWorker.value = [...deduplicate, ...selectedWorker.value];
convertEmployeeToTable();
return true;
}
</script>
<template>
<ImportWorker
v-model:open="pageState.importWorker"
v-model:data="importWorkerCriteria"
:import-worker="getWorkerFromCriteria"
/>
<div class="column surface-0 fullscreen">
<div class="color-bar" :class="{ dark: $q.dark.isActive }">
<div class="orange-segment"></div>
<div class="yellow-segment"></div>
<div class="gray-segment"></div>
</div>
<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="/quotation">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('quotation.title') }}
{{ code || quotationFormState.source?.code || '' }}
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg:
quotationFormState.mode === 'create'
? `${dateFormat(date, true)} ${dateFormat(date, true, true)}`
: `${dateFormat(quotationFull?.createdAt, true)} ${dateFormat(quotationFull?.createdAt, true, true)}`,
})
}}
</span>
</span>
</div>
<div
v-if="quotationFormState.mode !== 'create'"
class="column col-2 q-ml-auto"
style="gap: 10px"
>
<div class="row justify-end">
<BadgeComponent :title-i18n="$t('general.laborIdentified')" />
</div>
<div
class="row items-center justify-between surface-1 rounded q-pa-xs"
style="height: 40px; border-radius: 40px"
v-if="quotationFormData.quotationStatus === 'Issued'"
>
<div class="full-height full-width row">
<div
class="col-6 flex flex-center"
style="
color: white;
background: linear-gradient(to right, #035aa1 0%, #14ab35 100%);
border-radius: 20px;
gap: 10px;
"
>
<q-icon name="mdi-calendar-range" />
{{ $t('quotation.receiptDialog.remain') }}
</div>
<div class="col-6 flex flex-center">
<span
v-if="calculateDaysUntilExpire(quotationFormData.dueDate) <= 0"
>
{{
$t(
`general.${
calculateDaysUntilExpire(quotationFormData.dueDate) > 0
? 'beDue'
: calculateDaysUntilExpire(
quotationFormData.dueDate,
) === 0
? 'due'
: 'overDue'
}`,
)
}}
</span>
<span v-else>
{{ calculateDaysUntilExpire(quotationFormData.dueDate) }}
{{ $t('general.day') }}
</span>
</div>
</div>
</div>
</div>
</header>
<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">
<div
class="surface-1 q-pa-sm row no-wrap full-width scroll rounded"
style="gap: 10px"
>
<button
v-for="value in statusQuotationForm"
:key="value.title"
class="q-pa-sm bordered status-color row items-center"
:class="{
[`status-color-${value.status}`]: true,
'quotation-status-active': value.active?.(),
}"
@click="value.status !== 'waiting' && value.handler()"
style="min-width: 120px; cursor: pointer; text-wrap: nowrap"
>
<div class="q-px-sm">
<q-icon
class="icon-color quotation-status"
style="border-radius: 50%"
:name="`${value.status === 'done' ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'}`"
/>
</div>
<div class="col text-left">
{{ $t(`quotation.status.${value.title}`) }}
</div>
</button>
</div>
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span
class="text-weight-medium q-mr-md"
style="font-size: 18px"
>
{{ $t('general.document') }}
</span>
</div>
<q-checkbox
v-model="quotationFormData.urgent"
class="q-ml-auto"
size="xs"
:label="$t('general.urgent')"
:disable="readonly"
/>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<QuotationFormMetadata
class="q-mb-md"
:readonly
:actor="quotationFormState.createdBy?.($i18n.locale) || ''"
:quotation-no="(quotationFull && quotationFull.code) || ''"
v-model:urgent="quotationFormData.urgent"
v-model:work-name="quotationFormData.workName"
v-model:contactor="quotationFormData.contactName"
v-model:telephone="quotationFormData.contactTel"
v-model:due-date="quotationFormData.dueDate"
>
<template #issue-info>
<FormAbout
hide-add
class="col-12 col-md-8"
input-only
v-model:branch-id="quotationFormData.registeredBranchId"
v-model:customer-branch-id="
quotationFormData.customerBranchId
"
:readonly="readonly"
/>
</template>
</QuotationFormMetadata>
</div>
</q-expansion-item>
<template
v-if="
view === View.Quotation ||
view === View.Accepted ||
view === View.Invoice
"
>
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span
class="text-weight-medium q-mr-md"
style="font-size: 18px"
>
{{ $t('quotation.employeeList') }}
</span>
<template v-if="!readonly">
<ToggleButton class="q-mr-sm" v-model="toggleWorker" />
{{
toggleWorker
? $t('general.specify')
: $t('general.noSpecify')
}}
</template>
</div>
<nav class="q-ml-auto">
<AddButton
v-if="!readonly"
icon-only
@click.stop="triggerSelectEmployeeDialog"
/>
</nav>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<WorkerItem
@update:employee-amount="
(v) =>
(quotationFormData.workerMax = Math.max(
v,
selectedWorker.length,
))
"
:employee-amount="
quotationFormData.workerMax || selectedWorker.length
"
:readonly="readonly"
fallback-img="/images/employee-avatar.png"
:rows="
selectedWorker.map((e: Employee) => ({
foreignRefNo: e.employeePassport
? e.employeePassport[0]?.number || '-'
: '-',
employeeName:
$i18n.locale === 'eng'
? `${e.firstNameEN} ${e.lastNameEN}`
: `${e.firstName} ${e.lastName}`,
birthDate: dateFormat(e.dateOfBirth),
gender: e.gender,
age: calculateAge(e.dateOfBirth),
nationality: optionStore.mapOption(e.nationality),
documentExpireDate:
e.employeePassport !== undefined &&
e.employeePassport[0]?.expireDate !== undefined
? dateFormat(e.employeePassport[0]?.expireDate)
: '-',
imgUrl: `${baseUrl}/customer/${e.id}/image/${e.selectedImage}`,
status: e.status,
}))
"
@delete="(i) => deleteItem(selectedWorker, i)"
/>
</div>
</q-expansion-item>
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span class="text-weight-medium" style="font-size: 18px">
{{ $t('quotation.productList') }}
</span>
</div>
<nav class="q-ml-auto">
<AddButton
v-if="!readonly"
icon-only
class="q-ml-auto"
@click.stop="triggerProductServiceDialog"
/>
</nav>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<ProductItem
:installment-input="
quotationFormData.payCondition === 'SplitCustom'
"
:max-installment="quotationFormData.paySplitCount"
:readonly="readonly"
:agent-price="agentPrice"
:employee-rows="
selectedWorker.map((e: Employee) => ({
foreignRefNo:
e.employeePassport !== undefined
? e.employeePassport[0]?.number || '-'
: '-',
employeeName:
$i18n.locale === 'eng'
? `${e.firstNameEN} ${e.lastNameEN}`
: `${e.firstName} ${e.lastName}`,
birthDate: dateFormat(e.dateOfBirth),
gender: e.gender,
age: calculateAge(e.dateOfBirth),
nationality: optionStore.mapOption(e.nationality),
documentExpireDate:
e.employeePassport !== undefined &&
e.employeePassport[0]?.expireDate !== undefined
? dateFormat(e.employeePassport[0]?.expireDate) || '-'
: '-',
imgUrl: `${baseUrl}/customer/${e.id}/image/${e.selectedImage}`,
status: e.status,
}))
"
@delete="toggleDeleteProduct"
:rows="productService"
@update:rows="
(v) => {
view === View.Quotation && (productServiceList = v);
}
"
@update-table="handleUpdateProductTable"
/>
</div>
</q-expansion-item>
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span
class="text-weight-medium q-mr-md"
style="font-size: 18px"
>
{{ $t('general.payment') }}
</span>
</div>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<template v-if="true">
<QuotationFormInfo
:view="view"
:installment-amount="installmentAmount"
:installment-no="selectedInstallmentNo"
v-model:pay-type="quotationFormData.payCondition"
v-model:pay-bank="payBank"
v-model:pay-split-count="quotationFormData.paySplitCount"
v-model:pay-split="quotationFormData.paySplit"
:readonly
v-model:final-discount="quotationFormData.discount"
v-model:pay-bill-date="quotationFormData.payBillDate"
v-model:summary-price="summaryPrice"
class="q-mb-md"
@change-pay-type="handleChangePayType"
/>
</template>
</div>
</q-expansion-item>
<q-expansion-item
v-if="quotationFormState.mode !== 'create'"
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span
class="text-weight-medium q-mr-md"
style="font-size: 18px"
>
{{ $t('quotation.additionalFile') }}
</span>
</div>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<UploadFileSection
:readonly="
{
quotation: quotationFormState.mode !== 'edit',
invoice: false,
accepted: true,
}[view]
"
v-model:file-data="attachmentData"
:label="
$t('general.upload', { msg: $t('general.attachment') })
"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
@update:file="(f) => uploadAttachment(f)"
@close="(v) => triggerDelete(v)"
/>
</div>
</q-expansion-item>
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-ceenhancement nter full-width">
<div class="row items-center q-pr-md q-py-sm">
<span
class="text-weight-medium q-mr-md"
style="font-size: 18px"
>
{{ $t('general.remark') }}
</span>
</div>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<q-editor
dense
:readonly="readonly || !pageState.remarkWrite"
:model-value="
!pageState.remarkWrite || readonly
? convertTemplate(quotationFormData.remark || '', {
'quotation-payment': {
paymentType: quotationFormData.payCondition,
amount: getPrice(productServiceList).finalPrice,
installments: quotationFormData.paySplit,
},
'quotation-labor': {
name: selectedWorker.map(
(v, i) =>
`${i + 1}. ` +
`${v.namePrefix}. ${v.firstNameEN} ${v.lastNameEN}`.toUpperCase(),
),
},
})
: quotationFormData.remark || ''
"
min-height="5rem"
class="full-width"
:content-class="{
'q-my-sm rounded': true,
bordered: !readonly && pageState.remarkWrite,
}"
toolbar-bg="input-border"
style="cursor: auto; color: var(--foreground)"
:flat="!readonly"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
:toolbar="getToolbarConfig"
:toolbar-toggle-color="readonly ? 'disabled' : 'primary'"
:toolbar-color="
readonly ? 'disabled' : $q.dark.isActive ? 'white' : ''
"
:definitions="{
clip: {
icon: 'mdi-paperclip',
tip: 'Upload',
disable: readonly,
handler: () => console.log('upload'),
},
}"
@update:model-value="
(v) => {
quotationFormData.remark = v;
}
"
>
<template v-if="!readonly" v-slot:toggle>
<div class="text-caption row no-wrap q-px-sm">
<MainButton
:disabled="readonly"
:solid="!pageState.remarkWrite"
icon="mdi-eye-outline"
color="0 0% 40%"
@click="pageState.remarkWrite = false"
style="padding: 0 var(--size-2)"
:style="{
color: pageState.remarkWrite
? 'hsl(0 0% 40%)'
: undefined,
}"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<MainButton
:disabled="readonly"
:solid="pageState.remarkWrite"
icon="mdi-pencil-outline"
color="0 0% 40%"
@click="pageState.remarkWrite = true"
style="padding: 0 var(--size-2)"
:style="{
color: !pageState.remarkWrite
? 'hsl(0 0% 40%)'
: undefined,
}"
>
{{ $t('general.edit') }}
</MainButton>
</div>
</template>
</q-editor>
</div>
</q-expansion-item>
</template>
<template v-else>
<PaymentForm
v-if="view !== View.InvoicePre && view !== View.Receipt"
:data="quotationFormState.source"
@fetch-status="
() => {
fetchQuotation();
}
"
/>
<q-expansion-item
v-if="view === View.InvoicePre"
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<div class="surface-1 q-pa-md full-width">
<SwitchItem :value="view">
<template #[View.InvoicePre]>
<q-table
flat
bordered
:columns="columnPaySplit"
:rows="quotationFormData.paySplit"
selection="multiple"
v-model:selected="selectedInstallment"
@update:selected="
(v) => {
selectedInstallment = v.filter(
(value) => !value.invoiceId,
);
selectedInstallmentNo = selectedInstallment.map(
(value: any) => value.no,
);
installmentAmount = selectedInstallment.reduce(
(total: number, value: any) => total + value.amount,
0,
);
}
"
row-key="no"
:no-data-label="$t('general.noDataTable')"
:rows-per-page-options="[0]"
hide-pagination
:selected-rows-label="
(n) =>
$t('general.selected', {
number: n,
msg: $t('productService.product.product'),
})
"
>
<template v-slot:header="props">
<q-tr
:props="props"
:style="`background-color:hsla(var(--info-bg) / 0.07)`"
>
<q-th auto-width>
<q-checkbox v-model="props.selected" />
</q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ $t(col.label) }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr
:class="{ 'cursor-pointer': props.row.invoiceId }"
:props="props"
@click="
async () => {
if (props.row.invoiceId) {
await getInvoiceCode(props.row.invoiceId);
selectedInstallmentNo =
quotationFormState.source?.paySplit
.filter(
(v) =>
v.invoiceId === props.row.invoiceId,
)
.map((v) => v.no) || [];
installmentAmount = props.row.amount;
view = View.Invoice;
}
}
"
>
<q-td auto-width>
<q-checkbox
v-model="props.selected"
v-if="!props.row.invoiceId"
/>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<template v-if="col.name === 'installmentNo'">
{{ col.value }}
</template>
<template v-if="col.name === 'amount'">
{{ formatNumberDecimal(col.value, 2) }}
</template>
<template v-if="col.name === 'status'">
<BadgeComponent
:title="
!!col.value
? 'สร้างใบแจ้งหนี้เเล้ว'
: 'ยังไม่สร้างใบแจ้งหนี้'
"
:hsla-color="!!col.value ? '' : '--red-7-hsl'"
/>
</template>
</q-td>
</q-tr>
</template>
</q-table>
</template>
</SwitchItem>
</div>
</q-expansion-item>
</template>
<template v-if="view === View.Quotation || view === View.Payment">
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span
class="text-weight-medium q-mr-md"
style="font-size: 18px"
>
{{ $t('general.form') }}
</span>
</div>
</section>
</template>
<div class="surface-1 q-pa-md flex" style="gap: var(--size-2)">
<SelectInput
style="max-width: 250px"
class="q-mr-xl"
incremental
v-model="templateForm"
id="quotation-branch"
:option="templateFormOption"
:label="$t('quotation.templateForm')"
:option-label="locale === 'eng' ? 'labelEN' : 'label'"
/>
<MainButton
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
@click="storeDataLocal"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<MainButton
solid
icon="mdi-pencil-outline"
color="207 96% 32%"
@click="storeDataLocal"
>
{{ $t('general.designForm') }}
</MainButton>
</div>
</q-expansion-item>
</template>
<template v-if="view === View.Receipt">
<QuotationFormReceipt
v-for="(v, i) in receiptList"
:key="i"
:amount="v.amount"
:date="v.date"
:pay-type="quotationFormData.payCondition"
:index="i"
:pay-split-count="quotationFormData.paySplitCount || 0"
/>
</template>
</div>
</section>
</article>
<footer class="surface-1 q-pa-md full-width">
<div
class="row full-width"
:class="{
'justify-between': view !== View.InvoicePre,
'justify-end': view === View.InvoicePre,
}"
>
<MainButton
v-if="view !== View.InvoicePre && view !== View.PaymentPre"
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
@click="storeDataLocal"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<div
v-if="
view === View.Accepted &&
quotationFormData.quotationStatus === 'Issued'
"
>
<MainButton
solid
icon="mdi-account-multiple-check-outline"
color="207 96% 32%"
@click="
() => {
submitAccepted();
}
"
>
{{ $t('quotation.customerAcceptance') }}
</MainButton>
</div>
<template v-if="view === View.InvoicePre">
<MainButton
solid
icon="mdi-account-multiple-check-outline"
color="207 96% 32%"
@click="
() => {
view = View.Invoice;
}
"
>
{{ $t('quotation.selectInvoice') }}
</MainButton>
</template>
<div
v-if="
view === View.Invoice &&
((quotationFormData.quotationStatus !== 'PaymentPending' &&
quotationFormData.payCondition !== 'Full') ||
quotationFormData.quotationStatus === 'Accepted') &&
!isIssueInvoice()
"
>
<MainButton
solid
icon="mdi-account-multiple-check-outline"
color="207 96% 32%"
@click="
() => {
convertInvoiceToSubmit();
}
"
>
{{ $t('quotation.approveInvoice') }}
</MainButton>
</div>
<div
class="row"
style="gap: var(--size-2)"
v-if="
(view === View.Quotation &&
(quotationFormData.quotationStatus === 'Issued' ||
quotationFormData.quotationStatus === 'Expired')) ||
!quotationFormData.quotationStatus
"
>
<UndoButton
outlined
@click="closeTab()"
v-if="quotationFormState.mode === 'edit'"
/>
<CloseButton
outlined
@click="closeTab()"
v-if="quotationFormState.mode === 'info' && closeAble()"
/>
<SaveButton
v-if="
quotationFormState.mode === 'create' ||
quotationFormState.mode === 'edit'
"
@click="
() => {
convertDataToFormSubmit();
}
"
solid
/>
<EditButton
v-else
class="no-print"
@click="quotationFormState.mode = 'edit'"
solid
/>
</div>
</div>
</footer>
<!-- SEC: Dialog -->
<!-- add employee quotation -->
<DialogForm
:title="$t('general.select', { msg: $t('quotation.employeeList') })"
v-model:modal="pageState.employeeModal"
:submit-label="$t('general.select', { msg: $t('quotation.employee') })"
submit-icon="mdi-check"
height="75vh"
:submit="() => convertEmployeeToTable()"
:close="
() => {
(preSelectedWorker = []), (pageState.employeeModal = false);
}
"
>
<template #top-append>
<q-btn
class="q-mr-sm"
flat
size="sm"
icon="mdi-dots-horizontal"
:id="`btn-kebab-action`"
@click.stop
:title="$t('general.additional')"
style="max-width: 20px"
>
<q-menu class="bordered" ref="refMenu">
<q-list>
<q-item
v-close-popup
dense
clickable
class="row items-center"
style="white-space: nowrap"
@click.stop="
() => {
triggerCreateEmployee();
}
"
>
<q-icon
size="xs"
class="q-mr-sm"
name="mdi-plus"
style="color: hsl(var(--info-bg))"
/>
<span>
{{ $t('quotation.addWorker') }}
</span>
</q-item>
<q-item
v-close-popup
dense
clickable
style="white-space: nowrap"
class="row items-center"
@click.stop="() => (pageState.importWorker = true)"
>
<q-icon
size="xs"
class="q-mr-sm"
name="mdi-import"
style="color: hsl(195 90% 50%)"
/>
<span>{{ $t('quotation.importWorker') }}</span>
</q-item>
</q-list>
</q-menu>
</q-btn>
</template>
<section class="col row scroll">
<SelectZone
ref="refSelectZoneEmployee"
v-model:selected-item="preSelectedWorker"
@search="
(v) => {
searchEmployee(v);
}
"
:items="workerList"
:new-items="newWorkerList"
>
<template #data="{ item }">
<PersonCard
noAction
prefixId="asda"
class="full-width"
:data="{
name:
$i18n.locale === 'eng'
? `${item.firstNameEN} ${item.lastNameEN}`
: `${item.firstName} ${item.lastName}`,
code: item.employeePassport?.at(0)?.number,
female: item.gender === 'female',
male: item.gender === 'male',
img: `${baseUrl}/customer/${item.id}/image/${item.selectedImage}`,
fallbackImg: '/images/employee-avatar.png',
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(item.nationality),
},
{
icon: 'mdi-clock-outline',
value: calculateAge(item.dateOfBirth),
},
],
}"
></PersonCard>
</template>
<template #newData="{ item }">
<PersonCard
noAction
prefixId="asda"
class="full-width"
:data="{
name:
$i18n.locale === 'eng'
? `${item.firstNameEN} ${item.lastNameEN}`
: `${item.firstName} ${item.lastName}`,
code: item.code,
female: item.gender === 'female',
male: item.gender === 'male',
img: `${baseUrl}/customer/${item.id}/image/${item.selectedImage}`,
fallbackImg: '/images/employee-avatar.png',
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(item.nationality),
},
{
icon: 'mdi-clock-outline',
value: calculateAge(item.dateOfBirth),
},
],
}"
></PersonCard>
</template>
</SelectZone>
</section>
</DialogForm>
<!-- 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>
</div>
<!-- NOTE: START - Employee Add Form -->
<DialogForm
hide-footer
ref="formDialogRef"
v-model:modal="employeeFormState.dialogModal"
:title="$t('form.title.create', { name: $t('customer.employee') })"
:submit="
() => {
quotationForm.injectNewEmployee({ data: formDataEmployee }, () =>
setDefaultFormEmployee(),
);
}
"
:close="
() => {
employeeFormState.dialogModal = false;
}
"
>
<div
:class="{
'q-mx-lg q-my-md': $q.screen.gt.sm,
'q-mx-md q-my-sm': !$q.screen.gt.sm,
}"
>
<ProfileBanner
prefix="dialog"
active
useToggle
color="white"
icon="mdi-account-plus-outline"
:bg-color="
employeeFormState.profileUrl
? 'white'
: 'linear-gradient(135deg, rgba(43,137,223,1) 0%, rgba(230,51,81,1) 100%)'
"
v-model:current-tab="employeeFormState.currentTab"
v-model:toggle-status="currentFromDataEmployee.status"
fallbackCover="/images/employee-banner.png"
:img="employeeFormState.profileUrl || `/images/employee-avatar.png`"
:toggleTitle="$t('status.title')"
hideFade
@view="
() => {
employeeFormState.imageDialog = true;
employeeFormState.isImageEdit = false;
}
"
@edit="
employeeFormState.imageDialog = employeeFormState.isImageEdit = true
"
@update:toggle-status="
() => {
currentFromDataEmployee.status =
currentFromDataEmployee.status === 'CREATED'
? 'INACTIVE'
: 'CREATED';
}
"
/>
</div>
<div
class="col"
:class="{
'q-px-lg q-pb-lg': $q.screen.gt.sm,
'q-px-md q-pb-sm': !$q.screen.gt.sm,
}"
>
<div
style="overflow-y: auto"
class="row full-width full-height surface-1 rounded bordered relative-position"
>
<div
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 99999; top: 0; right: 0"
>
<div
v-if="currentFromDataEmployee.status !== 'INACTIVE'"
class="surface-1 row rounded"
>
<UndoButton
v-if="
employeeFormState.isEmployeeEdit &&
employeeFormState.dialogType !== 'create'
"
id="btn-info-basic-undo"
icon-only
@click="
() => {
employeeFormStore.resetFormDataEmployee();
employeeFormState.isEmployeeEdit = false;
employeeFormState.dialogType = 'info';
}
"
type="button"
/>
<SaveButton
v-if="employeeFormState.isEmployeeEdit"
id="btn-info-basic-save"
icon-only
type="submit"
/>
<EditButton
v-if="!employeeFormState.isEmployeeEdit"
id="btn-info-basic-edit"
icon-only
@click="
() => {
employeeFormState.isEmployeeEdit = true;
employeeFormState.dialogType = 'edit';
}
"
type="button"
/>
<DeleteButton
v-if="!employeeFormState.isEmployeeEdit"
id="btn-info-basic-delete"
icon-only
/>
</div>
</div>
<div
class="col-12 full-height q-col-gutter-sm q-py-md q-pl-md q-pr-sm"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
}"
id="branch-form"
style="overflow-y: auto"
>
<FormReferDocument
title="form.field.basicInformation"
prefixId="dialog"
dense
v-model:passport-no="formDataEmployee.passportNo"
v-model:document-expire-date="formDataEmployee.documentExpireDate"
class="q-mb-md"
/>
<FormPerson
id="form-personal"
prefix-id="form-employee"
dense
outlined
employee
separator
hideNameEn
title="personnel.form.personalInformation"
class="q-mb-md"
v-model:prefix-name="formDataEmployee.namePrefix"
v-model:first-name="formDataEmployee.firstName"
v-model:mid-name="formDataEmployee.middleName"
v-model:last-name="formDataEmployee.lastName"
v-model:birth-date="formDataEmployee.dateOfBirth"
v-model:gender="formDataEmployee.gender"
v-model:nationality="formDataEmployee.nationality"
/>
<UploadFileGroup
show-title
v-model="formDataEmployee.attachment"
hide-action
@submit="
async (group, allMeta) => {
if (allMeta === undefined) return;
if (group === 'passport') {
const fullName = allMeta['full_name'].split(' ');
let tempValue: {
oldData: { nameField: string; value: string }[];
newData: { nameField: string; value: string }[];
} = { oldData: [], newData: [] };
if (
formDataEmployee.gender !== '' &&
formDataEmployee.gender !== allMeta['sex']
) {
tempValue.oldData.push({
nameField: $t('form.gender'),
value: $t(`general.${formDataEmployee.gender}`),
});
tempValue.newData.push({
nameField: $t('form.gender'),
value: $t(`general.${allMeta['sex']}`),
});
}
if (formDataEmployee.firstName !== '') {
tempValue.oldData.push({
nameField: $t('personnel.form.firstName'),
value: formDataEmployee.firstName,
});
tempValue.newData.push({
nameField: $t('personnel.form.firstName'),
value: fullName[0],
});
}
if (formDataEmployee.lastName !== '') {
tempValue.oldData.push({
nameField: $t('personnel.form.lastName'),
value: formDataEmployee.lastName,
});
tempValue.newData.push({
nameField: $t('personnel.form.lastName'),
value: fullName[1],
});
}
if (formDataEmployee.passportNo !== '') {
tempValue.oldData.push({
nameField: $t('customerEmployee.form.passportNo'),
value: formDataEmployee.passportNo || '',
});
tempValue.newData.push({
nameField: $t('customerEmployee.form.passportNo'),
value: allMeta['doc_number'],
});
}
if (formDataEmployee.nationality !== '') {
tempValue.oldData.push({
nameField: $t('general.nationality'),
value: formDataEmployee.nationality || '',
});
tempValue.newData.push({
nameField: $t('general.nationality'),
value: allMeta['nationality'],
});
}
dialogCheckData({
action: async () => {
formDataEmployee.gender = allMeta['sex'];
formDataEmployee.firstName = fullName[0];
formDataEmployee.lastName = fullName[1];
formDataEmployee.passportNo = allMeta['doc_number'];
formDataEmployee.nationality = allMeta['nationality'];
},
checkData: () => {
return tempValue;
},
cancel: () => {
if (!formDataEmployee.firstName) {
formDataEmployee.gender = allMeta['gender'];
}
if (!formDataEmployee.firstName) {
formDataEmployee.firstName = fullName[0];
}
if (!formDataEmployee.firstName) {
formDataEmployee.firstName = fullName[0];
}
if (!formDataEmployee.lastName) {
formDataEmployee.lastName = fullName[1];
}
if (!formDataEmployee.passportNo) {
formDataEmployee.passportNo = allMeta['doc_number'];
}
if (!formDataEmployee.nationality) {
formDataEmployee.nationality = allMeta['nationality'];
}
},
});
}
}
"
:menu="uploadFileListEmployee"
:columns="columnsAttachment"
:ocr="
async (group, file) => {
if (group === 'passport') {
mrz = await runOcr(file, parseResultMRZ);
if (mrz !== null) {
const mapMrz = Object.entries(mrz.result || {}).map(
([key, value]) => ({
name: key,
value: value,
}),
);
const tempValue = {
status: true,
group,
meta: mapMrz,
};
return tempValue;
}
}
if (group === 'visa') {
const res = await ocrStore.sendOcr({
file: file,
category: group,
});
if (res) {
const tempValue = {
status: true,
group,
meta: res.fields,
};
return tempValue;
}
}
return { status: true, group, meta: [] };
}
"
:delete-item="
async () => {
return true;
}
"
:get-file-list="
async (group: 'passport' | 'visa' | 'attachment') => {
if (!!currentFromDataEmployee.id && group !== 'attachment') {
const resMeta = await employeeStore.getMetaList({
parentId: currentFromDataEmployee.id,
group,
});
const tempValue = resMeta.map(async (i: any) => {
return {
_meta: { ...i },
name: `${group}-${dateFormat(i.expireDate)}` || '',
group: group,
url: await employeeStore.getFile({
parentId: currentFromDataEmployee.id || '',
group,
fileId: i.id,
}),
file: undefined,
};
});
return await waitAll(tempValue);
} else {
const res = await employeeStore.listAttachment({
parentId: currentFromDataEmployee.id || '',
});
const tempValue = (res as string[]).map(async (i: any) => {
return {
_meta: { id: i, name: i },
name: i || '',
group: group,
url: await employeeStore.getAttachment({
parentId: currentFromDataEmployee.id || '',
name: i,
}),
file: undefined,
};
});
return await waitAll(tempValue);
}
}
"
>
<template #form="{ mode, meta, isEdit }">
<FormEmployeePassport
v-if="mode === 'passport' && meta"
prefix-id="drawer-info-employee"
id="form-passport"
dense
outlined
separator
ocr
:title="$t('customerEmployee.form.group.passport')"
:readonly="!isEdit"
v-model:birth-country="meta.birthCountry"
v-model:previous-passportRef="meta.previousPassportRef"
v-model:issue-place="meta.issuePlace"
v-model:issue-country="meta.issueCountry"
v-model:issue-date="meta.issueDate"
v-model:type="meta.type"
v-model:expire-date="meta.expireDate"
v-model:birth-date="meta.birthDate"
v-model:worker-status="meta.workerStatus"
v-model:nationality="meta.nationality"
v-model:gender="meta.gender"
v-model:last-name-en="meta.lastNameEN"
v-model:last-name="meta.lastName"
v-model:middle-name-en="meta.middleNameEN"
v-model:middle-name="meta.middleName"
v-model:first-name-en="meta.firstNameEN"
v-model:first-name="meta.firstName"
v-model:name-prefix="meta.namePrefix"
v-model:passport-number="meta.number"
/>
<FormEmployeeVisa
v-if="mode === 'visa' && meta"
prefix-id="drawer-info-employee"
id="form-visa"
ocr
dense
outlined
title="customerEmployee.form.group.visa"
:readonly="!isEdit"
v-model:arrival-at="meta.arrivalAt"
v-model:arrival-tm-no="meta.arrivalTMNo"
v-model:arrival-tm="meta.arrivalTM"
v-model:mrz="meta.mrz"
v-model:entry-count="meta.entryCount"
v-model:issue-place="meta.issuePlace"
v-model:issue-country="meta.issueCountry"
v-model:issueDate="meta.issueDate"
v-model:type="meta.type"
v-model:expire-date="meta.expireDate"
v-model:visa-issue-date="meta.issueDate"
v-model:visa-expiry-date="meta.expireDate"
v-model:remark="meta.remark"
v-model:worker-type="meta.workerType"
v-model:number="meta.number"
/>
<NoticeJobEmployment v-if="mode === 'noticeJobEmployment'" />
</template>
</UploadFileGroup>
</div>
</div>
</div>
</DialogForm>
<!-- NOTE: END - Employee Add Form -->
</template>
<style scoped>
.worker-list > :deep(*:not(:last-child)) {
margin-bottom: var(--size-2);
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1/1;
font-size: 1.5rem;
padding: var(--size-1);
border-radius: var(--radius-2);
}
.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);
}
.color-bar {
width: 100%;
height: 1vh;
background: linear-gradient(
90deg,
rgba(245, 159, 0, 1) 0%,
rgba(255, 255, 255, 1) 77%,
rgba(204, 204, 204, 1) 100%
);
display: flex;
overflow: hidden;
}
.color-bar.dark {
opacity: 0.7;
}
.orange-segment {
background-color: var(--yellow-7);
flex-grow: 4;
}
.yellow-segment {
background-color: hsla(var(--yellow-7-hsl) / 0.2);
flex-grow: 0.5;
}
.gray-segment {
background-color: #ccc;
flex-grow: 1;
}
.orange-segment,
.yellow-segment,
.gray-segment {
transform: skewX(-60deg);
}
:deep(i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon) {
color: hsl(var(--text-mute));
}
:deep(
i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon.q-expansion-item__toggle-icon--rotated
) {
color: var(--brand-1);
}
:deep(.q-editor__toolbar-group):nth-child(2) {
margin-left: auto !important;
}
:deep(.q-editor__toolbar.row.no-wrap.scroll-x) {
background-color: var(--surface-2) !important;
}
:deep(.q-editor__toolbar) {
border-color: var(--surface-3) !important;
}
:deep(
.q-item.q-item-type.row.no-wrap.q-item--dense.q-item--clickable.q-link.cursor-pointer.q-focusable.q-hoverable.surface-1
.q-focus-helper
) {
visibility: hidden;
}
.status-color {
--_color: var(--gray-0);
border-color: hsla(var(--_color));
background: hsla(var(--_color) / 0.05);
border-radius: 4px;
.icon-color {
color: hsla(var(--_color));
}
&.status-color-done {
--_color: var(--green-5-hsl);
color: var(--foreground);
}
&.status-color-doing {
--_color: var(--blue-5-hsl);
color: var(--foreground);
}
&.status-color-waiting {
--_color: var(--gray-4-hsl);
color: var(--foreground);
}
}
.quotation-status-active {
opacity: 1;
font-weight: 600;
transition: 1s box-shadow ease-in-out;
animation: status 1s infinite;
}
@keyframes status {
0% {
box-shadow: 0x 0px 0px hsla(var(--_color) / 0.5);
}
50% {
box-shadow: 0px 0px 1px 4px hsla(var(--_color) / 0.2);
}
100% {
box-shadow: 0px 0px 4px 7px hsla(var(--_color) / 0);
}
}
.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>