jws-frontend/src/pages/05_quotation/QuotationForm.vue
Methapon2001 d70c686cc8
Some checks failed
Spell Check / Spell Check with Typos (push) Failing after 6s
fix: price calc
2025-09-11 13:43:54 +07:00

2627 lines
80 KiB
Vue

<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { QSelect, useQuasar } from 'quasar';
import { getUserId } from 'src/services/keycloak';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import {
baseUrl,
dialogCheckData,
dialogWarningClose,
formatNumberDecimal,
canAccess,
isRoleInclude,
} from 'stores/utils';
import { ProductTree, quotationProductTree } from './utils';
// NOTE: Import stores
import { dateFormat, calculateAge, dateFormatJS } from 'src/utils/datetime';
import { useQuotationStore } from 'src/stores/quotations';
import useProductServiceStore from 'stores/product-service';
import { calculateDaysUntilExpire, dialog } from 'src/stores/utils';
import useEmployeeStore from 'stores/employee';
import { useInvoice, useReceipt, usePayment } from 'stores/payment';
import useCustomerStore from 'stores/customer';
import useOptionStore from 'stores/options';
import { useQuotationForm, DEFAULT_DATA } from './form';
import { deleteItem } from 'stores/utils';
// NOTE Import Types
import { RequestData, RequestDataStatus } from 'src/stores/request-list/types';
import { View } from './types';
import {
PayCondition,
ProductRelation,
ProductServiceList,
QuotationPayload,
} 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 TableRequest from './TableRequest.vue';
import SelectInput from 'components/shared/SelectInput.vue';
import SwitchItem from 'components/shared/SwitchItem.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 ImportWorker from './ImportWorker.vue';
import {
AddButton,
SaveButton,
EditButton,
UndoButton,
CancelButton,
MainButton,
} from 'components/button';
import QuotationFormReceipt from './QuotationFormReceipt.vue';
import QuotationFormProductSelect from './QuotationFormProductSelect.vue';
import QuotationFormInfo from './QuotationFormInfo.vue';
import QuotationFormWorkerSelect from './QuotationFormWorkerSelect.vue';
import QuotationFormWorkerAddDialog from './QuotationFormWorkerAddDialog.vue';
import UploadFileSection from 'src/components/upload-file/UploadFileSection.vue';
import DialogViewFile from 'src/components/dialog/DialogViewFile.vue';
import { columnPaySplit } from './constants';
import { precisionRound } from 'src/utils/arithmetic';
import { useConfigStore } from 'src/stores/config';
import { useRequestList } from 'src/stores/request-list';
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, Lang } from 'src/utils/ui';
import { convertTemplate } from 'src/utils/string-template';
import { CustomerBranch } from 'src/stores/customer';
type Node = {
[key: string]: any;
opened?: boolean;
checked?: boolean;
bg?: string;
fg?: string;
icon?: string;
children?: Node[];
};
type ProductGroupId = string;
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const employeeStore = useEmployeeStore();
const route = useRoute();
const useReceiptStore = useReceipt();
const configStore = useConfigStore();
const productServiceStore = useProductServiceStore();
const customerStore = useCustomerStore();
const quotationForm = useQuotationForm();
const quotationStore = useQuotationStore();
const invoiceStore = useInvoice();
const paymentStore = usePayment();
const optionStore = useOptionStore();
const requestStore = useRequestList();
const { t, locale } = useI18n();
const $q = useQuasar();
const openQuotation = ref<boolean>(false);
const formMetadata = ref();
const customerBranchOption = ref<CustomerBranch>();
const rowsRequestList = ref<RequestData[]>([]);
const {
currentFormData: quotationFormData,
currentFormState: quotationFormState,
invoicePayload: invoiceFormData,
newWorkerList,
fileItemNewWorker,
quotationFull,
} = storeToRefs(quotationForm);
const { data: config } = storeToRefs(configStore);
const receiptList = ref<Receipt[]>([]);
const refStatusFilter = ref<InstanceType<typeof QSelect>>();
const templateForm = ref<string>('');
const templateFormOption = ref<{ label: string; value: string }[]>([]);
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 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 selectedWorkerItem = computed(() => {
return [
...selectedWorker.value.map((e) => ({
foreignRefNo: e.employeePassport
? e.employeePassport[0]?.number || '-'
: '-',
employeeName:
locale.value === Lang.English
? `${e.firstNameEN} ${e.lastNameEN}`
: e.firstName
? `${e.firstName} ${e.lastName}`
: `${e.firstNameEN} ${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,
})),
...newWorkerList.value.map((v: any) => ({
foreignRefNo: v.passportNo,
employeeName:
locale.value === Lang.English
? `${v.firstNameEN} ${v.lastNameEN}`
: `${v.firstName} ${v.lastName}`,
birthDate: dateFormatJS({ date: v.dateOfBirth }),
gender: v.gender,
age: calculateAge(v.dateOfBirth),
nationality: optionStore.mapOption(v.nationality),
documentExpireDate: '-',
imgUrl: '',
status: 'CREATED',
})),
];
});
const firstCodePayment = ref('');
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 calcVat =
c.product[agentPrice.value ? 'agentPriceCalcVat' : 'calcVat'];
const vatFactor = calcVat ? (config.value?.vat ?? 0.07) : 0;
const price = precisionRound(
(c.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 = calcVat
? a.vatExcluded
: precisionRound(a.vatExcluded + price);
a.finalPrice = precisionRound(a.totalPrice - a.totalDiscount + a.vat);
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,
imageDialog: false,
imageDialogTitle: '',
imageDialogUrl: '',
});
const productList = ref<Partial<Record<ProductGroupId, Product[]>>>({});
const serviceList = ref<Partial<Record<ProductGroupId, Service[]>>>({});
const productGroup = ref<ProductGroup[]>([]);
const selectedGroupSub = ref<'product' | 'service' | null>(null);
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: () => {
code.value = '';
selectedInstallmentNo.value = [];
selectedInstallment.value = [];
getInvoice();
},
},
{
title: 'PaymentInProcess',
status: getStatus(quotationFormData.value.quotationStatus, 4, 1),
active: () =>
view.value === View.Payment || view.value === View.PaymentPre,
handler: () => {
code.value = '';
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: async () => {
view.value = View.Complete;
await fetchRequest();
},
},
];
}
async function fetchRequest() {
const res = await requestStore.getRequestDataList({
query: quotationFormState.value.inputSearchRequest,
page: 1,
pageSize: 999999,
requestDataStatus:
quotationFormState.value.statusFilterRequest === 'All'
? undefined
: quotationFormState.value.statusFilterRequest,
quotationId: quotationFormData.value.id,
});
if (res) {
rowsRequestList.value = res.result;
}
}
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),
);
}
assignWorkerToSelectedWorker();
await assignToProductServiceList();
await fetchStatus();
}
async function fetchReceipt() {
const res = await useReceiptStore.getReceiptList({
quotationId: quotationFormData.value.id,
quotationOnly: true,
debitNoteOnly: false,
});
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) => ({
id: v.id!,
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) => {
{
return v.id;
}
}),
...newWorkerList.value.map((v) => {
{
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 || '',
agentPrice: agentPrice.value,
sellerId: quotationFormData.value.sellerId,
};
newWorkerList.value = [];
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,
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 triggerSelectEmployeeDialog() {
pageState.employeeModal = true;
await nextTick();
}
function triggerProductServiceDialog() {
covertToNode();
pageState.productServiceModal = true;
}
function handleChangePayType(type: PayCondition) {
if (
type === 'Split' ||
(type === 'Full' &&
tempPaySplitCount.value !== undefined &&
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,
}));
}
if (type === 'SplitCustom') {
quotationFormData.value.paySplitCount = tempPaySplitCount.value || 1;
if (tempPaySplit.value.length === 0) {
quotationFormData.value.paySplit = [];
quotationFormData.value.paySplit.push({ no: Number(1), amount: 0 });
}
}
}
function handleUpdateProductTable(
data: QuotationPayload['productServiceList'][number],
opt?: {
newInstallmentNo: number;
},
) {
// handleChangePayType(quotationFormData.value.payCondition);
// calc price
const calc = (c: QuotationPayload['productServiceList'][number]) => {
const originalPrice = c.pricePerUnit || 0;
const finalPriceWithVat = precisionRound(
originalPrice * (1 + (config.value?.vat || 0.07)),
);
const finalPriceNoVat =
finalPriceWithVat / (1 + (config.value?.vat || 0.07));
const price = finalPriceNoVat * c.amount;
const vat = c.product.calcVat
? (finalPriceNoVat * c.amount - (c.discount || 0)) *
(config.value?.vat || 0.07)
: 0;
return precisionRound(price + vat);
};
// 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
? price / (1 + (config.value?.vat || 0.07))
: price;
const vat =
(pricePerUnit * currProduct.amount - currProduct.discount) *
(config.value?.vat || 0.07);
const finalPrice =
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[]) {
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);
if (list.length === 0) return;
quotationFormData.value.paySplitCount = Math.max(
...list.map((v) => v.installmentNo || 0),
);
tempPaySplitCount.value = quotationFormData.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 = quotationFormData.value.paySplitCount;
}
});
productServiceList.value = list;
tempTableProduct.value = JSON.parse(JSON.stringify(list));
if (nodes.length > 0) {
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,
}));
} else {
quotationFormData.value.paySplit = [];
quotationFormData.value.paySplitCount = 0;
quotationFormData.value.payCondition = PayCondition.Full;
}
tempPaySplit.value = JSON.parse(
JSON.stringify(quotationFormData.value.paySplit),
);
tempPaySplitCount.value = quotationFormData.value.paySplitCount;
pageState.productServiceModal = false;
}
function convertEmployeeToTable(selected: Employee[]) {
productServiceList.value.forEach((v) => {
if (selectedWorker.value.length === 0 && v.amount === 1) v.amount -= 1;
v.amount = Math.max(
v.amount + selected.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);
});
selected.forEach((item, i) => {
if (selectedWorker.value.find((n) => item.id === n.id)) return;
newWorkerIndex.push(i);
});
v.workerIndex = oldWorkerId
.map((id) => selected.findIndex((item) => item.id === id))
.filter((idx) => idx !== -1)
.concat(newWorkerIndex);
});
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();
}
function viewProductFile(data: ProductRelation) {
const base64 = data.detail.match(/src="([^"]+)"/);
pageState.imageDialog = true;
pageState.imageDialogTitle = data.name;
pageState.imageDialogUrl = base64 ? base64[1] : '';
}
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');
const { data: docTemplate, status } =
await api.get<string[]>('/doc-template');
if (status < 400) {
templateFormOption.value = docTemplate.map((v) => ({
label: v,
value: v,
}));
}
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;
quotationFormData.value.sellerId = getUserId();
await fetchQuotation();
await assignWorkerToSelectedWorker();
sessionData.value = parsed;
}
await fetchStatus();
if (quotationFormState.value.mode !== 'create') {
await getAttachment();
}
pageState.isLoaded = true;
if (route.query['tab'] === 'invoice') {
getInvoice();
}
if (route.query['tab'] === 'receipt') {
await fetchReceipt();
view.value = View.Receipt;
code.value = '';
}
});
watch(
() => quotationFormData.value.quotationStatus,
() => {
fetchStatus();
},
);
watch(
() => quotationFormData.value.customerBranchId,
async (v) => {
if (!v) return;
selectedWorker.value = [];
},
);
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,
() => {
covertToNode();
},
);
watch(customerBranchOption, () => {
if (!customerBranchOption.value) return;
quotationFormData.value.contactName = customerBranchOption.value.contactName;
quotationFormData.value.contactTel = customerBranchOption.value.contactTel;
});
// 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 = productService.value;
localStorage.setItem(
'quotation-preview',
JSON.stringify({
data: {
codeInvoice: code.value,
codePayment: firstCodePayment.value,
...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'),
agentPrice: agentPrice.value,
},
}),
);
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,
activeOnly: true,
});
if (!ret) return false; // error, do not close dialog
const deduplicate = ret.result.filter(
(a) => !selectedWorker.value.find((b) => a.id === b.id),
);
convertEmployeeToTable([...deduplicate, ...selectedWorker.value]);
return true;
}
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 getInvoice() {
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Invoice
: View.InvoicePre;
if (
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
) {
getInvoiceCodeFullPay();
} else {
code.value = '';
}
}
function handleWorkName() {
const validService = productService.value.find(
(item) => 'service' in item && item.service !== null,
);
if (!validService || validService?.service === null) return;
const workName = validService?.service.name;
if (
!!quotationFormData.value.workName &&
!(quotationFormData.value.workName === workName)
) {
dialogCheckData({
action: () => {
quotationFormData.value.workName = workName;
},
checkData: () => {
return {
oldData: [
{ nameField: 'workName', value: quotationFormData.value.workName },
],
newData: [{ nameField: 'workName', value: workName }],
};
},
cancel: () => {},
});
} else {
quotationFormData.value.workName = workName;
}
}
watch(
[
() => quotationFormState.value.statusFilterRequest,
() => quotationFormState.value.inputSearchRequest,
],
() => {
fetchRequest();
},
);
async function formDownload() {
if (!quotationFormData.value.id) return;
const res = await fetch(
baseUrl +
'/doc-template/' +
templateForm.value +
`?data=quotation&dataId=${quotationFormData.value.id}`,
);
const blob = await res.blob();
const a = document.createElement('a');
a.download = templateForm.value;
a.href = window.URL.createObjectURL(blob);
a.click();
a.remove();
}
function covertToNode() {
productServiceNodes.value = quotationProductTree(
productServiceList.value,
agentPrice.value,
config.value?.vat,
);
}
</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-sm-2 col-12 q-ml-auto"
style="gap: 10px"
>
<div
class="row"
:class="{
'justify-end': $q.screen.gt.xs,
'q-pl-xl q-mt-sm': $q.screen.lt.sm,
}"
>
<BadgeComponent
:title-i18n="$t('general.laborIdentified')"
:class="{
'q-ml-md': $q.screen.lt.sm,
}"
/>
</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"
:id="`btn-status-${value.title}`"
: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">
<q-form
ref="formMetadata"
@submit="
() => {
convertDataToFormSubmit();
}
"
>
<QuotationFormMetadata
class="q-mb-md"
:readonly
:actor="quotationFormState.createdBy?.($i18n.locale) || ''"
:quotation-no="(quotationFull && quotationFull.code) || ''"
:quotation-status="
quotationFormState.source?.quotationStatus === 'Expired'
"
:created-at="quotationFormState.createdAt"
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"
v-model:seller-id="quotationFormData.sellerId"
>
<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
"
v-model:customer-branch-option="customerBranchOption"
:readonly="readonly"
/>
</template>
</QuotationFormMetadata>
</q-form>
</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 v-if="canAccess('quotation', 'edit')" class="q-ml-auto">
<AddButton
id="btn-add-worker"
for="btn-add-worker"
v-if="
!readonly &&
(!quotationFormState.source ||
quotationFormState.source?.quotationStatus ===
'Issued' ||
quotationFormState.source?.quotationStatus ===
'Expired')
"
icon-only
@click.stop="triggerSelectEmployeeDialog"
/>
<AddButton
id="btn-add-worker"
for="btn-add-worker"
v-if="
!!quotationFormState.source &&
quotationFormState.source.quotationStatus !==
'Issued' &&
quotationFormState.source?.quotationStatus !== 'Expired'
"
@click.stop="openQuotation = !openQuotation"
icon-only
class="q-ml-auto"
/>
</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"
:rows="selectedWorkerItem"
@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"
id="trigger-product-service-dialog"
@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="selectedWorkerItem"
@delete="toggleDeleteProduct"
:rows="productService"
@update:rows="
(v) => {
view === View.Quotation && (productServiceList = v);
}
"
@update-table="handleUpdateProductTable"
@view-file="viewProductFile"
/>
</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:
isRoleInclude(['sale', 'head_of_sale']) ||
!canAccess('quotation', 'edit'),
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
v-if="false"
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.letterOfAcceptance') }}
</span>
</div>
</section>
</template>
<div class="surface-1 q-pa-md flex" style="gap: var(--size-2)">
<SelectInput
style="max-width: 250px"
class=""
incremental
v-model="templateForm"
id="quotation-branch"
:option="templateFormOption"
:label="$t('quotation.templateForm')"
/>
<!-- <MainButton
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
@click="() => {}"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton> -->
<MainButton
solid
icon="mdi-pencil-outline"
color="207 96% 32%"
@click="formDownload"
>
{{ $t('general.designForm') }}
</MainButton>
</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
:hint="$t('general.enterToAdd')"
: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.employeePassport.length !== 0 ? v.employeePassport[0].number + '_' : ''} ${v.namePrefix}.${v.firstNameEN ? `${v.firstNameEN} ${v.lastNameEN}` : `${v.firstName} ${v.lastName}`} `.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">
<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>
<p class="app-text-muted text-caption">
{{ $t('general.hintRemark') }}
<code>#[quotation-labor]</code>
{{ $t('general.quotationLabor') }}
{{ $t('general.or') }}
<code>#[quotation-payment]</code>
{{ $t('general.quotationPayment') }}
</p>
</div>
</q-expansion-item>
</template>
<template v-else>
<PaymentForm
v-if="
view !== View.InvoicePre &&
view !== View.Receipt &&
view !== View.Complete
"
:readonly="
isRoleInclude(['sale', 'head_of_sale']) ||
!canAccess('quotation', 'edit')
"
:data="quotationFormState.source"
v-model:first-code-payment="firstCodePayment"
@fetch-status="
() => {
fetchQuotation();
}
"
/>
<div
v-if="view === View.Complete"
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="quotationFormState.inputSearchRequest"
debounce="200"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
<template v-if="$q.screen.lt.md" v-slot:append>
<span class="row">
<q-separator vertical />
<q-btn
icon="mdi-filter-variant"
unelevated
class="q-ml-sm"
padding="4px"
size="sm"
rounded
@click="refStatusFilter?.showPopup"
/>
</span>
</template>
</q-input>
<q-select
v-show="$q.screen.gt.sm"
ref="refStatusFilter"
v-model="quotationFormState.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
v-if="view === View.Complete"
:rows="rowsRequestList"
/>
</div>
<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-if="
!quotationFormData.paySplit.every(
(p) => p.invoiceId,
)
"
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.Accepted) &&
quotationFormData.id
"
>
<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 row" style="gap: var(--size-2)">
<SelectInput
class="q-mr-xl col-md-3 col-12"
v-model="templateForm"
id="quotation-branch"
:option="templateFormOption"
:label="$t('quotation.templateForm')"
/>
<!-- <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="formDownload"
>
{{ $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"
@view="
() => {
if (quotationFormData.payCondition !== 'Full') {
selectedInstallmentNo = [v.invoice.installments[0].no];
installmentAmount = v.invoice.amount;
}
view = View.Quotation;
}
"
@example="
() => {
exampleReceipt(v.id);
}
"
/>
</template>
</div>
</section>
</article>
<footer class="surface-1 q-pa-md full-width">
<div class="row full-width justify-end">
<MainButton
class="q-mr-auto"
v-if="
view !== View.InvoicePre &&
view !== View.PaymentPre &&
view !== View.Receipt
"
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
id="btn-view-example"
@click="storeDataLocal"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<CancelButton
outlined
id="btn-close"
@click="closeTab()"
:label="$t('dialog.action.close')"
v-if="quotationFormState.mode === 'info' && closeAble()"
/>
<div
class="q-ml-sm"
v-if="
view === View.Accepted &&
canAccess('quotation', 'edit') &&
quotationFormData.quotationStatus === 'Issued'
"
>
<MainButton
solid
icon="mdi-account-multiple-check-outline"
color="207 96% 32%"
id="btn-submit-accepted"
@click="
() => {
submitAccepted();
}
"
>
{{ $t('quotation.customerAcceptance') }}
</MainButton>
</div>
<template
v-if="
view === View.InvoicePre &&
!quotationFormData.paySplit.every((p) => p.invoiceId)
"
>
<MainButton
solid
:disabled="selectedInstallment.length === 0"
icon="mdi-account-multiple-check-outline"
class="q-ml-sm"
color="207 96% 32%"
id="btn-select-invoice"
@click="
() => {
view = View.Invoice;
}
"
>
{{ $t('quotation.selectInvoice') }}
</MainButton>
</template>
<div
class="q-ml-sm"
v-if="
view === View.Invoice &&
canAccess('quotation', 'edit') &&
((quotationFormData.quotationStatus !== 'PaymentPending' &&
quotationFormData.payCondition !== 'Full') ||
quotationFormData.quotationStatus === 'Accepted') &&
!isIssueInvoice()
"
>
<MainButton
solid
icon="mdi-account-multiple-check-outline"
color="207 96% 32%"
id="btn-approve-invoice"
@click="
() => {
convertInvoiceToSubmit();
}
"
>
{{ $t('quotation.approveInvoice') }}
</MainButton>
</div>
<div
class="row q-ml-sm"
style="gap: var(--size-2)"
v-if="
(view === View.Quotation &&
canAccess('quotation', 'edit') &&
(quotationFormData.quotationStatus === 'Issued' ||
quotationFormData.quotationStatus === 'Expired')) ||
!quotationFormData.quotationStatus
"
>
<UndoButton
outlined
@click="closeTab()"
id="btn-undo"
v-if="quotationFormState.mode === 'edit'"
/>
<SaveButton
type="submit"
id="btn-save"
v-if="
quotationFormState.mode === 'create' ||
quotationFormState.mode === 'edit'
"
solid
@click="() => formMetadata.submit()"
/>
<EditButton
v-else
class="no-print"
id="btn-edit"
@click="quotationFormState.mode = 'edit'"
solid
/>
</div>
</div>
</footer>
<!-- SEC: Dialog -->
<!-- add employee quotation -->
<QuotationFormWorkerSelect
:preselect-worker="selectedWorker"
:customerBranchId="quotationFormData.customerBranchId"
v-model:open="pageState.employeeModal"
v-model:new-worker-list="newWorkerList"
@success="
(v) => {
selectedWorker = 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="
(node) => {
convertToTable(node);
handleWorkName();
}
"
@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>
<!-- add Worker -->
<QuotationFormWorkerAddDialog
v-if="quotationFormState.source"
:disabled-worker-id="selectedWorker.map((v) => v.id)"
:product-service-list="quotationFormState.source.productServiceList"
:quotation-id="quotationFormState.source.id"
:customer-branch-id="quotationFormState.source.customerBranchId"
v-model:open="openQuotation"
@success="
openQuotation = !openQuotation;
fetchQuotation();
"
/>
<DialogViewFile
:title="pageState.imageDialogTitle"
:url="pageState.imageDialogUrl"
v-model="pageState.imageDialog"
/>
</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>