Merge branch 'develop'

This commit is contained in:
Thanaphon Frappet 2025-02-07 14:41:28 +07:00
commit 8021d91356
113 changed files with 9689 additions and 3248 deletions

View file

@ -3,6 +3,7 @@ FROM node:20-slim AS build-stage
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm i -g corepack@latest
RUN corepack enable
WORKDIR /app

View file

@ -44,7 +44,7 @@ defineEmits<{
}>();
const bankBookOptions = ref<Record<string, unknown>[]>([]);
let bankBoookFilter: (
let bankBookFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
@ -77,7 +77,7 @@ function change(e: Event) {
onMounted(() => {
if (optionStore.globalOption) {
bankBoookFilter = selectFilterOptionRefMod(
bankBookFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption.bankBook),
bankBookOptions,
'label',
@ -94,7 +94,7 @@ onMounted(() => {
watch(
() => optionStore.globalOption,
() => {
bankBoookFilter = selectFilterOptionRefMod(
bankBookFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption.bankBook),
bankBookOptions,
'label',
@ -131,8 +131,8 @@ watch(
<div
v-for="(book, i) in bankBookList"
class="col-12 row"
:class="{ 'q-pt-lg': i !== 0 }"
class="col-12"
:class="{ 'q-pt-lg': i !== 0, row: $q.screen.gt.sm }"
:key="i"
>
<q-separator
@ -172,8 +172,8 @@ watch(
</span>
<div
class="bordered q-mr-sm rounded"
:class="{ 'pointer-none': readonly }"
class="bordered q-mr-sm rounded col text-center overflow-hidden"
:class="{ 'pointer-none': readonly, 'q-my-sm': $q.screen.lt.md }"
>
<ImageHover
:readonly="readonly"
@ -214,7 +214,7 @@ watch(
@update:model-value="
(v) => (typeof v === 'string' ? (book.bankName = v) : '')
"
@filter="bankBoookFilter"
@filter="bankBookFilter"
@clear="book.bankName = ''"
>
<template v-slot:option="scope">

View file

@ -67,7 +67,7 @@ defineProps<{
outlined
:readonly="readonly"
hide-bottom-space
class="col-6 col-md-4"
class="col-12 col-md-4"
:label="$t('form.telephone')"
for="input-telephone-no"
:model-value="readonly ? telephoneNo || '-' : telephoneNo"
@ -116,7 +116,7 @@ defineProps<{
outlined
:readonly="readonly"
hide-bottom-space
class="col-6 col-md-4"
class="col-12 col-md-4"
:label="$t('branch.form.contactTelephone')"
for="input-contact"
:model-value="readonly ? contact || '-' : contact"
@ -139,7 +139,7 @@ defineProps<{
outlined
:readonly="readonly"
hide-bottom-space
class="col-6 col-md-4"
class="col-12 col-md-4"
:label="$t('branch.form.webUrl')"
for="input-web-url"
:model-value="readonly ? webUrl || '-' : webUrl"

View file

@ -203,7 +203,7 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-12"
:label="$t('general.licenseNumber')"
v-model="permitNo"
:rules="[(val) => val && val.length > 0]"
@ -212,7 +212,7 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
/>
<DatePicker
class="col-3"
class="col-md-3 col-12"
id="input-start-date"
:readonly="readonly"
:label="$t('general.dateOfIssue')"
@ -221,7 +221,7 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
/>
<DatePicker
class="col-3"
class="col-md-3 col-12"
id="input-start-date"
:readonly="readonly"
:label="$t('general.expirationDate')"

View file

@ -186,6 +186,7 @@ watch(
/>
<SelectOffice
for="input-responsible-area"
v-model:value="responsibleArea"
v-if="userType === 'MESSENGER'"
:readonly="readonly"

View file

@ -221,7 +221,6 @@ watch(
class="col"
:label="$t('personnel.form.lastName')"
v-model="lastName"
:rules="[(val: string) => !!val || $t('form.error.required')]"
/>
</div>
@ -289,9 +288,8 @@ watch(
label="Surname"
v-model="lastNameEN"
:rules="[
(val: string) => !!val || $t('form.error.required'),
(val: string) =>
/^[A-Za-z\s]+$/.test(val) || $t('form.error.letterOnly'),
!val || /^[A-Za-z\s]+$/.test(val) || $t('form.error.letterOnly'),
]"
/>
</div>
@ -408,7 +406,7 @@ watch(
outlined
readonly
:label="$t('personnel.age')"
class="col-md-2 col-12"
class="col-md-2 col-6"
:model-value="
birthDate?.toString() === 'Invalid Date' ||
birthDate?.toString() === undefined
@ -472,7 +470,7 @@ watch(
input-debounce="0"
option-label="label"
option-value="value"
class="col-2"
class="col-md-2 col-6"
:dense="dense"
v-model="gender"
:readonly="readonly"
@ -506,7 +504,7 @@ watch(
option-label="label"
option-value="value"
v-model="nationality"
class="col-2"
class="col-md-2 col-6"
:dense="dense"
:readonly="readonly"
:options="nationalityOptions"

View file

@ -266,7 +266,7 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
:for="`${prefixId}-input-hospital`"
/>
<div class="col">
<div class="col-md col-6">
<DatePicker
v-model="checkup.coverageStartDate"
:id="`${prefixId}-input-coverage-start-date`"
@ -301,7 +301,7 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
map-options
hide-selected
hide-bottom-space
class="col-6"
class="col-md-6 col-12"
input-debounce="0"
option-value="value"
option-label="label"

View file

@ -79,7 +79,7 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
:readonly="readonly || employeeOther.statusSave"
hide-bottom-space
:label="$t('customerEmployee.formFamily.citizenId')"
class="col-4"
class="col-md-4 col-12"
v-model="employeeOther.citizenId"
/>
@ -90,7 +90,7 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
:readonly="readonly || employeeOther.statusSave"
hide-bottom-space
:label="$t('customerEmployee.formFamily.telephoneNo')"
class="col-3"
class="col-md-3 col-12"
v-model="employeeOther.telephoneNo"
/>
</div>

View file

@ -220,7 +220,7 @@ watch(
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="q-col-gutter-sm" :class="{ row: $q.screen.gt.sm }">
<div
class="col row justify-center q-col-gutter-sml"
style="max-height: 50%"
@ -252,7 +252,7 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
class="col-6"
class="col-md-6 col-12"
:dense="dense"
:readonly="readonly"
:options="workerStatusOptions"
@ -283,7 +283,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-6"
class="col-md-6 col-12"
:label="$t('customerEmployee.form.previousPassportNumber')"
v-model="previousPassportRef"
:rules="[
@ -306,7 +306,7 @@ watch(
option-label="label"
option-value="value"
hide-dropdown-icon
class="col-2"
class="col-md-2 col-6"
:dense="dense"
:readonly="readonly"
:options="prefixNameOptions"
@ -334,7 +334,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col"
class="col-md col-6"
:label="$t('form.firstName')"
:model-value="readonly ? firstName || '-' : firstName"
@update:model-value="
@ -388,7 +388,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('form.firstNameEN')"
:model-value="readonly ? firstNameEN || '-' : firstNameEN"
@update:model-value="
@ -405,7 +405,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('form.middleNameEN')"
:model-value="readonly ? middleNameEN || '-' : middleNameEN"
@update:model-value="
@ -419,7 +419,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('form.lastNameEN')"
:model-value="readonly ? lastNameEN || '-' : lastNameEN"
@update:model-value="
@ -453,7 +453,7 @@ watch(
input-debounce="0"
option-label="label"
option-value="value"
class="col-2"
class="col-md-2 col-4"
dense
:readonly="readonly"
:options="genderOptions"
@ -482,7 +482,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-6 col-md-3"
class="col-8 col-md-3"
:label="$t('customerEmployee.form.passportNo')"
v-model="passportNumber"
:rules="[
@ -494,7 +494,7 @@ watch(
<DatePicker
v-model="birthDate"
class="col-2"
class="col-md-2 col-6"
:id="`${prefixId}-input-birth-date`"
:readonly="readonly"
:label="$t('form.birthDate')"
@ -513,7 +513,7 @@ watch(
outlined
readonly
:label="$t('personnel.age')"
class="col-2"
class="col-md-2 col-6"
:model-value="
birthDate?.toString() === 'Invalid Date' ||
birthDate?.toString() === undefined
@ -534,7 +534,7 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
class="col"
class="col-md col-6"
:dense="dense"
:readonly="readonly"
:options="nationalityOptions"
@ -567,7 +567,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('customerEmployee.form.placeOfBirth')"
:model-value="optionStore.mapOption(birthCountry || '')"
@update:model-value="
@ -621,7 +621,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('customerEmployee.form.passportPlace')"
:model-value="
readonly

View file

@ -147,8 +147,13 @@ watch(
<slot name="button"></slot>
</div>
</div>
<div class="row">
<div v-if="!ocr" class="col row justify-center" style="max-height: 50%">
<div :class="{ row: $q.screen.gt.sm }">
<div
v-if="!ocr"
class="col row justify-center"
:class="{ 'q-mb-md': $q.screen.lt.md }"
style="max-height: 50%"
>
<q-avatar
style="border: 1px dashed; border-color: black"
square
@ -175,7 +180,7 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:class="{ 'col-md-4 col-6': !ocr, 'col-6': ocr }"
:dense="dense"
:readonly="readonly"
:options="workerTypeOptions"
@ -207,7 +212,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:class="{ 'col-md-4 col-6': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaNo')"
:model-value="readonly ? number || '-' : number"
@update:model-value="
@ -221,7 +226,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:class="{ 'col-md-4 col-12': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaPlace')"
:model-value="readonly ? issuePlace || '-' : issuePlace"
@update:model-value="
@ -280,7 +285,7 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:class="{ 'col-md-4 col-6': !ocr, 'col-6': ocr }"
:dense="dense"
:readonly="readonly"
:options="visaTypeOptions"
@ -303,7 +308,7 @@ watch(
</template>
</q-select>
<div class="col">
<div class="col-md col-6">
<DatePicker
:id="`${prefixId}-date-picker-visa-issuance`"
:readonly="readonly"
@ -362,7 +367,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-12"
:label="$t('customerEmployee.form.arrivalCardNo')"
:model-value="readonly ? arrivalTMNo || '-' : arrivalTMNo"
@update:model-value="
@ -374,7 +379,7 @@ watch(
/>
<DatePicker
class="col-4"
class="col-md-4 col-12"
:id="`${prefixId}-date-picker-visa-enter`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaEnter')"
@ -391,7 +396,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-12"
:label="$t('customerEmployee.form.visaCheckpoint')"
:model-value="readonly ? arrivalAt || '-' : arrivalAt"
@update:model-value="
@ -414,7 +419,7 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
class="col-4"
class="col-md-4 col-6"
:dense="dense"
:readonly="readonly"
:options="visaTypeOptions"
@ -446,7 +451,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('customerEmployee.form.entryCount')"
v-model="entryCount"
type="number"

View file

@ -237,7 +237,7 @@ const workplaceFilter = selectFilterOptionRefMod(
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-4"
class="col-md-4 col-12"
:label="$t('customerEmployee.formWorkHistory.identityNo')"
v-model="work.identityNo"
mask="#############"
@ -256,7 +256,7 @@ const workplaceFilter = selectFilterOptionRefMod(
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('customerEmployee.formWorkHistory.permitNo')"
v-model="work.workPermitNo"
/>
@ -266,7 +266,7 @@ const workplaceFilter = selectFilterOptionRefMod(
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-4"
class="col-md-4 col-6"
:label="$t('customerEmployee.formWorkHistory.permitIssuedAt')"
v-model="work.workPermitIssueAt"
/>
@ -274,7 +274,7 @@ const workplaceFilter = selectFilterOptionRefMod(
<DatePicker
:label="$t('customerEmployee.formWorkHistory.permitIssueDate')"
v-model="work.workPermitIssueDate"
class="col-3"
class="col-md-3 col-6"
:id="`${prefixId}-date-picker-work-permit-issue-date`"
:readonly="readonly || work.statusSave"
clearable
@ -282,7 +282,7 @@ const workplaceFilter = selectFilterOptionRefMod(
<DatePicker
:label="$t('customerEmployee.formWorkHistory.permitExpireDate')"
v-model="work.workPermitExpireDate"
class="col-3"
class="col-md-3 col-6"
:id="`${prefixId}-date-picker-work-permit-expire-date`"
:readonly="readonly || work.statusSave"
:disabled-dates="

View file

@ -116,7 +116,11 @@ defineEmits<{
"
>
<q-td class="text-center" v-if="fieldSelected.includes('orderNumber')">
{{ (currentPage - 1) * pageSize + props.rowIndex + 1 }}
{{
$q.screen.xs
? props.rowIndex + 1
: (currentPage - 1) * pageSize + props.rowIndex + 1
}}
</q-td>
<q-td v-if="fieldSelected.includes('firstName')">
@ -288,8 +292,8 @@ defineEmits<{
: `${props.row.firstName} ${props.row.lastName} `.trim(),
img:
`${baseUrl}/employee/${props.row.id}/image/${props.row.selectedImage}` ||
'/images/employee-avatar.png',
fallbackImg: '/images/employee-avatar.png',
`/images/employee-avatar-${props.row.gender}.png`,
fallbackImg: `/images/employee-avatar-${props.row.gender}.png`,
male: props.row.gender === 'male',
female: props.row.gender === 'female',
detail: [

View file

@ -289,7 +289,7 @@ defineEmits<{
hide-bottom-space
:readonly="readonly"
:disable="!readonly"
class="col-3"
class="col-md-3 col-12"
:label="$t('customerEmployee.form.employeeCode')"
v-model="code"
/>
@ -301,7 +301,7 @@ defineEmits<{
outlined
hide-bottom-space
:readonly="readonly"
class="col-6"
class="col-md-6 col-12"
:label="$t('customerEmployee.form.nrcNo')"
:model-value="nrcNo"
@update:model-value="(v) => (typeof v === 'string' ? (nrcNo = v) : '')"

View file

@ -19,6 +19,7 @@ import SelectMenuWithSearch from '../shared/SelectMenuWithSearch.vue';
import ToggleButton from 'src/components/button/ToggleButton.vue';
import NoData from '../NoData.vue';
import SelectBranch from '../shared/select/SelectBranch.vue';
import AddButton from '../button/AddButton.vue';
defineProps<{
readonly?: boolean;
@ -127,6 +128,7 @@ function optionSearch(val: string | null) {
defineEmits<{
(e: 'moveUp'): void;
(e: 'addStep'): void;
(e: 'moveDown'): void;
(e: 'changeStatus'): void;
(e: 'triggerProperties', step: WorkFlowPayloadStep): void;
@ -159,7 +161,10 @@ onMounted(async () => {
style="background-color: var(--surface-3)"
/>
{{ $t(`general.name`, { msg: $t('flow.title') }) }}
<span class="row q-ml-lg items-center text-weight-regular text-body2">
<span
class="row items-center text-weight-regular text-body2"
:class="{ 'q-ml-lg': $q.screen.gt.xs, 'q-mt-sm': $q.screen.lt.sm }"
>
<ToggleButton
class="q-mr-sm"
two-way
@ -216,6 +221,13 @@ onMounted(async () => {
style="background-color: var(--surface-3)"
/>
{{ $t(`flow.processStep`) }}
<AddButton
v-if="!readonly && $q.screen.lt.md"
id="btn-add-work"
icon-only
class="q-ml-sm"
@click="$emit('addStep')"
/>
</section>
<section

View file

@ -229,7 +229,7 @@ const detailEditorImageDrop = createEditorImageDrop(detail);
"
@drop="detailEditorImageDrop"
min-height="5rem"
class="q-mt-sm q-mb-xs"
class="q-mt-sm q-mb-xs col"
:flat="!readonly"
:readonly="readonly"
:toolbar-color="
@ -275,4 +275,8 @@ const detailEditorImageDrop = createEditorImageDrop(detail);
:deep(.q-editor__toolbar) {
border-color: var(--surface-3) !important;
}
:deep(.q-editor.q-editor--default) {
width: 1px;
}
</style>

View file

@ -168,7 +168,7 @@ const detailEditorImageDrop = createEditorImageDrop(detail);
/>
<q-field
class="full-width"
class="col-12"
outlined
for="input-service-description"
id="input-service-description"
@ -180,31 +180,34 @@ const detailEditorImageDrop = createEditorImageDrop(detail);
>
<q-editor
dense
class="q-mt-sm q-mb-xs col"
:model-value="
readonly ? serviceDescription || '-' : serviceDescription || ''
"
@update:model-value="
(v) => (typeof v === 'string' ? (serviceDescription = v) : '')
"
@drop="detailEditorImageDrop"
min-height="5rem"
class="q-mt-sm q-mb-xs"
:flat="!readonly"
:readonly="readonly"
:toolbar-toggle-color="readonly ? 'disabled' : 'primary'"
:toolbar-color="
readonly ? 'disabled' : $q.dark.isActive ? 'white' : ''
"
:toolbar-toggle-color="readonly ? 'disabled' : 'primary'"
style="
cursor: auto;
color: var(--foreground);
border-color: var(--surface-3);
"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
@update:model-value="
(v) => (typeof v === 'string' ? (serviceDescription = v) : '')
"
@drop="detailEditorImageDrop"
/>
</q-field>
</div>
</div>
</template>
<style scoped></style>
<style scoped>
:deep(.q-editor.q-editor--default) {
width: 1px;
}
</style>

View file

@ -106,6 +106,7 @@ function optionSearch(val: string | null) {
<div class="col-12 row q-col-gutter-sm">
<q-select
behavior="menu"
:readonly
outlined
dense

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { formatNumberDecimal, commaInput } from 'stores/utils';
import { QTableProps } from 'quasar';
import { QTableProps, QTableSlots } from 'quasar';
import { calculatePrice } from 'src/utils/arithmetic';
const serviceCharge = defineModel<number>('serviceCharge');
@ -9,24 +9,48 @@ const agentPrice = defineModel<number>('agentPrice');
const price = defineModel<number>('price');
const vatIncluded = defineModel<boolean>('vatIncluded');
const calcVat = defineModel<boolean>('calcVat');
const agentPriceVatIncluded = defineModel<boolean>('agentPriceVatIncluded');
const agentPriceCalcVat = defineModel<boolean>('agentPriceCalcVat');
const serviceChargeVatIncluded = defineModel<boolean>(
'serviceChargeVatIncluded',
);
const serviceChargeCalcVat = defineModel<boolean>('serviceChargeCalcVat');
const price4Show = ref<string>(commaInput(price.value?.toString() || '0'));
const agentPrice4Show = ref<string>(
const formattedPrice = ref<string>(commaInput(price.value?.toString() || '0'));
const formattedAgentPrice = ref<string>(
commaInput(agentPrice.value?.toString() || '0'),
);
const serviceCharge4Show = ref<string>(
const formattedServiceCharge = ref<string>(
commaInput(serviceCharge.value?.toString() || '0'),
);
const column = [
type RowData = {
pricePerUnit: number;
calcVat: boolean;
vatIncluded: boolean;
};
const columns = [
{
name: 'label',
align: 'center',
align: 'left',
label: 'productService.product.priceInformation',
field: 'label',
},
{
name: 'pricePerUnit',
name: '#calcVat',
align: 'center',
label: 'general.calculateVat',
field: '#calcVat',
},
{
name: '#vatIncluded',
align: 'center',
label: 'productService.product.vatIncluded',
field: '#vatIncluded',
},
{
name: '#pricePerUnit',
align: 'center',
label: 'quotation.pricePerUnit',
field: 'pricePerUnit',
@ -35,54 +59,77 @@ const column = [
name: 'beforeVat',
align: 'right',
label: 'quotation.priceBeforeVat',
field: 'beforeVat',
field: (data: RowData) =>
formatNumberDecimal(
calculatePrice({
output: 'beforeVat',
vatIncluded: data.vatIncluded,
price: data.pricePerUnit || 0,
}),
2,
),
},
{
name: 'vat',
align: 'right',
label: 'general.vat',
field: 'vat',
field: (data: RowData) =>
formatNumberDecimal(
calculatePrice({
output: 'vat',
calcVat: data.calcVat,
price: Number(
formatNumberDecimal(
calculatePrice({
output: 'beforeVat',
vatIncluded: data.vatIncluded,
price: data.pricePerUnit,
}),
2,
).replaceAll(',', ''),
),
}),
2,
),
},
{
name: 'total',
align: 'right',
label: 'quotation.sumPrice',
field: 'total',
field: (data: RowData) =>
formatNumberDecimal(
calculatePrice({
output: 'total',
vat: 0.07,
price: data.pricePerUnit,
}) +
(!data.vatIncluded
? calculatePrice({
output: 'vat',
calcVat: data.calcVat,
price: data.pricePerUnit,
})
: 0),
2,
),
},
] as const satisfies QTableProps['columns'];
] as QTableProps['columns'];
const row = [
{
label: 'productService.product.salePrice',
beforeVat: 0,
vat: 0,
total: 0,
},
{
label: 'productService.product.agentPrice',
beforeVat: 0,
vat: 0,
total: 0,
},
{
label: 'productService.product.processingPrice',
beforeVat: 0,
vat: 0,
total: 0,
},
] as const satisfies QTableProps['rows'];
watch(calcVat, () => {
if (calcVat.value === false) vatIncluded.value = false;
watch([calcVat, agentPriceCalcVat, serviceChargeCalcVat], () => {
if (calcVat.value === false) {
vatIncluded.value = false;
}
if (agentPriceCalcVat.value === false) {
agentPriceVatIncluded.value = false;
}
if (serviceChargeCalcVat.value === false) {
serviceChargeVatIncluded.value = false;
}
});
withDefaults(
defineProps<{
dense?: boolean;
outlined?: boolean;
readonly?: boolean;
separator?: boolean;
isType?: boolean;
priceDisplay?: {
price: boolean;
agentPrice: boolean;
@ -115,255 +162,239 @@ withDefaults(
{{ $t('productService.product.priceInformation') }}
</span>
</div>
<section class="q-pr-md">
<input
id="input-calc-vat"
type="checkbox"
v-model="calcVat"
:disabled="readonly"
/>
<label
class="q-pl-sm"
for="input-calc-vat"
:style="{ opacity: readonly ? '.5' : undefined }"
>
{{ $t('general.calculateVat') }}
</label>
</section>
<div
class="surface-3 q-px-sm q-py-xs row text-caption app-text-muted"
style="border-radius: var(--radius-3)"
v-if="calcVat"
>
<span
id="btn-include-vat"
for="btn-include-vat"
class="q-px-sm q-mr-lg rounded cursor-pointer"
:class="{
dark: $q.dark.isActive,
'active-addr': vatIncluded,
'cursor-not-allowed': readonly,
}"
@click="readonly ? '' : (vatIncluded = true)"
>
{{ $t('productService.product.vatIncluded') }}
</span>
<span
id="btn-no-include-vat"
for="btn-no-include-vat"
class="q-px-sm rounded cursor-pointer"
:class="{
dark: $q.dark.isActive,
'active-addr': !vatIncluded,
'cursor-not-allowed': readonly,
}"
@click="readonly ? '' : (vatIncluded = false)"
>
{{ $t('productService.product.vatExcluded') }}
</span>
</div>
</div>
<div class="col-12">
<div class="col-12 full-width">
<q-table
:columns="column"
:rows="row"
:rows-per-page-options="[0]"
:rows="[
{
label: $t('productService.product.salePrice'),
pricePerUnit: price,
calcVat,
vatIncluded,
},
{
label: $t('productService.product.agentPrice'),
calcVat: agentPriceCalcVat,
vatIncluded: agentPriceVatIncluded,
pricePerUnit: agentPrice,
},
{
label: $t('productService.product.processingPrice'),
calcVat: serviceChargeCalcVat,
vatIncluded: serviceChargeVatIncluded,
pricePerUnit: serviceCharge,
},
]"
:columns
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
class="full-width"
:no-data-label="$t('general.noDataTable')"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }}
<q-th v-for="col in columns" :key="col.name" :props="props">
<template v-if="!!col.label">
{{ $t(col.label) }}
</template>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr>
<q-td>{{ $t(props.row.label) }}</q-td>
<q-td class="text-right" style="width: 15%">
<q-input
v-if="priceDisplay?.price && props.rowIndex === 0"
id="input-price"
for="input-price"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
input-class="text-right"
:model-value="price4Show"
@blur="
() => {
price = Number(price4Show.replace(/,/g, ''));
if (price % 1 === 0) {
const [, dec] = price4Show.split('.');
if (!dec) {
price4Show += '.00';
<template
v-slot:body="props: {
row: RowData;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr :class="{ dark: $q.dark.isActive }" class="text-center">
<q-td v-for="(col, i) in columns" :align="col.align" :key="i">
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<span
v-if="col.name === 'total'"
class="text-weight-bold"
:class="{
['tags-color-orange']: props.rowIndex === 0,
['tags-color-purple']: props.rowIndex === 1,
['tags-color-pink']: props.rowIndex === 2,
['dark']: $q.dark.isActive,
}"
>
{{
typeof col.field === 'string'
? props.row[col.field as keyof RowData]
: col.field(props.row)
}}
</span>
<template v-else>
{{
typeof col.field === 'string'
? props.row[col.field as keyof RowData]
: col.field(props.row)
}}
</template>
</template>
<template v-if="col.name === '#calcVat'">
<q-checkbox
v-if="priceDisplay?.price && props.rowIndex === 0"
v-model="calcVat"
size="xs"
/>
<q-checkbox
v-if="priceDisplay?.agentPrice && props.rowIndex === 1"
v-model="agentPriceCalcVat"
size="xs"
/>
<q-checkbox
v-if="priceDisplay?.serviceCharge && props.rowIndex === 2"
v-model="serviceChargeCalcVat"
size="xs"
/>
</template>
<template v-if="col.name === '#vatIncluded'">
<q-select
:options="[
{ label: $t('general.included'), value: true },
{ label: $t('general.notIncluded'), value: false },
]"
v-if="priceDisplay?.price && props.rowIndex === 0"
map-options
emit-value
flat
outlined
dense
v-model="vatIncluded"
></q-select>
<q-select
:options="[
{ label: $t('general.included'), value: true },
{ label: $t('general.notIncluded'), value: false },
]"
v-if="priceDisplay?.agentPrice && props.rowIndex === 1"
map-options
emit-value
flat
outlined
dense
v-model="agentPriceVatIncluded"
></q-select>
<q-select
:options="[
{ label: $t('general.included'), value: true },
{ label: $t('general.notIncluded'), value: false },
]"
v-if="priceDisplay?.serviceCharge && props.rowIndex === 2"
map-options
emit-value
flat
outlined
dense
v-model="serviceChargeVatIncluded"
></q-select>
</template>
<template v-if="col.name === '#pricePerUnit'">
<q-input
v-if="priceDisplay?.price && props.rowIndex === 0"
id="input-price"
for="input-price"
dense
outlined
hide-bottom-space
input-class="text-right"
:readonly
:borderless="readonly"
:model-value="formattedPrice"
@blur="
() => {
price = Number(formattedPrice.replace(/,/g, ''));
if (price % 1 === 0 && !formattedPrice.split('.').at(1)) {
formattedPrice += '.00';
}
}
}
"
@update:model-value="
(v) => {
price4Show = commaInput(v?.toString() || '0', 'string');
}
"
/>
<q-input
v-if="priceDisplay?.agentPrice && props.rowIndex === 1"
id="input-agent-price"
for="input-agent-price"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
input-class="text-right"
:model-value="agentPrice4Show"
@blur="
() => {
agentPrice = Number(agentPrice4Show.replace(/,/g, ''));
if (agentPrice % 1 === 0) {
const [, dec] = agentPrice4Show.split('.');
if (!dec) {
agentPrice4Show += '.00';
"
@update:model-value="
(v) => {
formattedPrice = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
<q-input
v-if="priceDisplay?.agentPrice && props.rowIndex === 1"
id="input-agent-price"
for="input-agent-price"
dense
outlined
hide-bottom-space
input-class="text-right"
:readonly
:borderless="readonly"
:model-value="formattedAgentPrice"
@blur="
() => {
agentPrice = Number(
formattedAgentPrice.replace(/,/g, ''),
);
if (
agentPrice % 1 === 0 &&
!formattedAgentPrice.split('.').at(1)
) {
formattedAgentPrice += '.00';
}
}
}
"
@update:model-value="
(v) => {
agentPrice4Show = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
<q-input
v-if="priceDisplay?.serviceCharge && props.rowIndex === 2"
id="input-service-charge"
for="input-service-charge"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
input-class="text-right"
hide-bottom-space
:model-value="serviceCharge4Show"
@blur="
() => {
serviceCharge = Number(
serviceCharge4Show.replace(/,/g, ''),
);
if (serviceCharge % 1 === 0) {
const [, dec] = serviceCharge4Show.split('.');
if (!dec) {
serviceCharge4Show += '.00';
"
@update:model-value="
(v) => {
formattedAgentPrice = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
<q-input
v-if="priceDisplay?.serviceCharge && props.rowIndex === 2"
id="input-service-charge"
for="input-service-charge"
dense
outlined
input-class="text-right"
hide-bottom-space
:readonly
:borderless="readonly"
:model-value="formattedServiceCharge"
@blur="
() => {
serviceCharge = Number(
formattedServiceCharge.replace(/,/g, ''),
);
if (
serviceCharge % 1 === 0 &&
!formattedServiceCharge.split('.').at(1)
) {
formattedServiceCharge += '.00';
}
}
}
"
@update:model-value="
(v) => {
serviceCharge4Show = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
</q-td>
<q-td class="text-right" style="width: 15%">
{{
formatNumberDecimal(
calculatePrice({
output: 'beforeVat',
vatIncluded: vatIncluded,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
}),
2,
)
}}
</q-td>
<q-td class="text-right" style="width: 15%">
{{
formatNumberDecimal(
calculatePrice({
output: 'vat',
calcVat: calcVat,
price: Number(
formatNumberDecimal(
calculatePrice({
output: 'beforeVat',
vatIncluded: vatIncluded,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
}),
2,
).replaceAll(',', ''),
),
}),
2,
)
}}
</q-td>
<q-td class="text-right" style="width: 15%">
<span
class="text-weight-bold"
:class="{
'tags-color-orange': props.rowIndex === 0,
'tags-color-purple': props.rowIndex === 1,
'tags-color-pink': props.rowIndex === 2,
dark: $q.dark.isActive,
}"
>
{{
formatNumberDecimal(
calculatePrice({
output: 'total',
vat: 0.03,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
}) +
(!vatIncluded
? calculatePrice({
output: 'vat',
calcVat: calcVat,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
})
: 0),
2,
)
}}
</span>
"
@update:model-value="
(v) => {
formattedServiceCharge = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
</template>
</q-td>
</q-tr>
</template>
@ -373,16 +404,6 @@ withDefaults(
</template>
<style scoped>
.active-addr {
color: hsl(var(--info-bg));
background-color: hsla(var(--info-bg) / 0.1);
border-radius: var(--radius-3);
&.dark {
background-color: var(--surface-1);
}
}
.tags-color-orange {
color: var(--orange-5);
}

View file

@ -241,20 +241,22 @@ watch(
</span>
</template>
</SelectInput> -->
<q-btn
v-if="!readonly"
id="btn-delete-work"
icon="mdi-trash-can-outline"
dense
flat
round
padding="0"
class="q-ml-md"
color="negative"
@click.stop="$emit('deleteWork')"
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
<div :class="{ 'col-12 row justify-end q-mt-sm': $q.screen.lt.md }">
<q-btn
v-if="!readonly"
id="btn-delete-work"
icon="mdi-trash-can-outline"
dense
flat
round
padding="0"
class="q-ml-md"
color="negative"
@click.stop="$emit('deleteWork')"
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
</div>
</div>
</div>
</template>
@ -270,29 +272,29 @@ watch(
<!-- product -->
<div class="bordered-t">
<div
class="q-py-xs text-weight-medium row justify-between items-center q-px-md"
class="q-py-xs text-weight-medium justify-between items-center q-px-md"
:class="{ row: $q.screen.gt.xs }"
style="background-color: hsla(var(--info-bg) / 0.1)"
>
<span>
{{ $t('productService.service.productInWork') }}
{{ workIndex + 1 }}
{{ $t('productService.service.productInWork') }}
{{ workIndex + 1 }}
<span class="row items-center justify-between">
<q-checkbox
size="xs"
:class="$q.screen.gt.xs ? 'q-pl-lg' : 'q-pl-sm'"
v-model="attributes.showTotalPrice"
:label="$t('productService.service.showTotalPrice')"
style="color: hsl(var(--text-mute-2))"
:disable="readonly"
/>
<AddButton
v-if="!readonly"
icon-only
id="btn-add-work-product"
for="btn-add-work-product"
@click.stop="$emit('addProduct')"
/>
</span>
<AddButton
v-if="!readonly"
icon-only
id="btn-add-work-product"
for="btn-add-work-product"
@click.stop="$emit('addProduct')"
/>
</div>
<div
@ -302,19 +304,19 @@ watch(
<section
v-for="(product, index) in productItems"
:key="product.id"
class="full-width row items-center justify-between"
class="full-width items-center justify-between row"
>
<div
class="row col items-center justify-between full-width surface-1 q-px-sm q-py-xs"
class="row col-md col-12 items-center justify-between full-width surface-1 q-px-sm q-py-xs rounded"
style="min-height: 70px"
>
<!-- product detail -->
<section
class="row items-center col-md col-12 no-wrap"
class="row items-center col-md col-12"
v-if="productItems"
>
<q-btn
v-if="!readonly && $q.screen.gt.xs"
v-if="!readonly"
id="btn-product-up"
icon="mdi-arrow-up"
dense
@ -326,7 +328,7 @@ watch(
@click.stop="$emit('moveProductUp', productItems, index)"
/>
<q-btn
v-if="!readonly && $q.screen.gt.xs"
v-if="!readonly"
id="btn-product-down"
for="btn-product-down"
icon="mdi-arrow-down"
@ -347,25 +349,32 @@ watch(
{{ index + 1 }}
</q-avatar>
<div class="col row no-wrap items-center">
<div
class="col-md col-12 row items-center"
:class="{ 'q-pt-sm': $q.screen.lt.md }"
>
<div
v-if="$q.screen.gt.xs"
class="bordered q-mx-md col-3 image-box"
:class="{ 'col-12 flex justify-center': $q.screen.lt.md }"
>
<q-img
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
style="object-fit: cover; width: 100%; height: 100%"
>
<template #error>
<div
class="surface-3 full-width full-height no-padding"
>
<q-img src="blank-image.png"></q-img>
</div>
</template>
</q-img>
<div class="bordered q-mx-md col-md-3 image-box">
<q-img
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
style="object-fit: cover; width: 100%; height: 100%"
>
<template #error>
<div
class="surface-3 full-width full-height no-padding"
>
<q-img src="blank-image.png"></q-img>
</div>
</template>
</q-img>
</div>
</div>
<article class="column col full-width justify-between">
<article
class="column col-md col-12 full-width justify-between"
:class="{ 'q-py-sm': $q.screen.lt.md }"
>
<span
class="text-weight-medium ellipsis-2-lines full-width"
>
@ -391,7 +400,8 @@ watch(
<span class="col-12 row" style="color: var(--teal-9)">
<span
v-if="priceDisplay?.price"
class="col ellipsis price-orange text-weight-bold"
class="col-md col-12 ellipsis price-orange text-weight-bold"
:class="{ 'row justify-between': $q.screen.lt.md }"
>
<div class="text-caption app-text-muted-2">
{{ $t('productService.product.salePrice') }}
@ -407,7 +417,8 @@ watch(
</span>
<span
v-if="priceDisplay?.agentPrice"
class="col ellipsis price-purple text-weight-bold"
class="col-md col-12 ellipsis price-purple text-weight-bold"
:class="{ 'row justify-between': $q.screen.lt.md }"
>
<div class="text-caption app-text-muted-2">
{{ $t('productService.product.agentPrice') }}
@ -423,7 +434,8 @@ watch(
</span>
<span
v-if="priceDisplay?.serviceCharge"
class="col ellipsis price-pink column"
class="col-md col-12 ellipsis price-pink"
:class="{ 'row justify-between': $q.screen.lt.md }"
>
<div class="text-caption app-text-muted-2">
{{ $t('productService.product.processingPrice') }}
@ -437,14 +449,16 @@ watch(
฿{{ formatNumberDecimal(product.serviceCharge, 2) }}
</q-tooltip>
</span>
<span class="col ellipsis column text-weight-medium">
<span
class="col-md col-12 ellipsis text-weight-medium"
:class="{ 'row justify-between': $q.screen.lt.md }"
>
<div class="text-caption app-text-muted-2">
{{ $t('productService.service.InstallmentsNo') }}
</div>
{{ !readonly ? '' : product.installmentNo }}
<span class="row justify-end">
<span v-if="!readonly" class="row justify-end">
<q-input
v-if="!readonly && $q.screen.gt.xs"
outlined
:max="installments"
input-class="text-right no-padding"
@ -462,21 +476,25 @@ watch(
</div>
</div>
<q-btn
v-if="!readonly"
class="q-ml-md"
id="btn-delete-work-product"
for="btn-delete-work-product"
icon="mdi-trash-can-outline"
padding="0"
dense
flat
round
color="negative"
@click.stop="$emit('deleteProduct', productItems, index)"
<div
:class="{ 'col-12 row justify-end q-mt-sm': $q.screen.lt.md }"
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
<q-btn
v-if="!readonly"
class="q-ml-md"
id="btn-delete-work-product"
for="btn-delete-work-product"
icon="mdi-trash-can-outline"
padding="0"
dense
flat
round
color="negative"
@click.stop="$emit('deleteProduct', productItems, index)"
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
</div>
</section>
</div>
<div v-else class="app-text-muted q-py-md q-px-md">
@ -594,7 +612,11 @@ watch(
v-if="productItems.length > 0"
class="surface-1 rounded q-pa-xs"
>
<div v-for="product in productItems" :key="product.id">
<div
v-for="product in productItems"
:key="product.id"
class="ellipsis-2-lines"
>
<q-checkbox
v-if="attributes.workflowStep[stepIndex].productsId"
:disable="readonly"

View file

@ -38,7 +38,7 @@ defineEmits<{
style="background-color: var(--surface-3)"
/>
{{ $t(`general.about`) }}
<div class="q-ml-md text-weight-regular">
<div class="text-weight-regular" :class="{ 'q-ml-md ': $q.screen.gt.sm }">
<q-checkbox
:label="$t('productService.product.agentPrice')"
size="xs"
@ -54,11 +54,15 @@ defineEmits<{
/>
</div>
</div>
<div class="col-12 row q-col-gutter-sm">
<div class="col-12 row" :class="{ 'q-col-gutter-sm': !inputOnly }">
<SelectBranch
v-model:value="branchId"
:label="$t('quotation.branchVirtual')"
class="col-md-6 col-12"
:class="{
'field-one': inputOnly && $q.screen.gt.sm,
'q-mb-sm': inputOnly && $q.screen.lt.md,
}"
simple
required
:readonly
@ -71,6 +75,7 @@ defineEmits<{
})})`"
@create="$emit('addCustomer')"
class="col-md-6 col-12"
:class="{ 'field-two': inputOnly && $q.screen.gt.sm }"
:creatable-disabled="!branchId"
:creatable="!inputOnly"
simple
@ -80,3 +85,17 @@ defineEmits<{
</div>
</div>
</template>
<style scoped>
:deep(
label.q-field.row.no-wrap.items-start.q-field--outlined.q-select.field-one
) {
padding-right: 4px;
}
:deep(
label.q-field.row.no-wrap.items-start.q-field--outlined.q-select.field-two
) {
padding-left: 4px;
}
</style>

View file

@ -64,7 +64,7 @@ function calcPrice(c: (typeof rows.value)[number]) {
const finalPriceNoVat = finalPriceWithVat / (1 + (config.value?.vat || 0.07));
const price = finalPriceNoVat * c.amount - c.discount;
const vat = c.product.calcVat
const vat = c.product[props.agentPrice ? 'agentPriceCalcVat' : 'calcVat']
? (finalPriceNoVat * c.amount - c.discount) * (config.value?.vat || 0.07)
: 0;
return precisionRound(price + vat);
@ -234,7 +234,6 @@ watch(
const incoming = current.filter(
(lhs) =>
!before.find((rhs) => {
console.log(lhs, rhs);
return JSON.stringify(lhs) === JSON.stringify(rhs);
}),
);
@ -387,7 +386,9 @@ watch(
{{
formatNumberDecimal(
props.row.pricePerUnit +
(props.row.product.calcVat
(props.row.product[
agentPrice ? 'agentPriceCalcVat' : 'calcVat'
]
? props.row.pricePerUnit * (config?.vat || 0.07)
: 0),
2,
@ -443,7 +444,9 @@ watch(
<q-td align="right">
{{
formatNumberDecimal(
props.row.product.calcVat
props.row.product[
agentPrice ? 'agentPriceCalcVat' : 'calcVat'
]
? precisionRound(
(props.row.pricePerUnit * props.row.amount -
props.row.discount) *

View file

@ -1,9 +1,7 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { formatNumberDecimal } from 'src/stores/utils';
import BadgeComponent from 'components/BadgeComponent.vue';
import KebabAction from '../shared/KebabAction.vue';
import MainButton from '../button/MainButton.vue';
defineProps<{
title?: string;
@ -48,7 +46,7 @@ const rand = Math.random();
</script>
<template>
<div
class="surface-1 rounded q-pa-sm quo-card bordered"
class="surface-1 rounded q-pa-sm quo-card bordered column"
:class="{ 'urgent-card': urgent }"
:style="{ '--animation-delay': rand + 's' }"
>
@ -71,7 +69,7 @@ const rand = Math.random();
/>
</div>
<nav class="col text-right">
<nav class="col text-right no-wrap">
<q-btn
v-if="!hidePreview"
flat
@ -125,7 +123,7 @@ const rand = Math.random();
<!-- SEC: body -->
<section
class="rounded q-px-sm"
class="rounded q-px-sm col"
:class="{
'surface-1': urgent,
'surface-2': !urgent,

View file

@ -16,12 +16,16 @@ const props = withDefaults(
grid?: boolean;
visibleColumns?: string[];
hideEdit?: boolean;
page?: number;
pageSize?: number;
}>(),
{
row: () => [],
column: () => [],
grid: false,
visibleColumns: () => [],
page: 1,
pageSize: 30,
},
);
@ -64,7 +68,11 @@ defineEmits<{
<template v-slot:body="props">
<q-tr :class="{ urgent: props.row.urgent }">
<q-td v-if="visibleColumns.includes('order')">
{{ props.rowIndex + 1 }}
{{
$q.screen.xs
? props.rowIndex + 1
: (page - 1) * pageSize + props.rowIndex + 1
}}
</q-td>
<q-td v-if="visibleColumns.includes('workName')">

View file

@ -35,6 +35,7 @@ type Options = { label: string; value: string };
<div class="col-12 row q-col-gutter-sm">
<SelectInput
:class="{ col: $q.screen.lt.md }"
:disable="!readonly && onDrawer"
:readonly="readonly"
for="input-agencies-code"
@ -60,7 +61,7 @@ type Options = { label: string; value: string };
outlined
:readonly="readonly"
hide-bottom-space
class="col"
class="col-md col-12"
:label="$t('agencies.name')"
v-model="name"
:rules="[(val: string) => !!val || $t('form.error.required')]"
@ -71,7 +72,7 @@ type Options = { label: string; value: string };
outlined
:readonly="readonly"
hide-bottom-space
class="col"
class="col-md col-12"
:label="'Agencies Name'"
v-model="nameEn"
/>

View file

@ -32,6 +32,9 @@ const quotationId = defineModel<string>('quotationId', {
class="col"
v-model:value="quotationId"
:label="$t('general.select', { msg: $t('quotation.title') })"
:params="{
hasCancel: true,
}"
/>
</section>
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts" setup>
import SelectQuotation from 'components/shared/select/SelectQuotation.vue';
defineProps<{
readonly?: boolean;
}>();
const quotationId = defineModel<string>('quotationId', {
required: true,
});
</script>
<template>
<div class="row col-12">
<section
:id="`form-credit`"
class="col-12 q-pb-sm text-weight-bold text-body1 row items-center"
>
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-xs"
color="info"
name="mdi-file-outline"
style="background-color: var(--surface-3)"
/>
{{ $t(`general.document`) }}
</section>
<section class="col-12 row q-col-gutter-sm">
<SelectQuotation
for="select-quotation"
class="col"
v-model:value="quotationId"
:label="$t('general.select', { msg: $t('quotation.title') })"
:params="{
forDebitNote: true,
}"
/>
</section>
</div>
</template>
<style scoped></style>

View file

@ -23,6 +23,7 @@ defineProps<{
hideDelete?: boolean;
hideBtn?: boolean;
readonly?: boolean;
disabledSubmit?: boolean;
saveAmount?: number;
submitLabel?: string;
@ -202,7 +203,7 @@ const currentTab = defineModel<string>('currentTab');
</div>
<!-- center -->
<div class="col column full-height">
<div class="col column full-height no-wrap">
<slot></slot>
</div>
@ -235,6 +236,7 @@ const currentTab = defineModel<string>('currentTab');
id="btn-form-submit"
type="submit"
solid
:disabled="disabledSubmit"
:icon="submitIcon"
:label="submitLabel"
:amount="saveAmount"

View file

@ -193,14 +193,21 @@ watch(
</div>
<!-- body -->
<div class="q-px-lg surface-2 col row full-width">
<div
class="image-dialog-body q-my-lg relative-position rounded surface-1 flex items-center"
<section
class="surface-2 col row full-width no-wrap q-pa-md items-center"
:class="{
row: $q.screen.gt.sm,
column: $q.screen.lt.md,
}"
>
<aside
class="image-dialog-body relative-position rounded surface-1 flex items-center"
:class="{ 'q-mr-md': $q.screen.gt.sm, 'q-mb-md': $q.screen.lt.md }"
>
<img
:src="tempImage || fallbackUrl"
v-if="tempImage || fallbackUrl"
style="object-fit: contain; width: 100%"
style="object-fit: contain; height: 100%; width: 100%"
@error="
() => {
tempImage = '';
@ -237,12 +244,12 @@ watch(
style="color: hsla(var(--stone-0-hsl) / 0.7)"
></q-btn>
</div>
</div>
</aside>
<div
<aside
v-if="!hiddenFooter"
class="col q-ml-md q-pt-sm q-my-md"
style="width: 40em; height: 30em; overflow: auto"
class="self-start col scroll"
style="height: 30em; width: 34em"
>
<div class="row items-center q-gutter-sm">
<div
@ -254,6 +261,8 @@ watch(
object-fit: cover;
height: 5vw;
width: 5vw;
min-width: 70px;
min-height: 70px;
overflow: hidden;
"
@click="selectImg('')"
@ -274,7 +283,12 @@ watch(
? selectedImg === img
: selectedImg === img.split('/').pop(),
}"
style="height: 5vw; width: 5vw"
style="
height: 5vw;
width: 5vw;
min-width: 70px;
min-height: 70px;
"
@click="selectImg(img)"
>
<q-btn
@ -339,8 +353,8 @@ watch(
</div>
</template>
</div>
</div>
</div>
</aside>
</section>
<!-- footer -->
<div
@ -410,6 +424,10 @@ watch(
overflow: hidden;
width: 30em;
height: 30em;
/* width: calc(400px - 5vw); */
/* height: calc(400px - 5vw); */
/* max-width: 400px;
max-height: 400px; */
}
.upload-image-btn {

View file

@ -42,7 +42,9 @@ onMounted(() => {
:class="{ dark: $q.dark.isActive }"
:style="`background-color: ${bgColor}`"
>
<div style="font-size: 14px">{{ $t(text) }}</div>
<div style="font-size: 14px; color: var(--foreground)">
{{ $t(text) }}
</div>
</div>
</div>
</button>

View file

@ -154,15 +154,15 @@ const smallBanner = ref(false);
class="absolute-bottom-right"
style="
border-radius: 50%;
width: 20px;
height: 20px;
width: 1.25vw;
height: 1.25vw;
z-index: 2;
background: var(--surface-1);
"
>
<q-badge
class="absolute-center"
style="border-radius: 50%; width: 14px; height: 14px"
style="border-radius: 50%; width: 0.8vw; height: 0.8vw"
:style="`background: hsl(var(${active ? '--positive-bg' : '--text-mute'}))`"
></q-badge>
</q-badge>
@ -193,7 +193,7 @@ const smallBanner = ref(false);
>
<div
class="row justify-between full-height"
style="padding-left: 7.5vw"
style="padding-left: calc(6vw + 50px)"
>
<div class="col column">
<span
@ -261,7 +261,7 @@ const smallBanner = ref(false);
inline-label
mobile-arrows
v-model="currentTab"
active-class="active-tab text-weight-bold"
:active-class="`active-tab text-weight-bold ${$q.dark.isActive && 'dark'}`"
class="app-text-muted full-width"
align="left"
v-if="typeof tabsList === 'object'"
@ -271,6 +271,7 @@ const smallBanner = ref(false);
:id="`${prefix}-tab-${tab.label}`"
v-bind:key="tab.name"
class="content-tab text-capitalize"
:class="{ 'tab-label': currentTab !== tab.name }"
:name="tab.name"
:label="tab.label"
/>
@ -302,7 +303,11 @@ const smallBanner = ref(false);
>
<!-- profile -->
<span class="row col items-center">
<div class="flex items-center full-height q-pl-lg" style="z-index: 1">
<div
class="flex items-center full-height"
:class="{ 'q-pl-lg': $q.screen.gt.sm, 'q-pl-sm': $q.screen.lt.md }"
style="z-index: 1"
>
<div
class="surface-1"
style="border-radius: 50%; border: 2px solid var(--surface-1)"
@ -338,7 +343,7 @@ const smallBanner = ref(false);
>
<template #error>
<div
class="full-width full-height flex items-center justify-center"
class="full-width full-height flex items-center justify-center no-padding"
:style="{
background: `${bgColor || 'var(--brand-1)'}`,
color: `${color || 'white'}`,
@ -347,13 +352,14 @@ const smallBanner = ref(false);
<Icon
class="full-width full-height flex items-center justify-center"
:icon="icon || 'mdi-account'"
style="width: 25px !important"
/>
</div>
</template>
</q-img>
<div
v-else
class="full-width full-height flex items-center justify-center"
class="full-width full-height flex items-center justify-center no-padding"
:style="{
background: `${bgColor || 'var(--brand-1)'}`,
color: `${color || 'white'}`,
@ -362,6 +368,7 @@ const smallBanner = ref(false);
<Icon
class="full-width full-height flex items-center justify-center"
:icon="icon || 'mdi-account'"
style="width: 25px !important"
/>
</div>
</template>
@ -378,6 +385,7 @@ const smallBanner = ref(false);
<Icon
class="full-width full-height flex items-center justify-center"
:icon="icon || 'mdi-account'"
style="width: 25px !important"
/>
</div>
@ -425,7 +433,7 @@ const smallBanner = ref(false);
inline-label
mobile-arrows
v-model="currentTab"
active-class="active-tab text-weight-bold"
:active-class="`active-tab text-weight-bold ${$q.dark.isActive && 'dark'}`"
class="app-text-muted full-width"
align="left"
v-if="typeof tabsList === 'object'"
@ -435,6 +443,7 @@ const smallBanner = ref(false);
:id="`${prefix}-tab-${tab.label}`"
v-bind:key="tab.name"
class="content-tab text-capitalize"
:class="{ 'tab-label': currentTab !== tab.name }"
:name="tab.name"
:label="tab.label"
/>
@ -526,5 +535,13 @@ const smallBanner = ref(false);
.active-tab {
color: var(--brand-1);
&.dark {
filter: brightness(1.3);
}
}
.tab-label {
color: var(--foreground);
opacity: 75%;
}
</style>

View file

@ -57,13 +57,13 @@ async function downloadImage(url: string | null) {
<main class="column full-height">
<section
v-if="!hideTab"
style="background: var(--gray-3)"
:style="`background: var(${$q.dark.isActive ? '--gray-8' : '--gray-3'})`"
class="q-py-sm row justify-center"
>
<div class="surface-2 q-px-md q-py-sm rounded row no-wrap items-center">
<MainButton
icon="mdi-minus"
color="0 0% 0%"
:color="`var(--gray-${$q.dark.isActive ? '1' : '11'}-hsl)`"
icon-only
@click="
() => {
@ -90,7 +90,7 @@ async function downloadImage(url: string | null) {
></q-input>
<MainButton
icon="mdi-plus"
color="0 0% 0%"
:color="`var(--gray-${$q.dark.isActive ? '1' : '11'}-hsl)`"
icon-only
@click="
() => {

View file

@ -320,7 +320,7 @@ watchEffect(async () => {
<q-input
outlined
hide-bottom-space
class="col-3"
class="col-md-3 col-12"
v-model="homeCode"
mask="###########"
:dense="dense"
@ -344,7 +344,7 @@ watchEffect(async () => {
<q-input
outlined
hide-bottom-space
class="col"
class="col-md col-12"
:model-value="office"
:dense="dense"
:label="$t('customer.form.employmentOffice')"

View file

@ -16,8 +16,8 @@ const props = withDefaults(
id?: string;
label?: string;
option: T[];
optionLabel?: keyof T;
optionValue?: keyof T;
optionLabel?: keyof T | string;
optionValue?: keyof T | string;
placeholder?: string;
hideSelected?: boolean;

View file

@ -1,12 +1,16 @@
<script setup lang="ts">
import { watch } from 'vue';
import TableProductAndService from './table/TableProductAndService.vue';
import ToggleView from './ToggleView.vue';
import { ref } from 'vue';
const viewMode = ref<boolean>(false);
const search = defineModel<string>('search');
const selectedItem = defineModel<unknown[]>('selectedItem', { default: [] });
const props = withDefaults(
defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items: any;
newItems?: any;
color?: string;
@ -15,6 +19,7 @@ const props = withDefaults(
noPadding?: boolean;
noItemsIcon?: string;
noItemsLabel?: string;
type: 'service' | 'product';
}>(),
{
items: () => [],
@ -35,24 +40,24 @@ watch(search, () => {
emit('search', search.value || '');
});
function select(item?: unknown, all?: boolean) {
function select(item?: any, all?: boolean) {
if (all) {
if (props.items.every((item) => selectedItem.value.includes(item))) {
selectedItem.value = selectedItem.value.filter(
(item) => !props.items.includes(item),
);
if (selectedItem.value.length !== 0) {
selectedItem.value = [];
} else {
props.items.forEach((i) => {
const productExists = selectedItem.value.some((item) => item === i);
props.items.forEach((i: any) => {
const productExists = selectedItem.value.find(
(item: any) => item.id === i.id,
);
if (!productExists) {
selectedItem.value.push(i);
}
});
}
} else {
if (selectedItem.value.includes(item)) {
const index = selectedItem.value.indexOf(item);
selectedItem.value.splice(index, 1);
const findIdex = selectedItem.value.findIndex((v: any) => v.id === item.id);
if (findIdex !== -1) {
selectedItem.value.splice(findIdex, 1);
} else selectedItem.value.push(item);
}
}
@ -74,78 +79,76 @@ function assignSelect(to: unknown[], from: unknown[]) {
<template>
<section class="full-width column">
<header
class="row items-center no-wrap q-px-md q-py-sm"
class="row items-center q-px-md q-py-sm"
:class="{ 'bordered surface-3 ': borderSearchSection }"
>
<div class="col"><slot name="top"></slot></div>
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="q-ml-auto"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="search"
debounce="300"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
<div class="col-12 col-md"><slot name="top"></slot></div>
<div class="row items-stretch col-12 col-md-4 no-wrap">
<ToggleView
v-model="viewMode"
class="q-mr-sm"
style="margin-left: 0 !important"
/>
<q-input
for="input-search"
outlined
dense
class="col"
:label="$t('general.search')"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="search"
debounce="300"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
</header>
<slot name="tab"></slot>
<section class="col q-ma-md">
<div
v-if="items.length > 0 || newItems.length > 0"
class="column"
class="column full-height relative-position"
style="gap: var(--size-4)"
>
<!-- NOTE: START - item -->
<div class="row q-col-gutter-md">
<div v-for="(item, i) in items" :key="i" :class="`${itemClass}`">
<div
class="rounded cursor-pointer relative-position"
:style="`border: 1px solid ${selectedItem.includes(item) ? color : 'transparent'}`"
@click="() => select(item)"
<div style="inset: 0" class="absolute scroll">
<!-- NOTE: START - item -->
<div class="row q-col-gutter-md">
<TableProductAndService
v-model:selected="selectedItem"
:rows="items"
:grid="viewMode"
:type
>
<div
v-if="selectedItem.includes(item)"
class="badge absolute-top-right flex justify-center q-ma-sm"
:style="`background-color: ${color}`"
>
{{ selectedItem.indexOf(item) + 1 }}
</div>
<slot name="data" :item="item"></slot>
</div>
<template #grid="{ item }">
<div :key="item.index" :class="`${itemClass}`">
<div
class="rounded cursor-pointer relative-position"
:style="`border: 1px solid ${
item._selectedIndex !== -1 ? color : 'transparent'
}`"
@click="() => select(item)"
>
<div
v-if="item._selectedIndex !== -1"
class="badge absolute-top-right flex justify-center q-ma-sm"
:style="`background-color: ${color}`"
>
{{ item._selectedIndex + 1 }}
</div>
<slot name="data" :item="item"></slot>
</div>
</div>
</template>
</TableProductAndService>
</div>
</div>
<!-- NOTE: END - item -->
<q-separator v-if="newItems.length !== 0" dark inset />
<!-- NOTE: START - newItem -->
<div v-if="newItems.length !== 0" class="row q-col-gutter-md">
<div v-for="(item, i) in newItems" :key="i" :class="`${itemClass}`">
<div
class="rounded cursor-pointer relative-position"
:style="`border: 1px solid ${selectedItem.includes(item) ? color : 'transparent'}`"
@click="() => select(item)"
>
<div
v-if="selectedItem.includes(item)"
class="badge absolute-top-right flex justify-center q-ma-sm"
:style="`background-color: ${color}`"
>
{{ selectedItem.indexOf(item) + 1 }}
</div>
<slot name="newData" :item="item"></slot>
</div>
</div>
</div>
</div>
<!-- NOTE: END - newItem -->
<div v-else class="flex justify-center full-height">
<span class="col column items-center justify-center app-text-muted">
<q-avatar style="background: var(--surface-0)" class="q-mb-md">

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
const model = defineModel<boolean>('model');
</script>
<template>
<q-btn-toggle
id="btn-mode"
v-model="model"
dense
class="no-shadow bordered rounded surface-1 q-ml-sm"
:toggle-color="$q.dark.isActive ? 'grey-9' : 'grey-2'"
size="xs"
:options="[
{ value: true, slot: 'folder' },
{ value: false, slot: 'list' },
]"
>
<template v-slot:folder>
<q-icon
name="mdi-view-grid-outline"
size="16px"
class="q-px-sm q-py-xs rounded"
:style="{
color: $q.dark.isActive
? model
? '#C9D3DB '
: '#787B7C'
: model
? '#787B7C'
: '#C9D3DB',
}"
/>
</template>
<template v-slot:list>
<q-icon
name="mdi-format-list-bulleted"
class="q-px-sm q-py-xs rounded"
size="16px"
:style="{
color: $q.dark.isActive
? model === false
? '#C9D3DB'
: '#787B7C'
: model === false
? '#787B7C'
: '#C9D3DB',
}"
/>
</template>
</q-btn-toggle>
</template>

View file

@ -118,6 +118,9 @@ function visibleNode(text: string, node: Node, ancestor?: Node[]): boolean {
<template v-for="(node, i) in nodes" :key="i">
<div
class="tree-item"
:class="{
'cursor-pointer': node.children ? true : undefined,
}"
v-if="filterText ? visibleNode(filterText, node, ancestorNode) : true"
>
<slot
@ -205,19 +208,21 @@ function visibleNode(text: string, node: Node, ancestor?: Node[]): boolean {
/>
</label>
<div
class="item__icon flex items-center justify-center"
:style="`background: ${node.bg || dec?.bg}; color: ${node.fg || dec?.fg}; height: ${iconSize}; width: ${iconSize}`"
>
<div>
<div
class="flex items-center justify-center"
:style="`height: calc(${iconSize} - 40%); width: calc(${iconSize} - 40%)`"
class="item__icon flex items-center justify-center"
:style="`background: ${node.bg || dec?.bg}; color: ${node.fg || dec?.fg}; height: ${iconSize}; width: ${iconSize}`"
>
<Icon
v-if="(node.icon && dec && dec.icon) || (dec && dec.icon)"
:icon="node.icon || dec.icon"
class="full-width full-height"
/>
<div
class="flex items-center justify-center"
:style="`height: calc(${iconSize} - 40%); width: calc(${iconSize} - 40%)`"
>
<Icon
v-if="(node.icon && dec && dec.icon) || (dec && dec.icon)"
:icon="node.icon || dec.icon"
class="full-width full-height"
/>
</div>
</div>
</div>

View file

@ -47,7 +47,6 @@ const { getOptions, setFirstValue, getSelectedOption, filter } =
query,
...props.params,
pageSize: 30,
hasCancel: true,
includeRegisteredBranch: true,
});
if (ret) return ret.result;
@ -151,6 +150,7 @@ function setDefaultValue() {
class="surface-3 q-ml-sm"
rounded
style="color: var(--foreground)"
v-if="props.params?.hasCancel"
>
{{ scope.opt._count.canceledWork || 0 }}
</q-badge>
@ -191,6 +191,7 @@ function setDefaultValue() {
class="surface-3 q-ml-xs"
rounded
style="color: var(--foreground)"
v-if="props.params?.hasCancel"
>
{{ scope.opt._count.canceledWork || 0 }}
</q-badge>

View file

@ -0,0 +1,380 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Lang } from 'src/utils/ui';
import { QTableSlots, QTableColumn } from 'quasar';
import { Product, Service } from 'src/stores/product-service/types';
import { calculateAge, dateFormatJS } from 'src/utils/datetime';
import useOptionStore from 'stores/options';
import { formatNumberDecimal, isRoleInclude } from 'src/stores/utils';
import { computed } from 'vue';
const selected = defineModel<unknown[]>('selected');
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const { locale } = useI18n();
const optionStore = useOptionStore();
const priceDisplay = computed(() => ({
price: !isRoleInclude(['sale_agent']),
agentPrice: isRoleInclude([
'admin',
'head_of_admin',
'head_of_sale',
'system',
'owner',
'accountant',
'sale_agent',
]),
serviceCharge: isRoleInclude([
'admin',
'head_of_admin',
'system',
'owner',
'accountant',
]),
}));
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
type: 'service' | 'product';
grid?: boolean;
disabledWorkerId?: string[];
rows: Product[];
};
const columnsProduct = [
{
name: '#check',
align: 'left',
label: '',
field: (_) => '#check',
},
{
name: '#productName',
align: 'left',
label: 'general.name',
field: (v: Product) => v.name,
},
{
name: 'productProcessingTime',
align: 'center',
label: 'productService.product.processingTimeDay',
field: (v: Product) => v.process,
},
{
name: '#priceInformation',
align: 'center',
label: 'productService.product.priceInformation',
field: (v: Product) => v,
},
] satisfies QTableColumn[];
const columnsService = [
{
name: '#check',
align: 'left',
label: '',
field: (_) => '#check',
},
{
name: '#serviceName',
align: 'left',
label: 'general.name',
field: (v: Service) => v.name,
},
{
name: 'serviceDetail',
align: 'left',
label: 'general.detail',
field: (v: Service) => v.detail,
},
{
name: 'serviceWorkTotal',
align: 'left',
label: 'productService.service.totalWork',
field: (v: Service) => v.work.length,
},
{
name: 'createdAt',
align: 'left',
label: 'general.createdAt',
field: (v: Service) => dateFormatJS({ date: v.createdAt }),
},
] satisfies QTableColumn[];
const props = defineProps<ExclusiveProps>();
function getProductImageUrl(
item: (Product | Service) & { type: string; _index: number },
) {
if (item.selectedImage) {
return `${API_BASE_URL}/${item.type}/${item?.id}/image/${item?.selectedImage}`;
}
// NOTE: static image
return `/images/${item.type}-avatar.png`;
}
function handleUpdate() {
if (selected.value?.length === 0) {
selected.value = props.rows?.filter(
(v) => !props.disabledWorkerId?.includes(v.id),
);
} else {
selected.value = [];
}
}
function selectedIndex(item: any) {
return selected.value?.findIndex((v: any) => v.id === item.id);
}
</script>
<template>
<q-table
v-model:selected="selected"
:rows-per-page-options="[0]"
:rows="
rows.map((data, i) => ({
...data,
_index: i,
}))
"
:grid
:columns="type === 'product' ? columnsProduct : columnsService"
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
card-container-class="q-col-gutter-sm"
class="full-width"
row-key="id"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th
v-for="col in type === 'product' ? columnsProduct : columnsService"
:key="col.name"
:props="props"
>
<template v-if="!col.name.startsWith('#')">
{{ $t(col.label) }}
</template>
<template
v-if="
col.name === '#serviceName' ||
col.name === '#productName' ||
col.name === '#priceInformation'
"
>
{{ $t(col.label) }}
</template>
<template v-if="col.name === '#check'">
<q-checkbox
v-model="props.selected"
@update:model-value="(v) => handleUpdate()"
size="sm"
/>
</template>
</q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: (Product | Service) & { type: string; _index: number };
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{
dark: $q.dark.isActive,
'selectable-item__disabled': disabledWorkerId?.some(
(id) => id === props.row.id,
),
}"
class="text-center"
>
<q-td
v-for="col in type === 'product' ? columnsProduct : columnsService"
:align="col.align"
:key="col.name"
>
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<span>
{{
typeof col.field === 'string'
? props.row[col.field as keyof (Product | Service)]
: col.field(props.row as any)
}}
</span>
</template>
<template
v-if="col.name === '#productName' || col.name === '#serviceName'"
>
<div class="row full-height items-center">
<q-avatar size="md">
<q-img
:src="getProductImageUrl(props.row)"
:ratio="1"
class="text-center"
/>
</q-avatar>
<span class="column q-ml-sm">
<span class="col-6">
{{ props.row.name }}
</span>
<span class="col-6 app-text-muted">
{{ props.row.code }}
</span>
</span>
</div>
</template>
<template
v-if="
col.name === '#priceInformation' &&
!disabledWorkerId?.some((id) => id === props.row.id)
"
>
<div
class="row full-width q-gutter-x-md no-wrap items-center text-right"
>
<div
class="tags tags-color-orange col column ellipsis-2-lines"
:class="{
disable: props.row.status === 'INACTIVE',
}"
style="min-width: 50px"
v-if="priceDisplay.price"
>
<div class="col app-text-muted-2 text-caption">
{{ $t('productService.product.salePrice') }}
</div>
<div class="col text-weight-bold">
฿{{ formatNumberDecimal(props.row.price || 0, 2) }}
</div>
</div>
<div
class="tags tags-color-purple col column ellipsis-2-lines"
:class="{
disable: props.row.status === 'INACTIVE',
}"
style="min-width: 50px"
v-if="priceDisplay.agentPrice"
>
<div class="col app-text-muted-2 text-caption">
{{ $t('productService.product.agentPrice') }}
</div>
<div class="col text-weight-bold">
฿{{ formatNumberDecimal(props.row.agentPrice || 0, 2) }}
</div>
</div>
<div
class="tags tags-color-pink col column ellipsis-2-lines"
:class="{
disable: props.row.status === 'INACTIVE',
}"
style="min-width: 50px"
v-if="priceDisplay.serviceCharge"
>
<div class="col app-text-muted-2 text-caption">
{{ $t('productService.product.processingPrice') }}
</div>
<div class="col">
฿{{ formatNumberDecimal(props.row.serviceCharge || 0, 2) }}
</div>
</div>
</div>
</template>
<template
v-if="
col.name === '#check' &&
!disabledWorkerId?.some((id) => id === props.row.id)
"
>
<q-checkbox v-model="props.selected" size="sm" />
</template>
</q-td>
</q-tr>
</template>
<template v-slot:item="props: { row: any; rowIndex: number }">
<slot
name="grid"
:item="{
index: props.rowIndex,
...props.row,
_selectedIndex: selectedIndex(props.row),
}"
/>
</template>
</q-table>
</template>
<style scoped>
.selectable-item__disabled {
filter: grayscale(1);
opacity: 0.5;
& :deep(*) {
cursor: not-allowed;
}
}
.tags {
display: inline-block;
color: hsla(var(--_color-tag) / 1);
background: hsla(var(--_color-tag) / 0.075);
border-radius: var(--radius-2);
padding-inline: var(--size-2);
&.disable {
filter: grayscale(100%);
opacity: 80%;
}
}
.tags-color-green {
--_color-tag: var(--teal-10-hsl);
}
.dark .tags-color-green {
--_color-tag: var(--teal-8-hsl);
}
.tags-color-orange {
--_color-tag: var(--orange-5-hsl);
}
.dark .tags-color-orange {
--_color-tag: var(--orange-6-hsl);
}
.tags-color-purple {
--_color-tag: var(--violet-11-hsl);
}
.dark .tags-color-purple {
--_color-tag: var(--violet-10-hsl);
}
.tags-color-pink {
--_color-tag: var(--pink-6-hsl);
}
</style>

View file

@ -0,0 +1,220 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Lang } from 'src/utils/ui';
import { QTableSlots, QTableColumn } from 'quasar';
import { Employee } from 'src/stores/employee/types';
import { calculateAge, dateFormatJS } from 'src/utils/datetime';
import useOptionStore from 'stores/options';
const selected = defineModel<Employee[]>('selected');
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const { locale } = useI18n();
const optionStore = useOptionStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
grid?: boolean;
disabledWorkerId?: string[];
rows: Employee[];
};
const columns = [
{
name: '#check',
align: 'left',
label: '',
field: (_: any) => '#check',
},
{
name: 'order',
align: 'center',
label: 'general.order',
field: (e: Employee & { _index: number }) => e._index + 1,
},
{
name: 'foreignRefNo',
align: 'center',
label: 'quotation.foreignRefNo',
field: (v: Employee) =>
v.employeePassport !== undefined
? v.employeePassport[0]?.number || '-'
: '-',
},
{
name: 'employeeName',
align: 'left',
label: 'quotation.employeeName',
field: (v: Employee) =>
locale.value === Lang.English
? `${v.firstNameEN} ${v.lastNameEN}`
: `${v.firstName} ${v.lastName}`,
},
{
name: 'birthDate',
align: 'left',
label: 'general.birthDate',
field: (v: Employee) => dateFormatJS({ date: v.dateOfBirth }),
},
{
name: 'age',
align: 'left',
label: 'general.age',
field: (v: Employee) => calculateAge(v.dateOfBirth),
},
{
name: 'nationality',
align: 'left',
label: 'general.nationality',
field: (v: Employee) => optionStore.mapOption(v.nationality),
},
{
name: 'documentExpireDate',
align: 'left',
label: 'quotation.documentExpireDate',
field: (v: Employee) =>
v.employeePassport !== undefined &&
v.employeePassport[0]?.expireDate !== undefined
? dateFormatJS({ date: v.employeePassport[0]?.expireDate })
: '-',
},
] satisfies QTableColumn[];
const props = defineProps<ExclusiveProps>();
function getEmployeeImageUrl(item: Employee) {
if (item.selectedImage) {
return `${API_BASE_URL}/employee/${item.id}/image/${item.selectedImage}`;
}
// NOTE: static image
return `/images/employee-avatar-${item.gender}.png`;
}
function handleUpdate() {
if (selected.value?.length === 0) {
selected.value = props.rows?.filter(
(v) => !props.disabledWorkerId?.includes(v.id),
);
} else {
selected.value = [];
}
}
function selectedIndex(item: Employee) {
return selected.value?.findIndex((v) => v.id === item.id);
}
</script>
<template>
<q-table
v-model:selected="selected"
:rows-per-page-options="[0]"
:rows="
rows.map((data, i) => ({
...data,
_index: i,
}))
"
:grid
:columns
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
card-container-class="q-col-gutter-sm"
class="full-width"
row-key="id"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in columns" :key="col.name" :props="props">
<template v-if="!col.name.startsWith('#')">
{{ $t(col.label) }}
</template>
<template v-if="col.name === '#check'">
<q-checkbox
v-model="props.selected"
@update:model-value="(v) => handleUpdate()"
size="sm"
/>
</template>
</q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: Employee & { _index: number };
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{
dark: $q.dark.isActive,
'selectable-item__disabled': disabledWorkerId?.some(
(id) => id === props.row.id,
),
}"
class="text-center"
>
<q-td v-for="col in columns" :align="col.align" :key="col.name">
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<q-avatar
v-if="col.name === 'employeeName'"
class="q-mr-sm"
size="md"
>
<q-img
:src="getEmployeeImageUrl(props.row)"
:ratio="1"
class="text-center"
/>
</q-avatar>
<span>
{{
typeof col.field === 'string'
? props.row[col.field as keyof Employee]
: col.field(props.row)
}}
</span>
</template>
<template
v-if="
col.name === '#check' &&
!disabledWorkerId?.some((id) => id === props.row.id)
"
>
<q-checkbox v-model="props.selected" size="sm" />
</template>
</q-td>
</q-tr>
</template>
<template v-slot:item="props: { row: Employee; rowIndex: number }">
<slot
name="grid"
:index="props.rowIndex"
:item="{ ...props.row, _selectedIndex: selectedIndex(props.row) }"
/>
</template>
</q-table>
</template>
<style scoped>
.selectable-item__disabled {
filter: grayscale(1);
opacity: 0.5;
& :deep(*) {
cursor: not-allowed;
}
}
</style>

View file

@ -79,7 +79,7 @@ export default {
calculateVat: 'Calculate VAT',
discountAfterVat: 'Discount after vat',
totalAfterDiscount: 'Total after discount',
totalVatExcluded: 'Tax-exempt amoun',
totalVatExcluded: 'Tax-exempt amount',
totalVatIncluded: 'Taxable amount',
vat: 'VAT {msg}',
totalAmount: 'Total amount (Baht)',
@ -140,6 +140,13 @@ export default {
numberOfDay: 'Number of days',
other: 'Other',
agencyAddress: 'Agency Address',
hintRemark: 'You can add tags such as ',
quotationLabor: 'Used to display the list of employees',
quotationPayment: 'Used to display payment details',
orderDetail: 'To display the list of employees in that product',
ofPage: '{current} of {total}',
included: 'Included',
notIncluded: 'Not Included',
},
menu: {
@ -1125,6 +1132,8 @@ export default {
},
preview: {
dateAt: 'Date {msg}',
seller: 'Seller',
taskOrder: 'Work Order',
doc: 'View Document',
productList: 'Product List',
@ -1135,15 +1144,17 @@ export default {
vat: 'VAT',
value: 'Value',
netValue: 'Net Value',
dueDate: 'Due Date',
paymentMethods: 'Payment Methods',
title: {
creditNote: 'Credit Note',
quotation: 'Quotation',
invoice: 'Invoice',
payment: 'Payment',
receipt: 'Receipt',
debitNote: 'Debit Note',
},
},
address: {
subDistrict: 'Sub District',
subArea: 'Sub Area',
@ -1225,4 +1236,49 @@ export default {
dataSum: 'Summary of all receipts/tax invoices',
workSheetName: 'Worksheet name',
},
debitNote: {
title: 'Debit Note',
caption: 'All Debit Notes',
expire: 'Expired',
payment: 'Payment',
receipt: 'Receipt',
succeed: 'Completed',
downloadReceipt: 'Download Receipt',
downloadTaxInvoice: 'Download Tax Invoice',
label: {
additionalDetail: 'Additional Details',
specifyReasonForDebit: 'Specify Reason for Debit',
debitNoteInformation: 'Debit Note Information',
codeDebit: 'Debit Note Number',
codeQuotation: 'Quotation Number',
quotationWorkName: 'Work Name',
quotationPayment: 'Payment Method',
value: 'Net Value',
submit: 'Approve Debit Note',
},
stats: {
Pending: 'Debit Note',
Expire: 'Expired',
Payment: 'Payment',
Receipt: 'Receipt',
Succeed: 'Completed',
},
viewMode: {
payment: 'Payment',
receipt: 'Receipt/Tax Invoice',
processComplete: 'Completed',
},
status: {
Pending: 'Debit Note',
Expire: 'Expired',
Payment: 'Payment',
Receipt: 'Receipt',
Succeed: 'Completed',
},
},
};

View file

@ -142,6 +142,13 @@ export default {
numberOfDay: 'จำนวนวัน',
other: 'อื่นๆ',
agencyAddress: 'ที่อยู่หน่วยงาน ',
hintRemark: 'คุณสามารถใส่',
quotationLabor: 'ใช้แสดงรายชื่อลูกจ้าง',
quotationPayment: 'ใช้แสดงรายละเอียดในการชำระเงิน',
orderDetail: 'เพื่อแสดงรายชื่อลูกจ้างในสินค้านั้นๆ',
ofPage: '{current} จาก {total}',
included: 'รวม',
notIncluded: 'ไม่รวม',
},
menu: {
@ -1106,6 +1113,8 @@ export default {
},
preview: {
dateAt: 'วันที่{msg}',
seller: 'ผู้ขาย',
taskOrder: 'ใบสั่งงาน',
doc: 'ดูเอกสาร',
productList: 'รายการสินค้า',
@ -1116,12 +1125,15 @@ export default {
vat: 'ภาษี',
value: 'มูลค่า',
netValue: 'มูลค่าสุทธิ',
dueDate: 'วันครบกำหนดชำระ',
paymentMethods: 'ช่องทางชำระเงิน',
title: {
creditNote: 'ใบลดหนี้',
quotation: 'ใบเสนอราคา',
invoice: 'ใบแจ้งหนี้',
payment: 'ชำระหนี้',
receipt: 'ใบเสร็จรับเงิน',
debitNote: 'ใบเพิ่มหนี้',
},
},
@ -1206,4 +1218,48 @@ export default {
dataSum: 'สรุปใบเสร็จรับเงิน/กำกับภาษีทั้งหมด',
workSheetName: 'ชื่อใบงาน',
},
debitNote: {
title: 'ใบเพิ่มหนี้',
caption: 'ใบเพิ่มหนี้ทั้งหมด',
expire: 'พ้นกำหนด',
payment: 'ชำระเงิน',
receipt: 'ใบเสร็จรับเงิน',
succeed: 'เสร็จสิ้น',
downloadReceipt: 'ดาวน์โหลดใบเสร็จรับเงิน',
downloadTaxInvoice: 'ดาวน์โหลดใบกำกับภาษี',
label: {
additionalDetail: 'อธิบายเพิ่มเติม',
specifyReasonForDebit: 'ระบุสาเหตุการเพิ่มหนี้',
debitNoteInformation: 'ข้อมูลการเพิ่มหนี้',
codeDebit: 'เลขที่ใบเพิ่มหนี้',
codeQuotation: 'เลขที่ใบเสนอราคา',
quotationWorkName: 'ชื่อใบงาน',
quotationPayment: 'วิธีการชำระ',
value: 'มูลค่าสุทธิ',
submit: 'อนุมัติใบเพิ่มหนี้',
},
stats: {
Pending: 'ใบเพิ่มหนี้',
Expire: 'พ้นกำหนด',
Payment: 'ชำระเงิน',
Receipt: 'ใบเสร็จรับเงิน',
Succeed: 'เสร็จสิ้น',
},
viewMode: {
payment: 'ชำระเงิน',
receipt: 'ใบเสร็จรับเงิน/ใบกำกับภาษี',
processComplete: 'เสร็จสิ้น',
},
status: {
Pending: 'ใบเพิ่มหนี้',
Expire: 'พ้นกำหนด',
Payment: 'ชำระเงิน',
Receipt: 'ใบเสร็จรับเงิน',
Succeed: 'เสร็จสิ้น',
},
},
};

View file

@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia';
import { Icon } from '@iconify/vue';
import useMyBranch from 'stores/my-branch';
import { getUserId, getRole } from 'src/services/keycloak';
import { useQuasar } from 'quasar';
type Menu = {
label: string;
@ -17,6 +18,8 @@ type Menu = {
};
const router = useRouter();
const $q = useQuasar();
const userBranch = useMyBranch();
const { currentMyBranch } = storeToRefs(userBranch);
@ -35,17 +38,6 @@ const currentPath = computed(() => {
return router.currentRoute.value.path;
});
// const labelMenu = ref<
// {
// label: string;
// icon: string;
// route: string;
// hidden?: boolean;
// disabled?: boolean;
// isax?: boolean;
// }[]
// >([]);
function navigateTo(label: string, destination?: string) {
if (!destination) return;
router.push(`${destination}`);
@ -57,6 +49,8 @@ function reActiveMenu() {
);
const currMenuIndex = menuData.value.findIndex((m) => m === currMenu);
if ($q.screen.lt.sm) menuActive.value.fill(false);
menuActive.value[currMenuIndex] = true;
}
@ -158,8 +152,8 @@ onMounted(async () => {
disabled: false,
children: [
{ label: 'receipt', route: '/receipt' },
{ label: 'creditNote', route: '/credit-note', disabled: true },
{ label: 'debitNote', route: '', disabled: true },
{ label: 'creditNote', route: '/credit-note' },
{ label: 'debitNote', route: '/debit-note' },
],
},
@ -204,7 +198,6 @@ onMounted(async () => {
:width="mini ? 80 : 256"
show-if-above
>
<!-- :width="$q.screen.lt.sm ? $q.screen.width - 16 : 256" -->
<section
class="scroll"
style="overflow-x: hidden; scrollbar-gutter: stable"
@ -236,7 +229,6 @@ onMounted(async () => {
:disable="menu.disabled"
:header-class="{
row: true,
'justify-between': !mini,
'no-padding justify-center': mini,
'active-menu text-weight-bold': menuActive[i],
'text-weight-medium': !menu.disabled,
@ -254,7 +246,12 @@ onMounted(async () => {
:class="`isax ${menu.icon}`"
style="font-size: 24px"
/>
<Icon v-else :icon="menu.icon || ''" width="24px" />
<Icon
v-else
:icon="menu.icon || ''"
width="24px"
:class="{ 'fix-icon': !menuActive[i] }"
/>
<span
v-if="!mini"
class="q-pl-sm"
@ -514,4 +511,12 @@ onMounted(async () => {
border-bottom-right-radius: var(--radius-2);
}
}
.fix-icon {
color: var(--text-mute-2) !important;
}
:deep(.q-item.q-item-type.row.no-wrap.q-item--dense.disabled) {
opacity: 30% !important;
}
</style>

View file

@ -208,6 +208,9 @@ onMounted(async () => {
<div
class="q-px-lg row items-center justify-start q-pb-md q-pt-lg"
style="position: sticky; top: 0; z-index: 8"
:style="`
background: ${$q.screen.lt.md ? ($q.dark.isActive ? '#1c1d21' : '#ecedef') : 'transparent'};
`"
>
<q-btn
v-if="$q.screen.lt.sm"
@ -530,7 +533,7 @@ onMounted(async () => {
}
}
.avartar-border {
.avatar-border {
margin-top: 24px;
border: 5px solid var(--surface-1);
border-radius: 50%;

View file

@ -317,6 +317,7 @@ onMounted(async () => {
max-width="200"
:offset="[10, 0]"
style="width: 160px"
:touch-position="$q.screen.lt.sm"
>
<div v-for="(mode, index) in themeMode" :key="index">
<q-item clickable @click="theme = setTheme(mode.value)">

View file

@ -6,7 +6,7 @@ import { Icon } from '@iconify/vue';
import { BranchContact } from 'stores/branch-contact/types';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import type { QTableProps, QTableSlots } from 'quasar';
import type { QSelect, QTableProps, QTableSlots } from 'quasar';
import { resetScrollBar } from 'src/stores/utils';
import useBranchStore from 'stores/branch';
import useFlowStore from 'stores/flow';
@ -72,6 +72,7 @@ const typeBranchItem = [
color: 'var(--blue-6-hsl)',
},
];
const refFilter = ref<InstanceType<typeof QSelect>>();
const holdDialog = ref(false);
const isSubCreate = ref(false);
const columns = [
@ -302,7 +303,10 @@ const stats = ref<
}[]
>([]);
const splitterModel = ref(25);
// const splitterModel = ref(25);
const splitterModel = computed(() =>
$q.screen.lt.md ? (currentHq.value.id ? 0 : 100) : 25,
);
const defaultFormData = {
headOfficeId: null,
@ -1020,12 +1024,13 @@ watch(currentHq, () => {
class="col"
before-class="overflow-hidden"
after-class="overflow-hidden"
:disable="$q.screen.lt.sm"
>
<template v-slot:before>
<div class="surface-1 column full-height">
<div
class="row no-wrap full-width bordered-b text-weight-bold surface-3 items-center q-px-md q-py-sm"
:style="`min-height: ${$q.screen.gt.sm ? '57px' : '100.8px'}`"
:style="`min-height: ${$q.screen.gt.sm ? '57px' : ''}`"
>
<div class="col ellipsis-2-lines">
{{ $t('branch.allBranch') }}
@ -1157,7 +1162,7 @@ watch(currentHq, () => {
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-4"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="inputSearch"
debounce="200"
@ -1165,13 +1170,26 @@ watch(currentHq, () => {
<template v-slot: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="refFilter?.showPopup"
/>
</span>
</template>
</q-input>
<div
class="row col-12 col-md-6"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
>
<div class="row col-md-6 justify-end">
<q-select
v-show="$q.screen.gt.sm"
ref="refFilter"
v-model="statusFilter"
outlined
dense
@ -1179,7 +1197,7 @@ watch(currentHq, () => {
option-value="value"
:hide-dropdown-icon="$q.screen.lt.sm"
option-label="label"
class="col"
class="col-md-5"
map-options
:for="'field-select-status'"
emit-value
@ -1194,6 +1212,7 @@ watch(currentHq, () => {
></q-select>
<q-select
v-if="!modeView"
id="select-field"
for="select-field"
:options="
@ -1204,7 +1223,7 @@ watch(currentHq, () => {
"
:display-value="$t('general.displayField')"
:hide-dropdown-icon="$q.screen.lt.sm"
class="col q-mx-sm"
class="col q-ml-sm"
v-model="fieldSelected"
option-label="label"
option-value="value"
@ -1220,7 +1239,7 @@ watch(currentHq, () => {
id="btn-mode"
v-model="modeView"
dense
class="no-shadow bordered rounded surface-1"
class="no-shadow bordered rounded surface-1 q-ml-sm"
:toggle-color="$q.dark.isActive ? 'grey-9' : 'grey-2'"
size="xs"
:options="[
@ -1266,8 +1285,26 @@ watch(currentHq, () => {
<div
v-if="
inputSearch &&
treeData.flatMap((v) => [v, ...v.branch]).length === 0
(
currentSubBranch ||
(inputSearch !== ''
? treeData.flatMap((v) => [v, ...v.branch])
: treeData)
).filter((v) => {
if (
statusFilter === 'statusACTIVE' &&
v.status === 'INACTIVE'
) {
return false;
}
if (
statusFilter === 'statusINACTIVE' &&
v.status !== 'INACTIVE'
) {
return false;
}
return true;
}).length === 0
"
class="row items-center justify-center full-height"
>
@ -1788,7 +1825,7 @@ watch(currentHq, () => {
v-if="currentTab === 'main'"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 99999; top: 0; right: 0"
>
@ -1886,7 +1923,7 @@ watch(currentHq, () => {
class="col-12 col-md-10 full-height"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
id="branch-form"
style="overflow-y: auto"
@ -2137,7 +2174,7 @@ watch(currentHq, () => {
</div>
<div
class="col"
class="col full-width"
:class="{
'q-px-lg q-pb-lg': $q.screen.gt.sm,
'q-px-md q-pb-sm': !$q.screen.gt.sm,
@ -2150,7 +2187,7 @@ watch(currentHq, () => {
<div
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 99999; top: 0; right: 0"
>
@ -2241,7 +2278,7 @@ watch(currentHq, () => {
class="col-12 col-md-10 full-height"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
id="branch-info"
style="overflow-y: auto"

View file

@ -10,7 +10,7 @@ import useOptionStore from 'stores/options';
import useAddressStore from 'stores/address';
import useMyBranch from 'src/stores/my-branch';
import { calculateAge } from 'src/utils/datetime';
import { useQuasar, type QTableProps } from 'quasar';
import { QSelect, useQuasar, type QTableProps } from 'quasar';
import { dialog, baseUrl } from 'stores/utils';
import { useNavigator } from 'src/stores/navigator';
import { isRoleInclude, resetScrollBar } from 'src/stores/utils';
@ -73,6 +73,8 @@ const isImageEdit = ref(false);
const imageDialog = ref(false);
const infoDrawerEdit = ref(false);
const refreshImageState = ref(false);
const refFilter = ref<InstanceType<typeof QSelect>>();
const firstScroll = ref(false);
const inputSearch = ref('');
const currentTab = ref<string>('ALL');
@ -433,7 +435,7 @@ async function onSubmit(excludeDialog?: boolean) {
}
}
await fetchUserList();
await fetchUserList($q.screen.xs);
typeStats.value = await userStore.typeStats();
const res = await branchStore.userStats(formData.value.userType);
if (res) {
@ -465,9 +467,14 @@ async function onSubmit(excludeDialog?: boolean) {
}
}
currentTab.value = formData.value.userType;
if (
currentTab.value === formData.value.userType &&
currentTab.value !== 'ALL'
) {
await fetchUserList($q.screen.xs);
}
await fetchUserList();
currentTab.value = formData.value.userType;
typeStats.value = await userStore.typeStats();
const res = await branchStore.userStats(formData.value.userType);
@ -490,7 +497,7 @@ async function onDelete(id: string) {
action: async () => {
await userStore.deleteById(id);
await fetchUserList();
await fetchUserList($q.screen.xs);
typeStats.value = await userStore.typeStats();
infoDrawer.value = false;
flowStore.rotate();
@ -637,11 +644,15 @@ async function fetchImageList(id: string, selectedName?: string) {
return res;
}
async function fetchUserList() {
await userStore.fetchList({
async function fetchUserList(mobileFetch?: boolean) {
const userLength = userData.value?.result.length || 0;
const total = typeStats.value?.[currentTab.value] ?? 0;
const ret = await userStore.fetchList({
includeBranch: true,
pageSize: pageSize.value,
page: currentPage.value,
pageSize: mobileFetch
? userLength + (total === userLength ? 1 : 0)
: pageSize.value,
page: mobileFetch ? 1 : currentPage.value,
query: !!inputSearch.value ? inputSearch.value : undefined,
userType: currentTab.value === 'ALL' ? undefined : currentTab.value,
status:
@ -651,6 +662,21 @@ async function fetchUserList() {
? 'ACTIVE'
: 'INACTIVE',
});
if (ret) {
if ($q.screen.xs && !mobileFetch) {
if (!userData.value) {
userData.value = ret;
} else {
userData.value.page = ret.page;
userData.value.pageSize = ret.pageSize;
userData.value.total = ret.total;
userData.value?.result.push(...ret.result);
}
} else {
userData.value = ret;
}
}
}
function noPersonnel() {
@ -687,8 +713,11 @@ onMounted(async () => {
watch(
() => currentTab.value,
async (label) => {
firstScroll.value = true;
mapUserType(label);
await fetchUserList();
if (userData.value) userData.value.result = [];
currentPage.value = 1;
if ($q.screen.gt.xs) await fetchUserList();
const res = await branchStore.userStats(label);
if (res) {
userStats.value = res;
@ -721,7 +750,12 @@ watch(
},
);
watch([inputSearch, statusFilter, pageSize], async () => await fetchUserList());
watch([inputSearch, statusFilter, pageSize], async () => {
if (userData.value) userData.value.result = [];
currentPage.value = 1;
await fetchUserList();
});
watch(
() => $q.screen.lt.md,
@ -820,7 +854,7 @@ watch(
class="col surface-2 rounded justify-between column no-wrap bordered full-height overflow-hidden"
>
<div class="column">
<div
<header
class="row surface-3 justify-between full-width items-center bordered-b"
style="z-index: 1"
>
@ -830,7 +864,7 @@ watch(
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="inputSearch"
debounce="200"
@ -838,14 +872,26 @@ watch(
<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="refFilter?.showPopup"
/>
</span>
</template>
</q-input>
<div
class="row col-12 col-md-5"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5" style="white-space: nowrap">
<q-select
v-show="$q.screen.gt.sm"
ref="refFilter"
v-model="statusFilter"
outlined
dense
@ -936,7 +982,7 @@ watch(
</q-btn-toggle>
</div>
</div>
</div>
</header>
<div class="surface-2 bordered-b q-px-md full-width">
<q-tabs
@ -1053,11 +1099,27 @@ watch(
</div>
</div>
<div
<article
v-if="userData"
class="flex full-width full-height q-pa-md surface-2 column col scroll"
>
<template v-if="userData && userData.total > 0">
<q-infinite-scroll
:key="currentTab"
:offset="100"
@load="
(_, done) => {
if ($q.screen.gt.xs) return;
currentPage = firstScroll ? 1 : currentPage + 1;
fetchUserList().then(() => {
firstScroll = false;
done(currentPage >= currentMaxPage);
});
}
"
>
<q-table
v-if="userData && userData.total > 0"
flat
bordered
:grid="modeView"
@ -1102,7 +1164,11 @@ watch(
class="text-center"
v-if="fieldSelected.includes('orderNumber')"
>
{{ (currentPage - 1) * pageSize + props.rowIndex + 1 }}
{{
$q.screen.xs
? props.rowIndex + 1
: (currentPage - 1) * pageSize + props.rowIndex + 1
}}
</q-td>
<q-td v-if="fieldSelected.includes('name')">
@ -1362,7 +1428,15 @@ watch(
</div>
</template>
</q-table>
</template>
<template v-slot:loading>
<div
v-if="$q.screen.lt.sm && currentPage !== currentMaxPage"
class="row justify-center"
>
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
<div
v-if="
@ -1397,11 +1471,11 @@ watch(
>
<NoData :not-found="!!inputSearch" />
</div>
</div>
</article>
<div
<footer
class="row justify-between items-center q-px-md q-py-sm"
v-if="currentMaxPage > 0"
v-if="currentMaxPage > 0 && $q.screen.gt.xs"
>
<div class="col-4">
<div class="row items-center no-wrap">
@ -1414,10 +1488,15 @@ watch(
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: userData?.result.length,
total: userData?.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: userData?.result.length,
total: userData?.total,
})
: $t('general.ofPage', {
current: userData?.result.length,
total: userData?.total,
})
}}
</div>
<div class="col-4 row justify-end">
@ -1427,7 +1506,7 @@ watch(
:fetch-data="async () => await fetchUserList()"
/>
</div>
</div>
</footer>
</div>
</div>
@ -1521,7 +1600,7 @@ watch(
class="rounded row"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 999; top: 0; right: 0"
>
@ -1618,7 +1697,7 @@ watch(
class="col-12 col-md-10 relative-position"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
id="user-form-content"
style="height: 100%; max-height: 100; overflow-y: auto"
@ -1734,6 +1813,7 @@ watch(
}"
>
<ProfileBanner
:prefix="formData.firstName"
active
useToggle
color="white"
@ -1789,7 +1869,7 @@ watch(
style="position: absolute; z-index: 999; right: 0; top: 0"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
>
<div class="surface-1 row rounded">
@ -1837,7 +1917,7 @@ watch(
class="col-md-10 col-12 full-height scroll"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
>
<FormInformation

View file

@ -2,7 +2,7 @@
import { ref, onMounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { QTableProps } from 'quasar';
import { QSelect, QTableProps } from 'quasar';
import { useRoute } from 'vue-router';
import { baseUrl } from 'src/stores/utils';
@ -67,6 +67,7 @@ const currentCustomerUrlImage = defineModel<string | null>(
const { state: employeeFormState } = storeToRefs(employeeFormStore);
const refFilter = ref<InstanceType<typeof QSelect>>();
const currentStatus = ref<Status | 'All'>('All');
const currentBtnOpen = ref<boolean[]>([]);
const totalBranch = ref(0);
@ -334,11 +335,11 @@ watch(
</div>
</div>
<div class="row items-center justify-end col-12 col-md q-py-sm no-wrap">
<div class="row items-center justify-between col-12 col-md q-py-sm no-wrap">
<q-input
outlined
dense
class="col-6"
class="col-md-6 col"
:label="$t('general.search')"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="inputSearch"
@ -347,9 +348,25 @@ watch(
<template v-slot: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="refFilter?.showPopup"
/>
</span>
</template>
</q-input>
<q-select
v-show="$q.screen.gt.sm"
ref="refFilter"
id="select-status"
for="select-status"
v-model="currentStatus"
@ -657,7 +674,12 @@ watch(
totalEmployee: props.row._count?.employee,
}"
:visible-columns="branchFieldSelected"
@view-detail="$emit('viewDetail', props.row, props.rowIndex)"
@view-detail="
() => {
customerBranchFormStore.initForm('info', props.row.id);
customerBranchFormState.dialogModal = true;
}
"
/>
</div>
</template>
@ -676,10 +698,15 @@ watch(
<div class="col-4 flex justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: branch?.length,
total: totalBranch,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: branch?.length,
total: totalBranch,
})
: $t('general.ofPage', {
current: branch?.length,
total: totalBranch,
})
}}
</div>
<div class="col-4 flex justify-end">
@ -868,7 +895,11 @@ watch(
v-model:wage-rate="customerBranchFormData.wageRate"
v-model:wage-rate-text="customerBranchFormData.wageRateText"
/>
<div class="row q-col-gutter-sm q-mb-sm" id="employer-branch-address">
<div
v-if="customerType === 'CORP'"
class="row q-col-gutter-sm q-mb-sm"
id="employer-branch-address"
>
<div class="col-12 text-weight-bold text-body1 row items-center">
<q-icon
flat
@ -882,6 +913,7 @@ watch(
</div>
</div>
<EmployerFormAuthorized
v-if="customerType === 'CORP'"
class="q-mb-xl"
prefix-id="employer-branch"
:readonly="customerBranchFormState.dialogType === 'info'"

File diff suppressed because it is too large Load diff

View file

@ -167,7 +167,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-6 col-md-5"
class="col-12 col-md-5"
:label="$t('customer.form.employerName')"
for="input-legal-person-no"
:model-value="customerName"
@ -224,7 +224,7 @@ watch(
<div class="col-12 row q-col-gutter-sm">
<DatePicker
v-model="registerDate"
class="col-6 col-md-2"
class="col-12 col-md-2"
:id="`${prefixId}-input-register-date`"
:label="$t('customer.form.registerDate')"
:readonly="readonly"
@ -264,7 +264,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col-6 col-md-3"
class="col-12 col-md-3"
:label="$t('customer.form.headQuarters.telephoneNo')"
for="input-telephone-no"
:model-value="readonly ? telephoneNo || '-' : telephoneNo"
@ -285,7 +285,7 @@ watch(
</template>
<template v-if="customerType === 'PERS'">
<div class="col-7 row q-col-gutter-sm">
<div class="col-md-7 col-12 row q-col-gutter-sm">
<q-input
dense
outlined
@ -320,7 +320,7 @@ watch(
/>
</div>
<div class="col-9 row q-col-gutter-sm">
<div class="col-md-9 col-12 row q-col-gutter-sm">
<q-select
outlined
use-input
@ -360,7 +360,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col"
class="col-md col-12"
:label="$t('personnel.form.firstName')"
:model-value="firstName"
@update:model-value="
@ -384,7 +384,7 @@ watch(
/>
</div>
<div class="col-9 row q-col-gutter-sm">
<div class="col-md-9 col-12 row q-col-gutter-sm">
<q-input
:for="`${prefixId}-input-prefix-name`"
dense
@ -411,7 +411,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
class="col"
class="col-md col-12"
label="Name"
:model-value="firstNameEN"
@update:model-value="
@ -443,13 +443,13 @@ watch(
/>
</div>
<div class="row col-9 q-col-gutter-sm">
<div class="row col-md-9 col-12 q-col-gutter-sm">
<q-input
:for="`${prefixId}-input-telephone`"
dense
outlined
:readonly="readonly"
class="col-md col-6"
class="col-md col-12"
:label="$t('form.telephone')"
:mask="readonly ? '' : '##########'"
:model-value="readonly ? telephoneNo || '-' : telephoneNo"

View file

@ -96,7 +96,7 @@ const telephoneNo = defineModel<string>('telephoneNo', { default: '' });
</div>
<div class="col-12 row q-col-gutter-sm">
<div class="col-12 row q-col-gutter-sm">
<div class="col-md-5 col-12">
<SelectBranch
:for="`${prefixId}-input-source-registered-branch`"
class="col-md-6"

View file

@ -25,8 +25,6 @@ import {
} from 'components/button';
import { UploadFileGroup } from 'src/components/upload-file/';
import { uploadFileListCustomer, columnsAttachment } from '../../constant';
import { symOutlinedResume } from '@quasar/extras/material-symbols-outlined';
import { group } from 'console';
const ocrStore = useOcrStore();
const customerStore = useCustomerStore();
@ -114,6 +112,7 @@ withDefaults(
class="bordered-b"
active-color="primary"
no-caps
mobile-arrows
style="color: hsl(var(--text-mute))"
>
<q-tab name="main" :label="$t('customerBranch.tab.main')" />

View file

@ -100,7 +100,7 @@ function triggerPropertiesDialog(step: WorkFlowPayloadStep) {
style="position: absolute; z-index: 999; right: 0; top: 0"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
>
<div class="surface-1 row rounded">
@ -160,7 +160,7 @@ function triggerPropertiesDialog(step: WorkFlowPayloadStep) {
class="col-12 col-md-10"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="height: 100%; max-height: 100%; overflow-y: auto"
id="flow-form-dialog"
@ -169,6 +169,7 @@ function triggerPropertiesDialog(step: WorkFlowPayloadStep) {
v-model:flow-data="flowData"
v-model:register-branch-id="registerBranchId"
@trigger-properties="triggerPropertiesDialog"
@add-step="addStep"
/>
</section>
</div>
@ -199,7 +200,7 @@ function triggerPropertiesDialog(step: WorkFlowPayloadStep) {
class="rounded row"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 999; top: 0; right: 0"
>
@ -304,7 +305,7 @@ function triggerPropertiesDialog(step: WorkFlowPayloadStep) {
class="col-12 col-md-10"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="height: 100%; max-height: 100%; overflow-y: auto"
id="flow-form-drawer"
@ -317,6 +318,7 @@ function triggerPropertiesDialog(step: WorkFlowPayloadStep) {
v-model:register-branch-id="registerBranchId"
@change-status="$emit('changeStatus')"
@trigger-properties="triggerPropertiesDialog"
@add-step="addStep"
/>
</section>
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue';
import { QTableProps } from 'quasar';
import { QSelect, QTableProps } from 'quasar';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@ -68,6 +68,7 @@ const fieldSelectedOption = ref<{ label: string; value: string }[]>([
},
]);
const refFilter = ref<InstanceType<typeof QSelect>>();
const currWorkflowData = ref<WorkflowTemplate>();
const formDataWorkflow = ref<WorkflowTemplatePayload>({
status: 'CREATED',
@ -126,7 +127,7 @@ async function deleteWorkflow(id?: string) {
message: t('dialog.message.confirmDelete'),
action: async () => {
await workflowStore.deleteWorkflowTemplate(targetId);
await fetchWorkflowList();
await fetchWorkflowList($q.screen.xs);
resetForm();
},
@ -204,7 +205,7 @@ async function submit() {
...formDataWorkflow.value,
});
}
await fetchWorkflowList();
await fetchWorkflowList($q.screen.xs);
resetForm();
}
@ -261,25 +262,30 @@ function resetForm() {
};
}
async function fetchWorkflowList() {
{
const res = await workflowStore.getWorkflowTemplateList({
page: workflowPage.value,
pageSize: workflowPageSize.value,
query: pageState.inputSearch,
status:
statusFilter.value === 'all'
? undefined
: statusFilter.value === 'statusACTIVE'
? 'ACTIVE'
: 'INACTIVE',
});
if (res) {
workflowData.value = res.result;
workflowPageMax.value = Math.ceil(res.total / workflowPageSize.value);
if (pageState.inputSearch || statusFilter.value !== 'all') return;
pageState.total = res.total;
}
async function fetchWorkflowList(mobileFetch?: boolean) {
const res = await workflowStore.getWorkflowTemplateList({
page: mobileFetch ? 1 : workflowPage.value,
pageSize: mobileFetch
? workflowData.value.length +
(pageState.total === workflowData.value.length ? 1 : 0)
: workflowPageSize.value,
query: pageState.inputSearch,
status:
statusFilter.value === 'all'
? undefined
: statusFilter.value === 'statusACTIVE'
? 'ACTIVE'
: 'INACTIVE',
});
if (res) {
workflowData.value =
$q.screen.xs && !mobileFetch
? [...workflowData.value, ...res.result]
: res.result;
workflowPageMax.value = Math.ceil(res.total / workflowPageSize.value);
if (pageState.inputSearch || statusFilter.value !== 'all') return;
pageState.total = res.total;
}
}
@ -291,8 +297,19 @@ onMounted(async () => {
await fetchWorkflowList();
});
watch(statusFilter, fetchWorkflowList);
watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
watch(
() => statusFilter.value,
() => {
workflowData.value = [];
workflowPage.value = 1;
fetchWorkflowList();
},
);
watch([() => pageState.inputSearch, workflowPageSize], () => {
workflowData.value = [];
workflowPage.value = 1;
fetchWorkflowList();
});
</script>
<template>
<FloatingActionButton
@ -350,7 +367,7 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
<!-- SEC: header content -->
<section class="col surface-1 rounded bordered overflow-hidden">
<div class="column full-height">
<div class="column full-height no-wrap">
<header
class="row surface-3 justify-between full-width items-center bordered-b"
style="z-index: 1"
@ -361,7 +378,7 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -369,14 +386,26 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
<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="refFilter?.showPopup"
/>
</span>
</template>
</q-input>
<div
class="row col-12 col-md-5 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5" style="white-space: nowrap">
<q-select
v-show="$q.screen.gt.sm"
ref="refFilter"
v-model="statusFilter"
outlined
dense
@ -394,6 +423,7 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
]"
/>
<q-select
v-show="$q.screen.gt.sm"
id="select-field"
for="select-field"
class="col q-ml-sm"
@ -486,173 +516,118 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
</article>
<article v-else class="col q-pa-md surface-2 scroll full-width">
<q-table
flat
bordered
:grid="pageState.gridView"
:rows="workflowData"
:columns="columns"
class="full-width"
card-container-class="q-col-gutter-md"
row-key="name"
:rows-per-page-options="[0]"
hide-pagination
:visible-columns="fieldSelected"
<q-infinite-scroll
:offset="10"
@load="
(_, done) => {
if ($q.screen.gt.xs || workflowPage === workflowPageMax) return;
workflowPage = workflowPage + 1;
fetchWorkflowList().then(() =>
done(workflowPage >= workflowPageMax),
);
}
"
>
<template #header="{ cols }">
<q-tr style="background-color: hsla(var(--info-bg) / 0.07)">
<q-th
v-for="v in cols"
:key="v"
:class="{
'text-left': v.name === 'name',
'text-right': v.name === 'step',
}"
>
{{
v.label &&
$t(v.label, {
msg:
v.name === 'step'
? $t('flow.step')
: v.name === 'name'
? $t('flow.title')
: '',
})
}}
</q-th>
<q-th auto-width />
</q-tr>
</template>
<template #body="props">
<q-tr
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
'status-active': props.row.status !== 'INACTIVE',
'status-inactive': props.row.status === 'INACTIVE',
}"
:style="
props.rowIndex % 2 !== 0
? $q.dark.isActive
? 'background: hsl(var(--gray-11-hsl)/0.2)'
: `background: #f9fafc`
: ''
"
>
<q-td
v-if="fieldSelected.includes('order')"
class="text-center"
>
{{ props.rowIndex + 1 }}
<!-- {{ (currentPage - 1) * pageSize + props.rowIndex + 1 }} -->
</q-td>
<q-td v-if="fieldSelected.includes('name')">
<section class="row items-center no-wrap">
<q-avatar
class="q-mr-sm"
size="md"
style="
color: var(--gray-6);
background: hsla(var(--gray-6-hsl) / 0.1);
"
>
<q-icon name="mdi-cogs" />
<q-badge
class="absolute-bottom-right no-padding"
style="
border-radius: 50%;
min-width: 8px;
min-height: 8px;
"
:style="{
background: `var(--${props.row.status === 'INACTIVE' ? 'stone-5' : 'green-6'})`,
}"
></q-badge>
</q-avatar>
{{ props.row.name }}
</section>
</q-td>
<q-td v-if="fieldSelected.includes('step')" class="text-right">
{{ props.row.step.length }}
</q-td>
<q-td style="width: 20%" class="text-right">
<q-btn
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
/>
<KebabAction
:id-name="props.row.id"
:status="props.row.status"
@view="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
@edit="
() => {
assignFormData(props.row);
triggerDialog('edit');
}
"
@delete="() => deleteWorkflow(props.row.id)"
@change-status="() => triggerChangeStatus(props.row)"
/>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<section class="col-12 col-md-4 column">
<div
<q-table
flat
bordered
:grid="pageState.gridView"
:rows="workflowData"
:columns="columns"
class="full-width"
card-container-class="q-col-gutter-md"
row-key="name"
:rows-per-page-options="[0]"
hide-pagination
:visible-columns="fieldSelected"
>
<template #header="{ cols }">
<q-tr style="background-color: hsla(var(--info-bg) / 0.07)">
<q-th
v-for="v in cols"
:key="v"
:class="{
'text-left': v.name === 'name',
'text-right': v.name === 'step',
}"
>
{{
v.label &&
$t(v.label, {
msg:
v.name === 'step'
? $t('flow.step')
: v.name === 'name'
? $t('flow.title')
: '',
})
}}
</q-th>
<q-th auto-width />
</q-tr>
</template>
<template #body="props">
<q-tr
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
'status-active': props.row.status !== 'INACTIVE',
'status-inactive': props.row.status === 'INACTIVE',
}"
class="surface-1 rounded bordered row no-wrap col"
style="overflow: hidden"
:style="
props.rowIndex % 2 !== 0
? $q.dark.isActive
? 'background: hsl(var(--gray-11-hsl)/0.2)'
: `background: #f9fafc`
: ''
"
>
<div
class="col-md-2 col-3 flex items-center justify-center"
style="
color: var(--gray-6);
background: hsla(var(--gray-6-hsl) / 0.1);
"
<q-td
v-if="fieldSelected.includes('order')"
class="text-center"
>
<q-icon name="mdi-cogs" size="md" />
</div>
<article class="row q-pa-sm q-gutter-y-sm col">
<div
v-if="fieldSelected.includes('name')"
class="text-weight-bold col-12 ellipsis-2-lines"
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
}"
>
{{ props.row.name }}
{{
$q.screen.xs
? props.rowIndex + 1
: (workflowPage - 1) * workflowPageSize +
props.rowIndex +
1
}}
</q-td>
<q-td v-if="fieldSelected.includes('name')">
<section class="row items-center no-wrap">
<q-avatar
class="q-mr-sm"
size="md"
style="
color: var(--gray-6);
background: hsla(var(--gray-6-hsl) / 0.1);
"
>
<q-icon name="mdi-cogs" />
<q-tooltip>
{{ props.row.name }}
</q-tooltip>
</div>
<div v-if="fieldSelected.includes('step')" class="self-end">
<div class="bordered rounded q-px-sm">
<q-icon name="mdi-note-edit-outline" class="q-pr-sm" />
{{ props.row.step.length }}
</div>
</div>
</article>
<nav class="q-pa-sm row items-center self-start">
<q-badge
class="absolute-bottom-right no-padding"
style="
border-radius: 50%;
min-width: 8px;
min-height: 8px;
"
:style="{
background: `var(--${props.row.status === 'INACTIVE' ? 'stone-5' : 'green-6'})`,
}"
></q-badge>
</q-avatar>
{{ props.row.name }}
</section>
</q-td>
<q-td
v-if="fieldSelected.includes('step')"
class="text-right"
>
{{ props.row.step.length }}
</q-td>
<q-td style="width: 20%" class="text-right">
<q-btn
icon="mdi-eye-outline"
size="sm"
@ -684,17 +659,108 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
@delete="() => deleteWorkflow(props.row.id)"
@change-status="() => triggerChangeStatus(props.row)"
/>
</nav>
</div>
</section>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<section class="col-12 col-md-4 column">
<div
:class="{
'status-inactive': props.row.status === 'INACTIVE',
}"
class="surface-1 rounded bordered row no-wrap col"
style="overflow: hidden"
>
<div
class="col-md-2 col-3 flex items-center justify-center"
style="
color: var(--gray-6);
background: hsla(var(--gray-6-hsl) / 0.1);
"
>
<q-icon name="mdi-cogs" size="md" />
</div>
<article class="row q-pa-sm q-gutter-y-sm col">
<div
v-if="fieldSelected.includes('name')"
class="text-weight-bold col-12 ellipsis-2-lines"
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
}"
>
{{ props.row.name }}
<q-tooltip>
{{ props.row.name }}
</q-tooltip>
</div>
<div
v-if="fieldSelected.includes('step')"
class="self-end"
>
<div class="bordered rounded q-px-sm">
<q-icon
name="mdi-note-edit-outline"
class="q-pr-sm"
/>
{{ props.row.step.length }}
</div>
</div>
</article>
<nav class="q-pa-sm row items-center self-start">
<q-btn
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
/>
<KebabAction
:id-name="props.row.id"
:status="props.row.status"
@view="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
@edit="
() => {
assignFormData(props.row);
triggerDialog('edit');
}
"
@delete="() => deleteWorkflow(props.row.id)"
@change-status="() => triggerChangeStatus(props.row)"
/>
</nav>
</div>
</section>
</template>
</q-table>
<template v-slot:loading>
<div
v-if="$q.screen.lt.sm && workflowPage !== workflowPageMax"
class="row justify-center"
>
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-table>
</q-infinite-scroll>
</article>
<!-- SEC: footer content -->
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="workflowPageMax > 0"
v-if="workflowPageMax > 0 && $q.screen.gt.xs"
>
<div class="col-4">
<div class="row items-center no-wrap">
@ -706,19 +772,26 @@ watch([() => pageState.inputSearch, workflowPageSize], fetchWorkflowList);
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: workflowData.length,
total: pageState.inputSearch
? workflowData.length
: pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: workflowData.length,
total: pageState.inputSearch
? workflowData.length
: pageState.total,
})
: $t('general.ofPage', {
current: workflowData.length,
total: pageState.inputSearch
? workflowData.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">
<PaginationComponent
v-model:current-page="workflowPage"
v-model:max-page="workflowPageMax"
:fetch-data="fetchWorkflowList"
:fetch-data="() => fetchWorkflowList()"
/>
</nav>
</footer>

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
<script lang="ts" setup>
import { pageTabs, columnQuotation } from './constants';
import { onMounted, reactive, ref, watch, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuasar } from 'quasar';
// NOTE: Import stores
import { useQuotationStore } from 'src/stores/quotations';
@ -12,6 +11,7 @@ import useFlowStore from 'src/stores/flow';
import useMyBranch from 'stores/my-branch';
import { useQuotationForm } from './form';
import { hslaColors } from './constants';
import { pageTabs, columnQuotation } from './constants';
// NOTE Import Types
import { CustomerBranchCreate, CustomerType } from 'stores/customer/types';
@ -44,6 +44,7 @@ import { Quotation } from 'src/stores/quotations/types';
import TableQuotation from 'src/components/05_quotation/TableQuotation.vue';
import PaginationPageSize from 'src/components/PaginationPageSize.vue';
const $q = useQuasar();
const quotationFormStore = useQuotationForm();
const customerFormStore = useCustomerForm();
const flowStore = useFlowStore();
@ -240,6 +241,7 @@ const {
} = storeToRefs(quotationStore);
onMounted(async () => {
pageState.gridView = $q.screen.lt.md ? true : false;
navigatorStore.current.title = 'quotation.title';
navigatorStore.current.path = [
{
@ -276,10 +278,10 @@ onMounted(async () => {
flowStore.rotate();
});
async function fetchQuotationList() {
async function fetchQuotationList(mobileFetch?: boolean) {
{
const ret = await quotationStore.getQuotationList({
page: quotationPage.value,
page: mobileFetch ? 1 : quotationPage.value,
pageSize: quotationPageSize.value,
status:
pageState.currentTab !== 'Issued'
@ -299,7 +301,10 @@ async function fetchQuotationList() {
});
if (ret) {
quotationData.value = ret.result;
quotationData.value =
$q.screen.xs && !mobileFetch
? [...quotationData.value, ...ret.result]
: ret.result;
quotationPageMax.value = Math.ceil(ret.total / quotationPageSize.value);
pageState.total = ret.total;
}
@ -317,7 +322,11 @@ async function fetchQuotationList() {
watch(
[() => pageState.currentTab, () => pageState.inputSearch, quotationPageSize],
fetchQuotationList,
() => {
quotationPage.value = 1;
quotationData.value = [];
fetchQuotationList();
},
);
async function storeDataLocal(id: string) {
@ -381,19 +390,11 @@ async function storeDataLocal(id: string) {
"
>
{{
pageState.currentTab === 'Issued'
? quotationStats.issued
: pageState.currentTab === 'Accepted'
? quotationStats.accepted
: pageState.currentTab === 'Expired'
? quotationStats.expired
: pageState.currentTab === 'Invoice'
? quotationStats.paymentInProcess
: pageState.currentTab === 'PaymentSuccess'
? quotationStats.paymentSuccess
: pageState.currentTab === 'ProcessComplete'
? quotationStats.processComplete
: 0
quotationStats[
pageState.currentTab === 'Invoice'
? 'paymentInProcess'
: (pageState.currentTab.toLowerCase() as keyof typeof quotationStats)
]
}}
</q-badge>
<q-btn
@ -476,7 +477,7 @@ async function storeDataLocal(id: string) {
<header class="col surface-1 rounded bordered overflow-hidden">
<div class="column full-height">
<section
class="row surface-3 justify-between full-width items-center bordered-b"
class="row surface-3 justify-between full-width bordered-b"
style="z-index: 1"
>
<div class="row q-py-sm q-px-md justify-between full-width">
@ -485,7 +486,7 @@ async function storeDataLocal(id: string) {
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -495,16 +496,12 @@ async function storeDataLocal(id: string) {
</template>
</q-input>
<div
class="row col-12 col-md-3 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5 justify-end" style="white-space: nowrap">
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col q-ml-sm"
class="col-md-5 q-ml-sm"
:options="
fieldSelectedOption.map((v) => ({
...v,
@ -627,105 +624,134 @@ async function storeDataLocal(id: string) {
</article>
<article v-else class="col surface-2 full-width scroll">
<div class="q-pa-md">
<TableQuotation
:columns="columnQuotation"
:rows="quotationData"
:visible-columns="pageState.fieldSelected"
:grid="pageState.gridView"
:hide-edit="pageState.currentTab !== 'Issued'"
@preview="(id: any) => storeDataLocal(id)"
@view="
(item) => {
triggerQuotationDialog({
statusDialog: 'info',
quotationId: item.id,
branchId: item.customerBranch.customer.registeredBranchId,
<q-infinite-scroll
:key="pageState.currentTab"
:offset="100"
@load="
(_, done) => {
if ($q.screen.gt.xs) return;
quotationPage = quotationPage + 1;
fetchQuotationList().then(() => {
done(quotationPage >= quotationPageMax);
});
}
"
@edit="
(item) =>
triggerQuotationDialog({
statusDialog: 'edit',
quotationId: item.id,
branchId: item.customerBranch.customer.registeredBranchId,
})
"
@delete="(id) => triggerDialogDeleteQuottaion(id)"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
hide-kebab-delete
:hide-kebab-edit="!(pageState.currentTab === 'Issued')"
:urgent="item.row.urgent"
:code="item.row.code"
:title="item.row.workName"
:created-at="
new Date(item.row.createdAt).toLocaleString('th-TH', {
hour12: false,
})
"
:valid-until="
(() => {
const date = new Date(item.row.dueDate);
date.setHours(23, 59, 59, 0);
return date.toLocaleString('th-TH', {
<TableQuotation
:page="quotationPage"
:page-size="quotationPageSize"
:columns="columnQuotation"
:rows="quotationData"
:visible-columns="pageState.fieldSelected"
:grid="pageState.gridView"
:hide-edit="pageState.currentTab !== 'Issued'"
@preview="(id: any) => storeDataLocal(id)"
@view="
(item) => {
triggerQuotationDialog({
statusDialog: 'info',
quotationId: item.id,
branchId: item.customerBranch.customer.registeredBranchId,
});
}
"
@edit="
(item) =>
triggerQuotationDialog({
statusDialog: 'edit',
quotationId: item.id,
branchId: item.customerBranch.customer.registeredBranchId,
})
"
@delete="(id) => triggerDialogDeleteQuottaion(id)"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12 column">
<QuotationCard
class="col"
hide-kebab-delete
:hide-kebab-edit="!(pageState.currentTab === 'Issued')"
:urgent="item.row.urgent"
:code="item.row.code"
:title="item.row.workName"
:created-at="
new Date(item.row.createdAt).toLocaleString('th-TH', {
hour12: false,
});
})()
"
:status="$t(`quotation.status.${item.row.quotationStatus}`)"
:worker-count="item.row._count.worker"
:worker-max="item.row.workerMax || item.row._count.worker"
:customer-name="
item.row.customerBranch.registerName ||
`${item.row.customerBranch.firstName || '-'} ${item.row.customerBranch.lastName || ''}`
"
:reporter="
$i18n.locale === 'eng'
? item.row.createdBy.firstNameEN +
' ' +
item.row.createdBy.lastNameEN
: item.row.createdBy.firstName +
' ' +
item.row.createdBy.lastName
"
:total-price="item.row.finalPrice"
:badge-color="hslaColors[item.row.quotationStatus] || ''"
@preview="storeDataLocal(item.row.id)"
@view="
() => {
})
"
:valid-until="
(() => {
const date = new Date(item.row.dueDate);
date.setHours(23, 59, 59, 0);
return date.toLocaleString('th-TH', {
hour12: false,
});
})()
"
:status="
$t(`quotation.status.${item.row.quotationStatus}`)
"
:worker-count="item.row._count.worker"
:worker-max="item.row.workerMax || item.row._count.worker"
:customer-name="
item.row.customerBranch.registerName ||
`${item.row.customerBranch.firstName || '-'} ${item.row.customerBranch.lastName || ''}`
"
:reporter="
$i18n.locale === 'eng'
? item.row.createdBy.firstNameEN +
' ' +
item.row.createdBy.lastNameEN
: item.row.createdBy.firstName +
' ' +
item.row.createdBy.lastName
"
:total-price="item.row.finalPrice"
:badge-color="hslaColors[item.row.quotationStatus] || ''"
@preview="storeDataLocal(item.row.id)"
@view="
() => {
triggerQuotationDialog({
statusDialog: 'info',
quotationId: item.row.id,
branchId:
item.row.customerBranch.customer
.registeredBranchId,
});
}
"
@edit="
triggerQuotationDialog({
statusDialog: 'info',
statusDialog: 'edit',
quotationId: item.row.id,
branchId:
item.row.customerBranch.customer.registeredBranchId,
});
}
"
@edit="
triggerQuotationDialog({
statusDialog: 'edit',
quotationId: item.row.id,
branchId:
item.row.customerBranch.customer.registeredBranchId,
})
"
@link="triggerReceiptDialog(item.row)"
@upload="console.log('upload')"
@delete="triggerDialogDeleteQuottaion(item.row.id)"
/>
})
"
@link="triggerReceiptDialog(item.row)"
@upload="console.log('upload')"
@delete="triggerDialogDeleteQuottaion(item.row.id)"
/>
</div>
</template>
</TableQuotation>
<template v-slot:loading>
<div
v-if="$q.screen.lt.sm && quotationPage !== quotationPageMax"
class="row justify-center"
>
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</TableQuotation>
</q-infinite-scroll>
</div>
</article>
<!-- SEC: footer content -->
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="quotationPageMax > 0"
v-if="quotationPageMax > 0 && $q.screen.gt.xs"
>
<div class="col-4">
<div class="row items-center no-wrap">
@ -737,19 +763,26 @@ async function storeDataLocal(id: string) {
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: quotationData.length,
total: pageState.inputSearch
? quotationData.length
: pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: quotationData.length,
total: pageState.inputSearch
? quotationData.length
: pageState.total,
})
: $t('general.ofPage', {
current: quotationData.length,
total: pageState.inputSearch
? quotationData.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">
<PaginationComponent
v-model:current-page="quotationPage"
v-model:max-page="quotationPageMax"
:fetch-data="fetchQuotationList"
:fetch-data="() => fetchQuotationList()"
/>
</nav>
</footer>
@ -788,7 +821,12 @@ async function storeDataLocal(id: string) {
}
"
>
<header class="q-mx-lg q-mt-lg">
<header
:class="{
'q-mx-lg q-mt-md': $q.screen.gt.sm,
'q-mx-md q-mt-sm': $q.screen.lt.md,
}"
>
<ProfileBanner
prefix="dialog"
img="/images/quotation-bg-avatar.png"
@ -801,9 +839,19 @@ async function storeDataLocal(id: string) {
hideFade
/>
</header>
<section class="col surface-1 q-ma-lg rounded bordered row scroll">
<section
class="col surface-1 rounded bordered row scroll"
:class="{
'q-mx-lg q-my-md': $q.screen.gt.sm,
'q-mx-md q-my-sm': $q.screen.lt.md,
}"
>
<div
class="col-12 q-px-md q-py-lg"
class="col-12"
:class="{
'q-px-md q-py-lg': $q.screen.gt.sm,
'q-pa-sm': $q.screen.lt.md,
}"
id="customer-form-content"
style="height: 100%; max-height: 100%; overflow-y: auto"
>
@ -839,7 +887,7 @@ async function storeDataLocal(id: string) {
>
<div class="full-height row q-pa-md">
<ItemCard
class="col q-mx-sm full-height"
class="col q-mx-sm full-height cursor-pointer"
v-for="value in dialogCreateCustomerItem"
:key="value.text"
:icon="value.icon"
@ -850,7 +898,9 @@ async function storeDataLocal(id: string) {
() => {
triggerCreateCustomerd({
type:
value.text === 'customer.employerLegalEntity' ? 'CORP' : 'PERS',
value.text === 'customer.employerLegalEntity'
? CustomerType.Corporate
: CustomerType.Person,
});
emptyCreateDialog = false;
}

View file

@ -21,12 +21,16 @@ import { dateFormatJS } from 'src/utils/datetime';
import { QFile, QMenu } from 'quasar';
import UploadFileCard from 'src/components/upload-file/UploadFileCard.vue';
import { onMounted } from 'vue';
import { DebitNote } from 'src/stores/debit-note';
const configStore = useConfigStore();
const quotationPayment = useQuotationPayment();
const { data: config } = storeToRefs(configStore);
const prop = defineProps<{ data?: Quotation | QuotationFull }>();
const prop = defineProps<{
data?: Quotation | QuotationFull | DebitNote;
isDebitNote?: boolean;
}>();
const refQFile = ref<InstanceType<typeof QFile>[]>([]);
const refQMenu = ref<InstanceType<typeof QMenu>[]>([]);
@ -194,7 +198,12 @@ async function triggerSubmit() {
onMounted(async () => {
if (!prop.data) return;
const ret = await quotationPayment.getQuotationPayment(prop.data.id);
const ret = await quotationPayment.getQuotationPayment({
quotationId: prop.isDebitNote === true ? undefined : prop.data.id,
debitNoteId: prop.isDebitNote === true ? prop.data.id : undefined,
quotationOnly: !!prop.isDebitNote ? false : true,
debitNoteOnly: !!prop.isDebitNote ? true : false,
});
if (ret) {
paymentData.value = ret.result;
slipFile.value = paymentData.value.map((v) => ({
@ -362,10 +371,11 @@ onMounted(async () => {
"
class="row items-center q-pb-sm"
>
<span class="app-text-muted-2">
<span class="app-text-muted-2 col-12 col-md">
{{ $t('quotation.paySplitCount') }}
</span>
<span class="q-ml-auto">
<span>
{{ $t('quotation.receiptDialog.total') }}
</span>
<span class="bordered rounded surface-2 number-box q-mx-sm">
@ -406,49 +416,58 @@ onMounted(async () => {
<!-- summary total, paid, remain -->
<div class="row items-center">
<span
class="row col rounded q-px-sm q-py-md"
class="row col rounded q-px-sm q-py-md justify-end"
style="border: 1px solid hsl(var(--info-bg))"
>
{{ $t('quotation.receiptDialog.totalAmount') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(data.finalPrice, 2) }}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('quotation.receiptDialog.totalAmount') }}
</span>
{{ formatNumberDecimal(data.finalPrice, 2) }}
</span>
<span
class="row col rounded q-px-sm q-py-md q-mx-md"
class="row col rounded q-px-sm q-py-md q-mx-md justify-end"
style="border: 1px solid hsl(var(--positive-bg))"
>
{{ $t('quotation.receiptDialog.paid') }}
<span class="q-ml-auto">
{{
formatNumberDecimal(
paymentData.reduce(
(c, i) =>
i.paymentStatus === 'PaymentSuccess' ? c + i.amount : c,
0,
),
2,
)
}}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('quotation.receiptDialog.paid') }}
</span>
{{
formatNumberDecimal(
paymentData.reduce(
(c, i) =>
i.paymentStatus === 'PaymentSuccess' ? c + i.amount : c,
0,
),
2,
)
}}
</span>
<span
class="row col rounded q-px-sm q-py-md"
class="row col rounded q-px-sm q-py-md justify-end"
style="border: 1px solid hsl(var(--warning-bg))"
>
{{ $t('quotation.receiptDialog.remain') }}
<span class="q-ml-auto">
{{
formatNumberDecimal(
paymentData.reduce(
(c, i) =>
i.paymentStatus !== 'PaymentSuccess' ? c + i.amount : c,
0,
),
2,
)
}}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('quotation.receiptDialog.remain') }}
</span>
{{
formatNumberDecimal(
paymentData.reduce(
(c, i) =>
i.paymentStatus !== 'PaymentSuccess' ? c + i.amount : c,
0,
),
2,
)
}}
</span>
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useQuasar } from 'quasar';
import { QSelect, useQuasar } from 'quasar';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import {
baseUrl,
@ -13,28 +13,26 @@ import { ProductTree, quotationProductTree } from './utils';
// NOTE: Import stores
import { dateFormat, calculateAge, dateFormatJS } from 'src/utils/datetime';
import { useEmployeeForm } from 'src/pages/03_customer-management/form';
import { useQuotationStore } from 'src/stores/quotations';
import useProductServiceStore from 'stores/product-service';
import { waitAll, calculateDaysUntilExpire, dialog } from 'src/stores/utils';
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 } from './form';
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.ts';
import {
EmployeeWorker,
PayCondition,
ProductRelation,
ProductServiceList,
QuotationPayload,
} from 'src/stores/quotations/types';
import { Employee, EmployeeWork } from 'src/stores/employee/types';
import { Employee } from 'src/stores/employee/types';
import { Receipt } from 'src/stores/payment/types';
import {
ProductGroup,
@ -50,7 +48,6 @@ import ProductItem from 'components/05_quotation/ProductItem.vue';
import WorkerItem from 'components/05_quotation/WorkerItem.vue';
import ToggleButton from 'components/button/ToggleButton.vue';
import FormAbout from 'components/05_quotation/FormAbout.vue';
import SelectZone from 'components/shared/SelectZone.vue';
import ImportWorker from './ImportWorker.vue';
import {
AddButton,
@ -127,7 +124,7 @@ const {
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 }[]>([]);
@ -254,10 +251,13 @@ function getPrice(
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 = c.product.calcVat ? precisionRound(a.vat + vat) : a.vat;
a.vatExcluded = c.product.calcVat
a.vat = calcVat ? precisionRound(a.vat + vat) : a.vat;
a.vatExcluded = calcVat
? a.vatExcluded
: precisionRound(a.vatExcluded + price);
a.finalPrice = precisionRound(
@ -427,6 +427,7 @@ async function fetchQuotation() {
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),
@ -443,6 +444,8 @@ async function fetchQuotation() {
async function fetchReceipt() {
const res = await useReceiptStore.getReceiptList({
quotationId: quotationFormData.value.id,
quotationOnly: true,
debitNoteOnly: false,
});
if (res) {
@ -607,6 +610,7 @@ async function convertDataToFormSubmit() {
status: quotationFormData.value.status,
discount: quotationFormData.value.discount,
remark: quotationFormData.value.remark || '',
agentPrice: agentPrice.value,
};
newWorkerList.value = [];
@ -819,6 +823,7 @@ function convertToTable(nodes: Node[]) {
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),
@ -856,7 +861,7 @@ function convertToTable(nodes: Node[]) {
} else {
quotationFormData.value.paySplit = [];
quotationFormData.value.paySplitCount = 0;
quotationFormData.value.payCondition = 'Full';
quotationFormData.value.payCondition = PayCondition.Full;
}
tempPaySplit.value = JSON.parse(
@ -1070,25 +1075,25 @@ watch(
},
);
async function searchEmployee(text: string) {
let query: string | undefined = text;
let pageSize = 50;
// async function searchEmployee(text: string) {
// let query: string | undefined = text;
// let pageSize = 50;
if (!text) {
query = undefined;
pageSize = 9999;
}
// 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;
}
// const retEmp = await customerStore.fetchBranchEmployee(
// quotationFormData.value.customerBranchId,
// {
// query: query,
// pageSize: pageSize,
// passport: true,
// },
// );
// if (retEmp) workerList.value = retEmp.data.result;
// }
function storeDataLocal() {
quotationFormData.value.productServiceList = productServiceList.value;
@ -1120,6 +1125,7 @@ function storeDataLocal() {
},
selectedWorker: selectedWorker.value,
createdBy: quotationFormState.value.createdBy('tha'),
agentPrice: agentPrice.value,
},
}),
);
@ -1237,6 +1243,38 @@ function getInvoice() {
}
}
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,
@ -1306,12 +1344,24 @@ async function formDownload() {
<div
v-if="quotationFormState.mode !== 'create'"
class="column col-2 q-ml-auto"
class="column col-sm-2 col-12 q-ml-auto"
style="gap: 10px"
>
<div class="row justify-end">
<BadgeComponent :title-i18n="$t('general.laborIdentified')" />
<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"
@ -1542,7 +1592,6 @@ async function formDownload() {
quotationFormData.workerMax || selectedWorker.length
"
:readonly="readonly"
fallback-img="/images/employee-avatar.png"
:rows="selectedWorkerItem"
@delete="(i) => deleteItem(selectedWorker, i)"
/>
@ -1774,6 +1823,7 @@ async function formDownload() {
<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
@ -1823,7 +1873,7 @@ async function formDownload() {
"
>
<template v-if="!readonly" v-slot:toggle>
<div class="text-caption row no-wrap q-px-sm">
<div class="text-caption row no-wrap">
<MainButton
:disabled="readonly"
:solid="!pageState.remarkWrite"
@ -1857,6 +1907,15 @@ async function formDownload() {
</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>
@ -1894,9 +1953,25 @@ async function formDownload() {
<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
@ -2092,9 +2167,10 @@ async function formDownload() {
</section>
</template>
<div class="surface-1 q-pa-md flex" style="gap: var(--size-2)">
<div class="surface-1 q-pa-md row" style="gap: var(--size-2)">
<SelectInput
class="q-mr-sm"
class="q-mr-xl col-md-3 col-12"
incremental
v-model="templateForm"
id="quotation-branch"
:option="templateFormOption"
@ -2294,7 +2370,12 @@ async function formDownload() {
v-model:service-list="serviceList"
v-model:selected-product-group="selectedProductGroup"
:agent-price="agentPrice"
@submit="convertToTable"
@submit="
(node) => {
convertToTable(node);
handleWorkName();
}
"
@select-group="
async (id) => {
await getAllService(id);
@ -2314,6 +2395,7 @@ async function formDownload() {
></QuotationFormProductSelect>
</div>
<!-- add Worker -->
<QuotationFormWorkerAddDialog
v-if="quotationFormState.source"
:disabled-worker-id="selectedWorker.map((v) => v.id)"

View file

@ -36,6 +36,7 @@ defineProps<{
};
taskOrder?: boolean;
taskOrderComplete?: boolean;
debitNote?: boolean;
}>();
const { t } = useI18n();
@ -216,6 +217,7 @@ watch(
'invoice-color': view === View.Invoice,
'receipt-color': view === View.Receipt,
'task-order-color': taskOrder && !taskOrderComplete,
'debit-note-color': debitNote,
}"
>
<div class="col bordered-r" v-if="!taskOrder">
@ -444,9 +446,7 @@ watch(
<span class="q-ml-auto">
{{
formatNumberDecimal(
summaryPrice.totalPrice -
summaryPrice.totalDiscount +
summaryPrice.vat,
summaryPrice.totalPrice - summaryPrice.totalDiscount,
2,
)
}}
@ -530,6 +530,10 @@ watch(
--_color: var(--pink-7-hsl);
}
.debit-note-color {
--_color: var(--cyan-7-hsl);
}
.bg-color {
color: white;
background: hsla(var(--_color));

View file

@ -6,7 +6,6 @@ import useOptionStore from 'stores/options';
import DialogForm from 'src/components/DialogForm.vue';
import TreeView from 'src/components/shared/TreeView.vue';
import SelectZone from 'src/components/shared/SelectZone.vue';
import SelectInput from 'src/components/shared/SelectInput.vue';
import SelectProductGroup from 'src/components/shared/select/SelectProductGroup.vue';
import TotalProductCardComponent from 'src/components/04_product-service/TotalProductCardComponent.vue';
import DeleteButton from 'src/components/button/DeleteButton.vue';
@ -261,7 +260,11 @@ function mapNode() {
const price = prop.agentPrice
? p.product.agentPrice
: p.product.price;
const pricePerUnit = p.product.vatIncluded
const pricePerUnit = (
prop.agentPrice
? p.product.agentPriceVatIncluded
: p.product.vatIncluded
)
? precisionRound(price / (1 + (config.value?.vat || 0.07)))
: price;
productCount.value++;
@ -304,7 +307,11 @@ function mapNode() {
const price = prop.agentPrice
? p.product.agentPrice
: p.product.price;
const pricePerUnit = p.product.vatIncluded
const pricePerUnit = (
prop.agentPrice
? p.product.agentPriceVatIncluded
: p.product.vatIncluded
)
? precisionRound(price / (1 + (config.value?.vat || 0.07)))
: price;
productCount.value++;
@ -335,7 +342,9 @@ function mapNode() {
};
} else {
const price = prop.agentPrice ? v.raw.agentPrice : v.raw.price;
const pricePerUnit = v.raw.vatIncluded
const pricePerUnit = (
prop.agentPrice ? v.raw.agentPriceVatIncluded : v.raw.vatIncluded
)
? precisionRound(price / (1 + (config.value?.vat || 0.07)))
: price;
productCount.value++;
@ -419,6 +428,7 @@ watch(
<template>
<div>
<DialogForm
:disabled-submit="countCheckedProducts(nodes) === 0"
bg-color="var(--surface-2)"
v-model:modal="model"
:title="$t('general.list', { msg: $t('productService.title') })"
@ -491,7 +501,7 @@ watch(
</template>
</q-input>
<div class="row items-center q-gutter-x-sm">
<div class="row items-center q-gutter-x-sm no-wrap">
<q-btn
color="primary"
padding="4px"
@ -499,7 +509,6 @@ watch(
rounded
icon="mdi-store-plus-outline"
@click="triggerAddDialog"
style="color: hsl(var(--text-mute))"
/>
<q-btn
padding="4px"
@ -507,6 +516,7 @@ watch(
rounded
icon="mdi-information-outline"
@click="triggerInfo"
:color="pageState.infoDrawer ? 'info' : ''"
style="color: hsl(var(--text-mute))"
/>
</div>
@ -634,7 +644,8 @@ watch(
<div
v-if="pageState.infoDrawer"
class="column no-wrap surface-1"
style="z-index: 1; width: 20vw; position: sticky"
style="z-index: 1; position: sticky"
:style="`width:${$q.screen.gt.sm ? '20vw' : '100%'}`"
>
<span
v-if="selectedType === ''"
@ -805,6 +816,7 @@ watch(
border-search-section
item-class="col-md-3 col-sm-6 col-12"
v-model:selected-item="preSelectedItems"
:type="pageState.productServiceTab === '1' ? 'service' : 'product'"
@search="
(value) => {
$emit(
@ -840,12 +852,14 @@ watch(
>
<template #top>
<div class="row items-center app-text-muted">
{{ $t('productService.group.title') }}
<span class="q-pr-sm">
{{ $t('productService.group.title') }}
</span>
<SelectProductGroup
class="q-pl-sm col-5"
class="col-md-4 col-12"
:class="{ 'q-mb-sm': $q.screen.lt.md }"
id="product-group-select"
style="min-height: 50px"
clearable
v-model:value="selectedProductGroup"
:placeholder="
@ -924,9 +938,11 @@ watch(
<template #data="{ item }">
<TotalProductCardComponent
:key="item.index"
:priceDisplay="priceDisplay"
:title="item.name"
:data="item"
no-Time-Img
/>
</template>
</SelectZone>

View file

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { formatNumberDecimal } from 'stores/utils';
import { dateFormatJS } from 'src/utils/datetime';
import { Icon } from '@iconify/vue';
import { MainButton, ViewButton } from 'components/button';
@ -12,8 +13,10 @@ defineEmits<{
withDefaults(
defineProps<{
title?: string;
hideExampleBtn?: boolean;
successLabel?: string;
useBtnDownload?: boolean;
hideViewBtn?: boolean;
hideExampleBtn?: boolean;
payType?: string;
amount?: number;
@ -25,8 +28,11 @@ withDefaults(
);
</script>
<template>
<section class="surface-1 rounded row">
<aside class="column col bordered-r q-py-md q-pl-md">
<section class="surface-1 rounded" :class="{ row: $q.screen.gt.xs }">
<aside
class="column col q-py-md q-pl-md"
:class="{ 'bordered-r': $q.screen.gt.xs, ' bordered-b': $q.screen.lt.sm }"
>
<span class="text-weight-medium text-body1">
{{ title || $t('quotation.receiptDialog.PaymentReceive') }}
</span>
@ -52,7 +58,7 @@ withDefaults(
</aside>
<aside class="column col q-py-md text-right self-center q-px-md">
<div class="q-gutter-x-xs">
<div class="q-gutter-x-xs row justify-end">
<MainButton
v-if="!hideExampleBtn"
icon="mdi-play-box-outline"
@ -60,7 +66,40 @@ withDefaults(
@click="() => $emit('example', index)"
/>
<ViewButton icon-only @click="() => $emit('view', index)" />
<ViewButton
v-if="!hideViewBtn"
icon-only
@click="() => $emit('view', index)"
/>
<q-btn-dropdown
v-if="!!useBtnDownload"
flat
outline
dropdown-icon="mdi-download"
no-icon-animation
padding="0 0"
>
<q-list>
<q-item clickable>
<q-item-section side>
<Icon
icon="fluent:receipt-money-24-regular"
width="24px"
height="24px"
/>
</q-item-section>
{{ $t('debitNote.downloadReceipt') }}
</q-item>
<q-item clickable>
<q-item-section side>
<Icon icon="hugeicons:invoice-03" width="24px" height="24px" />
</q-item-section>
{{ $t('debitNote.downloadTaxInvoice') }}
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<span class="app-text-positive text-weight-bold text-body1">
{{ successLabel || $t('quotation.receiptDialog.receiptIssued') }}

View file

@ -23,6 +23,8 @@ import PersonCard from 'src/components/shared/PersonCard.vue';
import { QuotationFull } from 'src/stores/quotations/types';
import { Lang } from 'src/utils/ui';
import NoData from 'src/components/NoData.vue';
import TableWorker from 'src/components/shared/table/TableWorker.vue';
import ToggleView from 'src/components/shared/ToggleView.vue';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
@ -104,6 +106,7 @@ const employeeStore = useEmployeeStore();
const quotationStore = useQuotationStore();
const open = defineModel<boolean>('open', { default: false });
const viewMode = ref<boolean>(false);
const workerSelected = ref<Employee[]>([]);
const workerList = ref<Employee[]>([]);
const importWorkerCriteria = ref<{
@ -220,7 +223,7 @@ function getEmployeeImageUrl(item: Employee) {
return `${API_BASE_URL}/employee/${item.id}/image/${item.selectedImage}`;
}
// NOTE: static image
return '/images/employee-avatar.png';
return `/images/employee-avatar-${item.gender}.png`;
}
async function getWorkerList() {
@ -305,7 +308,7 @@ watch(() => state.search, getWorkerList);
</template>
</DialogHeader>
</template>
<div class="col scroll">
<div class="col full-width no-wrap scroll">
<q-tab-panels
class="surface-0 rounded full-height"
v-model="state.step"
@ -314,6 +317,8 @@ watch(() => state.search, getWorkerList);
<q-tab-panel class="q-pa-none" :name="1">
<div class="column q-pa-md full-height">
<section class="row justify-end q-mb-md">
<ToggleView v-model="viewMode" class="q-mr-sm" />
<q-input
for="input-search"
outlined
@ -340,55 +345,59 @@ watch(() => state.search, getWorkerList);
>
<NoData :not-found="!!state.search" />
</div>
<div
:key="emp.id"
v-for="(emp, index) in workerList.map((data) => ({
...data,
_selectedIndex: selectedIndex(data),
}))"
class="col-2"
<TableWorker
v-model:selected="workerSelected"
:rows="workerList"
:disabledWorkerId
:grid="viewMode"
>
<button
class="selectable-item full-width"
:class="{
['selectable-item__selected']: emp._selectedIndex !== -1,
['selectable-item__disabled']: disabledWorkerId?.some(
(id) => id === emp.id,
),
}"
@click="toggleSelect(emp)"
>
<span class="selectable-item__pos">
{{ emp._selectedIndex + 1 }}
</span>
<PersonCard
no-action
class="full-width"
:prefix-id="'employee-' + index"
:data="{
name:
locale === Lang.English
? `${emp.firstNameEN} ${emp.lastNameEN}`
: `${emp.firstName} ${emp.lastName}`,
code: emp.employeePassport?.at(0)?.number || '-',
female: emp.gender === 'female',
male: emp.gender === 'male',
img: getEmployeeImageUrl(emp),
fallbackImg: '/images/employee-avatar.png',
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(emp.nationality),
},
{
icon: 'mdi-clock-outline',
value: calculateAge(emp.dateOfBirth),
},
],
}"
/>
</button>
</div>
<template #grid="{ item: emp, index }">
<div :key="emp.id" class="col-md-2 col-sm-6 col-12">
<button
class="selectable-item full-width"
:class="{
['selectable-item__selected']:
emp._selectedIndex !== -1,
['selectable-item__disabled']: disabledWorkerId?.some(
(id) => id === emp.id,
),
}"
@click="toggleSelect(emp)"
>
<span class="selectable-item__pos">
{{ (emp._selectedIndex || 0) + 1 }}
</span>
<PersonCard
no-action
class="full-width"
:prefix-id="'employee-' + index"
:data="{
name:
locale === Lang.English
? `${emp.firstNameEN} ${emp.lastNameEN}`
: `${emp.firstName} ${emp.lastName}`,
code: emp.employeePassport?.at(0)?.number || '-',
female: emp.gender === 'female',
male: emp.gender === 'male',
img: getEmployeeImageUrl(emp),
fallbackImg: `/images/employee-avatar-${emp.gender}.png`,
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(emp.nationality),
},
{
icon: 'mdi-clock-outline',
value: calculateAge(emp.dateOfBirth),
},
],
}"
/>
</button>
</div>
</template>
</TableWorker>
</section>
</div>
</div>
@ -399,7 +408,10 @@ watch(() => state.search, getWorkerList);
<BackButton icon-only @click="prev" />
</section>
<section class="full-height scroll col">
<div class="rounded column" style="gap: var(--size-4)">
<div
class="rounded column full-width no-wrap"
style="gap: var(--size-4)"
>
<q-expansion-item
dense
default-opened
@ -408,6 +420,7 @@ watch(() => state.search, getWorkerList);
expand-icon="mdi-chevron-down-circle"
header-class="q-py-sm text-medium text-body items-center surface-1"
v-for="{ id, amount, worker, product } in productServiceList"
:key="id"
>
<template #header>
<q-avatar class="q-mr-md" size="md">
@ -453,82 +466,15 @@ watch(() => state.search, getWorkerList);
</template>
<div class="q-pa-md surface-1">
<q-table
<TableWorker
v-model:selected="productWorkerMap[id]"
:rows-per-page-options="[0]"
:rows="
workerSelected.map((data, i) => ({
...data,
_index: i,
}))
"
:columns
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
card-container-class="q-col-gutter-sm"
class="full-width"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th
v-for="col in columns"
:key="col.name"
:props="props"
>
<template v-if="!col.name.startsWith('#')">
{{ $t(col.label) }}
</template>
<template v-if="col.name === '#check'">
<q-checkbox v-model="props.selected" size="sm" />
</template>
</q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: Employee & { _index: number };
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{ dark: $q.dark.isActive }"
class="text-center"
>
<q-td v-for="col in columns" :align="col.align">
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<q-avatar
v-if="col.name === 'employeeName'"
class="q-mr-sm"
size="md"
>
<q-img
:src="getEmployeeImageUrl(props.row)"
:ratio="1"
class="text-center"
/>
</q-avatar>
<span>
{{
typeof col.field === 'string'
? props.row[col.field as keyof Employee]
: col.field(props.row)
}}
</span>
</template>
<template v-if="col.name === '#check'">
<q-checkbox v-model="props.selected" size="sm" />
</template>
</q-td>
</q-tr>
</template>
</q-table>
/>
</div>
</q-expansion-item>
</div>

View file

@ -46,6 +46,8 @@ import {
columnsAttachment,
} from 'src/pages/03_customer-management/constant';
import { storeToRefs } from 'pinia';
import ToggleView from 'src/components/shared/ToggleView.vue';
import TableWorker from 'src/components/shared/table/TableWorker.vue';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
@ -55,6 +57,8 @@ const quotationForm = useQuotationForm();
const { locale } = useI18n();
const ocrStore = useOcrStore();
const viewMode = ref<boolean>(false);
const { state: employeeFormState, currentFromDataEmployee } =
storeToRefs(employeeFormStore);
@ -207,7 +211,7 @@ function getEmployeeImageUrl(item: Employee) {
return `${API_BASE_URL}/employee/${item.id}/image/${item.selectedImage}`;
}
// NOTE: static image
return '/images/employee-avatar.png';
return `/images/employee-avatar-${item.gender}.png`;
}
function init() {
@ -329,14 +333,15 @@ watch(() => state.search, getWorkerList);
<div class="column full-height no-wrap surface-2">
<section class="row q-mb-md">
<header
class="row items-center q-py-sm q-px-md justify-between full-width surface-3 bordered-b no-wrap"
class="row items-center q-py-sm q-px-md justify-end full-width surface-3 bordered-b no-wrap"
>
<ToggleView v-model="viewMode" class="q-mr-sm full-height" />
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-md-4"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="state.search"
debounce="500"
@ -400,7 +405,7 @@ watch(() => state.search, getWorkerList);
: `${emp.firstName} ${emp.lastName}`,
female: emp.gender === 'female',
male: emp.gender === 'male',
img: '/images/employee-avatar.png',
img: `/images/employee-avatar-${emp.gender}.png`,
index: index,
detail: [
{
@ -447,7 +452,7 @@ watch(() => state.search, getWorkerList);
<div class="full-width q-pt-md">
<section
:class="{ ['items-center']: workerList.length === 0 }"
class="row q-col-gutter-md scroll"
class="row scroll"
>
<div
style="display: inline-block; margin-inline: auto"
@ -455,52 +460,59 @@ watch(() => state.search, getWorkerList);
>
<NoData :not-found="!!state.search" />
</div>
<div
:key="emp.id"
v-for="(emp, index) in workerList.map((data) => ({
...data,
_selectedIndex: selectedIndex(data),
}))"
class="col-2"
<TableWorker
v-if="workerList.length !== 0"
v-model:selected="workerSelected"
:rows="workerList"
:disabledWorkerId
:grid="viewMode"
>
<button
class="selectable-item full-width"
:class="{
['selectable-item__selected']: emp._selectedIndex !== -1,
['selectable-item__disabled']: disabledWorkerId?.some(
(id) => id === emp.id,
),
}"
@click="toggleSelect(emp)"
>
<PersonCard
no-action
class="full-width"
:prefix-id="'employee-' + index"
:data="{
name:
locale === Lang.English
? `${emp.firstNameEN} ${emp.lastNameEN}`
: `${emp.firstName} ${emp.lastName}`,
code: emp.employeePassport?.at(0)?.number || '-',
female: emp.gender === 'female',
male: emp.gender === 'male',
img: getEmployeeImageUrl(emp),
fallbackImg: '/images/employee-avatar.png',
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(emp.nationality),
},
{
icon: 'mdi-clock-outline',
value: calculateAge(emp.dateOfBirth),
},
],
}"
/>
</button>
</div>
<template #grid="{ item: emp, index }">
<div :key="emp.id" class="col-md-2 col-sm-6 col-12">
<button
class="selectable-item full-width"
:class="{
['selectable-item__selected']:
emp._selectedIndex !== -1,
['selectable-item__disabled']: disabledWorkerId?.some(
(id) => id === emp.id,
),
}"
@click="toggleSelect(emp)"
>
<span class="selectable-item__pos">
{{ (emp._selectedIndex || 0) + 1 }}
</span>
<PersonCard
no-action
class="full-width"
:prefix-id="'employee-' + index"
:data="{
name:
locale === Lang.English
? `${emp.firstNameEN} ${emp.lastNameEN}`
: `${emp.firstName} ${emp.lastName}`,
code: emp.employeePassport?.at(0)?.number || '-',
female: emp.gender === 'female',
male: emp.gender === 'male',
img: getEmployeeImageUrl(emp),
fallbackImg: `/images/employee-avatar-${emp.gender}.png`,
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(emp.nationality),
},
{
icon: 'mdi-clock-outline',
value: calculateAge(emp.dateOfBirth),
},
],
}"
/>
</button>
</div>
</template>
</TableWorker>
</section>
</div>
</q-expansion-item>
@ -567,7 +579,10 @@ watch(() => state.search, getWorkerList);
v-model:current-tab="employeeFormState.currentTab"
v-model:toggle-status="currentFromDataEmployee.status"
fallbackCover="/images/employee-banner.png"
:img="employeeFormState.profileUrl || `/images/employee-avatar.png`"
:img="
employeeFormState.profileUrl ||
`/images/employee-avatar-${currentFromDataEmployee.gender}.png`
"
:toggleTitle="$t('status.title')"
hideFade
@view="

View file

@ -1,7 +1,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { QTableColumn, QTableSlots } from 'quasar';
import { computed, reactive, ref, watch } from 'vue';
import { RequestData, RequestDataStatus } from 'src/stores/request-list/types';
import BadgeComponent from 'components/BadgeComponent.vue';
@ -46,8 +44,6 @@ const emits = defineEmits<{
(e: 'success'): void;
}>();
const open = defineModel<boolean>('open', { default: false });
function goToRequestList(id: string) {
const url = new URL(`/request-list/${id}`, window.location.origin);
window.open(url.toString(), '_blank');
@ -64,13 +60,15 @@ function goToRequestList(id: string) {
}))
"
:columns
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
card-container-class="q-col-gutter-sm"
:no-data-label="$t('general.noDataTable')"
class="full-width"
:pagination="{
rowsPerPage: 0,
}"
>
<template v-slot:header="props">
<q-tr
@ -89,7 +87,7 @@ function goToRequestList(id: string) {
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr :class="{ dark: $q.dark.isActive }" class="text-center">
<q-td v-for="col in columns" :align="col.align">
<q-td v-for="col in columns" :align="col.align" :key="col.name">
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<span>

View file

@ -20,7 +20,7 @@ import { useInvoice } from 'stores/payment';
import useEmployeeStore from 'stores/employee';
import { getName } from 'src/services/keycloak';
const DEFAULT_DATA: QuotationPayload = {
export const DEFAULT_DATA: QuotationPayload = {
productServiceList: [],
urgent: false,
customerBranchId: '',
@ -39,6 +39,7 @@ const DEFAULT_DATA: QuotationPayload = {
_count: { worker: 0 },
status: 'CREATED',
remark: '#[quotation-labor]<br/><br/>#[quotation-payment]',
agentPrice: false,
};
const DEFAULT_DATA_INVOICE: InvoicePayload = {

View file

@ -19,6 +19,7 @@ import {
QuotationPayload,
Details,
QuotationFull,
ProductRelation,
} from 'src/stores/quotations/types';
// NOTE: Import Components
@ -40,11 +41,12 @@ type Product = {
code: string;
detail: string;
amount: number;
priceUnit: number;
pricePerUnit: number;
discount: number;
vat: number;
value: number;
calcVat: boolean;
product: ProductRelation;
};
type SummaryPrice = {
@ -60,6 +62,8 @@ const branch = ref<Branch>();
const productList = ref<Product[]>([]);
const bankList = ref<BankBook[]>([]);
const agentPrice = ref(false);
const elements = ref<HTMLElement[]>([]);
const chunks = ref<Product[][]>([[]]);
const attachmentList = ref<
@ -194,6 +198,8 @@ onMounted(async () => {
customer.value = resCustomerBranch;
}
agentPrice.value = data.value.agentPrice || parsed?.meta?.agentPrice;
details.value = {
code: parsed?.meta?.source?.code ?? data.value?.code,
createdAt:
@ -247,11 +253,12 @@ onMounted(async () => {
code: v.product.code,
detail: v.product.name,
amount: v.amount || 0,
priceUnit: v.pricePerUnit || 0,
pricePerUnit: v.pricePerUnit || 0,
discount: v.discount || 0,
vat: v.vat || 0,
value: precisionRound(price + (v.product.calcVat ? vat : 0)),
calcVat: v.product.calcVat,
product: v.product,
};
},
) || [];
@ -273,10 +280,13 @@ onMounted(async () => {
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 = c.product.calcVat ? precisionRound(a.vat + vat) : a.vat;
a.vatExcluded = c.product.calcVat
a.vat = calcVat ? precisionRound(a.vat + vat) : a.vat;
a.vatExcluded = calcVat
? a.vatExcluded
: precisionRound(a.vatExcluded + price);
a.finalPrice = precisionRound(
@ -285,7 +295,6 @@ onMounted(async () => {
a.vat -
Number(data.value?.discount || 0),
);
return a;
},
{
@ -300,6 +309,20 @@ onMounted(async () => {
assignData();
});
function calcPrice(c: Product) {
const originalPrice = c.pricePerUnit;
const finalPriceWithVat = precisionRound(
originalPrice + originalPrice * (config.value?.vat || 0.07),
);
const finalPriceNoVat = finalPriceWithVat / (1 + (config.value?.vat || 0.07));
const price = finalPriceNoVat * c.amount - c.discount;
const vat = c.product[agentPrice.value ? 'agentPriceCalcVat' : 'calcVat']
? (finalPriceNoVat * c.amount - c.discount) * (config.value?.vat || 0.07)
: 0;
return precisionRound(price + vat);
}
watch(elements, () => {});
function print() {
@ -359,7 +382,15 @@ function print() {
<td>{{ v.detail }}</td>
<td style="text-align: right">{{ v.amount }}</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.priceUnit, 2) }}
{{
formatNumberDecimal(
v.pricePerUnit +
(v.product[agentPrice ? 'agentPriceCalcVat' : 'calcVat']
? v.pricePerUnit * (config?.vat || 0.07)
: 0),
2,
)
}}
</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.discount, 2) }}
@ -367,9 +398,9 @@ function print() {
<td style="text-align: right">
{{
formatNumberDecimal(
v.calcVat
v.product[agentPrice ? 'agentPriceCalcVat' : 'calcVat']
? precisionRound(
(v.priceUnit * v.amount - v.discount) *
(v.pricePerUnit * v.amount - v.discount) *
(config?.vat || 0.07),
)
: 0,
@ -378,7 +409,7 @@ function print() {
}}
</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.value, 2) }}
{{ formatNumberDecimal(calcPrice(v), 2) }}
</td>
</tr>
</tbody>
@ -435,9 +466,7 @@ function print() {
<td class="text-right">
{{
formatNumberDecimal(
summaryPrice.totalPrice -
summaryPrice.totalDiscount +
summaryPrice.vat,
summaryPrice.totalPrice - summaryPrice.totalDiscount,
2,
)
}}

View file

@ -251,12 +251,16 @@ watch(
id="agencies-form-content"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="height: 100%; max-height: 100%; overflow-y: auto"
>
<div
class="q-py-md q-px-lg"
class="rounded"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 99999; top: 0; right: 0"
>
<div class="surface-1 row rounded">
@ -346,7 +350,7 @@ watch(
class="rounded row"
:class="{
'q-py-md q-px-lg': $q.screen.gt.sm,
'q-py-sm q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
style="position: absolute; z-index: 999; top: 0; right: 0"
>
@ -425,7 +429,7 @@ watch(
class="col-12 col-md-10 relative-position"
:class="{
'q-py-md q-pr-md ': $q.screen.gt.sm,
'q-py-md q-px-lg': !$q.screen.gt.sm,
'q-pa-sm': !$q.screen.gt.sm,
}"
id="user-form-content"
style="height: 100%; max-height: 100; overflow-y: auto"

View file

@ -1,10 +1,11 @@
<script lang="ts" setup>
import { QTableProps } from 'quasar';
import { dialog } from 'src/stores/utils';
import { onMounted, reactive, ref } from 'vue';
import { onMounted, reactive, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { baseUrl } from 'src/stores/utils';
import { useNavigator } from 'src/stores/navigator';
@ -12,6 +13,7 @@ import { useInstitution } from 'src/stores/institution';
import { Institution, InstitutionPayload } from 'src/stores/institution/types';
import { formatAddress } from 'src/utils/address';
import PaginationPageSize from 'src/components/PaginationPageSize.vue';
import PaginationComponent from 'src/components/PaginationComponent.vue';
import KebabAction from 'src/components/shared/KebabAction.vue';
import StatCardComponent from 'src/components/StatCardComponent.vue';
@ -19,9 +21,9 @@ import FloatingActionButton from 'src/components/FloatingActionButton.vue';
import CreateButton from 'src/components/AddButton.vue';
import NoData from 'src/components/NoData.vue';
import AgenciesDialog from './AgenciesDialog.vue';
import { watch } from 'vue';
const { t } = useI18n();
const $q = useQuasar();
const navigatorStore = useNavigator();
const institutionStore = useInstitution();
@ -189,7 +191,7 @@ async function submit(opt?: { selectedImage: string }) {
pageState.isDrawerEdit = false;
currAgenciesData.value = ret;
formData.value.selectedImage = ret.selectedImage;
await fetchData();
await fetchData($q.screen.xs);
if (refAgenciesDialog.value && opt?.selectedImage) {
refAgenciesDialog.value.clearImageState();
@ -205,14 +207,12 @@ async function submit(opt?: { selectedImage: string }) {
onCreateImageList.value,
);
await fetchData();
await fetchData($q.screen.xs);
pageState.addModal = false;
return;
}
}
async function triggerChangeStatus(data?: Institution) {}
async function triggerDelete(id?: string) {
if (!id) return;
dialog({
@ -225,21 +225,26 @@ async function triggerDelete(id?: string) {
action: async () => {
await institutionStore.deleteInstitution(id);
resetForm();
await fetchData();
await fetchData($q.screen.xs);
},
cancel: () => {},
});
}
async function fetchData() {
async function fetchData(mobileFetch?: boolean) {
const ret = await institutionStore.getInstitutionList({
page: page.value,
pageSize: pageSize.value,
page: mobileFetch ? 1 : page.value,
pageSize: mobileFetch
? data.value.length + (pageState.total === data.value.length ? 1 : 0)
: pageSize.value,
query: pageState.inputSearch,
});
if (ret) {
data.value = ret.result;
data.value =
$q.screen.xs && !mobileFetch
? [...data.value, ...ret.result]
: ret.result;
pageMax.value = Math.ceil(ret.total / pageSize.value);
pageState.total = ret.total;
}
@ -248,13 +253,18 @@ async function fetchData() {
onMounted(async () => {
navigatorStore.current.title = 'agencies.title';
navigatorStore.current.path = [{ text: 'agencies.caption', i18n: true }];
pageState.gridView = $q.screen.lt.md ? true : false;
await fetchData();
});
watch(
() => pageState.inputSearch,
() => fetchData(),
() => {
page.value = 1;
data.value = [];
fetchData();
},
);
</script>
<template>
@ -313,7 +323,7 @@ watch(
<!-- SEC: header content -->
<section class="col surface-1 rounded bordered overflow-hidden">
<div class="column full-height">
<div class="column full-height no-wrap">
<header
class="row surface-3 justify-between full-width items-center bordered-b"
style="z-index: 1"
@ -324,7 +334,7 @@ watch(
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -334,11 +344,7 @@ watch(
</template>
</q-input>
<div
class="row col-12 col-md-3 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5 justify-end" style="white-space: nowrap">
<!-- <q-select
v-model="statusFilter"
outlined
@ -361,7 +367,7 @@ watch(
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col"
class="col-md-5 q-ml-sm"
:options="
fieldSelectedOption.map((v) => ({
...v,
@ -436,12 +442,12 @@ watch(
class="col surface-2 flex items-center justify-center"
>
<NoData
v-if="pageState.total !== 0"
v-if="pageState.inputSearch"
:not-found="!!pageState.inputSearch"
/>
<CreateButton
v-if="pageState.total === 0"
v-if="!pageState.inputSearch && pageState.total === 0"
@click="triggerDialog('add')"
label="general.add"
:i18n-args="{ text: $t('agencies.title') }"
@ -449,107 +455,107 @@ watch(
</article>
<article v-else class="col q-pa-md surface-2 scroll full-width">
<q-table
flat
bordered
:grid="pageState.gridView"
:rows="data"
:columns="columns"
class="full-width"
card-container-class="q-col-gutter-md"
row-key="name"
:rows-per-page-options="[0]"
hide-pagination
:visible-columns="fieldSelected"
<q-infinite-scroll
:offset="100"
@load="
(_, done) => {
if ($q.screen.gt.xs || page === pageMax) return;
page = page + 1;
fetchData().then(() => done(page >= pageMax));
}
"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ $t(col.label) }}
</q-th>
<q-th auto-width />
</q-tr>
</template>
<template v-slot:body="props">
<q-tr
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
'status-active': props.row.status !== 'INACTIVE',
'status-inactive': props.row.status === 'INACTIVE',
}"
:props="props"
:style="
props.rowIndex % 2 !== 0
? $q.dark.isActive
? 'background: hsl(var(--gray-11-hsl)/0.2)'
: `background: #f9fafc`
: ''
"
>
<q-td
class="text-center"
v-if="fieldSelected.includes('orderNumber')"
<q-table
flat
bordered
:grid="pageState.gridView"
:rows="data"
:columns="columns"
class="full-width"
card-container-class="q-col-gutter-md"
row-key="name"
:rows-per-page-options="[0]"
hide-pagination
:visible-columns="fieldSelected"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
{{ props.rowIndex + 1 }}
<!-- {{ (currentPage - 1) * pageSize + props.rowIndex + 1 }} -->
</q-td>
<q-td v-if="fieldSelected.includes('name')">
<section class="row items-center no-wrap">
<q-avatar size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/institution/${props.row.id}/image/${props.row.selectedImage}?ts=${Date.now()}`"
>
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
style="
background: hsla(var(--green-8-hsl) / 0.1);
color: hsla(var(--green-8-hsl) / 1);
"
>
<Icon icon="ph-building-office" />
</div>
</template>
</q-img>
</q-avatar>
<span class="col q-pl-md">
<div>
{{
$i18n.locale === 'eng'
? props.row.nameEN
: props.row.name
}}
</div>
<div class="app-text-muted">
{{ props.row.code }}
</div>
</span>
</section>
</q-td>
<q-td v-if="fieldSelected.includes('address')">
{{
formatAddress({
address: props.row.address,
addressEN: props.row.addressEN,
moo: props.row.moo,
mooEN: props.row.mooEN,
soi: props.row.soi,
soiEN: props.row.soiEN,
street: props.row.street,
streetEN: props.row.streetEN,
province: props.row.province,
district: props.row.district,
subDistrict: props.row.subDistrict,
en: $i18n.locale === 'eng',
})
}}
<q-tooltip>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ $t(col.label) }}
</q-th>
<q-th auto-width />
</q-tr>
</template>
<template v-slot:body="props">
<q-tr
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
'status-active': props.row.status !== 'INACTIVE',
'status-inactive': props.row.status === 'INACTIVE',
}"
:props="props"
:style="
props.rowIndex % 2 !== 0
? $q.dark.isActive
? 'background: hsl(var(--gray-11-hsl)/0.2)'
: `background: #f9fafc`
: ''
"
>
<q-td
class="text-center"
v-if="fieldSelected.includes('orderNumber')"
>
{{
$q.screen.xs
? props.rowIndex + 1
: (page - 1) * pageSize + props.rowIndex + 1
}}
</q-td>
<q-td v-if="fieldSelected.includes('name')">
<section class="row items-center no-wrap">
<q-avatar size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/institution/${props.row.id}/image/${props.row.selectedImage}?ts=${Date.now()}`"
>
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
style="
background: hsla(var(--green-8-hsl) / 0.1);
color: hsla(var(--green-8-hsl) / 1);
"
>
<Icon icon="ph-building-office" />
</div>
</template>
</q-img>
</q-avatar>
<span class="col q-pl-md">
<div>
{{
$i18n.locale === 'eng'
? props.row.nameEN
: props.row.name
}}
</div>
<div class="app-text-muted">
{{ props.row.code }}
</div>
</span>
</section>
</q-td>
<q-td v-if="fieldSelected.includes('address')">
{{
formatAddress({
address: props.row.address,
@ -566,119 +572,7 @@ watch(
en: $i18n.locale === 'eng',
})
}}
</q-tooltip>
</q-td>
<q-td>
<q-btn
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
/>
<KebabAction
hide-toggle
:id-name="props.row.id"
:status="props.row.status"
@view="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
@edit="
() => {
assignFormData(props.row);
triggerDialog('edit');
}
"
@delete="() => triggerDelete(props.row.id)"
@change-status="() => triggerChangeStatus(props.row)"
/>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<section class="column col-12 col-md-6">
<div class="bordered col surface-1 rounded q-pa-md">
<header class="row items-center">
<q-avatar size="xl">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/institution/${props.row.id}/image/${props.row.selectedImage}?ts=${Date.now()}`"
>
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
style="
background: hsla(var(--green-8-hsl) / 0.1);
color: hsla(var(--green-8-hsl) / 1);
"
>
<Icon icon="ph-building-office" />
</div>
</template>
</q-img>
</q-avatar>
<span class="text-weight-bold column q-pl-md">
{{
$i18n.locale === 'eng'
? props.row.nameEN
: props.row.name
}}
<span class="text-caption app-text-muted-2">
{{ props.row.code }}
</span>
</span>
<nav class="row q-ml-auto items-center justify-end no-wrap">
<q-btn
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
/>
<KebabAction
hide-toggle
:id-name="props.row.id"
:status="props.row.status"
@view="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
@edit="
() => {
assignFormData(props.row);
triggerDialog('edit');
}
"
@delete="() => triggerDelete(props.row.id)"
@change-status="() => triggerChangeStatus(props.row)"
/>
</nav>
</header>
<q-separator spaced="lg" />
<div class="row full-width">
<span class="col-2 app-text-muted">
{{ $t('general.address') }}
</span>
<span class="col">
<q-tooltip>
{{
formatAddress({
address: props.row.address,
@ -695,7 +589,119 @@ watch(
en: $i18n.locale === 'eng',
})
}}
<q-tooltip>
</q-tooltip>
</q-td>
<q-td>
<q-btn
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
/>
<KebabAction
hide-toggle
:id-name="props.row.id"
:status="props.row.status"
@view="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
@edit="
() => {
assignFormData(props.row);
triggerDialog('edit');
}
"
@delete="() => triggerDelete(props.row.id)"
/>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<section class="column col-12 col-md-6">
<div class="bordered col surface-1 rounded q-pa-md">
<header class="row items-center">
<q-avatar size="xl">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/institution/${props.row.id}/image/${props.row.selectedImage}?ts=${Date.now()}`"
>
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
style="
background: hsla(var(--green-8-hsl) / 0.1);
color: hsla(var(--green-8-hsl) / 1);
"
>
<Icon icon="ph-building-office" />
</div>
</template>
</q-img>
</q-avatar>
<span class="text-weight-bold column q-pl-md">
{{
$i18n.locale === 'eng'
? props.row.nameEN
: props.row.name
}}
<span class="text-caption app-text-muted-2">
{{ props.row.code }}
</span>
</span>
<nav
class="row q-ml-auto items-center justify-end no-wrap"
>
<q-btn
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
/>
<KebabAction
hide-toggle
:id-name="props.row.id"
:status="props.row.status"
@view="
() => {
assignFormData(props.row);
triggerDialog('view');
}
"
@edit="
() => {
assignFormData(props.row);
triggerDialog('edit');
}
"
@delete="() => triggerDelete(props.row.id)"
/>
</nav>
</header>
<q-separator spaced="lg" />
<div class="row full-width">
<span class="col-2 app-text-muted">
{{ $t('general.address') }}
</span>
<span class="col">
{{
formatAddress({
address: props.row.address,
@ -712,19 +718,45 @@ watch(
en: $i18n.locale === 'eng',
})
}}
</q-tooltip>
</span>
<q-tooltip>
{{
formatAddress({
address: props.row.address,
addressEN: props.row.addressEN,
moo: props.row.moo,
mooEN: props.row.mooEN,
soi: props.row.soi,
soiEN: props.row.soiEN,
street: props.row.street,
streetEN: props.row.streetEN,
province: props.row.province,
district: props.row.district,
subDistrict: props.row.subDistrict,
en: $i18n.locale === 'eng',
})
}}
</q-tooltip>
</span>
</div>
</div>
</div>
</section>
</section>
</template>
</q-table>
<template v-slot:loading>
<div
v-if="$q.screen.lt.sm && page !== pageMax"
class="row justify-center"
>
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-table>
</q-infinite-scroll>
</article>
<!-- SEC: footer content -->
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="pageMax > 0"
v-if="pageMax > 0 && $q.screen.gt.xs"
>
<div class="col-4">
<div class="row items-center no-wrap">
@ -732,42 +764,32 @@ watch(
{{ $t('general.recordPerPage') }}
</div>
<div>
<q-btn-dropdown
dense
unelevated
:label="pageSize"
class="bordered q-pl-md"
>
<q-list>
<q-item
v-for="v in [10, 30, 50, 100, 500, 1000]"
:key="v"
clickable
v-close-popup
@click="pageSize = v"
>
<q-item-section>
<q-item-label>{{ v }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<PaginationPageSize v-model="pageSize" />
</div>
</div>
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch ? data.length : pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
: $t('general.ofPage', {
current: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">
<PaginationComponent
v-model:current-page="page"
v-model:max-page="pageMax"
:fetch-data="fetchData"
:fetch-data="() => fetchData()"
/>
</nav>
</footer>
@ -778,7 +800,6 @@ watch(
<AgenciesDialog
ref="refAgenciesDialog"
:data-id="currAgenciesData && currAgenciesData.id"
@change-status="triggerChangeStatus"
@drawer-delete="
() => {
if (currAgenciesData) triggerDelete(currAgenciesData.id);

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
// NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { QSelect, useQuasar } from 'quasar';
// NOTE: Components
import StatCardComponent from 'src/components/StatCardComponent.vue';
@ -19,12 +20,15 @@ import { useRequestList } from 'src/stores/request-list';
import { RequestData, RequestDataStatus } from 'src/stores/request-list/types';
import { dialogWarningClose } from 'src/stores/utils';
const $q = useQuasar();
const navigatorStore = useNavigator();
const flowStore = useFlowStore();
const requestListStore = useRequestList();
const { t } = useI18n();
const { data, stats, page, pageMax, pageSize } = storeToRefs(requestListStore);
const refFilter = ref<InstanceType<typeof QSelect>>();
// NOTE: Variable
const pageState = reactive({
hideStat: false,
@ -54,7 +58,8 @@ async function fetchList(opts?: { rotateFlowId?: boolean }) {
});
if (ret) {
data.value = ret.result;
$q.screen.xs ? data.value.push(...ret.result) : (data.value = ret.result);
pageState.total = ret.total;
pageMax.value = Math.ceil(ret.total / pageSize.value);
}
@ -89,6 +94,7 @@ function triggerView(opts: { requestData: RequestData }) {
}
onMounted(async () => {
pageState.gridView = $q.screen.lt.md ? true : false;
navigatorStore.current.title = 'requestList.title';
navigatorStore.current.path = [{ text: 'requestList.caption', i18n: true }];
@ -96,9 +102,11 @@ onMounted(async () => {
await fetchList({ rotateFlowId: true });
});
watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
fetchList({ rotateFlowId: true }),
);
watch([() => pageState.inputSearch, () => pageState.statusFilter], () => {
page.value = 1;
data.value = [];
fetchList({ rotateFlowId: true });
});
</script>
<template>
<div class="column full-height no-wrap">
@ -185,7 +193,7 @@ watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -193,14 +201,26 @@ watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
<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="refFilter?.showPopup"
/>
</span>
</template>
</q-input>
<div
class="row col-12 col-md-5 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5" style="white-space: nowrap">
<q-select
v-show="$q.screen.gt.sm"
ref="refFilter"
v-model="pageState.statusFilter"
outlined
dense
@ -315,20 +335,40 @@ watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
<NoData :not-found="!!pageState.inputSearch" />
</article>
<article v-else class="col surface-2 full-width scroll q-pa-md">
<TableRequestList
:columns="column"
:rows="data"
:grid="pageState.gridView"
:visible-columns="pageState.fieldSelected"
@view="(data) => triggerView({ requestData: data })"
@delete="(data) => triggerCancel(data.id)"
/>
<q-infinite-scroll
@load="
(_, done) => {
if ($q.screen.gt.xs) return;
page = page + 1;
fetchList({ rotateFlowId: true }).then(() =>
done(page >= pageMax),
);
}
"
>
<TableRequestList
:columns="column"
:rows="data"
:grid="pageState.gridView"
:visible-columns="pageState.fieldSelected"
@view="(data) => triggerView({ requestData: data })"
@delete="(data) => triggerCancel(data.id)"
/>
<template v-slot:loading>
<div
v-if="$q.screen.lt.sm && page !== pageMax"
class="row justify-center"
>
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
</article>
<!-- SEC: footer content -->
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="pageMax > 0"
v-if="pageMax > 0 && $q.screen.gt.xs"
>
<div class="col-4">
<div class="row items-center no-wrap">
@ -340,10 +380,19 @@ watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch ? data.length : pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
: $t('general.ofPage', {
current: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">

View file

@ -265,11 +265,12 @@ function getEmployeeName(
@cancel="$emit('delete', props.row)"
>
<template v-slot:responsiblePerson="{ props: subProps }">
<div class="col-4 app-text-muted q-pr-sm self-center">
<div class="col-4 app-text-muted q-pr-sm">
{{ subProps.label }}
</div>
<div class="col-8">
<AvatarGroup
v-if="(responsiblePerson(props.row.quotation) ?? []).length > 0"
:data="
responsiblePerson(props.row.quotation)?.map((v) => {
return {
@ -286,6 +287,7 @@ function getEmployeeName(
})
"
/>
<span v-else>-</span>
</div>
</template>
</QuotationCard>

View file

@ -2,6 +2,7 @@
// NOTE: Library
import { computed, onMounted, reactive, watch, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
// NOTE: Components
@ -21,14 +22,13 @@ import {
} from 'src/stores/task-order/types';
import { useNavigator } from 'src/stores/navigator';
import { useTaskOrderStore } from 'src/stores/task-order';
import { useTaskOrderForm } from './form';
import useFlowStore from 'src/stores/flow';
import { pageTabs, column, pageTabsReceive } from './constants';
import { dialogWarningClose, isRoleInclude } from 'src/stores/utils';
import { PaginationResult } from 'src/types';
const { t } = useI18n();
const taskOrderFormStore = useTaskOrderForm();
const $q = useQuasar();
const navigatorStore = useNavigator();
const flowStore = useFlowStore();
const taskOrderStore = useTaskOrderStore();
@ -138,6 +138,7 @@ async function deleteTaskOrder(id: string) {
}
onMounted(async () => {
pageState.gridView = $q.screen.lt.md ? true : false;
navigatorStore.current.title = 'taskOrder.title';
navigatorStore.current.path = [{ text: 'taskOrder.caption', i18n: true }];
fetchTaskOrderList();
@ -284,7 +285,7 @@ watch(
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -294,11 +295,7 @@ watch(
</template>
</q-input>
<div
class="row col-12 col-md-3 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5 justify-end" style="white-space: nowrap">
<!-- <q-select
v-model="pageState.statusFilter"
outlined
@ -334,7 +331,7 @@ watch(
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col"
class="col-md-5 q-ml-sm"
:options="
fieldSelectedOption.map((v) => ({
...v,
@ -487,12 +484,19 @@ watch(
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: taskOrderList.length,
total: pageState.inputSearch
? taskOrderList.length
: pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: taskOrderList.length,
total: pageState.inputSearch
? taskOrderList.length
: pageState.total,
})
: $t('general.ofPage', {
current: taskOrderList.length,
total: pageState.inputSearch
? taskOrderList.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">

View file

@ -212,8 +212,10 @@ function handleCheck(
function disableCheckAll() {
if (!props.selectReady) return false;
const firstTemplate = props.rows[0]?._template.id;
const hasDifferent = props.rows.some((r) => r._template.id !== firstTemplate);
const firstTemplate = props.rows[0]?._template?.id;
const hasDifferent = props.rows.some(
(r) => r._template?.id !== firstTemplate,
);
return hasDifferent && selectedEmployee.value.length === 0;
}
@ -277,7 +279,7 @@ function disableCheckAll() {
(selectReady
? rows.filter(
(t) =>
t._template.id ===
t._template?.id ===
selectedEmployeeInTable[0]?._template?.id,
).length
: validate

View file

@ -199,7 +199,7 @@ onMounted(async () => {
})
.reduce(
(a, c) => {
const priceNoVat = c.product.vatIncluded
const priceNoVat = c.product.serviceChargeVatIncluded
? c.pricePerUnit / (1 + (config.value?.vat || 0.07))
: c.pricePerUnit;
const adjustedPriceWithVat = precisionRound(
@ -219,9 +219,10 @@ onMounted(async () => {
priceUnit: precisionRound(priceNoVat),
amount: c.amount,
discount: c.discount,
vat: c.product.calcVat ? precisionRound(rawVat) : 0,
vat: c.product.serviceChargeCalcVat ? precisionRound(rawVat) : 0,
value: precisionRound(
priceNoVat * c.amount + (c.product.calcVat ? rawVatTotal : 0),
priceNoVat * c.amount +
(c.product.serviceChargeCalcVat ? rawVatTotal : 0),
),
});
@ -373,9 +374,7 @@ function print() {
<td class="text-right">
{{
formatNumberDecimal(
summaryPrice.totalPrice -
summaryPrice.totalDiscount +
summaryPrice.vat,
summaryPrice.totalPrice - summaryPrice.totalDiscount,
2,
)
}}

View file

@ -56,18 +56,25 @@ function openList(index: number) {
}
function calcPricePerUnit(product: RequestWork['productService']['product']) {
return product.vatIncluded
? (props.creditNote
const val = props.creditNote
? props.agentPrice
? product.agentPrice
: product.price
: product.serviceCharge;
if (
product[
props.creditNote
? props.agentPrice
? product.agentPrice
: product.price
: product.serviceCharge) /
(1 + (config.value?.vat || 0.07))
: props.creditNote
? props.agentPrice
? product.agentPrice
: product.price
: product.serviceCharge;
? 'agentPriceCalcVat'
: 'calcVat'
: 'serviceChargeCalcVat'
]
) {
return val / (1 + (config.value?.vat || 0.07));
} else {
return val;
}
}
function calcPrice(
@ -81,12 +88,24 @@ function calcPrice(
: product.serviceCharge;
const discount =
taskProduct.value.find((v) => v.productId === product.id)?.discount || 0;
const priceNoVat = product.vatIncluded
const priceNoVat = product[
props.creditNote
? props.agentPrice
? 'agentPriceVatIncluded'
: 'vatIncluded'
: 'serviceChargeVatIncluded'
]
? pricePerUnit / (1 + (config.value?.vat || 0.07))
: pricePerUnit;
const priceDiscountNoVat = priceNoVat * amount - discount;
const rawVatTotal = product.calcVat
const rawVatTotal = product[
props.creditNote
? props.agentPrice
? 'agentPriceCalcVat'
: 'calcVat'
: 'serviceChargeCalcVat'
]
? priceDiscountNoVat * (config.value?.vat || 0.07)
: 0;

View file

@ -6,6 +6,7 @@ import { ref } from 'vue';
import { MainButton } from 'src/components/button';
const props = defineProps<{
defaultRemark?: string;
readonly?: boolean;
itemsDiscount?: {
productId: string;
@ -115,6 +116,10 @@ const getToolbarConfig = computed(() => {
</div>
</template>
</q-editor>
<p class="app-text-muted text-caption">
<slot name="hint" />
</p>
</main>
</q-expansion-item>
</template>

View file

@ -15,17 +15,18 @@ import { RequestWork } from 'src/stores/request-list';
// NOTE: Import stores
import { useTaskOrderStore } from 'src/stores/task-order';
export const DEFAULT_DATA: TaskOrderPayload = {
taskList: [],
institutionId: '',
contactTel: '',
contactName: '',
taskName: '',
remark: '#[order-detail]',
};
export const useTaskOrderForm = defineStore('task-order-form', () => {
const { t } = useI18n();
const taskOrderStore = useTaskOrderStore();
const DEFAULT_DATA: TaskOrderPayload = {
taskList: [],
institutionId: '',
contactTel: '',
contactName: '',
taskName: '',
remark: '#[order-detail]',
};
const state = ref<{
mode: null | 'info' | 'create' | 'edit';

View file

@ -28,7 +28,7 @@ import FailRemarkDialog from '../receive_view/FailRemarkDialog.vue';
import SelectReadyRequestWork from '../SelectReadyRequestWork.vue';
import { dialogWarningClose } from 'stores/utils';
import useOptionStore from 'src/stores/options';
import { useTaskOrderForm } from '../form';
import { useTaskOrderForm, DEFAULT_DATA } from '../form';
import { useTaskOrderStore } from 'src/stores/task-order';
import { dateFormatJS, dateFormat } from 'src/utils/datetime';
import { initLang, initTheme } from 'src/utils/ui';
@ -135,7 +135,7 @@ function getPrice(
const discount =
taskProduct.value.find((v) => v.productId === c.product.id)?.discount ||
0;
const priceNoVat = c.product.vatIncluded
const priceNoVat = c.product.serviceChargeVatIncluded
? pricePerUnit / (1 + (config.value?.vat || 0.07))
: pricePerUnit;
const adjustedPriceWithVat = precisionRound(
@ -149,8 +149,8 @@ function getPrice(
a.totalPrice = a.totalPrice + priceDiscountNoVat;
a.totalDiscount = a.totalDiscount + Number(discount);
a.vat = c.product.calcVat ? a.vat + rawVatTotal : a.vat;
a.vatExcluded = c.product.calcVat
a.vat = c.product.serviceChargeCalcVat ? a.vat + rawVatTotal : a.vat;
a.vatExcluded = c.product.serviceChargeCalcVat
? a.vatExcluded
: precisionRound(a.vatExcluded + priceDiscountNoVat);
a.finalPrice = a.totalPrice - a.totalDiscount + a.vat;
@ -982,7 +982,14 @@ watch(
:readonly="!['create', 'edit'].includes(state.mode || '')"
:items="taskListGroup"
:items-discount="taskProduct"
/>
:default-remark="DEFAULT_DATA.remark"
>
<template #hint>
{{ $t('general.hintRemark') }}
<code>#[order-detail]</code>
{{ $t('general.orderDetail') }}
</template>
</RemarkExpansion>
<template
v-if="
@ -1033,55 +1040,63 @@ watch(
header-class="text-medium text-body items-center bordered-b "
>
<template #header>
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
<section class="row items-center full-width">
<div class="flex items-center col-sm col-12 no-wrap">
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
</div>
</span>
</div>
<span
class="row items-center q-gutter-x-sm"
:class="{ 'q-py-xs': $q.screen.lt.sm }"
>
<template #error>
<div
v-for="taskStatus in 3"
:key="taskStatus"
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.1)"
:style="`color: hsl(var(--${
taskStatus === 1
? 'warning'
: taskStatus === 2
? 'positive'
: 'negative'
}-bg))`"
>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
</div>
</span>
<span class="q-ml-auto row items-center q-gutter-x-sm">
<div
v-for="taskStatus in 3"
:key="taskStatus"
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.1)"
:style="`color: hsl(var(--${
taskStatus === 1
? 'warning'
: taskStatus === 2
? 'positive'
: 'negative'
}-bg))`"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{
taskStatusCount(
taskStatus,
product.id,
v.responsibleUser.id,
)
}}
</div>
</span>
{{
taskStatusCount(
taskStatus,
product.id,
v.responsibleUser.id,
)
}}
</div>
</span>
</section>
</template>
<div>

View file

@ -15,7 +15,7 @@ import TaskStatusComponent from '../TaskStatusComponent.vue';
import FailRemarkDialog from './FailRemarkDialog.vue';
// NOTE: Import Types and Store
import { dateFormatJS, dateFormat } from 'src/utils/datetime';
import { dateFormatJS } from 'src/utils/datetime';
import { useTaskOrderForm } from '../form';
import { initLang, initTheme, Lang } from 'src/utils/ui';
import { RequestWork } from 'src/stores/request-list';
@ -393,17 +393,10 @@ watch([currentFormData.value.taskStatus], () => {
: new Date(Date.now()),
dayStyle: 'numeric',
monthStyle: 'long',
locale: $i18n.locale === 'tha' ? 'th-Th' : 'en-US',
withTime: true,
}),
})
}}
{{
dateFormat(
fullTaskOrder ? fullTaskOrder.createdAt : new Date(Date.now()),
false,
true,
)
}}
</span>
</span>
</div>
@ -440,42 +433,55 @@ watch([currentFormData.value.taskStatus], () => {
class="row items-center surface-1 q-pa-md rounded gradient-stat"
>
<span
class="row col rounded q-px-sm q-py-md info"
class="row col rounded q-px-sm q-py-md info justify-end"
style="border: 1px solid hsl(var(--info-bg))"
>
{{ $t('taskOrder.allProduct') }}
<span class="q-ml-auto">{{ fullTaskOrder.taskList.length }}</span>
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('taskOrder.allProduct') }}
</span>
{{ fullTaskOrder.taskList.length }}
</span>
<span
class="row col rounded q-px-sm q-py-md q-mx-md positive"
class="row col rounded q-px-sm q-py-md q-mx-md positive justify-end"
style="border: 1px solid hsl(var(--positive-bg))"
>
{{ $t('taskOrder.alreadySentTask') }}
<span class="q-ml-auto">
{{
fullTaskOrder.taskList.filter(
(t) =>
t.taskStatus === TaskStatus.Complete ||
t.taskStatus === TaskStatus.Success ||
t.taskStatus === TaskStatus.Validate ||
t.taskStatus === TaskStatus.Redo ||
t.taskStatus === TaskStatus.Failed,
).length
}}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('taskOrder.alreadySentTask') }}
</span>
{{
fullTaskOrder.taskList.filter(
(t) =>
t.taskStatus === TaskStatus.Complete ||
t.taskStatus === TaskStatus.Success ||
t.taskStatus === TaskStatus.Validate ||
t.taskStatus === TaskStatus.Redo ||
t.taskStatus === TaskStatus.Failed,
).length
}}
</span>
<span
class="row col rounded q-px-sm q-py-md warning"
class="row col rounded q-px-sm q-py-md warning justify-end"
style="border: 1px solid hsl(var(--warning-bg))"
>
{{ $t('taskOrder.status.Pending') }}
<span class="q-ml-auto">
{{
fullTaskOrder.taskList.filter(
(t) => t.taskStatus === TaskStatus.InProgress,
).length
}}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('taskOrder.status.Pending') }}
</span>
{{
fullTaskOrder.taskList.filter(
(t) => t.taskStatus === TaskStatus.InProgress,
).length
}}
</span>
</article>
@ -553,64 +559,79 @@ watch([currentFormData.value.taskStatus], () => {
header-class="q-py-sm text-medium text-body items-center rounded q-mx-md q-my-sm"
>
<template #header>
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
<section class="row items-center full-width">
<div class="flex items-center col-sm col-12 no-wrap">
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
</div>
</span>
</div>
</span>
<span
v-if="
fullTaskOrder.taskOrderStatus === TaskOrderStatus.Pending
"
class="q-ml-auto"
>
<div
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.15)"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ list.length }}
</div>
</span>
<span v-else class="q-ml-auto row items-center q-gutter-x-sm">
<div
v-for="v in 3"
:key="v"
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.1)"
:style="`color: hsl(var(--${
v === 1 ? 'warning' : v === 2 ? 'positive' : 'negative'
}-bg))`"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ taskStatusCount(v, product.id) }}
<div
class="row items-center"
:class="{ 'q-py-xs': $q.screen.lt.sm }"
>
<span
v-if="
fullTaskOrder.taskOrderStatus ===
TaskOrderStatus.Pending
"
class="q-ml-auto"
>
<div
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.15)"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ list.length }}
</div>
</span>
<span v-else class="row items-center q-gutter-x-sm">
<div
v-for="v in 3"
:key="v"
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.1)"
:style="`color: hsl(var(--${
v === 1
? 'warning'
: v === 2
? 'positive'
: 'negative'
}-bg))`"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ taskStatusCount(v, product.id) }}
</div>
</span>
</div>
</span>
</section>
</template>
<div>

View file

@ -1,7 +1,8 @@
<script lang="ts" setup>
// NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { QSelect, useQuasar } from 'quasar';
// NOTE: Components
import StatCardComponent from 'src/components/StatCardComponent.vue';
@ -15,15 +16,15 @@ import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
import { useNavigator } from 'src/stores/navigator';
import { columns, hslaColors } from './constants';
import useFlowStore from 'src/stores/flow';
import { useRequestList } from 'src/stores/request-list';
import { useInvoice } from 'src/stores/payment';
import { Invoice, PaymentDataStatus } from 'src/stores/payment/types';
import { Quotation } from 'src/stores/quotations';
const $q = useQuasar();
const navigatorStore = useNavigator();
const flowStore = useFlowStore();
const invoiceStore = useInvoice();
const { data, stats, page, pageMax, pageSize } = storeToRefs(invoiceStore);
const refFilter = ref<InstanceType<typeof QSelect>>();
// NOTE: Variable
const pageState = reactive({
@ -57,7 +58,7 @@ async function fetchList(opts?: { rotateFlowId?: boolean }) {
debitNoteOnly: false,
});
if (ret) {
data.value = ret.result;
data.value = $q.screen.xs ? [...data.value, ...ret.result] : ret.result;
pageState.total = ret.total;
pageMax.value = Math.ceil(ret.total / pageSize.value);
}
@ -74,7 +75,7 @@ async function fetchStats() {
}
function triggerView(opts: { quotationId: string }) {
const url = new URL(`/quotation/view?tab=invoice`, window.location.origin);
const url = new URL('/quotation/view?tab=invoice', window.location.origin);
localStorage.setItem(
'new-quotation',
@ -105,6 +106,8 @@ function viewDocExample(quotationId: string) {
}
onMounted(async () => {
pageState.gridView = $q.screen.lt.md ? true : false;
navigatorStore.current.title = 'invoice.title';
navigatorStore.current.path = [{ text: 'invoice.caption', i18n: true }];
@ -117,9 +120,12 @@ watch(
() => pageState.inputSearch,
() => pageState.statusFilter,
() => pageSize.value,
() => page.value,
],
() => fetchList({ rotateFlowId: true }),
() => {
page.value = 1;
data.value = [];
fetchList({ rotateFlowId: true });
},
);
</script>
<template>
@ -189,7 +195,7 @@ watch(
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -197,14 +203,26 @@ watch(
<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="refFilter?.showPopup"
/>
</span>
</template>
</q-input>
<div
class="row col-12 col-md-5 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5" style="white-space: nowrap">
<q-select
v-show="$q.screen.gt.sm"
ref="refFilter"
v-model="pageState.statusFilter"
outlined
dense
@ -311,80 +329,101 @@ watch(
<NoData :not-found="!!pageState.inputSearch" />
</article>
<article v-else class="col surface-2 full-width scroll q-pa-md">
<TableInvoice
:columns="columns"
:rows="data"
:grid="pageState.gridView"
@view="(quotationId) => triggerView({ quotationId })"
@preview="(quotationId) => viewDocExample(quotationId)"
<q-infinite-scroll
:offset="100"
@load="
(_, done) => {
if ($q.screen.gt.xs || page === pageMax) return;
page = page + 1;
fetchList({ rotateFlowId: true }).then(() =>
done(page >= pageMax),
);
}
"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
hide-action
:code="item.row.code"
:title="item.row.quotation.workName"
:status="
$t(`invoice.status.${item.row.payment.paymentStatus}`)
"
:badge-color="
hslaColors[item.row.payment.paymentStatus] || ''
"
:custom-data="[
{
label: $t('general.customer'),
value:
item.row.quotation.customerBranch.registerName ||
`${item.row.quotation.customerBranch?.firstName || '-'} ${item.row.quotation.customerBranch?.lastName || ''}`,
},
{
label: $t('taskOrder.issueDate'),
value: new Date(item.row.createdAt).toLocaleString(
'th-TH',
{
hour12: false,
},
),
},
{
label: $t('invoice.paymentDueDate'),
value: new Date(
item.row.quotation.dueDate,
).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: 'numeric',
}),
},
{
label: $t('quotation.payType'),
value: $t(
`quotation.type.${item.row.quotation.payCondition}`,
),
},
{
label: $t('preview.netValue'),
value: item.row.amount,
},
]"
@view="
() => triggerView({ quotationId: item.row.quotation.id })
"
@preview="
() => {
viewDocExample(item.row.quotation.id);
}
"
/>
<TableInvoice
:columns="columns"
:rows="data"
:grid="pageState.gridView"
@view="(quotationId) => triggerView({ quotationId })"
@preview="(quotationId) => viewDocExample(quotationId)"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
hide-action
:code="item.row.code"
:title="item.row.quotation.workName"
:status="
$t(`invoice.status.${item.row.payment.paymentStatus}`)
"
:badge-color="
hslaColors[item.row.payment.paymentStatus] || ''
"
:custom-data="[
{
label: $t('general.customer'),
value:
item.row.quotation.customerBranch.registerName ||
`${item.row.quotation.customerBranch?.firstName || '-'} ${item.row.quotation.customerBranch?.lastName || ''}`,
},
{
label: $t('taskOrder.issueDate'),
value: new Date(item.row.createdAt).toLocaleString(
'th-TH',
{
hour12: false,
},
),
},
{
label: $t('invoice.paymentDueDate'),
value: new Date(
item.row.quotation.dueDate,
).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: 'numeric',
}),
},
{
label: $t('quotation.payType'),
value: $t(
`quotation.type.${item.row.quotation.payCondition}`,
),
},
{
label: $t('preview.netValue'),
value: item.row.amount,
},
]"
@view="
() => triggerView({ quotationId: item.row.quotation.id })
"
@preview="
() => {
viewDocExample(item.row.quotation.id);
}
"
/>
</div>
</template>
</TableInvoice>
<template v-slot:loading>
<div
v-if="$q.screen.lt.sm && page !== pageMax"
class="row justify-center"
>
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</TableInvoice>
</q-infinite-scroll>
</article>
<!-- SEC: footer content -->
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="pageMax > 0"
v-if="pageMax > 0 && $q.screen.gt.xs"
>
<div class="col-4">
<div class="row items-center no-wrap">
@ -396,10 +435,19 @@ watch(
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch ? data.length : pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
: $t('general.ofPage', {
current: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">

View file

@ -79,7 +79,7 @@ defineEmits<{
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr :class="{ dark: $q.dark.isActive }" class="text-center">
<q-td v-for="col in columns" :align="col.align">
<q-td v-for="col in columns" :align="col.align" :key="col.name">
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<span>
@ -92,8 +92,10 @@ defineEmits<{
</template>
<template v-if="col.name === '#order'">
{{
col.field(props.row) +
(invoiceStore.page - 1) * invoiceStore.pageSize
$q.screen.xs
? props.rowIndex + 1
: col.field(props.row) +
(invoiceStore.page - 1) * invoiceStore.pageSize
}}
</template>
<template v-if="col.name === '#status'">

View file

@ -19,7 +19,7 @@ import AdditionalFileExpansion from '../09_task-order/expansion/AdditionalFileEx
import PaymentExpansion from './expansion/PaymentExpansion.vue';
import CreditNoteExpansion from './expansion/CreditNoteExpansion.vue';
import StateButton from 'src/components/button/StateButton.vue';
import ProductExpansion from '../09_task-order/expansion/ProductExpansion.vue';
import ProductExpansion from './expansion/ProductExpansion.vue';
import SelectReadyRequestWork from '../09_task-order/SelectReadyRequestWork.vue';
import RefundInformation from './RefundInformation.vue';
import QuotationFormReceipt from '../05_quotation/QuotationFormReceipt.vue';
@ -87,6 +87,8 @@ const pageState = reactive({
fileDialog: false,
});
const defaultRemark = '#[quotation-labor]<br/><br/>#[quotation-payment]';
const formData = ref<CreditNotePayload>({
quotationId: '',
requestWorkId: [],
@ -96,7 +98,7 @@ const formData = ref<CreditNotePayload>({
paybackBank: '',
paybackAccount: '',
paybackAccountName: '',
remark: '#[quotation-labor]<br/><br/>#[quotation-payment]',
remark: defaultRemark,
});
const formTaskList = ref<
@ -109,7 +111,7 @@ const formTaskList = ref<
let taskListGroup = computed(() => {
const cacheData = formTaskList.value.reduce<
{
product: RequestWork['productService']['product'];
product: RequestWork['productService'];
list: RequestWork[];
}[]
>((acc, curr) => {
@ -127,7 +129,7 @@ let taskListGroup = computed(() => {
exist.list.push(task.requestWork);
} else {
acc.push({
product: task.requestWork.productService.product,
product: task.requestWork.productService,
list: [record],
});
}
@ -187,37 +189,28 @@ async function initStatus() {
function getPrice(
list: {
product: RequestWork['productService']['product'];
product: RequestWork['productService'];
list: RequestWork[];
}[],
) {
return list.reduce(
(a, c) => {
const pricePerUnit = quotationData.value?.agentPrice
? c.product.agentPrice
: c.product.price;
const pricePerUnit =
c.product.pricePerUnit - c.product.discount / c.product.amount;
const amount = c.list.length;
const discount = 0;
const priceNoVat = c.product.vatIncluded
? pricePerUnit / (1 + (config.value?.vat || 0.07))
: pricePerUnit;
const priceDiscountNoVat = priceNoVat * amount - discount;
const priceNoVat = pricePerUnit;
const priceDiscountNoVat = priceNoVat * amount;
const rawVatTotal = priceDiscountNoVat * (config.value?.vat || 0.07);
// const rawVat = rawVatTotal / amount;
a.totalPrice = a.totalPrice + priceDiscountNoVat;
a.totalDiscount = a.totalDiscount + Number(discount);
a.vat = c.product.calcVat ? a.vat + rawVatTotal : a.vat;
a.vatExcluded = c.product.calcVat ? a.vatExcluded : a.vat + rawVatTotal;
a.finalPrice = a.totalPrice - a.totalDiscount + a.vat;
a.vat = c.product.vat !== 0 ? a.vat + rawVatTotal : a.vat;
a.finalPrice = a.totalPrice + a.vat;
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
@ -747,12 +740,22 @@ onMounted(async () => {
"
/>
<!-- TODO: bind remark -->
<RemarkExpansion
v-if="view !== CreditNoteStatus.Success"
:readonly="readonly"
v-model:remark="formData.remark"
/>
:default-remark="defaultRemark"
:items="[]"
:readonly
>
<template #hint>
{{ $t('general.hintRemark') }}
<code>#[quotation-labor]</code>
{{ $t('general.quotationLabor') }}
{{ $t('general.or') }}
<code>#[quotation-payment]</code>
{{ $t('general.quotationPayment') }}
</template>
</RemarkExpansion>
<QuotationFormReceipt
v-if="creditNoteData && view === CreditNoteStatus.Success"

View file

@ -3,6 +3,7 @@
import { computed, onMounted, reactive, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
// NOTE: Components
import StatCardComponent from 'src/components/StatCardComponent.vue';
@ -24,6 +25,7 @@ import { CreditNoteStatus, useCreditNote } from 'src/stores/credit-note';
import TableCreditNote from './TableCreditNote.vue';
import { dialogWarningClose } from 'src/stores/utils';
const $q = useQuasar();
const { t } = useI18n();
const flow = useFlowStore();
const navigator = useNavigator();
@ -115,6 +117,7 @@ function close() {
}
onMounted(async () => {
pageState.gridView = $q.screen.lt.md ? true : false;
navigator.current.title = 'creditNote.title';
navigator.current.path = [{ text: 'creditNote.caption', i18n: true }];
@ -209,7 +212,7 @@ watch(
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
@ -219,16 +222,12 @@ watch(
</template>
</q-input>
<div
class="row col-12 col-md-3 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<div class="row col-md-5 justify-end" style="white-space: nowrap">
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col"
class="col-md-5 q-ml-sm"
:options="
fieldSelectedOption.map((v) => ({
...v,
@ -413,10 +412,19 @@ watch(
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch ? data.length : pageState.total,
})
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
: $t('general.ofPage', {
current: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">

View file

@ -125,31 +125,40 @@ const refundOpts = ref<
class="row col-12 items-center surface-1 q-py-sm rounded gradient-stat"
>
<span
class="row col rounded q-px-sm q-py-md info"
class="row col rounded q-px-sm q-py-md info justify-end"
style="border: 1px solid hsl(var(--info-bg))"
>
{{ $t('creditNote.label.totalAmount') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(total) }}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('creditNote.label.totalAmount') }}
</span>
{{ formatNumberDecimal(total) }}
</span>
<span
class="row col rounded q-px-sm q-mx-md q-py-md positive"
class="row col rounded q-px-sm q-mx-md q-py-md positive justify-end"
style="border: 1px solid hsl(var(--positive-bg))"
>
{{ $t('creditNote.label.paid') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(paid) }}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('creditNote.label.paid') }}
</span>
{{ formatNumberDecimal(paid) }}
</span>
<span
class="row col rounded q-px-sm q-py-md warning"
class="row col rounded q-px-sm q-py-md warning justify-end"
style="border: 1px solid hsl(var(--warning-bg))"
>
{{ $t('creditNote.label.remain') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(remain) }}
<span
class="col-sm col-12"
:class="{ 'text-right': $q.screen.lt.sm }"
>
{{ $t('creditNote.label.remain') }}
</span>
{{ formatNumberDecimal(remain) }}
</span>
</article>
</article>

View file

@ -76,3 +76,60 @@ export const hslaColors: Record<string, string> = {
Pending: '--orange-5-hsl',
Success: '--blue-6-hsl',
};
export const productColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'code',
align: 'center',
label: 'productService.product.code',
field: 'code',
},
{
name: 'productList',
align: 'center',
label: 'taskOrder.productList',
field: 'productList',
},
{
name: 'amountOfEmployee',
align: 'center',
label: 'taskOrder.amountOfEmployee',
field: 'amountOfEmployee',
},
{
name: 'pricePerUnit',
align: 'center',
label: 'quotation.pricePerUnit',
field: 'pricePerUnit',
},
{
name: 'discount',
align: 'center',
label: 'general.discount',
field: 'discount',
},
{
name: 'priceBeforeVat',
align: 'center',
label: 'quotation.priceBeforeVat',
field: 'priceBeforeVat',
},
{
name: 'vat',
align: 'center',
label: 'general.vat',
field: 'vat',
},
{
name: 'totalPriceBaht',
align: 'center',
label: 'quotation.totalPriceBaht',
field: 'totalPriceBaht',
},
] as const satisfies QTableProps['columns'];

View file

@ -0,0 +1,232 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { QTableSlots } from 'quasar';
import { storeToRefs } from 'pinia';
import { AddButton } from 'src/components/button';
import TableEmployee from '../../09_task-order/TableEmployee.vue';
import { useConfigStore } from 'stores/config';
import { RequestWork } from 'src/stores/request-list';
import { baseUrl, formatNumberDecimal } from 'src/stores/utils';
import { precisionRound } from 'src/utils/arithmetic';
import { productColumn } from '../constants';
const configStore = useConfigStore();
const { data: config } = storeToRefs(configStore);
const currentExpanded = ref<boolean[]>([]);
defineProps<{
readonly?: boolean;
taskList: {
product: RequestWork['productService'];
list: RequestWork[];
}[];
}>();
defineEmits<{
(e: 'addProduct'): void;
}>();
defineExpose({ calcPricePerUnit });
function openList(index: number) {
if (!currentExpanded.value[index]) {
currentExpanded.value.map((_, i) => {
if (i !== index) currentExpanded.value[i] = false;
});
}
currentExpanded.value[index] = !currentExpanded.value[index];
}
function calcPricePerUnit(product: RequestWork['productService']) {
return product.pricePerUnit - product.discount / product.amount;
}
function calcPrice(c: RequestWork['productService'], amount: number) {
const pricePerUnit = c.pricePerUnit - c.discount / c.amount;
const priceNoVat = pricePerUnit;
const priceDiscountNoVat = priceNoVat * amount;
const rawVatTotal =
c.vat === 0 ? 0 : priceDiscountNoVat * (config.value?.vat || 0.07);
return precisionRound(priceNoVat * amount + rawVatTotal);
}
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
default-opened
>
<template #header>
<span
class="row items-center justify-between full-width"
style="min-height: 31.01px"
>
{{ $t('general.information', { msg: $t('taskOrder.productList') }) }}
<AddButton
icon-only
@click.stop="$emit('addProduct')"
v-if="!readonly"
/>
</span>
</template>
<main class="q-px-md q-py-sm surface-1">
<q-table
:columns="
productColumn.filter(
(v) =>
v.name !== 'discount' &&
v.name !== 'priceBeforeVat' &&
v.name !== 'vat',
)
"
:rows="taskList"
bordered
flat
hide-pagination
card-container-class="q-col-gutter-sm"
class="full-width"
:rows-per-page-options="[0]"
:no-data-label="$t('general.noDataTable')"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }}
{{ col.label === 'quotation.vat' ? '%' : '' }}
</q-th>
<q-th></q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: {
product: RequestWork['productService'];
list: RequestWork[];
};
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr class="text-center">
<q-td>
{{ props.rowIndex + 1 }}
</q-td>
<q-td>
{{ props.row.product.product.code }}
</q-td>
<q-td style="width: 100%" class="text-left">
<q-avatar class="q-mr-sm" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${props.row.product.id}/image/${props.row.product.product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
{{ props.row.product.product.name }}
</q-td>
<q-td>
{{ props.row.list.length }}
</q-td>
<q-td class="text-right">
{{
formatNumberDecimal(
calcPricePerUnit(props.row.product) +
(props.row.product.vat > 0
? calcPricePerUnit(props.row.product) *
(config?.vat || 0.07)
: 0),
2,
)
}}
</q-td>
<!-- total -->
<q-td class="text-right">
{{
formatNumberDecimal(
calcPrice(props.row.product, props.row.list.length),
2,
)
}}
</q-td>
<q-td>
<q-btn
dense
flat
class="rounded"
@click.stop="openList(props.rowIndex)"
>
<div class="row items-center no-wrap">
<q-icon name="mdi-account-group-outline" />
<q-icon
class="btn-arrow-right"
:class="{
active: currentExpanded[props.rowIndex],
}"
size="xs"
:name="`mdi-chevron-${currentExpanded[props.rowIndex] ? 'down' : 'up'}`"
/>
</div>
</q-btn>
</q-td>
</q-tr>
<q-tr v-show="currentExpanded[props.rowIndex]" :props="props">
<q-td colspan="100%" style="padding: 16px">
<TableEmployee :rows="props.row.list" />
</q-td>
</q-tr>
</template>
</q-table>
<div class="q-pt-md row items-center">
<span class="q-ml-auto q-mr-sm">
{{
$t('general.numberOf', {
msg: $t('productService.product.product'),
})
}}
</span>
<div class="surface-3 q-px-sm rounded">{{ taskList.length }}</div>
</div>
</main>
</q-expansion-item>
</template>
<style scoped>
.product-status {
padding-left: 8px;
border-radius: 20px;
color: hsl(var(--_color));
background: hsla(var(--_color) / 0.15);
&.warning {
--_color: var(--warning-bg);
}
&.positive {
--_color: var(--positive-bg);
}
&.negative {
--_color: var(--negative-bg);
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,505 @@
<script lang="ts" setup>
// NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
// NOTE: Components
import StatCardComponent from 'src/components/StatCardComponent.vue';
import NoData from 'src/components/NoData.vue';
import PaginationComponent from 'src/components/PaginationComponent.vue';
import PaginationPageSize from 'src/components/PaginationPageSize.vue';
import FloatingActionButton from 'components/FloatingActionButton.vue';
import DialogFormContainer from 'src/components/dialog/DialogFormContainer.vue';
import DialogHeader from 'src/components/dialog/DialogHeader.vue';
import { CancelButton, SaveButton } from 'src/components/button';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
import FormDebit from 'src/components/12_debit-note/FormDebit.vue';
import TableDebitNote from './TableDebitNote.vue';
// NOTE: Stores & Type
import { useNavigator } from 'src/stores/navigator';
import useFlowStore from 'src/stores/flow';
import { pageTabs, columns, hslaColors } from './constants';
import { DebitNoteStatus, useDebitNote } from 'src/stores/debit-note';
import { dialogWarningClose } from 'src/stores/utils';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const { t } = useI18n();
const flow = useFlowStore();
const navigator = useNavigator();
const debitNote = useDebitNote();
const { stats, pageMax, page, data, pageSize } = storeToRefs(debitNote);
// NOTE: Variable
const pageState = reactive({
quotationId: '',
currentTab: DebitNoteStatus.Pending,
hideStat: false,
statusFilter: 'None',
inputSearch: '',
fieldSelected: columns
.filter((v) => !v.name.startsWith('#'))
.map((v) => v.name),
gridView: false,
total: 0,
debitDialog: false,
});
const fieldSelectedOption = computed(() => {
return columns
.filter((v) => !v.name.startsWith('#'))
.map((v) => ({
label: v.label,
value: v.name,
}));
});
// NOTE: Function
async function getList(opts?: { page?: number; pageSize?: number }) {
const res = await debitNote.getDebitNoteList({
page: opts?.page || page.value,
pageSize: opts?.pageSize || pageSize.value,
query: pageState.inputSearch === '' ? undefined : pageState.inputSearch,
deebitNoteStatus: pageState.currentTab as DebitNoteStatus | undefined,
includeRegisteredBranch: true,
});
if (res) {
data.value = res.result;
pageState.total = res.total;
pageMax.value = Math.ceil(res.total / pageSize.value);
}
}
async function triggerDelete(id: string) {
dialogWarningClose(t, {
message: t('dialog.message.confirmDelete'),
actionText: t('dialog.action.ok'),
action: async () => {
const res = await debitNote.deleteDebitNote(id);
if (!!res) {
getList();
}
},
cancel: () => {},
});
}
async function triggerCreateDebitNote() {
pageState.debitDialog = true;
}
function navigateTo(opts: {
statusDialog: 'info' | 'edit' | 'create';
quotationId?: string;
debitId?: string;
}) {
const url = new URL(
`/debit-note/${opts.statusDialog === 'create' ? 'add' : opts.debitId}`,
window.location.origin,
);
if (opts.statusDialog === 'create') {
url.searchParams.append('quotationId', opts.quotationId || '');
}
url.searchParams.append('mode', opts.statusDialog);
window.open(url.toString(), '_blank');
}
async function submit() {
navigateTo({ statusDialog: 'create', quotationId: pageState.quotationId });
}
function close() {
pageState.debitDialog = false;
}
onMounted(async () => {
pageState.gridView = $q.screen.lt.md ? true : false;
navigator.current.title = 'debitNote.title';
navigator.current.path = [{ text: 'debitNote.caption', i18n: true }];
debitNote.getDebitNoteStats().then((res) => res && (stats.value = res));
getList();
});
watch(
[
() => pageState.currentTab,
() => pageState.inputSearch,
() => pageSize.value,
() => pageState.statusFilter,
],
() => getList(),
);
</script>
<template>
<!-- #TODO add trigger dialog add debit -->
<FloatingActionButton
style="z-index: 999"
hide-icon
@click.stop="() => triggerCreateDebitNote()"
></FloatingActionButton>
<div class="column full-height no-wrap">
<!-- SEC: stat -->
<section class="text-body-2 q-mb-xs flex items-center">
{{ $t('general.dataSum') }}
<q-badge
rounded
class="q-ml-sm"
style="
background-color: hsla(var(--info-bg) / 0.15);
color: hsl(var(--info-bg));
"
>
{{ pageState.total }}
</q-badge>
<q-btn
class="q-ml-sm"
icon="mdi-pin-outline"
color="primary"
size="sm"
flat
dense
rounded
@click="pageState.hideStat = !pageState.hideStat"
:style="pageState.hideStat ? 'rotate: 90deg' : ''"
style="transition: 0.1s ease-in-out"
/>
</section>
<transition name="slide">
<div v-if="!pageState.hideStat" class="scroll q-mb-md">
<div style="display: inline-block">
<StatCardComponent
labelI18n
:branch="[
{
icon: 'material-symbols-light:receipt-long',
count: stats[DebitNoteStatus.Pending] || 0,
label: `debitNote.stats.${DebitNoteStatus.Pending}`,
color: 'orange',
},
{
icon: 'mdi-clock-alert-outline',
count: stats[DebitNoteStatus.Expire] || 0,
label: `debitNote.stats.${DebitNoteStatus.Expire}`,
color: 'cyan',
},
{
icon: 'tabler:cash-register',
count: stats[DebitNoteStatus.Payment] || 0,
label: `debitNote.stats.${DebitNoteStatus.Payment}`,
color: 'dark-orange',
},
{
icon: 'fluent:receipt-money-16-regular',
count: stats[DebitNoteStatus.Receipt] || 0,
label: `debitNote.stats.${DebitNoteStatus.Receipt}`,
color: 'green',
},
{
icon: 'mdi-check-decagram-outline',
count: stats[DebitNoteStatus.Succeed] || 0,
label: `debitNote.stats.${DebitNoteStatus.Succeed}`,
color: 'blue',
},
]"
:dark="$q.dark.isActive"
/>
</div>
</div>
</transition>
<section class="col surface-1 rounded bordered overflow-hidden">
<div class="column full-height">
<!-- SEC: header content -->
<header
class="row surface-3 justify-between full-width items-center"
style="z-index: 1"
>
<section
class="row q-py-sm q-px-md bordered-b justify-between full-width"
>
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="col col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
<div class="row col-md-5 justify-end" style="white-space: nowrap">
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col-md-5 q-ml-sm"
:options="
fieldSelectedOption.map((v) => ({
...v,
label: v.label && $t(v.label),
}))
"
:display-value="$t('general.displayField')"
:hide-dropdown-icon="$q.screen.lt.sm"
v-model="pageState.fieldSelected"
option-label="label"
option-value="value"
map-options
emit-value
outlined
multiple
dense
/>
<q-btn-toggle
id="btn-mode"
v-model="pageState.gridView"
dense
class="no-shadow bordered rounded surface-1 q-ml-sm"
:toggle-color="$q.dark.isActive ? 'grey-9' : 'grey-2'"
size="xs"
:options="[
{ value: true, slot: 'folder' },
{ value: false, slot: 'list' },
]"
>
<template v-slot:folder>
<q-icon
name="mdi-view-grid-outline"
size="16px"
class="q-px-sm q-py-xs rounded"
:style="{
color: $q.dark.isActive
? pageState.gridView
? '#C9D3DB '
: '#787B7C'
: pageState.gridView
? '#787B7C'
: '#C9D3DB',
}"
/>
</template>
<template v-slot:list>
<q-icon
name="mdi-format-list-bulleted"
class="q-px-sm q-py-xs rounded"
size="16px"
:style="{
color: $q.dark.isActive
? pageState.gridView === false
? '#C9D3DB'
: '#787B7C'
: pageState.gridView === false
? '#787B7C'
: '#C9D3DB',
}"
/>
</template>
</q-btn-toggle>
</div>
</section>
<nav class="surface-2 bordered-b q-px-md full-width">
<q-tabs
inline-label
mobile-arrows
dense
v-model="pageState.currentTab"
align="left"
class="full-width"
active-color="info"
>
<q-tab
v-for="tab in pageTabs"
:name="tab.value"
:key="tab.value"
@click="
() => {
pageState.currentTab = tab.value;
pageState.inputSearch = '';
flow.rotate();
}
"
>
<div
class="row text-capitalize"
:class="
pageState.currentTab === tab.value
? 'text-bold'
: 'app-text-muted'
"
>
{{ $t(`debitNote.status.${tab.label}`) }}
</div>
</q-tab>
</q-tabs>
</nav>
</header>
<!-- SEC: body content -->
<article
v-if="data.length === 0"
class="col surface-2 flex items-center justify-center"
>
<NoData :not-found="!!pageState.inputSearch" />
</article>
<article v-else class="col surface-2 full-width scroll q-pa-md">
<!-- #TODO change Table -->
<TableDebitNote
:grid="pageState.gridView"
:visible-columns="pageState.fieldSelected"
@view="
(item) => navigateTo({ statusDialog: 'info', debitId: item.id })
"
@edit="
(item) => navigateTo({ statusDialog: 'edit', debitId: item.id })
"
@delete="(item) => triggerDelete(item.id)"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
hide-kebab-edit
@view="
() =>
navigateTo({ statusDialog: 'info', debitId: item.row.id })
"
@edit="
() =>
navigateTo({ statusDialog: 'edit', debitId: item.row.id })
"
@delete="() => triggerDelete(item.row.id)"
:title="item.row.debitNoteQuotation?.workName"
:code="item.row.code"
:status="$t(`quotation.status.${item.row.quotationStatus}`)"
:badge-color="hslaColors[item.row.quotationStatus] || ''"
:custom-data="[
{
label: $t('branch.card.branchVirtual'),
value:
$i18n.locale === 'tha'
? item.row.registeredBranch.name
: item.row.registeredBranch.nameEN,
},
{
label: $t('quotation.customer'),
value:
item.row.customerBranch.customer.customerType === 'CORP'
? item.row.customerBranch.customerName
: $i18n.locale === 'tha'
? `${item.row.customerBranch.firstName} ${item.row.customerBranch.lastName}`
: `${item.row.customerBranch.firstNameEN} ${item.row.customerBranch.lastNameEN}`,
},
{
label: $t('requestList.quotationCode'),
value: item.row.debitNoteQuotation?.code,
},
{
label: $t('debitNote.label.quotationPayment'),
value: $t(
`quotation.type.${item.row.debitNoteQuotation?.payCondition}`,
),
},
]"
/>
</div>
</template>
</TableDebitNote>
</article>
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="pageMax > 0"
>
<div class="col-4">
<div class="row items-center no-wrap">
<div class="app-text-muted q-mr-sm" v-if="$q.screen.gt.sm">
{{ $t('general.recordPerPage') }}
</div>
<div><PaginationPageSize v-model="pageSize" /></div>
</div>
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$q.screen.gt.sm
? $t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
: $t('general.ofPage', {
current: data.length,
total: pageState.inputSearch
? data.length
: pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">
<!-- #TODO add getList at fectch-data -->
<PaginationComponent
v-model:current-page="page"
v-model:max-page="pageMax"
:fetch-data="() => getList()"
/>
</nav>
</footer>
</div>
</section>
</div>
<!-- SEC: Dialog -->
<!-- dialog create -->
<!-- #TODO add submit at submit -->
<DialogFormContainer
width="60vw"
height="500px"
v-model="pageState.debitDialog"
@submit="() => submit()"
>
<template #header>
<DialogHeader
:title="$t(`general.add`, { text: $t('debitNote.title') })"
/>
</template>
<section class="q-pa-md col full-width">
<div class="surface-1 rounded bordered q-pa-md full-height full-width">
<!-- TODO: bind quotation id -->
<FormDebit v-model:quotation-id="pageState.quotationId" />
</div>
</section>
<template #footer>
<CancelButton class="q-ml-auto" outlined @click="close()" />
<SaveButton
:label="$t(`general.add`, { text: $t('debitNote.title') })"
class="q-ml-sm"
icon="mdi-check"
solid
type="submit"
/>
</template>
</DialogFormContainer>
</template>
<style scoped></style>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { QTableSlots } from 'quasar';
import { DebitNote, useDebitNote } from 'src/stores/debit-note';
import { columns } from './constants.ts';
import KebabAction from 'src/components/shared/KebabAction.vue';
const debitNote = useDebitNote();
const { data, page, pageSize } = storeToRefs(debitNote);
const prop = defineProps<{ grid: boolean; visibleColumns: string[] }>();
defineEmits<{ (evt: 'view' | 'delete' | 'edit', val: DebitNote): void }>();
const visible = computed(() =>
columns.filter(
(v) => prop.visibleColumns.includes(v.name) || v.name === '#action',
),
);
</script>
<template>
<q-table
:rows-per-page-options="[0]"
:rows="
data.map((item, i) => ({
...item,
_index: i,
_page: page,
_pageSize: pageSize,
}))
"
:columns="visible"
:grid
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
card-container-class="q-col-gutter-sm"
class="full-width"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in visible" :key="col.name" :props="props">
<template v-if="!col.name.startsWith('#')">
{{ $t(col.label) }}
</template>
</q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: DebitNote & { _index: number; _page: number; _pageSize: number };
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr :class="{ dark: $q.dark.isActive }" class="text-center">
<q-td v-for="col in visible" :align="col.align">
<!-- NOTE: custom column will starts with # -->
<template v-if="!col.name.startsWith('#')">
<span v-if="col.name !== 'quotationPayment'">
{{
typeof col.field === 'string'
? props.row[col.field as keyof DebitNote]
: col.field(props.row)
}}
</span>
<span v-if="col.name === 'quotationPayment'">
{{ $t(`quotation.type.${col.field(props.row)}`) }}
</span>
</template>
<template v-if="col.name === '#action'">
<KebabAction
hide-toggle
hide-edit
@edit="$emit('edit', props.row)"
@delete="$emit('delete', props.row)"
@view="$emit('view', props.row)"
/>
</template>
</q-td>
</q-tr>
</template>
<template v-slot:item="props: { row: DebitNote }">
<slot name="grid" :item="props" />
</template>
</q-table>
</template>

View file

@ -0,0 +1,90 @@
import { QTableProps } from 'quasar';
import { DebitNoteStatus, DebitNote } from 'src/stores/debit-note';
import { formatNumberDecimal } from 'src/stores/utils';
export const taskStatusOpts = [
{
status: DebitNoteStatus.Expire,
name: `debitNote.status.${DebitNoteStatus.Expire}`,
},
{
status: DebitNoteStatus.Payment,
name: `debitNote.status.${DebitNoteStatus.Payment}`,
},
{
status: DebitNoteStatus.Receipt,
name: `debitNote.status.${DebitNoteStatus.Receipt}`,
},
{
status: DebitNoteStatus.Succeed,
name: `debitNote.status.${DebitNoteStatus.Succeed}`,
},
];
export const pageTabs = [
{ label: 'Pending', value: DebitNoteStatus.Pending },
{ label: 'Expire', value: DebitNoteStatus.Expire },
{ label: 'Payment', value: DebitNoteStatus.Payment },
{ label: 'Receipt', value: DebitNoteStatus.Receipt },
{ label: 'Succeed', value: DebitNoteStatus.Succeed },
];
export enum Status {
taskOrder = 'taskOrder',
receiveTaskOrder = 'receiveTaskOrder',
sendTaskOrder = 'sendTaskOrder',
payment = 'payment',
goodReceipt = 'goodReceipt',
}
export const columns = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: (
data: DebitNote & { _index: number; _page: number; _pageSize: number },
) => (data._page - 1) * data._pageSize + data._index + 1,
},
{
name: 'code',
align: 'center',
label: 'debitNote.label.codeDebit',
field: (data: DebitNote) => data.code,
},
{
name: 'quotationCode',
align: 'center',
label: 'debitNote.label.codeQuotation',
field: (data: DebitNote) => data.debitNoteQuotation?.code,
},
{
name: 'quotationWorkName',
align: 'center',
label: 'debitNote.label.quotationWorkName',
field: (data: DebitNote) => data.workName,
},
{
name: 'quotationPayment',
align: 'center',
label: 'debitNote.label.quotationPayment',
field: (data: DebitNote) => data.debitNoteQuotation?.payCondition,
},
{
name: 'creditNoteValue',
align: 'center',
label: 'debitNote.label.value',
field: (data: DebitNote) => formatNumberDecimal(data.totalPrice),
},
{
name: '#action',
align: 'center',
label: '',
field: (_) => '#action',
},
] as const satisfies QTableProps['columns'];
export const hslaColors: Record<string, string> = {
Pending: '--blue-6-hsl',
Success: '--red-6-hsl',
};

View file

@ -0,0 +1,113 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { QSelect } from 'quasar';
// NOTE: Import stores
import { selectFilterOptionRefMod } from 'stores/utils';
import useOptionStore from 'stores/options';
// NOTE Import Types
import { BankBook } from 'stores/branch/types';
// NOTE: Import Components
const optionStore = useOptionStore();
const bankBookOptions = ref<Record<string, unknown>[]>([]);
let bankBookFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
defineProps<{
bankBook: BankBook;
index: number;
}>();
watch(
() => optionStore.globalOption,
() => {
bankBookFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption.bankBook),
bankBookOptions,
'label',
);
},
);
</script>
<template>
<fieldset class="rounded" style="border: 1px solid var(--gray-4)">
<legend>องทางท {{ index + 1 }}</legend>
<div class="border-5 full-width row" style="gap: var(--size-4)">
<div class="column q-pa-sm" style="width: fit-content">
<img
:src="bankBook.bankUrl"
class="rounded"
style="
border: 1px solid var(--gray-3);
object-fit: scale-down;
width: 1in;
height: 1in;
"
/>
</div>
<div class="column col" style="gap: var(--size-3)">
<div class="row" style="justify-content: space-between">
<div class="text-with-label">
<div>เลขบญชธนาคาร</div>
<div class="row items-start">
<img
width="25px"
height="25px"
class="q-mr-xs"
:src="`/img/bank/${bankBook.bankName}.png`"
/>
{{ bankBook.accountNumber }}
</div>
</div>
<div class="text-with-label">
<div>เลขบญชธนาคาร</div>
<div>{{ bankBook.accountNumber }}</div>
</div>
<div class="text-with-label">
<div>อบญช</div>
<div>{{ bankBook.accountName }}</div>
</div>
</div>
<div class="row">
<div class="text-with-label">
<div>สาขา</div>
<div>{{ bankBook.bankBranch }}</div>
</div>
</div>
</div>
</div>
</fieldset>
</template>
<style scoped>
legend {
background-color: white;
padding: 5px 10px;
}
.bordered-2 {
border: 2px solid var(--border-color);
}
.border-5 {
border-radius: 5px;
}
.text-with-label {
display: flex;
flex-direction: column;
flex: 1;
& > :first-child {
color: var(--gray-6);
}
}
</style>

View file

@ -0,0 +1,654 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia';
import { onMounted, nextTick, ref, watch } from 'vue';
import { precisionRound } from 'src/utils/arithmetic';
import ThaiBahtText from 'thai-baht-text';
// NOTE: Import stores
import { formatNumberDecimal } from 'stores/utils';
import { useConfigStore } from 'stores/config';
import useBranchStore from 'stores/branch';
import { baseUrl } from 'stores/utils';
import useCustomerStore from 'stores/customer';
import { DebitNotePayload, useDebitNote } from 'src/stores/debit-note';
// NOTE Import Types
import { CustomerBranch } from 'stores/customer/types';
import { BankBook, Branch } from 'stores/branch/types';
import {
CustomerBranchRelation,
Details,
ProductServiceList,
} from 'src/stores/quotations/types';
// NOTE: Import Components
import ViewPDF from './ViewPdf.vue';
import ViewHeader from './ViewHeader.vue';
import ViewFooter from './ViewFooter.vue';
import BankComponents from './BankComponents.vue';
import PrintButton from 'src/components/button/PrintButton.vue';
import { convertTemplate } from 'src/utils/string-template';
const configStore = useConfigStore();
const branchStore = useBranchStore();
const customerStore = useCustomerStore();
const debitNoteStore = useDebitNote();
const { data: config } = storeToRefs(configStore);
type Product = {
id: string;
code: string;
detail: string;
amount: number;
priceUnit: number;
discount: number;
vat: number;
value: number;
};
type SummaryPrice = {
totalPrice: number;
totalDiscount: number;
vat: number;
vatExcluded: number;
finalPrice: number;
};
const customer = ref<CustomerBranch>();
const branch = ref<Branch>();
const productList = ref<Product[]>([]);
const bankList = ref<BankBook[]>([]);
const elements = ref<HTMLElement[]>([]);
const chunks = ref<Product[][]>([[]]);
const attachmentList = ref<
{
url: string;
isImage?: boolean;
isPDF?: boolean;
}[]
>([]);
const data = ref<
DebitNotePayload & {
customerBranch: CustomerBranchRelation;
customerBranchId: string;
registeredBranchId: string;
}
>();
const productServiceList = ref<ProductServiceList[]>([]);
const summaryPrice = ref<SummaryPrice>({
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
});
async function getAttachment(quotationId: string) {
const attachment = await debitNoteStore.listAttachment({
parentId: quotationId,
});
if (attachment) {
attachmentList.value = await Promise.all(
attachment.map(async (v) => {
const url = await debitNoteStore.getAttachment({
parentId: quotationId,
name: v,
});
const ft = v.substring(v.lastIndexOf('.') + 1);
return {
url,
isImage: ['png', 'jpg', 'jpeg'].includes(ft),
isPDF: ft === 'pdf',
};
}),
);
}
}
async function assignData() {
for (let i = 0; i < productList.value.length; i++) {
let el = elements.value.at(-1);
if (!el) return;
if (getHeight(el) < 500) {
chunks.value.at(-1)?.push(productList.value[i]);
} else {
chunks.value.push([]);
i--;
}
await nextTick();
}
}
function getHeight(el: HTMLElement) {
const shadow = document.createElement('div');
shadow.style.opacity = '0';
shadow.style.position = 'absolute';
shadow.style.top = '-999999px';
shadow.style.left = '-999999px';
shadow.style.pointerEvents = 'none';
document.body.appendChild(shadow);
shadow.appendChild(el.cloneNode(true));
const height = shadow.offsetHeight;
document.body.removeChild(shadow);
return height;
}
const details = ref<Details>();
enum View {
DebitNote,
Invoice,
Payment,
Receipt,
}
const view = ref<View>(View.DebitNote);
onMounted(async () => {
let str =
localStorage.getItem('debit-note-preview') ||
sessionStorage.getItem('debit-note-preview');
if (!str) return;
const obj: DebitNotePayload = JSON.parse(str);
if (obj) sessionStorage.setItem('debit-note-preview', JSON.stringify(obj));
delete localStorage['debit-note-preview'];
const parsed = JSON.parse(sessionStorage.getItem('debit-note-preview') || '');
data.value = 'data' in parsed ? parsed.data : undefined;
if (data.value) {
if (data.value.id) {
await getAttachment(data.value.id);
}
const resCustomerBranch = await customerStore.getBranchById(
data.value.customerBranchId,
);
if (resCustomerBranch) {
customer.value = resCustomerBranch;
}
details.value = {
code: parsed.meta.source.code,
createdAt: parsed.meta.source.createdAt,
createdBy: `${parsed.meta.createdBy} ${!parsed.meta.source.createdBy ? '' : parsed.meta.source.createdBy.telephoneNo}`,
payCondition: parsed.meta.source.payCondition,
contactName: parsed.meta.source.contactName,
contactTel: parsed.meta.source.contactTel,
workName: parsed.meta.source.workName,
dueDate: parsed.meta.source.dueDate,
worker: parsed.meta.selectedWorker,
};
const resBranch = await branchStore.fetchById(
data.value?.registeredBranchId,
);
if (resBranch) {
branch.value = resBranch;
bankList.value = resBranch.bank.map((v) => ({
...v,
bankUrl: `${baseUrl}/branch/${resBranch.id}/bank-qr/${v.id}?ts=${Date.now()}`,
}));
}
productServiceList.value = parsed.meta.productServicelist;
productList.value =
productServiceList.value?.map((v) => ({
id: v.product.id,
code: v.product.code,
detail: v.product.name,
amount: v.amount || 0,
priceUnit: v.pricePerUnit || 0,
discount: v.discount || 0,
vat: v.vat || 0,
value: precisionRound(
(v.pricePerUnit || 0) * v.amount -
(v.discount || 0) +
(v.product.calcVat
? ((v.pricePerUnit || 0) * v.amount - (v.discount || 0)) *
(config.value?.vat || 0.07)
: 0),
),
})) || [];
}
summaryPrice.value = (productServiceList.value || []).reduce(
(a, c) => {
const price = precisionRound((c.pricePerUnit || 0) * c.amount);
const vat = precisionRound(
((c.pricePerUnit || 0) * c.amount - (c.discount || 0)) *
(config.value?.vat || 0.07),
);
a.totalPrice = precisionRound(a.totalPrice + price);
a.totalDiscount = precisionRound(a.totalDiscount + Number(c.discount));
a.vat = c.product.calcVat ? precisionRound(a.vat + vat) : a.vat;
a.vatExcluded = c.product.calcVat
? a.vatExcluded
: precisionRound(a.vat + vat);
a.finalPrice = precisionRound(
a.totalPrice -
a.totalDiscount +
a.vat -
Number(data.value?.discount || 0),
);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
assignData();
});
watch(elements, () => {});
function print() {
window.print();
}
</script>
<template>
<div class="toolbar">
<PrintButton solid @click="print" />
</div>
<div class="row justify-between container color-debit-note">
<section class="content" v-for="chunk in chunks">
<ViewHeader
v-if="!!branch && !!customer && !!details"
:branch="branch"
:customer="customer"
:details="details"
:view="view"
/>
<span
class="q-mb-sm q-mt-md"
style="
font-weight: 800;
font-size: 16px;
color: var(--main);
display: block;
border-bottom: 2px solid var(--main);
"
>
{{ $t('preview.productList') }}
</span>
<table ref="elements" class="q-mb-sm" cellpadding="0" style="width: 100%">
<tbody class="color-tr">
<tr>
<th>{{ $t('preview.rank') }}</th>
<th>{{ $t('preview.productCode') }}</th>
<th>{{ $t('general.detail') }}</th>
<th>{{ $t('general.amount') }}</th>
<th>{{ $t('preview.pricePerUnit') }}</th>
<th>{{ $t('preview.discount') }}</th>
<th>{{ $t('preview.vat') }}</th>
<th>{{ $t('preview.value') }}</th>
</tr>
<tr v-for="(v, i) in chunk">
<td class="text-center">{{ i + 1 }}</td>
<td>{{ v.code }}</td>
<td>{{ v.detail }}</td>
<td style="text-align: right">{{ v.amount }}</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.priceUnit, 2) }}
</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.discount, 2) }}
</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.vat, 2) }}
</td>
<td style="text-align: right">
{{ formatNumberDecimal(v.value, 2) }}
</td>
</tr>
</tbody>
</table>
<table
style="width: 40%; margin-left: auto"
class="q-mb-md"
cellpadding="0"
>
<tbody class="color-tr">
<tr>
<td>{{ $t('general.total') }}</td>
<td class="text-right">
{{ formatNumberDecimal(summaryPrice.totalPrice, 2) }}
฿
</td>
</tr>
<tr>
<td>{{ $t('general.discount') }}</td>
<td class="text-right">
{{ formatNumberDecimal(summaryPrice.totalDiscount, 2) || 0 }} ฿
</td>
</tr>
<tr>
<td>{{ $t('general.totalAfterDiscount') }}</td>
<td class="text-right">
{{
formatNumberDecimal(
summaryPrice.totalPrice - summaryPrice.totalDiscount,
2,
)
}}
฿
</td>
</tr>
<tr>
<td>{{ $t('general.totalVatExcluded') }}</td>
<td class="text-right">
{{ formatNumberDecimal(summaryPrice.vatExcluded, 2) || 0 }}
฿
</td>
</tr>
<tr>
<td>{{ $t('general.vat', { msg: '7%' }) }}</td>
<td class="text-right">
{{ formatNumberDecimal(summaryPrice.vat, 2) }} ฿
</td>
</tr>
<tr>
<td>{{ $t('general.totalVatIncluded') }}</td>
<td class="text-right">
{{
formatNumberDecimal(
summaryPrice.totalPrice -
summaryPrice.totalDiscount +
summaryPrice.vat,
2,
)
}}
฿
</td>
</tr>
<tr>
<td>{{ $t('general.discountAfterVat') }}</td>
<td class="text-right">
{{ formatNumberDecimal(data?.discount || 0, 2) }}
฿
</td>
</tr>
</tbody>
</table>
<div class="row justify-between q-mb-md" style="width: 100%">
<div
class="column set-width bg-color full-height"
style="padding: 12px"
>
({{ ThaiBahtText(summaryPrice.finalPrice) }})
</div>
<div
class="row text-right border-5 items-center"
style="width: 40%; background: var(--main); padding: 8px"
>
<span style="color: white; font-weight: 600">ยอดรวมสทธ</span>
<span
class="border-5"
style="
width: 70%;
margin-left: auto;
background: white;
padding: 4px;
"
>
{{
formatNumberDecimal(Math.max(summaryPrice.finalPrice, 0), 2) || 0
}}
฿
</span>
</div>
</div>
</section>
<section class="content">
<ViewHeader
v-if="!!branch && !!customer && !!details"
:branch="branch"
:customer="customer"
:details="details"
:view="view"
/>
<span
class="q-mb-sm q-mt-md"
style="
font-weight: 800;
font-size: 16px;
color: var(--main);
display: block;
border-bottom: 2px solid var(--main);
"
>
{{ $t('general.remark') }}
</span>
<div
class="border-5 surface-0 detail-note q-mb-md"
style="width: 100%; padding: 8px 16px; white-space: pre-wrap"
>
<div
v-html="
convertTemplate(data?.remark || '', {
'quotation-payment': {
paymentType: data?.payCondition || 'Full',
amount: summaryPrice.finalPrice,
installments: data?.paySplit,
},
'quotation-labor': {
name:
details?.worker.map(
(v, i) =>
`${i + 1}. ` +
`${v.namePrefix}. ${v.firstNameEN} ${v.lastNameEN}`.toUpperCase(),
) || [],
},
}) || '-'
"
></div>
</div>
</section>
<section class="content">
<ViewHeader
v-if="!!branch && !!customer && !!details"
:branch="branch"
:customer="customer"
:details="details"
:view="view"
/>
<span
class="q-mb-sm"
style="
font-weight: 800;
font-size: 16px;
color: var(--main);
display: block;
border-bottom: 2px solid var(--main);
"
>
{{ $t('preview.paymentMethods') }}
</span>
<article style="height: 5.8in">
<BankComponents
v-for="(bank, index) in bankList"
:index="index"
:bank-book="bank"
:key="bank.id"
/>
</article>
<ViewFooter
:data="{
name: '',
company: branch?.name || '',
buyer: '',
buyDate: '',
approveDate: '',
approver: '',
}"
/>
</section>
<section
v-for="item in attachmentList.filter((v) => v.isImage)"
class="content"
>
<q-img :src="item.url" />
</section>
<ViewPDF
v-for="item in attachmentList.filter((v) => v.isPDF)"
:url="item.url"
/>
</div>
</template>
<style scoped>
.color-debit-note {
--main: var(--cyan-7);
--main-hsl: var(--cyan-7-hsl);
}
.toolbar {
width: 100%;
position: sticky;
top: 0;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
padding: 1rem;
background: white;
border-bottom: 1px solid var(--gray-3);
z-index: 99999;
}
table {
border-collapse: collapse;
}
th {
background: var(--main);
color: white;
padding: 4px;
}
td {
padding: 4px 8px;
}
.border-5 {
border-radius: 5px;
}
.set-width {
width: 50%;
}
.bg-color {
background-color: hsla(var(--main-hsl) / 0.1);
}
.color-tr > tr:nth-child(odd) {
background-color: hsla(var(--main-hsl) / 0.1);
}
.container {
padding: 1rem;
display: flex;
gap: 1rem;
margin-inline: auto;
background: var(--gray-3);
width: calc(8.3in + 1rem);
}
.container :deep(*) {
font-size: 95%;
}
.content {
width: 100%;
padding: 0.5in;
align-items: center;
background: white;
page-break-after: always;
break-after: page;
height: 11.7in;
max-height: 11.7in;
}
.position-bottom {
margin-top: auto;
}
.detail-note {
display: flex;
flex-direction: column;
gap: 8px;
& > * {
display: flex;
flex-direction: column;
}
}
hr {
border-style: solid;
border-color: var(--main);
}
@media print {
.toolbar {
display: none;
}
.container {
padding: 0;
gap: 0;
width: 100%;
background: white;
}
.content {
padding: 0;
height: unset;
}
}
</style>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
defineProps<{
data?: {
name: string;
buyer: string;
buyDate: string;
company: string;
approver: string;
approveDate: string;
};
}>();
</script>
<template>
<div class="footer-container">
<div class="footer-top">
<div>ในนาม {{ data?.name || '-' }}</div>
<div>ในนาม {{ data?.company || '-' }}</div>
</div>
<img src="/images/jws-stamp.png" alt="${0}" />
<div class="footer-bottom">
<section>
<div>
<span class="data-placeholder"></span>
<span>งซอสนค</span>
</div>
<div>
<span class="data-placeholder"></span>
<span>นท</span>
</div>
</section>
<section>
<div>
<span class="data-placeholder"></span>
<span>อน</span>
</div>
<div>
<span class="data-placeholder"></span>
<span>นท</span>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.footer-container {
display: flex;
position: relative;
justify-content: center;
height: 1.5in;
}
.footer-top {
position: absolute;
width: 100;
top: 0;
left: 0;
right: 0;
padding: 1rem;
display: flex;
justify-content: space-between;
& > * {
width: 38%;
}
}
.footer-bottom {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
& > * {
display: flex;
width: 38%;
justify-content: space-around;
& > * {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
}
}
.data-placeholder {
display: block;
min-width: 1.2in;
border-bottom: 1px dotted black;
margin-bottom: 2mm;
}
</style>

View file

@ -0,0 +1,192 @@
<script lang="ts" setup>
import { dateFormat } from 'src/utils/datetime';
// NOTE: Import stores
import { formatAddress } from 'src/utils/address';
// NOTE Import Types
import { Branch } from 'src/stores/branch/types';
import { CustomerBranchRelation, Details } from 'src/stores/quotations/types';
// NOTE: Import Components
enum View {
DebitNote,
Invoice,
Payment,
Receipt,
}
defineProps<{
branch: Branch;
customer: CustomerBranchRelation;
details: Details;
view: View;
}>();
function titleMode(mode: View): string {
if (mode === View.DebitNote) {
return 'preview.title.debitNote';
}
if (mode === View.Invoice) {
return 'preview.title.invoice';
}
if (mode === View.Payment) {
return 'preview.title.payment';
}
if (mode === View.Receipt) {
return 'preview.title.receipt';
}
return '';
}
</script>
<template>
<div class="row items-center q-mb-lg">
<div class="column" style="width: 50%">
<img src="/logo.png" width="192px" style="object-fit: scale-down" />
</div>
<div
class="column"
style="text-align: center; width: 50%; font-weight: 800; font-size: 24px"
>
{{ $t(titleMode(view)) }}
</div>
</div>
<article class="detail-card">
<section class="detail-customer-info">
<article>
<b>
{{ !!branch.virtual ? '' : $t('general.company') }} {{ branch.name }}
</b>
<span v-if="branch.province && branch.district && branch.subDistrict">
{{
formatAddress({
address: branch.address,
addressEN: branch.addressEN,
moo: branch.moo,
mooEN: branch.mooEN,
soi: branch.soi,
soiEN: branch.soiEN,
street: branch.street,
streetEN: branch.streetEN,
province: branch.province,
district: branch.district,
subDistrict: branch.subDistrict,
})
}}
</span>
<span>เลขประจำตวผเสยภาษ {{ branch.taxNo }}</span>
<span>เบอรโทร {{ branch.telephoneNo }}</span>
<span>{{ branch.webUrl }}</span>
</article>
<article>
<b>กค</b>
<span>
{{
formatAddress({
address: customer.address,
addressEN: customer.addressEN,
moo: customer.moo,
mooEN: customer.mooEN,
soi: customer.soi,
soiEN: customer.soiEN,
street: customer.street,
streetEN: customer.streetEN,
province: customer.province,
district: customer.district,
subDistrict: customer.subDistrict,
})
}}
</span>
<span>เลขประจำตวผเสยภาษ {{ customer.citizenId }}</span>
<span>เบอรโทร {{ customer.telephoneNo }}</span>
</article>
</section>
<section class="detail-quotation-info">
<div>
<div>{{ $t('general.itemNo', { msg: `${$t(titleMode(view))}` }) }}</div>
<div>{{ details.code }}</div>
</div>
<div>
<div>{{ $t('preview.dateAt', { msg: `${$t(titleMode(view))}` }) }}</div>
<div>{{ dateFormat(details.createdAt, true, false, true) }}</div>
</div>
<div>
<div>{{ $t('preview.seller') }}</div>
<div>{{ details.createdBy }}</div>
</div>
<div>
<div>{{ $t('quotation.paymentCondition') }}</div>
<div>
{{
{
Full: $t('quotation.type.fullAmountCash'),
Split: $t('quotation.type.installmentsCash'),
BillFull: $t('quotation.type.fullAmountBill'),
BillSplit: $t('quotation.type.installmentsBill'),
}[details.payCondition]
}}
</div>
</div>
<div>
<div>{{ $t('quotation.workName') }}</div>
<div>{{ details.workName }}</div>
</div>
<div>
<div>{{ $t('quotation.contactName') }}</div>
<div>{{ details.contactName }}</div>
</div>
<div>
<div>{{ $t('preview.dueDate') }}</div>
<div>{{ dateFormat(details.dueDate, true, false, true) }}</div>
</div>
</section>
</article>
</template>
<style scoped>
.detail-card {
display: flex;
gap: 16px;
& > * {
flex-grow: 1;
}
& > :first-child {
max-width: 57.5%;
}
}
.detail-customer-info {
display: flex;
flex-direction: column;
gap: 16px;
& > * {
display: flex;
flex-direction: column;
& > :first-child {
color: var(--main);
}
}
}
.detail-quotation-info {
& > * {
display: flex;
& > :first-child {
color: var(--main);
}
& > * {
width: 50%;
}
}
}
</style>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { VuePDF, usePDF } from '@tato30/vue-pdf';
const props = defineProps<{
url: string;
}>();
const { pdf, pages } = usePDF(props.url);
</script>
<template>
<section v-for="page in pages" class="content">
<VuePDF style="width: 100%" :pdf="pdf" :page="page" :scale="1.5" />
</section>
</template>
<style scoped>
.content :deep(canvas) {
width: 100% !important;
height: auto !important;
}
@media print {
.content :deep(canvas) {
scale: 1.1;
}
}
</style>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import SelectInput from 'src/components/shared/SelectInput.vue';
defineProps<{
readonly?: boolean;
}>();
const reason = defineModel<string>('reason');
const detail = defineModel<string>('detail');
</script>
<template>
<q-expansion-item
dense
:default-opened="true"
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
>
<template #header>
<span>
{{ $t('debitNote.label.debitNoteInformation') }}
</span>
</template>
<main class="q-px-md q-py-sm surface-1 row q-col-gutter-sm">
<SelectInput
:readonly
for="select-debit-note-specify-reason"
:label="$t('debitNote.label.specifyReasonForDebit')"
class="col-md col-12"
v-model="reason"
:option="[
{ label: $t('debitNote.label.reasonReturn'), value: 'Return' },
{ label: $t('debitNote.label.reasonCanceled'), value: 'Canceled' },
]"
></SelectInput>
<q-input
:readonly
for="input-debit-note-additional-detail"
:label="$t('debitNote.label.additionalDetail')"
outlined
dense
class="col"
v-model="detail"
></q-input>
</main>
</q-expansion-item>
</template>
<style scoped></style>

View file

@ -0,0 +1,120 @@
<script lang="ts" setup>
import SelectBranch from 'src/components/shared/select/SelectBranch.vue';
import SelectCustomer from 'src/components/shared/select/SelectCustomer.vue';
import DatePicker from 'src/components/shared/DatePicker.vue';
import DataDisplay from 'src/components/08_request-list/DataDisplay.vue';
defineProps<{
readonly?: boolean;
}>();
defineEmits<{
(e: 'gotoQuotation'): void;
}>();
const registeredBranchId = defineModel<string>('registeredBranchId');
const customerId = defineModel<string>('customerId');
const issueDate = defineModel<string>('issueDate');
const dueDate = defineModel<string | Date>('dueDate');
const quotationCode = defineModel<string>('quotationCode');
const quotationWorkName = defineModel<string>('quotationWorkName');
const quotationContactName = defineModel<string>('quotationContactName');
const quotationContactTel = defineModel<string>('quotationContactTel');
const quotationCreatedBy = defineModel<string>('quotationCreatedBy');
</script>
<template>
<q-expansion-item
default-opened
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
>
<template #header>
<span>
{{ $t('general.information', { msg: $t('general.document') }) }}
</span>
</template>
<main class="q-px-md q-py-sm surface-1 row q-col-gutter-sm">
<SelectBranch
readonly
class="col-md-4 col-12"
:label="`${$t('creditNote.label.quotationRegisteredBranch')}`"
v-model:value="registeredBranchId"
/>
<SelectCustomer
readonly
simple
class="col-md-4 col-12"
:label="`${$t('creditNote.label.customer')}`"
v-model:value="customerId"
/>
<DatePicker
:label="$t('general.createdAt')"
class="col-md-2 col-6"
:model-value="issueDate || new Date(Date.now())"
:readonly
:disabled="!readonly"
/>
<DatePicker
:label="$t('general.createdAt')"
class="col-md-2 col-6"
:model-value="dueDate || new Date(Date.now())"
@update:model-value="
(v) => {
if (typeof v === 'string') dueDate = v;
}
"
:readonly
/>
<DataDisplay
clickable
class="col-md col-6"
style="padding-inline: 20px"
:label="$t('creditNote.label.quotationCode')"
:value="quotationCode"
@label-click="$emit('gotoQuotation')"
/>
<q-input
readonly
:label="$t('creditNote.label.quotationWorkName')"
outlined
dense
class="col-md col-6"
v-model="quotationWorkName"
/>
<q-input
readonly
:label="$t('quotation.contactName')"
outlined
dense
class="col-md col-6"
v-model="quotationContactName"
/>
<q-input
readonly
:label="$t('general.telephone')"
outlined
dense
class="col-md col-6"
v-model="quotationContactTel"
/>
<q-input
:readonly
:label="$t('creditNote.label.quotationCreatedBy')"
outlined
dense
class="col-md col-6"
:disable="!readonly"
:model-value="quotationCreatedBy || '-'"
/>
</main>
</q-expansion-item>
</template>
<style scoped></style>

View file

@ -0,0 +1,116 @@
<script lang="ts" setup>
import useOptionStore from 'src/stores/options';
import { watch } from 'vue';
import QuotationFormInfo from 'src/pages/05_quotation/QuotationFormInfo.vue';
import { PayCondition } from 'src/stores/quotations';
const props = withDefaults(
defineProps<{
readonly?: boolean;
installmentNo?: number[];
installmentAmount?: number;
}>(),
{},
);
const payBillDate = defineModel<Date | null | undefined>('payBillDate', {
required: false,
});
const payType = defineModel<PayCondition>('payType', { required: true });
const paySplitCount = defineModel<number | null>('paySplitCount', {
default: 1,
});
const paySplit = defineModel<{ no: number; name?: string; amount: number }[]>(
'paySplit',
{ required: true },
);
const summaryPrice = defineModel<{
totalPrice: number;
totalDiscount: number;
vat: number;
vatExcluded: number;
finalPrice: number;
}>('summaryPrice', {
required: true,
default: {
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
});
const optionStore = useOptionStore();
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
>
<template #header>
<span>
{{ $t('general.payment') }}
</span>
</template>
<main class="surface-1 q-pa-md full-width">
<QuotationFormInfo
v-bind="{ ...$props }"
debit-note
v-model:pay-type="payType"
v-model:pay-split-count="paySplitCount"
v-model:pay-split="paySplit"
v-model:pay-bill-date="payBillDate"
v-model:summary-price="summaryPrice"
/>
</main>
</q-expansion-item>
</template>
<style scoped>
.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);
}
:deep(.price-tag .q-field__control) {
display: flex;
align-items: center;
width: 90px;
height: 25px;
}
.credit-note-color {
--_color: var(--indigo-10-hsl);
}
.bg-color {
color: white;
background: hsla(var(--_color));
}
.dark .bg-color {
--_color: var(--orange-6-hsl);
}
.bg-color-light {
background: hsla(var(--_color) / 0.1);
}
.dark .bg-color-light {
--_color: var(--orange-6-hsl / 0.2);
}
.price-container > * {
padding: var(--size-1);
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts" setup>
import ProductItem from 'src/components/05_quotation/ProductItem.vue';
import { AddButton } from 'components/button';
import {
ProductRelation,
ProductServiceList,
QuotationPayload,
} from 'src/stores/quotations';
defineProps<{
readonly?: boolean;
agentPrice: boolean;
installmentInput?: boolean;
maxInstallment?: number | null;
creditNote?: boolean;
employeeRows: {
foreignRefNo: string;
employeeName: string;
birthDate: string;
gender: string;
age: string;
nationality: string;
documentExpireDate: string;
imgUrl: string;
status: string;
}[];
}>();
const rows = defineModel<
Required<QuotationPayload['productServiceList'][number]>[]
>('rows', { required: true });
defineEmits<{
(e: 'addProduct'): void;
(e: 'viewFile', data: ProductRelation): void;
(e: 'delete', index: number): void;
(
e: 'updateTable',
v: QuotationPayload['productServiceList'][number],
opt?: {
newInstallmentNo: number;
},
): void;
(e: 'updateRows', v: Required<ProductServiceList>[]): void;
}>();
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
default-opened
>
<template #header>
<span
class="row items-center justify-between full-width"
style="min-height: 31.01px"
>
{{ $t('general.information', { msg: $t('taskOrder.productList') }) }}
<AddButton
icon-only
@click.stop="$emit('addProduct')"
v-if="!readonly"
/>
</span>
</template>
<main class="q-px-md q-py-sm surface-1">
<ProductItem
:installment-input
:max-installment
:readonly
:agent-price
:employee-rows
:rows="rows"
@delete="(v) => $emit('delete', v)"
@update:rows="(v) => $emit('updateRows', v)"
@update-table="(data, opt) => $emit('updateTable', data, opt)"
@view-file="(v) => $emit('viewFile', v)"
/>
</main>
</q-expansion-item>
</template>
<style scoped>
.product-status {
padding-left: 8px;
border-radius: 20px;
color: hsl(var(--_color));
background: hsla(var(--_color) / 0.15);
&.warning {
--_color: var(--warning-bg);
}
&.positive {
--_color: var(--positive-bg);
}
&.negative {
--_color: var(--negative-bg);
}
}
</style>

View file

@ -0,0 +1,67 @@
<script lang="ts" setup>
defineProps<{
readonly?: boolean;
}>();
const remark = defineModel<string>('remark', { default: '' });
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
>
<template #header>
<span>
{{ $t('general.remark') }}
</span>
</template>
<main class="surface-1 q-pa-md full-width">
<q-editor
dense
:readonly="readonly"
:model-value="remark"
min-height="5rem"
class="full-width"
toolbar-bg="input-border"
style="cursor: auto; color: var(--foreground)"
:content-class="readonly ? 'q-mt-sm' : 'bordered q-mt-sm rounded'"
:flat="!readonly"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
:toolbar="[['left', 'center', 'justify'], ['clip']]"
: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) => {
remark = v;
}
"
/>
</main>
</q-expansion-item>
</template>
<style scoped>
: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;
}
</style>

View file

@ -0,0 +1,77 @@
<script setup lang="ts">
import { AddButton } from 'components/button';
import WorkerItem from 'src/components/05_quotation/WorkerItem.vue';
defineProps<{
readonly?: boolean;
hideBtnAddWorker?: boolean;
employeeAmount?: number;
rowWorker: {
foreignRefNo: string;
employeeName: string;
birthDate: string;
gender: string;
age: string;
nationality: string;
documentExpireDate: string;
imgUrl?: string;
status: string;
}[];
}>();
defineEmits<{
(e: 'addWorker'): void;
(e: 'edit'): void;
(e: 'update:employeeAmount', number: number): void;
(e: 'delete', index: number): void;
}>();
const toggleWorker = defineModel<boolean>('toggleWorker');
</script>
<template>
<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="q-mr-md" style="font-size: 18px">
{{ $t('quotation.employeeList') }}
</span>
<template v-if="!readonly">
<ToggleButton class="q-mr-sm" v-model="toggleWorker" />
{{ toggleWorker ? $t('general.specify') : $t('general.noSpecify') }}
</template>
</div>
<nav class="q-ml-auto">
<AddButton
v-if="!hideBtnAddWorker"
icon-only
@click.stop="$emit('addWorker')"
/>
</nav>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<WorkerItem
@update:employee-amount="(v) => $emit('update:employeeAmount', v)"
:employee-amount
:readonly="readonly"
:rows="rowWorker"
@delete="(i) => $emit('delete', i)"
/>
</div>
</q-expansion-item>
</template>
<style scoped></style>

View file

@ -0,0 +1,64 @@
import { dialog } from 'stores/utils';
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { DebitNotePayload } from 'src/stores/debit-note';
import { PayCondition } from 'src/stores/quotations';
// NOTE: Import types
// NOTE: Import stores
const DEFAULT_DATA: DebitNotePayload = {
productServiceList: [],
debitNoteQuotationId: '',
worker: [],
payBillDate: new Date(),
paySplit: [],
paySplitCount: 0,
payCondition: PayCondition.Full,
dueDate: new Date(),
discount: 0,
status: 'CREATED',
remark: '#[quotation-labor]<br/><br/>#[quotation-payment]',
quotationId: '',
agentPrice: false,
};
export const useDebitNoteForm = defineStore('form-debit-note', () => {
const { t } = useI18n();
let resetFormData = structuredClone(DEFAULT_DATA);
const currentFormData = ref<DebitNotePayload>(structuredClone(resetFormData));
const currentFormState = ref<{
mode: null | 'info' | 'create' | 'edit';
}>({
mode: null,
});
function isFormDataDifferent() {
const { ...resetData } = resetFormData;
const { ...currData } = currentFormData.value;
return JSON.stringify(resetData) !== JSON.stringify(currData);
}
function resetForm(clean = false) {
if (clean) {
currentFormData.value = structuredClone(DEFAULT_DATA);
resetFormData = structuredClone(DEFAULT_DATA);
return;
}
currentFormData.value = structuredClone(resetFormData);
currentFormState.value.mode = 'info';
}
return {
isFormDataDifferent,
resetForm,
};
});

Some files were not shown because too many files have changed in this diff Show more