2616 lines
79 KiB
Vue
2616 lines
79 KiB
Vue
<script lang="ts" setup>
|
|
import { useI18n } from 'vue-i18n';
|
|
import { storeToRefs } from 'pinia';
|
|
import { QSelect, useQuasar } from 'quasar';
|
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
|
import {
|
|
baseUrl,
|
|
dialogCheckData,
|
|
dialogWarningClose,
|
|
formatNumberDecimal,
|
|
} 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 { getRole } from 'src/services/keycloak';
|
|
|
|
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 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 workerList = ref<Employee[]>([]);
|
|
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 hideBtnApproveInvoice = computed(() => {
|
|
const role = getRole();
|
|
const allowedRoles = [
|
|
'system',
|
|
'head_of_admin',
|
|
'admin',
|
|
'head_of_accountant',
|
|
'accountant',
|
|
];
|
|
return !role || !role.some((r) => allowedRoles.includes(r));
|
|
});
|
|
|
|
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 originalPrice = c.pricePerUnit;
|
|
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 =
|
|
(finalPriceNoVat * c.amount - c.discount) * (config.value?.vat || 0.07);
|
|
|
|
const calcVat =
|
|
c.product[agentPrice.value ? 'agentPriceCalcVat' : 'calcVat'];
|
|
|
|
a.totalPrice = precisionRound(a.totalPrice + price);
|
|
a.totalDiscount = precisionRound(a.totalDiscount + Number(c.discount));
|
|
a.vat = calcVat ? precisionRound(a.vat + vat) : a.vat;
|
|
a.vatExcluded = calcVat
|
|
? a.vatExcluded
|
|
: precisionRound(a.vatExcluded + price);
|
|
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,
|
|
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,
|
|
};
|
|
|
|
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;
|
|
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;
|
|
|
|
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,
|
|
() => {
|
|
covertToNode();
|
|
},
|
|
);
|
|
|
|
// 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'
|
|
"
|
|
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>
|
|
</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 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: 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
|
|
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
|
|
"
|
|
: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-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;
|
|
|
|
console.log(code);
|
|
}
|
|
}
|
|
"
|
|
>
|
|
<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 &&
|
|
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">
|
|
<MainButton
|
|
solid
|
|
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 &&
|
|
((quotationFormData.quotationStatus !== 'PaymentPending' &&
|
|
quotationFormData.payCondition !== 'Full') ||
|
|
quotationFormData.quotationStatus === 'Accepted') &&
|
|
!isIssueInvoice()
|
|
"
|
|
>
|
|
<MainButton
|
|
v-if="!hideBtnApproveInvoice"
|
|
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 &&
|
|
(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>
|