Merge branch 'develop'

This commit is contained in:
Methapon2001 2025-01-17 19:28:38 +07:00
commit 5d672ccda6
135 changed files with 4505 additions and 290 deletions

View file

@ -40,6 +40,7 @@
"vue-router": "^4.4.3" "vue-router": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^9.3.0",
"@iconify/vue": "^4.1.2", "@iconify/vue": "^4.1.2",
"@intlify/unplugin-vue-i18n": "^4.0.0", "@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.46.1", "@playwright/test": "^1.46.1",
@ -50,6 +51,7 @@
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dotenv": "^16.4.7",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.27.0", "eslint-plugin-vue": "^9.27.0",

View file

@ -1,3 +1,4 @@
import 'dotenv/config';
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
/** /**
@ -24,7 +25,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000', baseURL: 'http://192.168.1.62:20101',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',

22
pnpm-lock.yaml generated
View file

@ -78,6 +78,9 @@ importers:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3(vue@3.4.38(typescript@5.5.4)) version: 4.4.3(vue@3.4.38(typescript@5.5.4))
devDependencies: devDependencies:
'@faker-js/faker':
specifier: ^9.3.0
version: 9.3.0
'@iconify/vue': '@iconify/vue':
specifier: ^4.1.2 specifier: ^4.1.2
version: 4.1.2(vue@3.4.38(typescript@5.5.4)) version: 4.1.2(vue@3.4.38(typescript@5.5.4))
@ -108,6 +111,9 @@ importers:
autoprefixer: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.4.20(postcss@8.4.41) version: 10.4.20(postcss@8.4.41)
dotenv:
specifier: ^16.4.7
version: 16.4.7
eslint: eslint:
specifier: ^8.57.0 specifier: ^8.57.0
version: 8.57.0 version: 8.57.0
@ -455,6 +461,10 @@ packages:
resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@faker-js/faker@9.3.0':
resolution: {integrity: sha512-r0tJ3ZOkMd9xsu3VRfqlFR6cz0V/jFYRswAIpC+m/DIfAUXq7g8N7wTAlhSANySXYGKzGryfDXwtwsY8TxEIDw==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -1587,8 +1597,8 @@ packages:
resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==} resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==}
engines: {node: '>=12'} engines: {node: '>=12'}
dotenv@16.4.5: dotenv@16.4.7:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
duplexify@3.7.1: duplexify@3.7.1:
@ -3902,6 +3912,8 @@ snapshots:
'@eslint/js@8.57.0': {} '@eslint/js@8.57.0': {}
'@faker-js/faker@9.3.0': {}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@ -4058,7 +4070,7 @@ snapshots:
compression: 1.7.4 compression: 1.7.4
cross-spawn: 7.0.3 cross-spawn: 7.0.3
dot-prop: 9.0.0 dot-prop: 9.0.0
dotenv: 16.4.5 dotenv: 16.4.7
dotenv-expand: 11.0.6 dotenv-expand: 11.0.6
elementtree: 0.1.7 elementtree: 0.1.7
esbuild: 0.23.1 esbuild: 0.23.1
@ -5270,9 +5282,9 @@ snapshots:
dotenv-expand@11.0.6: dotenv-expand@11.0.6:
dependencies: dependencies:
dotenv: 16.4.5 dotenv: 16.4.7
dotenv@16.4.5: {} dotenv@16.4.7: {}
duplexify@3.7.1: duplexify@3.7.1:
dependencies: dependencies:

View file

@ -57,17 +57,13 @@ const currentBtnOpen = ref<{ title: string; opened: boolean[] }[]>([
]); ]);
function calcPrice(c: (typeof rows.value)[number]) { function calcPrice(c: (typeof rows.value)[number]) {
return precisionRound( const price = c.pricePerUnit * c.amount;
c.pricePerUnit * c.amount - const vat = c.product.calcVat
c.discount + ? (c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) *
precisionRound( (config.value?.vat || 0.07) *
c.product.calcVat (!c.discount ? c.amount : 1)
? (c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) * : 0;
(config.value?.vat || 0.07) return precisionRound(price + vat);
: 0,
) *
(!c.discount ? c.amount : 1),
);
} }
const discount4Show = ref<string[]>([]); const discount4Show = ref<string[]>([]);

View file

@ -22,7 +22,9 @@ defineProps<{
badgeColor?: string; badgeColor?: string;
hideKebabView?: boolean; hideKebabView?: boolean;
hideKebabEdit?: boolean; hideKebabEdit?: boolean;
hideKebabDelete?: boolean;
hideAction?: boolean; hideAction?: boolean;
useCancel?: boolean;
customData?: { customData?: {
label: string; label: string;
@ -39,6 +41,7 @@ defineEmits<{
(e: 'delete'): void; (e: 'delete'): void;
(e: 'example'): void; (e: 'example'): void;
(e: 'preview'): void; (e: 'preview'): void;
(e: 'cancel'): void;
}>(); }>();
const rand = Math.random(); const rand = Math.random();
@ -93,7 +96,8 @@ const rand = Math.random();
:idName="code" :idName="code"
status="ACTIVE" status="ACTIVE"
hide-toggle hide-toggle
hide-delete :use-cancel
:hide-delete="hideKebabDelete"
:hide-view="hideKebabView" :hide-view="hideKebabView"
:hide-edit="hideKebabEdit" :hide-edit="hideKebabEdit"
@view="$emit('view')" @view="$emit('view')"
@ -101,6 +105,7 @@ const rand = Math.random();
@link="$emit('link')" @link="$emit('link')"
@upload="$emit('upload')" @upload="$emit('upload')"
@delete="$emit('delete')" @delete="$emit('delete')"
@cancel="$emit('cancel')"
/> />
</nav> </nav>
</header> </header>

View file

@ -0,0 +1,39 @@
<script lang="ts" setup>
import SelectQuotation from '../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') })"
/>
</section>
</div>
</template>
<style scoped></style>

View file

@ -6,29 +6,57 @@ import DialogHeader from './DialogHeader.vue';
import MainButton from '../button/MainButton.vue'; import MainButton from '../button/MainButton.vue';
import NoData from '../NoData.vue'; import NoData from '../NoData.vue';
defineProps<{ const props = defineProps<{
title: string; title?: string;
url?: string; url?: string;
hideTab?: boolean;
download?: boolean;
transformUrl?: (url: string) => string | Promise<string>;
}>(); }>();
const open = defineModel<boolean>({ default: false }); const open = defineModel<boolean>({ default: false });
const state = reactive({ const state = reactive({
imageZoom: 100, imageZoom: 100,
transformedUrl: props.url,
}); });
function openDialog() { async function openDialog() {
state.imageZoom = 100; state.imageZoom = 100;
if (props.url && props.transformUrl) {
state.transformedUrl = await props.transformUrl(props.url);
} else {
state.transformedUrl = props.url;
}
}
async function downloadImage(url: string | null) {
if (!url) return;
const res = await fetch(url);
const blob = await res.blob();
let extension = '';
if (blob.type === 'image/jpeg') extension = '.jpg';
else if (blob.type === 'image/png') extension = '.png';
else return;
let a = document.createElement('a');
a.download = `download${extension}`;
a.href = window.URL.createObjectURL(blob);
a.click();
a.remove();
} }
</script> </script>
<template> <template>
<DialogFormContainer v-model="open" v-on:open="openDialog"> <DialogFormContainer v-model="open" v-on:open="openDialog">
<template #header> <template #header>
<DialogHeader :title="title" /> <DialogHeader :title="title || ''" />
</template> </template>
<main class="column full-height"> <main class="column full-height">
<section <section
v-if="!hideTab"
style="background: var(--gray-3)" style="background: var(--gray-3)"
class="q-py-sm row justify-center" class="q-py-sm row justify-center"
> >
@ -74,18 +102,32 @@ function openDialog() {
</section> </section>
<div <div
:class="{ 'cursor-pointer': state.imageZoom > 100 }"
class="full-height full-width flex justify-center items-center col scroll q-pa-md" class="full-height full-width flex justify-center items-center col scroll q-pa-md"
:class="{ 'cursor-pointer': state.imageZoom > 100 }"
v-dragscroll v-dragscroll
> >
<q-img <q-img
v-if="url" v-if="state.transformedUrl"
class="full-height" class="full-height"
:src="url" :src="state.transformedUrl"
fit="contain" fit="contain"
:style="{ transform: `scale(${state.imageZoom / 100})` }" :style="{ transform: `scale(${state.imageZoom / 100})` }"
style="transform-origin: 0 0" style="transform-origin: 0 0"
/> >
<div v-if="download" style="border-radius: 50%" class="no-padding">
<q-btn
v-if="state.transformedUrl"
class="upload-image-btn"
icon="mdi-download-outline"
id="btn-download-img"
size="md"
unelevated
round
@click="downloadImage(state.transformedUrl)"
style="color: hsla(var(--stone-0-hsl) / 0.7)"
/>
</div>
</q-img>
<NoData v-else /> <NoData v-else />
</div> </div>
</main> </main>

View file

@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import useOptionStore from 'src/stores/options';
import { Quotation, QuotationFull } from 'src/stores/quotations/types';
import { useQuotationStore as useStore } from 'src/stores/quotations';
type SelectOption = Quotation | QuotationFull;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { getQuotationList: getList, getQuotation: getById } = useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
codeOnly?: boolean;
selectFirstValue?: boolean;
branchVirtual?: boolean;
checkRole?: string[];
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
pageSize: 30,
hasCancel: true,
includeRegisteredBranch: true,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
function getCustomerName(
record: Quotation,
opts?: {
locale?: string;
noCode?: boolean;
},
) {
const customer = record.customerBranch;
return (
{
['CORP']: {
['eng']: customer.registerNameEN,
['tha']: customer.registerName,
}[opts?.locale || 'eng'],
['PERS']:
{
['eng']: `${useOptionStore().mapOption(customer?.namePrefix || '')} ${customer?.firstNameEN} ${customer?.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(customer?.namePrefix || '')} ${customer?.firstName} ${customer?.lastName}`,
}[opts?.locale || 'eng'] || '-',
}[customer.customer.customerType] +
(opts?.noCode ? '' : ' ' + `(${customer.code})`)
);
}
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
option-value="id"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="selectOptions"
:hide-selected="false"
:fill-input="false"
:rules="[
(v: string) => !props.required || !!v || $t('form.error.required'),
]"
@filter="filter"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
<template #option="{ scope }">
<q-item
v-if="scope.opt"
v-bind="scope.itemProps"
class="row items-start col-12 no-padding"
>
<div class="q-mx-sm q-my-xs">
<q-icon name="mdi-file" style="color: var(--brand-1)" />
</div>
<div class="q-pt-xs">
<span class="row">
<span style="font-weight: 600">
{{ $t('productService.service.work') }}:
</span>
&nbsp;
{{ scope.opt.workName }}
({{ scope.opt.code }})
<q-badge
dense
class="surface-3 q-ml-sm"
rounded
style="color: var(--foreground)"
>
{{ scope.opt._count.canceledWork || 0 }}
</q-badge>
</span>
<div class="text-caption app-text-muted-2 q-mb-xs">
<span class="col column">
<!-- TODO: register branch id -->
{{ $t(`creditNote.label.servicePoint`) }}
{{
$i18n.locale === 'eng'
? scope.opt.registeredBranch.nameEN
: scope.opt.registeredBranch.name
}}
({{ scope.opt.registeredBranch.code }})
</span>
<span class="col">
{{ $t('quotation.customer') }}
{{ getCustomerName(scope.opt, { locale: $i18n.locale }) }}
</span>
</div>
</div>
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #selected-item="{ scope }">
<div v-if="scope.opt" class="row items-center no-wrap">
<div class="q-mr-sm">
<span style="font-weight: 600">
{{ $t('productService.service.work') }}:
</span>
&nbsp;
{{ scope.opt.workName }}
({{ scope.opt.code }})
<q-badge
dense
class="surface-3 q-ml-xs"
rounded
style="color: var(--foreground)"
>
{{ scope.opt._count.canceledWork || 0 }}
</q-badge>
</div>
<div class="text-caption app-text-muted-2">
{{ $t(`creditNote.label.servicePoint`) }}
{{
$i18n.locale === 'eng'
? scope.opt.registeredBranch.nameEN
: scope.opt.registeredBranch.name
}}
({{ scope.opt.registeredBranch.code }}),
{{ $t('quotation.customer') }}
{{ getCustomerName(scope.opt, { locale: $i18n.locale }) }}
</div>
</div>
</template>
</SelectInput>
</template>

View file

@ -14,6 +14,7 @@ const props = withDefaults(
url?: string; url?: string;
progress?: number; progress?: number;
uploading?: { loaded: number; total: number }; uploading?: { loaded: number; total: number };
idle?: boolean;
clickable?: boolean; clickable?: boolean;
closeable?: boolean; closeable?: boolean;
@ -53,7 +54,7 @@ onMounted(() => {
@click="$emit('click')" @click="$emit('click')"
> >
<q-icon :name="icon" size="lg" :style="`color: ${color}`" /> <q-icon :name="icon" size="lg" :style="`color: ${color}`" />
<article class="col column q-pl-md"> <article class="col column q-pl-md ellipsis">
<span>{{ name }}</span> <span>{{ name }}</span>
<span class="text-caption app-text-muted-2"> <span class="text-caption app-text-muted-2">
{{ {{
@ -68,8 +69,13 @@ onMounted(() => {
color="primary" color="primary"
size="1.5em" size="1.5em"
/> />
<q-icon v-else name="mdi-check-circle" color="positive" size="1rem" /> <q-icon
{{ progress !== 1 ? `Uploading...` : 'Completed' }} v-if="progress === 1 && !idle"
name="mdi-check-circle"
color="positive"
size="1rem"
/>
{{ idle ? `Pending` : progress !== 1 ? `Uploading...` : 'Completed' }}
</span> </span>
</article> </article>
<q-btn <q-btn

View file

@ -33,6 +33,7 @@ const fileData = defineModel<
loaded: number; loaded: number;
total: number; total: number;
url?: string; url?: string;
placeholder?: boolean;
}[] }[]
>('fileData', { required: true }); >('fileData', { required: true });
@ -54,7 +55,8 @@ function pickFile() {
<template> <template>
<div :class="{ row: layout === 'column' }"> <div :class="{ row: layout === 'column' }">
<div <div
class="upload-section column rounded q-py-md items-center justify-center no-wrap surface-2 col" class="upload-section column rounded q-py-md items-center justify-center no-wrap surface-2"
:class="{ 'col-12': fileData.length === 0, col: fileData.length > 0 }"
> >
<q-img src="/images/upload.png" width="150px" /> <q-img src="/images/upload.png" width="150px" />
{{ label }} {{ label }}
@ -76,11 +78,10 @@ function pickFile() {
</div> </div>
<!-- upload card --> <!-- upload card -->
<section class="row col" :class="{ 'q-pl-md': layout === 'column' }"> <section class="column col" :class="{ 'q-pl-md': layout === 'column' }">
<div <div
v-for="(d, j) in fileData" v-for="(d, j) in fileData"
:key="j" :key="j"
class="col-12"
:class="{ :class="{
'q-pt-md': layout === 'row' && j === 0, 'q-pt-md': layout === 'row' && j === 0,
'q-pt-sm': j > 0, 'q-pt-sm': j > 0,
@ -91,6 +92,7 @@ function pickFile() {
:progress="d.progress" :progress="d.progress"
:uploading="{ loaded: d.loaded, total: d.total }" :uploading="{ loaded: d.loaded, total: d.total }"
:url="d.url" :url="d.url"
:idle="d.placeholder"
icon="mdi-file-image-outline" icon="mdi-file-image-outline"
color="hsl(var(--text-mute))" color="hsl(var(--text-mute))"
clickable clickable

View file

@ -192,6 +192,7 @@ export default {
sales: { sales: {
title: 'Sales', title: 'Sales',
quotation: 'Quotation', quotation: 'Quotation',
invoice: 'Invoice',
}, },
order: { order: {
@ -1100,6 +1101,7 @@ export default {
'Validation failed. Each installment must include at least one product. Please review and update the installments accordingly.', 'Validation failed. Each installment must include at least one product. Please review and update the installments accordingly.',
flowTemplateNotFound: 'Workflow template cannot be found', flowTemplateNotFound: 'Workflow template cannot be found',
taskOrderNotFound: 'Task order cannot be found.', taskOrderNotFound: 'Task order cannot be found.',
quotationNotFound: 'Quotation cannot be found.',
}, },
}, },
@ -1113,6 +1115,7 @@ export default {
discount: 'Discount', discount: 'Discount',
vat: 'VAT', vat: 'VAT',
value: 'Value', value: 'Value',
netValue: 'Net Value',
title: { title: {
quotation: 'Quotation', quotation: 'Quotation',
invoice: 'Invoice', invoice: 'Invoice',
@ -1135,4 +1138,71 @@ export default {
include: 'Include Duty', include: 'Include Duty',
cost: 'Duty Cost (Baht)', cost: 'Duty Cost (Baht)',
}, },
creditNote: {
title: 'Credit Note',
caption: 'All Credit Notes',
label: {
code: 'Credit Note Code',
quotationCode: 'Quotation Code',
quotationWorkName: 'Work Name',
quotationPayment: 'Payment Method',
value: 'Value',
servicePoint: 'Service Point',
quotationRegisteredBranch: 'Service Point Issuing The Quotation',
quotationCreatedBy: 'Made By',
customer: 'Customer',
BankTransfer: 'Bank Transfer',
Cash: 'Cash',
accountNumber: 'Bank Account Number',
accountName: 'Bank Account Name',
creditNoteInformation: 'Credit Note Information',
additionalDetail: 'Detail',
specifyReason: 'Specify Reason',
reasonReturn:
'The customer returned all or part of the goods because they did not meet their requirements.',
reasonCanceled:
'The customer canceled certain items or services listed on the invoice.',
submit: 'Approve the credit note',
refund: 'Refund',
refundMethod: 'Refund Method',
totalRefund: 'Total refund amount',
totalAmount: 'Total',
paid: 'Paid',
remain: 'Remaining',
refundDocs: 'Refund Documents',
refundSuccess: 'Refund Success',
},
status: {
Pending: 'Pending Refund',
Success: 'Refund Completed',
Canceled: 'Canceled',
payback: {
Pending: 'Pending',
Verify: 'Verify',
Done: 'Done',
},
},
stats: {
Pending: 'Pending Refund',
Success: 'Refund Completed',
},
},
invoice: {
title: 'Invoice',
caption: 'All Invoices',
workSheetName: 'Worksheet Name',
paymentDueDate: 'Payment Duedate',
status: {
PaymentSuccess: 'Payment Success',
PaymentWait: 'Waiting For Payment',
},
},
receipt: {
title: 'Receipt / Tax Invoice',
caption: 'All Receipt / Tax Invoice',
dataSum: 'Summary of all receipts/tax invoices',
workSheetName: 'Worksheet name',
},
}; };

View file

@ -1,3 +1,5 @@
import { title } from 'process';
export default { export default {
general: { general: {
ok: 'ตกลง', ok: 'ตกลง',
@ -192,6 +194,7 @@ export default {
sales: { sales: {
title: 'งานซื้อขาย', title: 'งานซื้อขาย',
quotation: 'ใบเสนอราคา', quotation: 'ใบเสนอราคา',
invoice: 'ใบแจ้งหนี้',
}, },
order: { order: {
@ -1001,7 +1004,8 @@ export default {
confirmSavingStatus: confirmSavingStatus:
'คุณต้องการยืนยันการบันทึกข้อมูลการเปลี่ยนสถานะใช่หรือไม่', 'คุณต้องการยืนยันการบันทึกข้อมูลการเปลี่ยนสถานะใช่หรือไม่',
confirmSending: 'ยืนยันการส่งงานใช่หรือไม่', confirmSending: 'ยืนยันการส่งงานใช่หรือไม่',
confirmEndWorkWarning: `ท่านต้องการให้ดำเนินการจบงานในขณะนี้หรือไม่? สถานะปัจจุบันที่แสดงว่า 'รอดำเนินการ' 'กำลังดำเนินการ' 'ไปดำเนินการใหม่' จะถูกเปลี่ยนเป็น 'ทำใหม่ทั้งหมด'`, confirmEndWorkWarning:
"ท่านต้องการให้ดำเนินการจบงานในขณะนี้หรือไม่? สถานะปัจจุบันที่แสดงว่า 'รอดำเนินการ' 'กำลังดำเนินการ' 'ไปดำเนินการใหม่' จะถูกเปลี่ยนเป็น 'ทำใหม่ทั้งหมด'",
confirmEndWork: 'ท่านต้องการจบงานใช่หรือไม่', confirmEndWork: 'ท่านต้องการจบงานใช่หรือไม่',
}, },
action: { action: {
@ -1078,6 +1082,7 @@ export default {
'ข้อมูลงวดไม่ถูกต้อง กรุณาตรวจสอบและยืนยันว่าแต่ละงวดมีสินค้าอย่างน้อยหนึ่งรายการ', 'ข้อมูลงวดไม่ถูกต้อง กรุณาตรวจสอบและยืนยันว่าแต่ละงวดมีสินค้าอย่างน้อยหนึ่งรายการ',
flowTemplateNotFound: 'ไม่พบขั้นตอนการทำงาน', flowTemplateNotFound: 'ไม่พบขั้นตอนการทำงาน',
taskOrderNotFound: 'ไม่พบใบสั่งงาน', taskOrderNotFound: 'ไม่พบใบสั่งงาน',
quotationNotFound: 'ไม่พบใบเสนอราคา',
}, },
}, },
@ -1091,6 +1096,7 @@ export default {
discount: 'ส่วนลด', discount: 'ส่วนลด',
vat: 'ภาษี', vat: 'ภาษี',
value: 'มูลค่า', value: 'มูลค่า',
netValue: 'มูลค่าสุทธิ',
title: { title: {
quotation: 'ใบเสนอราคา', quotation: 'ใบเสนอราคา',
invoice: 'ใบแจ้งหนี้', invoice: 'ใบแจ้งหนี้',
@ -1113,4 +1119,71 @@ export default {
include: 'ติดอากร', include: 'ติดอากร',
cost: 'จำนวนเงินอากร (บาท)', cost: 'จำนวนเงินอากร (บาท)',
}, },
creditNote: {
title: 'ใบลดหนี้',
caption: 'ใบลดหนี้ทั้งหมด',
label: {
code: 'รหัสใบลดหนี้',
quotationCode: 'เลขที่ใบเสนอราคา',
quotationWorkName: 'ชื่อใบงาน',
quotationPayment: 'วิธีการชำระเงิน',
value: 'มูลค่า',
servicePoint: 'จุดรับบริการ',
quotationRegisteredBranch: 'จุดรับบริการที่ออกใบเสนอราคา',
quotationCreatedBy: 'ผู้ที่ทำรายการ',
customer: 'ลูกค้า',
BankTransfer: 'โอนธนาคาร',
Cash: 'เงินสด',
accountNumber: 'เลขที่บัญชี',
accountName: 'ชื่อบัญชี',
creditNoteInformation: 'ข้อมูลการลดหนี้',
additionalDetail: 'อธิบายเพิ่มเติม',
specifyReason: 'ระบุสาเหตุการลดหนี้',
reasonReturn:
'ลูกค้าคืนสินค้าทั้งหมดหรือบางส่วน เนื่องจากสินค้าไม่ตรงตามความต้องการ',
reasonCanceled:
'ลูกค้ายกเลิกคำสั่งซื้อบางรายการหรือบริการที่ระบุในใบแจ้งหนี้',
submit: 'อนุมัติใบลดหนี้',
refund: 'การคืนเงิน',
refundMethod: 'วิธีการคืนเงิน',
totalRefund: 'ยอดคืนเงินทั้งหมด',
totalAmount: 'ยอดทั้งหมด',
paid: 'ชำระไปแล้ว',
remain: 'คงเหลือ',
refundDocs: 'เอกสารการคืนเงิน',
refundSuccess: 'คืนเงินเสร็จเรียบร้อย',
},
status: {
Pending: 'รอคืนเงิน',
Success: 'คืนเงินเสร็จสิ้น',
Canceled: 'ยกเลิกรายการ',
payback: {
Pending: 'รอคืนเงิน',
Verify: 'คืนเงินแล้ว รอตรวจสอบ',
Done: 'คืนเงินเรียบร้อย',
},
},
stats: {
Pending: 'รอคืนเงิน',
Success: 'คืนเงินเสร็จสิ้น',
},
},
invoice: {
title: 'ใบแจ้งหนี้',
caption: 'ใบแจ้งหนี้ทั้งหมด',
workSheetName: 'ชื่อใบงาน',
paymentDueDate: 'กำหนดชำระ',
status: {
PaymentSuccess: 'ชำระเงินแล้ว',
PaymentWait: 'ยังไม่ชำระ',
},
},
receipt: {
title: 'ใบเสร็จรับเงิน/กำกับภาษี',
caption: 'ใบเสร็จรับเงิน/กำกับภาษีทั้งหมด',
dataSum: 'สรุปใบเสร็จรับเงิน/กำกับภาษีทั้งหมด',
workSheetName: 'ชื่อใบงาน',
},
}; };

View file

@ -134,7 +134,10 @@ onMounted(async () => {
{ {
label: 'menu.sales', label: 'menu.sales',
icon: 'mdi-store-settings-outline', icon: 'mdi-store-settings-outline',
children: [{ label: 'quotation', route: '/quotation' }], children: [
{ label: 'quotation', route: '/quotation' },
{ label: 'invoice', route: '/invoice' },
],
}, },
{ {
label: 'menu.order', label: 'menu.order',
@ -152,12 +155,11 @@ onMounted(async () => {
{ {
label: 'menu.account', label: 'menu.account',
icon: 'mdi-bank-outline', icon: 'mdi-bank-outline',
disabled: true, disabled: false,
children: [ children: [
{ label: 'uploadSlip', route: '' }, { label: 'receipt', route: '/receipt' },
{ label: 'receipt', route: '' }, { label: 'creditNote', route: '/credit-note', disabled: true },
{ label: 'creditNote', route: '' }, { label: 'debitNote', route: '', disabled: true },
{ label: 'debitNote', route: '' },
], ],
}, },

View file

@ -3886,7 +3886,7 @@ watch(
</div> </div>
<div <div
class="col surface-1 rounded bordered scroll row relative-position full-width" class="col surface-1 rounded bordered scroll row relative-position"
id="product-form" id="product-form"
:class="{ :class="{
'q-mb-lg q-mx-lg ': $q.screen.gt.sm, 'q-mb-lg q-mx-lg ': $q.screen.gt.sm,

View file

@ -14,7 +14,7 @@ import { useQuotationForm } from './form';
import { hslaColors } from './constants'; import { hslaColors } from './constants';
// NOTE Import Types // NOTE Import Types
import { CustomerBranchCreate } from 'stores/customer/types'; import { CustomerBranchCreate, CustomerType } from 'stores/customer/types';
// NOTE: Import Components // NOTE: Import Components
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue'; import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
@ -181,7 +181,7 @@ async function triggerDialogDeleteQuottaion(id: string) {
}); });
} }
function triggerCreateCustomerd(opts: { type: 'CORP' | 'PERS' }) { function triggerCreateCustomerd(opts: { type: CustomerType }) {
setDefaultCustomer(); setDefaultCustomer();
customerFormState.value.dialogType = 'create'; customerFormState.value.dialogType = 'create';
customerFormData.value.customerType = opts?.type; customerFormData.value.customerType = opts?.type;
@ -656,6 +656,8 @@ async function storeDataLocal(id: string) {
<template #grid="{ item }"> <template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12"> <div class="col-md-4 col-sm-6 col-12">
<QuotationCard <QuotationCard
hide-kebab-delete
:hide-kebab-edit="!(pageState.currentTab === 'Issued')"
:urgent="item.row.urgent" :urgent="item.row.urgent"
:code="item.row.code" :code="item.row.code"
:title="item.row.workName" :title="item.row.workName"

View file

@ -242,12 +242,11 @@ function getPrice(
return a; return a;
} }
const price = precisionRound(c.pricePerUnit * c.amount); const price = c.pricePerUnit * c.amount;
const vat = const vat =
precisionRound( (c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) *
(c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) * (config.value?.vat || 0.07) *
(config.value?.vat || 0.07), (!c.discount ? c.amount : 1);
) * (!c.discount ? c.amount : 1);
a.totalPrice = precisionRound(a.totalPrice + price); a.totalPrice = precisionRound(a.totalPrice + price);
a.totalDiscount = precisionRound(a.totalDiscount + Number(c.discount)); a.totalDiscount = precisionRound(a.totalDiscount + Number(c.discount));
@ -356,18 +355,8 @@ async function fetchStatus() {
code.value = ''; code.value = '';
selectedInstallmentNo.value = []; selectedInstallmentNo.value = [];
selectedInstallment.value = []; selectedInstallment.value = [];
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Invoice
: View.InvoicePre;
if ( getInvoice();
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
) {
getInvoiceCodeFullPay();
}
}, },
}, },
{ {
@ -1011,35 +1000,7 @@ onMounted(async () => {
pageState.isLoaded = true; pageState.isLoaded = true;
if (route.query['tab'] === 'invoice') { if (route.query['tab'] === 'invoice') {
if (route.query['id']) { getInvoice();
const queryInvoiceId = route.query['id'] as string;
const queryInvoiceAmount = Number(route.query['amount']) || 0;
await getInvoiceCode(queryInvoiceId);
selectedInstallmentNo.value =
quotationFormState.value.source?.paySplit
.filter((v) => v.invoiceId === queryInvoiceId)
.map((v) => v.no) || [];
installmentAmount.value = queryInvoiceAmount;
view.value = View.Invoice;
return;
}
selectedInstallmentNo.value = [];
selectedInstallment.value = [];
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Invoice
: View.InvoicePre;
if (
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
) {
getInvoiceCodeFullPay();
}
} }
if (route.query['tab'] === 'receipt') { if (route.query['tab'] === 'receipt') {
await fetchReceipt(); await fetchReceipt();
@ -1241,6 +1202,23 @@ async function exampleReceipt(id: string) {
} }
} }
function getInvoice() {
view.value =
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
? View.Invoice
: View.InvoicePre;
if (
quotationFormData.value.payCondition === 'Full' ||
quotationFormData.value.payCondition === 'BillFull'
) {
getInvoiceCodeFullPay();
} else {
code.value = '';
}
}
watch( watch(
[ [
() => quotationFormState.value.statusFilterRequest, () => quotationFormState.value.statusFilterRequest,
@ -1865,7 +1843,7 @@ watch(
<div <div
v-if="view === View.Complete" v-if="view === View.Complete"
class="surface-1 q-pa-md full-width q-gutter-y-md" class="surface-1 q-pa-md full-width q-gutter-y-md rounded"
> >
<div class="row justify-between items-center"> <div class="row justify-between items-center">
<q-input <q-input

View file

@ -11,31 +11,40 @@ defineEmits<{
withDefaults( withDefaults(
defineProps<{ defineProps<{
payType: string; title?: string;
amount: number; hideExampleBtn?: boolean;
date: string | Date; successLabel?: string;
index: number;
paySplitCount: number; payType?: string;
amount?: number;
date?: string | Date;
index?: number;
paySplitCount?: number;
}>(), }>(),
{ payType: 'Full', amount: 0, date: () => new Date() }, { payType: 'Full', amount: 0, date: () => new Date(), index: 0 },
); );
</script> </script>
<template> <template>
<section class="surface-1 rounded row"> <section class="surface-1 rounded row">
<aside class="column col bordered-r q-py-md q-pl-md"> <aside class="column col bordered-r q-py-md q-pl-md">
<span class="text-weight-medium text-body1"> <span class="text-weight-medium text-body1">
{{ $t('quotation.receiptDialog.PaymentReceive') }} {{ title || $t('quotation.receiptDialog.PaymentReceive') }}
</span> </span>
<span class="app-text-muted-2 q-pt-md"> <span class="app-text-muted-2 q-pt-md">
{{ $t('quotation.receiptDialog.paymentMethod') }} {{ $t('quotation.receiptDialog.paymentMethod') }}
</span> </span>
<span class="row items-center"> <span class="row items-center">
{{ $t(`quotation.type.${payType}`) }} <div v-if="$slots.payType" class="row items-center">
{{ payType !== 'Full' ? `${index + 1} / ${paySplitCount}` : `` }} <slot name="payType"></slot>
<q-icon name="mdi-minus" class="q-px-xs" /> </div>
<article class="app-text-positive text-weight-medium"> <div v-else class="row items-center">
{{ $t('quotation.receiptDialog.PaymentSuccess') }} {{ $t(`quotation.type.${payType}`) }}
</article> {{ payType !== 'Full' ? `${index + 1} / ${paySplitCount}` : `` }}
<q-icon name="mdi-minus" class="q-px-xs" />
<article class="app-text-positive text-weight-medium">
{{ $t('quotation.receiptDialog.PaymentSuccess') }}
</article>
</div>
<article class="q-ml-auto text-weight-bold text-body1 q-pr-lg"> <article class="q-ml-auto text-weight-bold text-body1 q-pr-lg">
฿ {{ formatNumberDecimal(amount, 2) }} ฿ {{ formatNumberDecimal(amount, 2) }}
</article> </article>
@ -45,6 +54,7 @@ withDefaults(
<aside class="column col q-py-md text-right self-center q-px-md"> <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">
<MainButton <MainButton
v-if="!hideExampleBtn"
icon="mdi-play-box-outline" icon="mdi-play-box-outline"
color="207 96% 32%" color="207 96% 32%"
@click="() => $emit('example', index)" @click="() => $emit('example', index)"
@ -53,7 +63,7 @@ withDefaults(
<ViewButton icon-only @click="() => $emit('view', index)" /> <ViewButton icon-only @click="() => $emit('view', index)" />
</div> </div>
<span class="app-text-positive text-weight-bold text-body1"> <span class="app-text-positive text-weight-bold text-body1">
{{ $t('quotation.receiptDialog.receiptIssued') }} {{ successLabel || $t('quotation.receiptDialog.receiptIssued') }}
</span> </span>
<div class="app-text-muted-2"> <div class="app-text-muted-2">
<q-icon name="mdi-calendar-blank-outline" /> <q-icon name="mdi-calendar-blank-outline" />
@ -62,6 +72,7 @@ withDefaults(
date: date, date: date,
dayStyle: '2-digit', dayStyle: '2-digit',
monthStyle: '2-digit', monthStyle: '2-digit',
withTime: true,
}) })
}} }}
</div> </div>

View file

@ -5,9 +5,7 @@ import { precisionRound } from 'src/utils/arithmetic';
import ThaiBahtText from 'thai-baht-text'; import ThaiBahtText from 'thai-baht-text';
// NOTE: Import stores // NOTE: Import stores
import useOptionStore from 'stores/options';
import { formatNumberDecimal } from 'stores/utils'; import { formatNumberDecimal } from 'stores/utils';
import { useQuotationForm } from 'pages/05_quotation/form';
import { useConfigStore } from 'stores/config'; import { useConfigStore } from 'stores/config';
import useBranchStore from 'stores/branch'; import useBranchStore from 'stores/branch';
import { baseUrl } from 'stores/utils'; import { baseUrl } from 'stores/utils';
@ -19,8 +17,8 @@ import { CustomerBranch } from 'stores/customer/types';
import { BankBook, Branch } from 'stores/branch/types'; import { BankBook, Branch } from 'stores/branch/types';
import { import {
QuotationPayload, QuotationPayload,
CustomerBranchRelation,
Details, Details,
QuotationFull,
} from 'src/stores/quotations/types'; } from 'src/stores/quotations/types';
// NOTE: Import Components // NOTE: Import Components
@ -70,9 +68,7 @@ const attachmentList = ref<
isPDF?: boolean; isPDF?: boolean;
}[] }[]
>([]); >([]);
const data = ref< const data = ref<QuotationFull & { remark?: string }>();
QuotationPayload & { customerBranch: CustomerBranchRelation; id: string }
>();
const summaryPrice = ref<SummaryPrice>({ const summaryPrice = ref<SummaryPrice>({
totalPrice: 0, totalPrice: 0,
@ -82,6 +78,12 @@ const summaryPrice = ref<SummaryPrice>({
finalPrice: 0, finalPrice: 0,
}); });
async function fetchQuotationById(id: string) {
const res = await quotationStore.getQuotation(id);
if (res) data.value = res;
}
async function getAttachment(quotationId: string) { async function getAttachment(quotationId: string) {
const attachment = await quotationStore.listAttachment({ const attachment = await quotationStore.listAttachment({
parentId: quotationId, parentId: quotationId,
@ -178,10 +180,13 @@ onMounted(async () => {
data.value = 'data' in parsed ? parsed.data : undefined; data.value = 'data' in parsed ? parsed.data : undefined;
if (data.value) { if (data.value) {
await getAttachment(data.value.id); if (!!data.value.id) {
await getAttachment(data.value.id);
await fetchQuotationById(data.value.id);
}
const resCustomerBranch = await customerStore.getBranchById( const resCustomerBranch = await customerStore.getBranchById(
data.value.customerBranchId, data.value?.customerBranchId,
); );
if (resCustomerBranch) { if (resCustomerBranch) {
@ -189,19 +194,27 @@ onMounted(async () => {
} }
details.value = { details.value = {
code: parsed.meta.source.code, code: parsed?.meta?.source?.code ?? data.value?.code,
createdAt: parsed.meta.source.createdAt, createdAt:
createdBy: `${parsed.meta.createdBy} ${!parsed.meta.source.createdBy ? '' : parsed.meta.source.createdBy.telephoneNo}`, parsed?.meta?.source?.createdAt ??
payCondition: parsed.meta.source.payCondition, new Date(data.value?.createdAt || ''),
contactName: parsed.meta.source.contactName, createdBy:
contactTel: parsed.meta.source.contactTel, `${parsed?.meta?.source?.createdBy?.firstName ?? ''} ${parsed?.meta?.source?.createdBy?.telephoneNo ?? ''}`.trim() ||
workName: parsed.meta.source.workName, `${data.value?.createdBy?.firstName ?? ''} ${data.value?.createdBy?.telephoneNo ?? ''}`.trim(),
dueDate: parsed.meta.source.dueDate, payCondition:
worker: parsed.meta.selectedWorker, parsed?.meta?.source?.payCondition ?? data.value?.payCondition,
contactName: parsed?.meta?.source?.contactName ?? data.value?.contactName,
contactTel: parsed?.meta?.source?.contactTel ?? data.value?.contactTel,
workName: parsed?.meta?.source?.workName ?? data.value?.workName,
dueDate:
parsed?.meta?.source?.dueDate ?? new Date(data.value?.dueDate || ''),
worker:
parsed?.meta?.selectedWorker ??
data.value?.worker.map((v) => v.employee),
}; };
const resBranch = await branchStore.fetchById( const resBranch = await branchStore.fetchById(
data.value?.registeredBranchId, data.value?.registeredBranchId ?? data.value?.registeredBranchId,
); );
if (resBranch) { if (resBranch) {
@ -214,26 +227,31 @@ onMounted(async () => {
} }
productList.value = productList.value =
data.value?.productServiceList.map((v) => ({ (data?.value?.productServiceList ?? data.value?.productServiceList).map(
id: v.product.id, (v) => ({
code: v.product.code, id: v.product.id,
detail: v.product.name, code: v.product.code,
amount: v.amount || 0, detail: v.product.name,
priceUnit: v.pricePerUnit || 0, amount: v.amount || 0,
discount: v.discount || 0, priceUnit: v.pricePerUnit || 0,
vat: v.vat || 0, discount: v.discount || 0,
value: precisionRound( vat: v.vat || 0,
(v.pricePerUnit || 0) * v.amount - value: precisionRound(
(v.discount || 0) + (v.pricePerUnit || 0) * v.amount -
(v.product.calcVat (v.discount || 0) +
? ((v.pricePerUnit || 0) * v.amount - (v.discount || 0)) * (v.product.calcVat
(config.value?.vat || 0.07) ? ((v.pricePerUnit || 0) * v.amount - (v.discount || 0)) *
: 0), (config.value?.vat || 0.07)
), : 0),
})) || []; ),
}),
) || [];
} }
summaryPrice.value = (data.value?.productServiceList || []).reduce( summaryPrice.value = (
(data?.value?.productServiceList ?? data.value?.productServiceList) ||
[]
).reduce(
(a, c) => { (a, c) => {
const price = precisionRound((c.pricePerUnit || 0) * c.amount); const price = precisionRound((c.pricePerUnit || 0) * c.amount);
const vat = precisionRound( const vat = precisionRound(

View file

@ -121,14 +121,7 @@ function titleMode(mode: View): string {
<div> <div>
<div>เงอนไขการชำระ</div> <div>เงอนไขการชำระ</div>
<div> <div>
{{ {{ $t(`quotation.type.${details.payCondition}`) }}
{
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>
<div> <div>

View file

@ -119,7 +119,7 @@ function assignToForm() {
id="btn-info-basic-save" id="btn-info-basic-save"
icon-only icon-only
type="submit" type="submit"
@click.stop="refForm?.submit" @click.stop="(e) => refForm?.submit(e)"
/> />
<EditButton <EditButton
v-if="!state.isEdit" v-if="!state.isEdit"

View file

@ -35,7 +35,13 @@ const props = withDefaults(
}, },
); );
const readonlyField = ['quotationNo', 'contactPerson', 'telephone', 'employer']; const readonlyField = [
'quotationNo',
'contactPerson',
'telephone',
'employer',
'employee',
];
const formRemark = ref<string>(''); const formRemark = ref<string>('');
const formData = ref<{ const formData = ref<{

View file

@ -219,6 +219,8 @@ function getEmployeeName(
hide-preview hide-preview
hide-kebab-view hide-kebab-view
hide-kebab-edit hide-kebab-edit
hide-kebab-delete
use-cancel
:badge-color=" :badge-color="
props.row.requestDataStatus === RequestDataStatus.Pending props.row.requestDataStatus === RequestDataStatus.Pending
? '--orange-5-hsl' ? '--orange-5-hsl'
@ -259,6 +261,7 @@ function getEmployeeName(
}, },
]" ]"
@view="$emit('view', props.row)" @view="$emit('view', props.row)"
@cancel="$emit('delete', props.row)"
> >
<template v-slot:responsiblePerson="{ props: subProps }"> <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 self-center">

View file

@ -14,20 +14,25 @@ import FormGroupHead from '../08_request-list/FormGroupHead.vue';
import NoData from 'src/components/NoData.vue'; import NoData from 'src/components/NoData.vue';
import { baseUrl } from 'src/stores/utils'; import { baseUrl } from 'src/stores/utils';
import { Task } from 'src/stores/task-order/types'; import { Task, TaskStatus } from 'src/stores/task-order/types';
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select', value: RequestWork[]): void; (e: 'select', value: RequestWork[]): void;
(e: 'afterSubmit'): void; (e: 'afterSubmit'): void;
}>(); }>();
const props = defineProps<{
creditNote?: boolean;
fetchParams?: Parameters<typeof requestListStore.getRequestWorkList>[0];
}>();
const requestListStore = useRequestList(); const requestListStore = useRequestList();
const taskList = defineModel< const taskList = defineModel<
{ {
step: number; step?: number;
requestWorkId: string; requestWorkId: string;
requestWorkStep?: Task; requestWorkStep?: Task | { requestWork: RequestWork };
}[] }[]
>('taskList', { >('taskList', {
default: [], default: [],
@ -36,6 +41,7 @@ const open = defineModel<boolean>('open', { default: false });
const selectedEmployee = ref< const selectedEmployee = ref<
(RequestWork & { (RequestWork & {
taskStatus: TaskStatus;
_template?: { _template?: {
id: string; id: string;
templateName: string; templateName: string;
@ -76,7 +82,7 @@ async function getList() {
page: 1, page: 1,
pageSize: 99999, pageSize: 99999,
query: state.search, query: state.search,
readyToTask: true, ...props.fetchParams,
}); });
if (!res) return; if (!res) return;
@ -141,13 +147,19 @@ function submit() {
selectedEmployee.value.forEach((v, i) => { selectedEmployee.value.forEach((v, i) => {
const curr = v.stepStatus.find( const curr = v.stepStatus.find(
(s) => s.workStatus === RequestWorkStatus.Ready, (s) =>
s.workStatus ===
(props.creditNote
? RequestWorkStatus.Canceled
: RequestWorkStatus.Ready),
); );
if (curr) { if (curr) {
const task: Task = { const task: Task = {
...curr, ...curr,
attributes: curr.attributes, attributes: curr.attributes,
workStatus: RequestWorkStatus.Ready, workStatus: props.creditNote
? RequestWorkStatus.Canceled
: RequestWorkStatus.Ready,
taskOrderId: '', taskOrderId: '',
requestWork: selectedEmployee.value[i], requestWork: selectedEmployee.value[i],
}; };
@ -254,7 +266,8 @@ function onDialogOpen() {
<div class="q-pa-md full-width"> <div class="q-pa-md full-width">
<TableEmployee <TableEmployee
checkbox-on checkbox-on
step-on :step-on="!creditNote"
:statusOn="creditNote"
:rows=" :rows="
list.map((v) => list.map((v) =>
Object.assign(v, { _template: getTemplateData(v) }), Object.assign(v, { _template: getTemplateData(v) }),

View file

@ -19,6 +19,7 @@ const props = withDefaults(
checkboxOn?: boolean; checkboxOn?: boolean;
checkAll?: boolean; checkAll?: boolean;
stepOn?: boolean; stepOn?: boolean;
statusOn?: boolean;
rows: QTableProps['rows']; rows: QTableProps['rows'];
grid?: boolean; grid?: boolean;
}>(), }>(),
@ -180,7 +181,23 @@ function handleCheck(
}, },
...employeeColumn.slice(2), ...employeeColumn.slice(2),
] ]
: employeeColumn : statusOn
? [
...employeeColumn,
{
name: 'urgent',
align: 'center',
label: '',
field: (v) => v.product.code,
},
{
name: 'status',
align: 'center',
label: 'general.status',
field: (v) => v.product.code,
},
]
: employeeColumn
" "
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
:no-data-label="$t('general.noDataTable')" :no-data-label="$t('general.noDataTable')"
@ -316,7 +333,7 @@ function handleCheck(
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }} {{ col.label && $t(col.label) }}
</q-th> </q-th>
<q-th></q-th> <q-th v-if="!statusOn"></q-th>
<q-th v-if="$slots.append"></q-th> <q-th v-if="$slots.append"></q-th>
<q-th v-if="$slots.action"></q-th> <q-th v-if="$slots.action"></q-th>
@ -338,7 +355,7 @@ function handleCheck(
> >
<q-tr <q-tr
:class="{ :class="{
urgent: props.row.request.quotation.urgent, urgent: props.row.request.quotation?.urgent,
dark: $q.dark.isActive, dark: $q.dark.isActive,
['disabled-row']: ['disabled-row']:
selectedEmployee.length > 0 && selectedEmployee.length > 0 &&
@ -392,7 +409,7 @@ function handleCheck(
<q-img <q-img
class="text-center" class="text-center"
:ratio="1" :ratio="1"
:src="`${baseUrl}/employee/${props.row.request.employee.id}/image/${props.row.request.employee.selectedImage}`" :src="`${baseUrl}/employee/${props.row.request.employee?.id}/image/${props.row.request.employee?.selectedImage}`"
> >
<template #error> <template #error>
<span class="full-width full-height"> <span class="full-width full-height">
@ -407,27 +424,27 @@ function handleCheck(
</div> </div>
<div class="app-text-muted"> <div class="app-text-muted">
{{ props.row.request.employee.code }} {{ props.row.request.employee?.code }}
</div> </div>
</div> </div>
<Icon <Icon
class="q-ml-md" class="q-ml-md"
:class="`app-text-${props.row.request.employee.gender}`" :class="`app-text-${props.row.request.employee?.gender}`"
:icon="`material-symbols:${props.row.request.employee.gender}`" :icon="`material-symbols:${props.row.request.employee?.gender}`"
width="24px" width="24px"
/> />
</div> </div>
</q-td> </q-td>
<q-td>{{ calculateAge(props.row.request.employee.dateOfBirth) }}</q-td> <q-td>{{ calculateAge(props.row.request.employee?.dateOfBirth) }}</q-td>
<q-td> <q-td>
{{ {{
useOptionStore().mapOption(props.row.request.employee.nationality) useOptionStore().mapOption(props.row.request.employee?.nationality)
}} }}
</q-td> </q-td>
<q-td> <q-td>
{{ {{
dateFormatJS({ dateFormatJS({
date: props.row.request.quotation.dueDate, date: props.row.request.quotation?.dueDate,
locale: $i18n.locale, locale: $i18n.locale,
dayStyle: '2-digit', dayStyle: '2-digit',
monthStyle: 'short', monthStyle: 'short',
@ -436,7 +453,7 @@ function handleCheck(
</q-td> </q-td>
<q-td> <q-td>
<ExpirationDate <ExpirationDate
:expiration-date="new Date(props.row.request.quotation.dueDate)" :expiration-date="new Date(props.row.request.quotation?.dueDate)"
/> />
</q-td> </q-td>
<q-td> <q-td>
@ -444,12 +461,12 @@ function handleCheck(
class="cursor-pointer link" class="cursor-pointer link"
@click="goToQuotation(props.row.request.quotation)" @click="goToQuotation(props.row.request.quotation)"
> >
{{ props.row.request.quotation.code }} {{ props.row.request.quotation?.code }}
</span> </span>
</q-td> </q-td>
<q-td> <q-td>
<BadgeComponent <BadgeComponent
v-if="props.row.request.quotation.urgent" v-if="props.row.request.quotation?.urgent"
icon="mdi-fire" icon="mdi-fire"
:title="$t('general.urgent2')" :title="$t('general.urgent2')"
hsla-color="--gray-1-hsl" hsla-color="--gray-1-hsl"
@ -457,6 +474,12 @@ function handleCheck(
solid solid
/> />
</q-td> </q-td>
<q-td v-if="statusOn">
<BadgeComponent
:title="$t('creditNote.status.Canceled')"
hsla-color="--red-8-hsl"
/>
</q-td>
<q-td v-if="$slots.append"> <q-td v-if="$slots.append">
<slot name="append" :props="props"></slot> <slot name="append" :props="props"></slot>
</q-td> </q-td>

View file

@ -19,6 +19,7 @@ const fileData = defineModel<
loaded: number; loaded: number;
total: number; total: number;
url?: string; url?: string;
placeholder?: boolean;
}[] }[]
>('fileData', { default: [] }); >('fileData', { default: [] });
</script> </script>

View file

@ -26,12 +26,14 @@ const taskProduct = defineModel<{ productId: string; discount?: number }[]>(
}, },
); );
defineProps<{ const props = defineProps<{
readonly?: boolean; readonly?: boolean;
agentPrice?: boolean;
taskList: { taskList: {
product: RequestWork['productService']['product']; product: RequestWork['productService']['product'];
list: RequestWork[]; list: RequestWork[];
}[]; }[];
creditNote?: boolean;
}>(); }>();
defineEmits<{ defineEmits<{
@ -55,15 +57,28 @@ function openList(index: number) {
function calcPricePerUnit(product: RequestWork['productService']['product']) { function calcPricePerUnit(product: RequestWork['productService']['product']) {
return product.vatIncluded return product.vatIncluded
? product.serviceCharge / (1 + (config.value?.vat || 0.07)) ? (props.creditNote
: product.serviceCharge; ? props.agentPrice
? product.agentPrice
: product.price
: product.serviceCharge) /
(1 + (config.value?.vat || 0.07))
: props.creditNote
? props.agentPrice
? product.agentPrice
: product.price
: product.serviceCharge;
} }
function calcPrice( function calcPrice(
product: RequestWork['productService']['product'], product: RequestWork['productService']['product'],
amount: number, amount: number,
) { ) {
const pricePerUnit = product.serviceCharge; const pricePerUnit = props.creditNote
? props.agentPrice
? product.agentPrice
: product.price
: product.serviceCharge;
const discount = const discount =
taskProduct.value.find((v) => v.productId === product.id)?.discount || 0; taskProduct.value.find((v) => v.productId === product.id)?.discount || 0;
const priceNoVat = product.vatIncluded const priceNoVat = product.vatIncluded
@ -102,7 +117,16 @@ function calcPrice(
<main class="q-px-md q-py-sm surface-1"> <main class="q-px-md q-py-sm surface-1">
<q-table <q-table
:columns="productColumn" :columns="
creditNote
? productColumn.filter(
(v) =>
v.name !== 'discount' &&
v.name !== 'priceBeforeVat' &&
v.name !== 'vat',
)
: productColumn
"
:rows="taskList" :rows="taskList"
bordered bordered
flat flat
@ -174,7 +198,7 @@ function calcPrice(
}} }}
</q-td> </q-td>
<!-- TODO: display price detail --> <!-- TODO: display price detail -->
<q-td align="center"> <q-td align="center" v-if="!creditNote">
<q-input <q-input
:readonly :readonly
:bg-color="readonly ? 'transparent' : ''" :bg-color="readonly ? 'transparent' : ''"
@ -213,11 +237,11 @@ function calcPrice(
/> />
</q-td> </q-td>
<!-- before vat --> <!-- before vat -->
<q-td class="text-right"> <q-td class="text-right" v-if="!creditNote">
{{ formatNumberDecimal(calcPricePerUnit(props.row.product), 2) }} {{ formatNumberDecimal(calcPricePerUnit(props.row.product), 2) }}
</q-td> </q-td>
<!-- vat --> <!-- vat -->
<q-td class="text-right"> <q-td class="text-right" v-if="!creditNote">
{{ {{
formatNumberDecimal( formatNumberDecimal(
props.row.product.calcVat props.row.product.calcVat
@ -267,7 +291,11 @@ function calcPrice(
<q-tr v-show="currentBtnOpen[props.rowIndex]" :props="props"> <q-tr v-show="currentBtnOpen[props.rowIndex]" :props="props">
<q-td colspan="100%" style="padding: 16px"> <q-td colspan="100%" style="padding: 16px">
<TableEmployee step-on :rows="props.row.list" /> <TableEmployee
:step-on="!creditNote"
:status-on="creditNote"
:rows="props.row.list"
/>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>

View file

@ -1130,6 +1130,7 @@ watch([currentFormData.value.taskStatus], () => {
<!-- SEC: Dialog --> <!-- SEC: Dialog -->
<SelectReadyRequestWork <SelectReadyRequestWork
:fetch-params="{ readyToTask: true }"
v-model:open="pageState.productDialog" v-model:open="pageState.productDialog"
v-model:task-list="currentFormData.taskList" v-model:task-list="currentFormData.taskList"
@after-submit=" @after-submit="

View file

@ -0,0 +1,412 @@
<script lang="ts" setup>
// NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue';
import { storeToRefs } from 'pinia';
// 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 TableInvoice from './TableInvoice.vue';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
// NOTE: Stores & Type
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 navigatorStore = useNavigator();
const flowStore = useFlowStore();
const invoiceStore = useInvoice();
const { data, stats, page, pageMax, pageSize } = storeToRefs(invoiceStore);
// NOTE: Variable
const pageState = reactive({
hideStat: false,
statusFilter: 'None' as 'None' | PaymentDataStatus,
inputSearch: '',
fieldSelected: [...columns.map((v) => v.name)],
gridView: false,
total: 0,
});
const fieldSelectedOption = computed(() => {
return columns.map((v) => ({
label: v.label,
value: v.name,
}));
});
async function fetchList(opts?: { rotateFlowId?: boolean }) {
const ret = await invoiceStore.getInvoiceList({
page: page.value,
pageSize: pageSize.value,
query: pageState.inputSearch,
pay:
pageState.statusFilter === PaymentDataStatus.Success
? true
: pageState.statusFilter === PaymentDataStatus.Wait
? false
: undefined,
});
if (ret) {
data.value = ret.result;
pageState.total = ret.total;
pageMax.value = Math.ceil(ret.total / pageSize.value);
}
if (opts?.rotateFlowId) flowStore.rotate();
}
async function fetchStats() {
const ret = await invoiceStore.getInvoiceStats();
if (ret) stats.value = ret;
}
function triggerView(opts: { quotationId: string }) {
const url = new URL(`/quotation/view?tab=invoice`, window.location.origin);
localStorage.setItem(
'new-quotation',
JSON.stringify({
quotationId: opts.quotationId,
statusDialog: 'info',
}),
);
window.open(url.toString(), '_blank');
}
function viewDocExample(quotationId: string) {
localStorage.setItem(
'quotation-preview',
JSON.stringify({
data: {
id: quotationId,
},
}),
);
const url = new URL('/quotation/document-view', window.location.origin);
url.searchParams.append('type', 'invoice');
window.open(url, '_blank');
}
onMounted(async () => {
navigatorStore.current.title = 'invoice.title';
navigatorStore.current.path = [{ text: 'invoice.caption', i18n: true }];
await fetchStats();
await fetchList({ rotateFlowId: true });
});
watch(
[
() => pageState.inputSearch,
() => pageState.statusFilter,
() => pageSize.value,
() => page.value,
],
() => fetchList({ rotateFlowId: true }),
);
</script>
<template>
<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));
"
>
{{ Object.values(stats).reduce((a, c) => a + c, 0) }}
</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: 'mdi-timer-sand',
count: stats[PaymentDataStatus.Wait],
label: 'invoice.status.PaymentWait',
color: 'orange',
},
{
icon: 'mdi-check-decagram-outline',
count: stats[PaymentDataStatus.Success],
label: 'invoice.status.PaymentSuccess',
color: 'light-green',
},
]"
: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 bordered-b"
style="z-index: 1"
>
<div class="row q-py-sm q-px-md justify-between full-width">
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 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-12 col-md-5 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<q-select
v-model="pageState.statusFilter"
outlined
dense
option-value="value"
option-label="label"
class="col"
:class="{ 'offset-md-5': pageState.gridView }"
map-options
emit-value
:for="'field-select-status'"
:hide-dropdown-icon="$q.screen.lt.sm"
:options="[
{
label: $t('general.all'),
value: 'None',
},
{
label: $t('invoice.status.PaymentWait'),
value: PaymentDataStatus.Wait,
},
{
label: $t('invoice.status.PaymentSuccess'),
value: PaymentDataStatus.Success,
},
]"
/>
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col 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>
</div>
</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">
<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>
</article>
<!-- SEC: footer content -->
<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">
{{
$t('general.recordsPage', {
resultcurrentPage: 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="() => fetchList({ rotateFlowId: true })"
/>
</nav>
</footer>
</div>
</section>
</div>
</template>
<style scoped></style>

View file

@ -0,0 +1,146 @@
<script setup lang="ts" generic="T">
import { QTableSlots } from 'quasar';
import { columns } from './constants';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import { Invoice, PaymentDataStatus } from 'src/stores/payment/types';
import { ViewButton } from 'components/button';
import { useInvoice } from 'src/stores/payment';
const invoiceStore = useInvoice();
withDefaults(
defineProps<{
rows: T[];
readonly?: boolean;
flat?: boolean;
bordered?: boolean;
grid?: boolean;
hideHeader?: boolean;
buttonDownload?: boolean;
buttonDelete?: boolean;
hidePagination?: boolean;
inTable?: boolean;
hideView?: boolean;
btnSelected?: boolean;
imgColumn?: string;
customColumn?: string[];
}>(),
{
row: () => [],
flat: false,
bordered: false,
grid: false,
hideHeader: false,
buttonDownload: false,
imgColumn: '',
customColumn: () => [],
},
);
defineEmits<{
(e: 'view', id: string): void;
(e: 'preview', id: string): void;
(e: 'edit', data: T, index: number): void;
(e: 'delete', data: T, index: number): void;
(e: 'download', data: T, index: number): void;
}>();
</script>
<template>
<q-table
:rows-per-page-options="[0]"
:rows="rows.map((data, i) => ({ ...data, _index: i }))"
:columns
: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 columns" :key="col.name" :props="props">
{{ $t(col.label) }}
</q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: Invoice & { _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('#')">
<span>
{{
typeof col.field === 'string'
? props.row[col.field as keyof Invoice]
: col.field(props.row)
}}
</span>
</template>
<template v-if="col.name === '#order'">
{{
col.field(props.row) +
(invoiceStore.page - 1) * invoiceStore.pageSize
}}
</template>
<template v-if="col.name === '#status'">
<BadgeComponent
:hsla-color="
{
[PaymentDataStatus.Success]: '--green-8-hsl',
[PaymentDataStatus.Wait]: '--orange-5-hsl',
}[props.row.payment.paymentStatus]
"
:title="$t(`invoice.status.${props.row.payment.paymentStatus}`)"
/>
</template>
<template v-if="col.name === '#action'">
<q-btn
:id="`btn-preview-${props.row.quotation.workName}`"
flat
dense
rounded
icon="mdi-play-box-outline"
size="12px"
:title="$t('preview.doc')"
@click.stop="$emit('preview', props.row.quotation.id)"
/>
<q-btn
:id="`btn-eye-${props.row.quotation.workName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row.quotation.id)"
/>
</template>
</q-td>
</q-tr>
</template>
<template v-slot:item="props: { row: Invoice }">
<slot name="grid" :item="props" />
</template>
</q-table>
</template>
<style lang="scss" scoped>
:deep(i.q-icon.mdi.mdi-alert.q-table__bottom-nodata-icon) {
color: #ffc224 !important;
}
</style>

View file

@ -0,0 +1,111 @@
import { QTableProps } from 'quasar';
import { Invoice } from 'src/stores/payment/types';
import { formatNumberDecimal } from 'src/stores/utils';
import { dateFormatJS } from 'src/utils/datetime';
export const columns = [
{
name: '#order',
align: 'center',
label: 'general.order',
field: (v: Invoice & { _index: number }) => v._index + 1,
},
{
name: 'invoiceCode',
align: 'center',
label: 'requestList.invoiceCode',
field: 'code',
},
{
name: 'workSheetName',
align: 'center',
label: 'invoice.workSheetName',
field: (v: Invoice) => v.quotation.workName,
},
{
name: 'customer',
align: 'center',
label: 'general.customer',
field: (v: Invoice) => v.quotation.customerBranch.customerName,
},
{
name: 'issueDate',
align: 'center',
label: 'taskOrder.issueDate',
field: (v: Invoice) => dateFormatJS({ date: v.createdAt }),
},
{
name: 'paymentDueDate',
align: 'center',
label: 'invoice.paymentDueDate',
field: (v: Invoice) => dateFormatJS({ date: v.quotation.dueDate }),
},
{
name: 'value',
align: 'center',
label: 'preview.value',
field: (v: Invoice) => formatNumberDecimal(v.amount, 2),
},
{
name: '#status',
align: 'center',
label: 'general.status',
field: (_) => '#status',
},
{
name: '#action',
align: 'center',
label: '',
field: (_) => '#action',
},
] as const satisfies QTableProps['columns'];
export const docColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'document',
align: 'left',
label: 'general.document',
field: 'document',
},
{
name: 'attachment',
align: 'left',
label: 'requestList.attachment',
field: 'attachment',
},
{
name: 'amount',
align: 'center',
label: 'general.amount',
field: 'amount',
},
{
name: 'documentInSystem',
align: 'center',
label: 'requestList.documentInSystem',
field: 'documentInSystem',
},
{
name: 'status',
align: 'center',
label: 'general.status',
field: 'status',
},
] as const satisfies QTableProps['columns'];
export const hslaColors: Record<string, string> = {
PaymentWait: '--orange-5-hsl',
PaymentSuccess: '--green-8-hsl',
};

View file

@ -0,0 +1,841 @@
<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue';
import { api } from 'src/boot/axios';
import { useRoute, useRouter } from 'vue-router';
import { dateFormatJS } from 'src/utils/datetime';
import { initLang, initTheme } from 'src/utils/ui';
import { useQuotationStore } from 'src/stores/quotations';
import {
CreditNote,
CreditNotePaybackStatus,
CreditNotePayload,
CreditNoteStatus,
useCreditNote,
} from 'src/stores/credit-note';
import { useConfigStore } from 'src/stores/config';
import DocumentExpansion from './expansion/DocumentExpansion.vue';
import RemarkExpansion from '../09_task-order/expansion/RemarkExpansion.vue';
import AdditionalFileExpansion from '../09_task-order/expansion/AdditionalFileExpansion.vue';
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 SelectReadyRequestWork from '../09_task-order/SelectReadyRequestWork.vue';
import RefundInformation from './RefundInformation.vue';
import QuotationFormReceipt from '../05_quotation/QuotationFormReceipt.vue';
import DialogViewFile from 'src/components/dialog/DialogViewFile.vue';
import { MainButton, SaveButton } from 'src/components/button';
import { RequestWork } from 'src/stores/request-list/types';
import { storeToRefs } from 'pinia';
import useOptionStore from 'src/stores/options';
import { dialogWarningClose } from 'src/stores/utils';
import { useI18n } from 'vue-i18n';
import { QForm } from 'quasar';
const route = useRoute();
const router = useRouter();
const creditNote = useCreditNote();
const quotation = useQuotationStore();
const configStore = useConfigStore();
const { data: config } = storeToRefs(configStore);
const { t } = useI18n();
const refForm = ref<InstanceType<typeof QForm>>();
const creditNoteData = ref<CreditNote>();
const quotationData = ref<CreditNote['quotation']>();
const view = ref<CreditNoteStatus | null>(null);
const fileList = ref<FileList>();
const attachmentList = ref<FileList>();
const fileData = ref<
{
name: string;
progress: number;
loaded: number;
total: number;
url?: string;
}[]
>([]);
const attachmentData = ref<
{
name: string;
progress: number;
loaded: number;
total: number;
url?: string;
placeholder?: boolean;
}[]
>([]);
const statusTabForm = ref<
{
title: string;
status: 'done' | 'doing' | 'waiting';
handler: () => void;
active?: () => boolean;
}[]
>([]);
const readonly = computed(
() =>
creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Pending ||
creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Success,
);
const pageState = reactive({
productDialog: false,
fileDialog: false,
});
const formData = ref<CreditNotePayload>({
quotationId: '',
requestWorkId: [],
remark: '',
reason: '',
detail: '',
paybackType: 'Cash',
paybackBank: '',
paybackAccount: '',
paybackAccountName: '',
});
const formTaskList = ref<
{
requestWorkId: string;
requestWorkStep?: { requestWork: RequestWork };
}[]
>([]);
let taskListGroup = computed(() => {
const cacheData = formTaskList.value.reduce<
{
product: RequestWork['productService']['product'];
list: RequestWork[];
}[]
>((acc, curr) => {
const task = curr.requestWorkStep;
if (!task) return acc;
if (task.requestWork) {
let exist = acc.find(
(item) => task.requestWork.productService.productId == item.product.id,
);
const record = Object.assign(task.requestWork);
if (exist) {
exist.list.push(task.requestWork);
} else {
acc.push({
product: task.requestWork.productService.product,
list: [record],
});
}
}
return acc;
}, []);
return cacheData;
});
const summaryPrice = computed(() => getPrice(taskListGroup.value));
async function initStatus() {
if (creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Pending)
view.value = CreditNoteStatus.Pending;
if (creditNoteData.value?.creditNoteStatus === CreditNoteStatus.Success)
view.value = CreditNoteStatus.Success;
statusTabForm.value = [
{
title: 'title',
status: creditNoteData.value?.id !== undefined ? 'done' : 'doing',
active: () => view.value === null,
handler: () => {
view.value = null;
},
},
{
title: 'Pending',
status: creditNoteData.value?.id
? creditNoteData.value.creditNoteStatus === CreditNoteStatus.Success
? 'done'
: 'doing'
: 'waiting',
active: () => view.value === CreditNoteStatus.Pending,
handler: async () => {
view.value = CreditNoteStatus.Pending;
creditNoteData.value &&
(await getFileList(creditNoteData.value.id, true));
},
},
{
title: 'Success',
status:
creditNoteData.value?.id &&
creditNoteData.value.creditNoteStatus === CreditNoteStatus.Success
? 'done'
: 'waiting',
active: () => view.value === CreditNoteStatus.Success,
handler: () => {
view.value = CreditNoteStatus.Success;
},
},
];
}
function getPrice(
list: {
product: RequestWork['productService']['product'];
list: RequestWork[];
}[],
) {
return list.reduce(
(a, c) => {
const pricePerUnit = quotationData.value?.agentPrice
? c.product.agentPrice
: c.product.price;
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 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;
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
}
function openProductDialog() {
pageState.productDialog = true;
}
async function getCreditNote() {
if (typeof route.params['id'] !== 'string') return;
const ret = await creditNote.getCreditNote(route.params['id']);
if (!ret) return;
creditNoteData.value = ret;
assignFormData();
}
function assignFormData() {
if (!creditNoteData.value) return;
const current = creditNoteData.value;
formData.value = {
quotationId: creditNoteData.value.quotationId,
requestWorkId: creditNoteData.value.requestWork.map((v) => v.id || ''),
reason: creditNoteData.value.reason,
remark: creditNoteData.value.remark,
detail: creditNoteData.value.detail,
paybackType: creditNoteData.value.paybackType,
paybackBank: creditNoteData.value.paybackBank,
paybackAccount: creditNoteData.value.paybackAccount,
paybackAccountName: creditNoteData.value.paybackAccountName,
};
formTaskList.value = creditNoteData.value.requestWork.map((v) => ({
requestWorkId: v.id || '',
requestWorkStep: {
requestWork: {
...v,
request: { ...v.request, quotation: current.quotation },
},
},
}));
}
function openSlipDialog() {
pageState.fileDialog = true;
}
async function getQuotation() {
if (creditNoteData.value) {
quotationData.value = creditNoteData.value.quotation;
return;
}
if (
route.name !== 'CreditNoteNew' ||
typeof route.query['quotationId'] !== 'string'
) {
return;
}
const ret = await quotation.getQuotation(route.query['quotationId']);
if (!ret) return;
quotationData.value = ret;
}
async function submit() {
const payload = formData.value;
payload.requestWorkId = formTaskList.value.map((v) => v.requestWorkId);
payload.quotationId =
typeof route.query['quotationId'] === 'string'
? route.query['quotationId']
: '';
const res = await creditNote.createCreditNote(payload);
if (res) {
await router.push(`/credit-note/${res.id}`);
await getCreditNote();
if (attachmentList.value) {
await uploadFile(res.id, attachmentList.value, false);
}
initStatus();
}
}
function goToQuotation() {
if (!quotationData.value) return;
const quotation = quotationData.value;
const url = new URL('/quotation/view', window.location.origin);
localStorage.setItem(
'new-quotation',
JSON.stringify({
customerBranchId: quotation.customerBranchId,
agentPrice: quotation.agentPrice,
statusDialog: 'info',
quotationId: quotation.id,
}),
);
window.open(url.toString(), '_blank');
}
async function changePaybackStatus(status: CreditNotePaybackStatus) {
if (!creditNoteData.value) return;
const res = await creditNote.action.updatePaybackStatus(
creditNoteData.value.id,
status,
);
if (res) {
creditNoteData.value.paybackStatus = status;
if (status === CreditNotePaybackStatus.Done) {
creditNoteData.value.creditNoteStatus = CreditNoteStatus.Success;
initStatus();
}
}
}
async function uploadFile(
creditNoteId: string,
list: FileList,
slip?: boolean,
) {
const promises: ReturnType<
typeof creditNote.putAttachment | typeof creditNote.putFile
>[] = [];
if (!slip) {
attachmentData.value = attachmentData.value.filter((v) => !v.placeholder);
}
for (let i = 0; i < list.length; i++) {
const data = {
name: list[i].name,
progress: 1,
loaded: 0,
total: 0,
url: `/credit-note/${creditNoteId}/${slip ? 'file-slip' : 'attachment'}/${list[i].name}`,
};
promises.push(
slip
? creditNote.putFile({
group: 'slip',
parentId: creditNoteId,
fileId: list[i].name,
file: list[i],
uploadUrl: true,
onUploadProgress: (e) => {
const exists = fileData?.value.find((v) => v.name === data.name);
if (!exists) return fileData?.value.push(data);
exists.total = e.total || 0;
exists.progress = e.progress || 0;
exists.loaded = e.loaded;
},
})
: creditNote.putAttachment({
parentId: creditNoteId,
name: list[i].name,
file: list[i],
onUploadProgress: (e) => {
const exists = attachmentData?.value.find(
(v) => v.name === data.name,
);
if (!exists) return attachmentData?.value.push(data);
exists.total = e.total || 0;
exists.progress = e.progress || 0;
exists.loaded = e.loaded;
},
}),
);
slip ? fileData?.value.push(data) : attachmentData?.value.push(data);
}
fileList.value = undefined;
attachmentList.value = undefined;
const beforeUnloadHandler = (e: Event) => {
e.preventDefault();
};
window.addEventListener('beforeunload', beforeUnloadHandler);
return await Promise.all(promises).then((v) => {
window.removeEventListener('beforeunload', beforeUnloadHandler);
return v;
});
}
async function remove(creditNoteId: string, n: string, slip?: boolean) {
dialogWarningClose(t, {
message: t('dialog.message.confirmDelete'),
actionText: t('dialog.action.ok'),
action: async () => {
const res = slip
? await creditNote.delFile({
group: 'slip',
parentId: creditNoteId,
fileId: n,
})
: await creditNote.delAttachment({
parentId: creditNoteId,
name: n,
});
if (res) {
getFileList(creditNoteId, slip);
}
},
cancel: () => {},
});
}
async function getFileList(creditNoteId: string, slip?: boolean) {
const list = slip
? await creditNote.listFile({
group: 'slip',
parentId: creditNoteId,
})
: await creditNote.listAttachment({
parentId: creditNoteId,
});
if (list)
slip
? (fileData.value = await Promise.all(
list.map(async (v) => {
const rse = await creditNote.headFile({
group: 'slip',
parentId: creditNoteId,
fileId: v,
});
let contentLength = 0;
if (rse) contentLength = Number(rse['content-length']);
return {
name: v,
progress: 1,
loaded: contentLength,
total: contentLength,
url: `/credit-note/${creditNoteId}/file-slip/${v}`,
};
}),
))
: (attachmentData.value = await Promise.all(
list.map(async (v) => {
const rse = await creditNote.headAttachment({
parentId: creditNoteId,
fileId: v,
});
let contentLength = 0;
if (rse) contentLength = Number(rse['content-length']);
return {
name: v,
progress: 1,
loaded: contentLength,
total: contentLength,
url: `/credit-note/${creditNoteId}/attachment/${v}`,
};
}),
));
}
onMounted(async () => {
initTheme();
initLang();
await useConfigStore().getConfig();
await getCreditNote();
await getQuotation();
creditNoteData.value && (await getFileList(creditNoteData.value.id, true));
initStatus();
});
</script>
<template>
<div class="column surface-0 fullscreen">
<div class="color-bar" :class="{ ['dark']: $q.dark.isActive }">
<div :class="{ ['indigo-segment']: true }"></div>
<div :class="{ ['light-indigo-segment']: true }"></div>
<div class="white-segment"></div>
</div>
<!-- SEC: Header -->
<header
class="row q-px-md q-py-sm items-center justify-between relative-position"
>
<section class="banner" :class="{ dark: $q.dark.isActive }"></section>
<div style="flex: 1" class="row items-center">
<RouterLink to="/credit-note">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('creditNote.title') }}
<!-- {{ code || '' }} -->
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg: dateFormatJS({
date: creditNoteData?.createdAt || new Date(Date.now()),
monthStyle: 'long',
}),
})
}}
</span>
</span>
</div>
</header>
<!-- SEC: Body -->
<article
class="col full-width q-pa-md"
style="flex-grow: 1; overflow-y: auto"
>
<section class="col-sm col-12">
<div class="col q-gutter-y-md">
<nav
class="surface-1 q-pa-sm row no-wrap full-width scroll rounded"
style="gap: 10px"
>
<StateButton
v-for="i in statusTabForm"
:key="i.title"
:label="
$t(
`creditNote${i.title === 'title' ? '' : '.stats'}.${i.title}`,
)
"
:status-active="i.active?.()"
:status-done="i.status === 'done'"
:status-waiting="i.status === 'waiting'"
@click="i.handler()"
/>
</nav>
<DocumentExpansion
readonly
:registered-branch-id="quotationData?.registeredBranchId"
:customer-id="quotationData?.customerBranchId"
:quotation-code="quotationData?.code || '-'"
:quotation-work-name="quotationData?.workName || '-'"
:quotation-contact-name="quotationData?.contactName || '-'"
:quotation-contact-tel="quotationData?.contactTel || '-'"
@goto-quotation="goToQuotation"
/>
<q-form
ref="refForm"
greedy
@submit.prevent
@validation-success="submit"
>
<CreditNoteExpansion
v-if="view === null"
:readonly="readonly"
v-model:reason="formData.reason"
v-model:detail="formData.detail"
/>
</q-form>
<ProductExpansion
v-if="view === null"
creditNote
:readonly="readonly"
:agentPrice="quotationData?.agentPrice"
:task-list="taskListGroup"
@add-product="openProductDialog"
/>
<PaymentExpansion
v-if="view === null"
:readonly="readonly"
:total-price="summaryPrice.finalPrice"
v-model:payback-type="formData.paybackType"
v-model:payback-bank="formData.paybackBank"
v-model:payback-account="formData.paybackAccount"
v-model:payback-account-name="formData.paybackAccountName"
/>
<RefundInformation
v-if="view === CreditNoteStatus.Pending"
:total="creditNoteData?.value"
:paid="
creditNoteData?.paybackStatus === CreditNotePaybackStatus.Done
? creditNoteData?.value
: 0
"
:remain="
creditNoteData?.paybackStatus === CreditNotePaybackStatus.Pending
? creditNoteData?.value
: 0
"
:payback-status="creditNoteData?.paybackStatus"
v-model:payback-type="formData.paybackType"
v-model:payback-bank="formData.paybackBank"
v-model:payback-account="formData.paybackAccount"
v-model:payback-account-name="formData.paybackAccountName"
v-model:file-data="fileData"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
@change-status="changePaybackStatus"
@upload="
async (f) => {
if (!creditNoteData) return;
fileList = f;
await uploadFile(creditNoteData.id, f, true);
}
"
@remove="
async (n) => {
if (!creditNoteData) return;
await remove(creditNoteData.id, n, true);
}
"
/>
<AdditionalFileExpansion
v-if="view !== CreditNoteStatus.Success"
:readonly="false"
v-model:file-data="attachmentData"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
@fetch-file-list="
() => {
if (!creditNoteData) return;
getFileList(creditNoteData.id);
}
"
@upload="
async (f) => {
attachmentList = f;
attachmentData = [];
Array.from(f).forEach((el) => {
attachmentData.push({
name: el.name,
progress: 1,
loaded: 0,
total: el.size,
placeholder: true,
});
});
if (!creditNoteData) return;
await uploadFile(creditNoteData.id, f);
}
"
@remove="
async (n) => {
const attIndex = attachmentData.findIndex((v) => v.name === n);
attachmentData.splice(attIndex, 1);
if (!creditNoteData) return;
await remove(creditNoteData.id, n);
}
"
/>
<!-- TODO: bind remark -->
<RemarkExpansion
v-if="view !== CreditNoteStatus.Success"
:readonly="readonly"
v-model:remark="formData.remark"
/>
<QuotationFormReceipt
v-if="creditNoteData && view === CreditNoteStatus.Success"
hide-example-btn
:title="$t('creditNote.label.refund')"
:amount="creditNoteData.value"
:success-label="$t('creditNote.label.refundSuccess')"
:date="
creditNoteData.paybackDate
? new Date(creditNoteData.paybackDate)
: undefined
"
@view="openSlipDialog"
>
<template #payType>
<span v-if="creditNoteData.paybackType === 'BankTransfer'">
<q-img
:src="`/img/bank/${creditNoteData?.paybackBank}.png`"
class="bordered q-mr-xs"
style="border-radius: 50%; width: 20px"
/>
{{ useOptionStore().mapOption(creditNoteData?.paybackBank) }}
{{ creditNoteData?.paybackAccount }}
{{
`${$t('creditNote.label.accountName')} ${creditNoteData?.paybackAccountName}`
}}
</span>
<span v-else>{{ $t('creditNote.label.Cash') }}</span>
</template>
</QuotationFormReceipt>
</div>
</section>
</article>
<!-- SEC: footer -->
<footer class="surface-1 q-pa-md full-width">
<nav class="row justify-end">
<!-- TODO: view example -->
<MainButton
class="q-mr-auto"
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
@click="console.log('view example')"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<!-- @click="submit" -->
<SaveButton
v-if="!readonly"
type="submit"
@click.stop="(e) => refForm?.submit(e)"
:label="$t('creditNote.label.submit')"
icon="mdi-account-multiple-check-outline"
solid
></SaveButton>
</nav>
</footer>
</div>
<!-- SEC: Dialog -->
<SelectReadyRequestWork
v-if="quotationData"
creditNote
:fetch-params="{ cancelOnly: true, quotationId: quotationData.id }"
v-model:open="pageState.productDialog"
v-model:task-list="formTaskList"
/>
<DialogViewFile
hide-tab
download
v-model="pageState.fileDialog"
:url="fileData[0]?.url"
:transform-url="
async (url: string) => {
const result = await api.get<string>(url);
return result.data;
}
"
/>
</template>
<style scoped>
.color-bar {
width: 100%;
height: 1vh;
background: linear-gradient(
90deg,
rgb(47, 68, 173) 0%,
rgba(255, 255, 255, 1) 77%,
rgba(204, 204, 204, 1) 100%
);
display: flex;
overflow: hidden;
}
.color-bar.dark {
opacity: 0.7;
}
.indigo-segment {
background-color: var(--indigo-10);
flex-grow: 4;
}
.light-indigo-segment {
background-color: hsla(var(--indigo-10-hsl) / 0.2);
flex-grow: 0.5;
}
.white-segment {
background-color: #ffffff;
flex-grow: 1;
}
.indigo-segment,
.light-indigo-segment,
.white-segment {
transform: skewX(-60deg);
}
.banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url('/images/building-banner.png');
background-repeat: no-repeat;
background-size: cover;
z-index: -1;
&.dark {
filter: invert(100%);
}
}
</style>

View file

@ -0,0 +1,467 @@
<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 FormCredit from 'src/components/11_credit-note/FormCredit.vue';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
// NOTE: Stores & Type
import { useNavigator } from 'src/stores/navigator';
import useFlowStore from 'src/stores/flow';
import { pageTabs, columns, hslaColors } from './constants';
import { CreditNoteStatus, useCreditNote } from 'src/stores/credit-note';
import TableCreditNote from './TableCreditNote.vue';
import { dialogWarningClose } from 'src/stores/utils';
const { t } = useI18n();
const flow = useFlowStore();
const navigator = useNavigator();
const creditNote = useCreditNote();
const { stats, pageMax, page, data, pageSize } = storeToRefs(creditNote);
// NOTE: Variable
const pageState = reactive({
quotationId: '',
currentTab: CreditNoteStatus.Pending,
hideStat: false,
statusFilter: 'None',
inputSearch: '',
fieldSelected: columns
.filter((v) => !v.name.startsWith('#'))
.map((v) => v.name),
gridView: false,
total: 0,
creditDialog: 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 creditNote.getCreditNoteList({
page: opts?.page || page.value,
pageSize: opts?.pageSize || pageSize.value,
query: pageState.inputSearch === '' ? undefined : pageState.inputSearch,
creditNoteStatus: pageState.currentTab as CreditNoteStatus | undefined,
});
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 creditNote.deleteCreditNote(id);
if (!!res) {
getList();
}
},
cancel: () => {},
});
}
async function triggerCreateCreditNote() {
pageState.creditDialog = true;
}
function navigateTo(opts: {
statusDialog: 'info' | 'edit' | 'create';
quotationId?: string;
creditId?: string;
}) {
const url = new URL(
`/credit-note/${opts.statusDialog === 'create' ? 'add' : opts.creditId}`,
window.location.origin,
);
if (opts.statusDialog === 'create') {
url.searchParams.append('quotationId', opts.quotationId || '');
}
window.open(url.toString(), '_blank');
}
async function submit() {
navigateTo({ statusDialog: 'create', quotationId: pageState.quotationId });
}
function close() {
pageState.creditDialog = false;
}
onMounted(async () => {
navigator.current.title = 'creditNote.title';
navigator.current.path = [{ text: 'creditNote.caption', i18n: true }];
creditNote.getCreditNoteStats().then((res) => res && (stats.value = res));
getList();
});
watch(
[
() => pageState.currentTab,
() => pageState.inputSearch,
() => pageSize.value,
() => pageState.statusFilter,
],
() => {
getList();
},
);
</script>
<template>
<FloatingActionButton
style="z-index: 999"
hide-icon
@click.stop="triggerCreateCreditNote()"
></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[CreditNoteStatus.Pending],
label: `creditNote.stats.${CreditNoteStatus.Pending}`,
color: 'blue',
},
{
icon: 'mdi-check-decagram-outline',
count: stats[CreditNoteStatus.Success],
label: `creditNote.stats.${CreditNoteStatus.Success}`,
color: 'orange',
},
]"
: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="q-mr-md col-12 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-12 col-md-3 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col"
: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(`creditNote.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">
<TableCreditNote
:grid="pageState.gridView"
:visible-columns="pageState.fieldSelected"
:hide-delete="pageState.currentTab === CreditNoteStatus.Success"
@view="(v) => navigateTo({ statusDialog: 'info', creditId: v.id })"
@delete="(v) => triggerDelete(v.id)"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
hide-kebab-edit
@view="
() =>
navigateTo({
statusDialog: 'info',
creditId: item.row.id,
})
"
@delete="() => triggerDelete(item.row.id)"
:title="item.row.quotation.workName"
:code="item.row.code"
:status="$t(`creditNote.status.${item.row.creditNoteStatus}`)"
:badge-color="hslaColors[item.row.creditNoteStatus] || ''"
:custom-data="[
{
label: $t('branch.card.branchVirtual'),
value:
$i18n.locale === 'tha'
? item.row.quotation.registeredBranch.name
: item.row.quotation.registeredBranch.nameEN,
},
{
label: $t('quotation.customer'),
value:
item.row.quotation.customerBranch.customer
.customerType === 'CORP'
? item.row.quotation.customerBranch.customerName
: $i18n.locale === 'tha'
? `${item.row.quotation.customerBranch.firstName} ${item.row.quotation.customerBranch.lastName}`
: `${item.row.quotation.customerBranch.firstNameEN} ${item.row.quotation.customerBranch.lastNameEN}`,
},
{
label: $t('requestList.quotationCode'),
value: item.row.quotation.code,
},
{
label: $t('creditNote.label.quotationPayment'),
value: $t(
`quotation.type.${item.row.quotation.payCondition}`,
),
},
]"
/>
</div>
</template>
</TableCreditNote>
</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">
{{
$t('general.recordsPage', {
resultcurrentPage: 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="() => getList()"
/>
</nav>
</footer>
</div>
</section>
</div>
<!-- SEC: Dialog -->
<!-- dialog create -->
<DialogFormContainer
width="60vw"
height="500px"
v-model="pageState.creditDialog"
@submit="submit"
>
<template #header>
<DialogHeader
:title="$t(`general.add`, { text: $t('creditNote.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 -->
<FormCredit 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('creditNote.title') })"
class="q-ml-sm"
icon="mdi-check"
solid
type="submit"
/>
</template>
</DialogFormContainer>
</template>
<style scoped></style>

View file

@ -0,0 +1,280 @@
<script setup lang="ts">
import useOptionStore from 'src/stores/options';
import { formatNumberDecimal } from 'src/stores/utils';
import UploadFileSection from 'src/components/upload-file/UploadFileSection.vue';
import { computed, ref } from 'vue';
import { CreditNotePaybackStatus } from 'src/stores/credit-note/types';
const props = withDefaults(
defineProps<{
paybackType: 'Cash' | 'BankTransfer';
paybackBank: string;
paybackAccount: string;
paybackAccountName: string;
paybackStatus: CreditNotePaybackStatus;
readonly?: boolean;
total?: number;
paid?: number;
remain?: number;
transformUrl?: (url: string) => string | Promise<string>;
}>(),
{
paybackStatus: CreditNotePaybackStatus.Pending,
paybackType: 'Cash',
total: 0,
paid: 0,
remain: 0,
},
);
defineEmits<{
(e: 'changeStatus', status: CreditNotePaybackStatus): void;
(e: 'upload', file: FileList): void;
(e: 'remove', name: string): void;
}>();
const fileData = defineModel<
{
name: string;
progress: number;
loaded: number;
total: number;
url?: string;
}[]
>('fileData', { default: [] });
const currStatus = computed(() =>
refundOpts.value.find((v) => v.value === props.paybackStatus),
);
const refundOpts = ref<
{
icon: string;
value: CreditNotePaybackStatus;
color: string;
}[]
>([
{
value: CreditNotePaybackStatus.Pending,
icon: 'mdi-hand-coin-outline',
color: 'warning',
},
{
value: CreditNotePaybackStatus.Verify,
icon: 'mdi-credit-card-clock-outline',
color: 'danger',
},
{
value: CreditNotePaybackStatus.Done,
icon: 'mdi-check-decagram-outline',
color: 'positive',
},
]);
</script>
<template>
<div class="surface-1 rounded q-px-md q-py-sm">
<span
class="row items-center justify-between full-width text-medium text-body1"
style="min-height: 31.01px"
>
{{ $t('general.information', { msg: $t('creditNote.label.refund') }) }}
</span>
<section :class="{ row: $q.screen.gt.sm }">
<article
class="col column q-pr-md"
:class="{
'bordered-r': $q.screen.gt.sm,
'bordered-b q-pb-sm': $q.screen.lt.md,
}"
>
<div class="row">
<span class="app-text-muted q-mr-auto">
{{ $t('creditNote.label.refundMethod') }}
</span>
<q-badge>
{{ $t(`creditNote.label.${paybackType}`) }}
</q-badge>
</div>
<div class="row" v-if="paybackType === 'BankTransfer'">
<span class="app-text-muted q-mr-auto">
{{ $t('branch.form.bank') }}
</span>
<span>
<q-img
:src="`/img/bank/${paybackBank}.png`"
class="bordered q-mr-xs"
style="border-radius: 50%; width: 20px"
/>
{{ useOptionStore().mapOption(paybackBank) }}
{{ paybackAccount }}
{{ `${$t('creditNote.label.accountName')} ${paybackAccountName}` }}
</span>
</div>
</article>
<article
class="col row"
:class="{ 'q-pl-md': $q.screen.gt.sm, 'q-pt-sm': $q.screen.lt.md }"
>
<span class="col-12 app-text-muted">
{{ $t('creditNote.label.totalRefund') }}
</span>
<article
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"
style="border: 1px solid hsl(var(--info-bg))"
>
{{ $t('creditNote.label.totalAmount') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(total) }}
</span>
</span>
<span
class="row col rounded q-px-sm q-mx-md q-py-md positive"
style="border: 1px solid hsl(var(--positive-bg))"
>
{{ $t('creditNote.label.paid') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(paid) }}
</span>
</span>
<span
class="row col rounded q-px-sm q-py-md warning"
style="border: 1px solid hsl(var(--warning-bg))"
>
{{ $t('creditNote.label.remain') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(remain) }}
</span>
</span>
</article>
</article>
</section>
</div>
<div class="surface-1 rounded">
<span
class="bordered-b q-px-md q-py-sm row items-center justify-between full-width text-medium text-body1"
style="min-height: 31.01px"
>
{{ $t('creditNote.label.refund') }}
<q-btn-dropdown
dense
unelevated
:label="$t(`creditNote.status.payback.${paybackStatus}`)"
class="text-capitalize text-weight-regular product-status rounded"
:class="{
warning: paybackStatus === CreditNotePaybackStatus.Pending,
danger: paybackStatus === CreditNotePaybackStatus.Verify,
'positive hide-dropdown q-pr-md':
paybackStatus === CreditNotePaybackStatus.Done,
}"
:menu-offset="[0, 8]"
dropdown-icon="mdi-chevron-down"
content-class="bordered rounded"
@click.stop
:icon="currStatus?.icon"
>
<q-list v-if="paybackStatus !== CreditNotePaybackStatus.Done" dense>
<q-item
v-for="(v, index) in paybackStatus ===
CreditNotePaybackStatus.Verify
? refundOpts.filter(
(v) => v.value === CreditNotePaybackStatus.Done,
)
: refundOpts.filter(
(v) => v.value !== CreditNotePaybackStatus.Pending,
)"
clickable
v-close-popup
class="items-center"
:key="index"
@click="$emit('changeStatus', v.value)"
>
<q-icon
:style="`color: hsl(var(--${v.color}-bg))`"
:name="v.icon"
class="q-pr-sm"
size="xs"
></q-icon>
{{ $t(`creditNote.status.payback.${v.value}`) }}
</q-item>
</q-list>
</q-btn-dropdown>
</span>
<section class="q-px-md q-py-sm">
<UploadFileSection
multiple
:layout="$q.screen.gt.sm ? 'column' : 'row'"
:readonly
:label="`${$t('general.upload', { msg: ' E-slip' })} ${$t(
'general.or',
{
msg: $t('general.upload', {
msg: $t('creditNote.label.refundDocs'),
}),
},
)}`"
:transform-url="transformUrl"
v-model:file-data="fileData"
@update:file="(f) => $emit('upload', f as unknown as FileList)"
@close="(v) => $emit('remove', v)"
/>
</section>
</div>
</template>
<style scoped>
.gradient-stat {
& .info {
background-image: linear-gradient(
to right,
hsl(var(--info-bg) / 0.15),
var(--surface-1)
);
}
& .positive {
background-image: linear-gradient(
to right,
hsl(var(--positive-bg) / 0.15),
var(--surface-1)
);
}
& .warning {
background-image: linear-gradient(
to right,
hsl(var(--warning-bg) / 0.15),
var(--surface-1)
);
}
}
.product-status {
padding-left: 8px;
border-radius: 20px;
color: hsl(var(--_color));
background: hsla(var(--_color) / 0.15);
&.warning {
--_color: var(--warning-bg);
}
&.danger {
--_color: var(--danger-bg);
}
&.positive {
--_color: var(--positive-bg);
}
}
:deep(
.hide-dropdown
i.q-icon.mdi.mdi-chevron-down.q-btn-dropdown__arrow.q-btn-dropdown__arrow-container
) {
display: none;
}
</style>

View file

@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { QTableSlots } from 'quasar';
import { CreditNote, useCreditNote } from 'src/stores/credit-note';
import { columns } from './constants.ts';
import KebabAction from 'src/components/shared/KebabAction.vue';
const creditNote = useCreditNote();
const { data, page } = storeToRefs(creditNote);
const prop = defineProps<{
grid: boolean;
visibleColumns: string[];
hideDelete: boolean;
}>();
defineEmits<{ (evt: 'view' | 'delete', val: CreditNote): 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 }))"
: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: CreditNote & { _index: number; _page: number };
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr :class="{ dark: $q.dark.isActive }" class="text-center">
<q-td v-for="(col, i) in visible" :align="col.align" :key="i">
<!-- 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 CreditNote]
: 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-edit
hide-toggle
:hide-delete
@delete="$emit('delete', props.row)"
@view="$emit('view', props.row)"
/>
</template>
</q-td>
</q-tr>
</template>
<template v-slot:item="props: { row: CreditNote }">
<slot name="grid" :item="props" />
</template>
</q-table>
</template>

View file

@ -0,0 +1,78 @@
import { QTableProps } from 'quasar';
import { CreditNote, CreditNoteStatus } from 'src/stores/credit-note';
import { formatNumberDecimal } from 'src/stores/utils';
export const taskStatusOpts = [
{
status: CreditNoteStatus.Pending,
name: `creditNote.status.${CreditNoteStatus.Pending}`,
},
{
status: CreditNoteStatus.Success,
name: `creditNote.status.${CreditNoteStatus.Success}`,
},
];
export const pageTabs = [
{ label: CreditNoteStatus.Pending, value: CreditNoteStatus.Pending },
{ label: CreditNoteStatus.Success, value: CreditNoteStatus.Success },
];
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: CreditNote & { _index: number; _page: number }) =>
data._page * (data._index + 1),
},
{
name: 'code',
align: 'center',
label: 'creditNote.label.code',
field: (data: CreditNote) => data.code,
},
{
name: 'quotationCode',
align: 'center',
label: 'creditNote.label.quotationCode',
field: (data: CreditNote) => data.quotation.code,
},
{
name: 'quotationWorkName',
align: 'center',
label: 'creditNote.label.quotationWorkName',
field: (data: CreditNote) => data.quotation.workName,
},
{
name: 'quotationPayment',
align: 'center',
label: 'creditNote.label.quotationPayment',
field: (data: CreditNote) => data.quotation.payCondition,
},
{
name: 'creditNoteValue',
align: 'center',
label: 'creditNote.label.value',
field: (data: CreditNote) => formatNumberDecimal(data.value),
},
{
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,53 @@
<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('creditNote.label.creditNoteInformation') }}
</span>
</template>
<main class="q-px-md q-py-sm surface-1 row q-col-gutter-sm">
<SelectInput
:readonly
for="select-credit-note-specify-reason"
:label="$t('creditNote.label.specifyReason')"
class="col-md col-12"
v-model="reason"
:option="[
{ label: $t('creditNote.label.reasonReturn'), value: 'Return' },
{ label: $t('creditNote.label.reasonCanceled'), value: 'Canceled' },
]"
:rules="[(val: string) => !!val || $t('form.error.required')]"
></SelectInput>
<q-input
:readonly
for="input-credit-note-additional-detail"
:label="$t('creditNote.label.additionalDetail')"
outlined
dense
class="col"
v-model="detail"
:rules="[(val: string) => !!val || $t('form.error.required')]"
></q-input>
</main>
</q-expansion-item>
</template>
<style scoped></style>

View file

@ -0,0 +1,108 @@
<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 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-4 col-6"
:model-value="issueDate || new Date(Date.now())"
:readonly
:disabled="!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,235 @@
<script lang="ts" setup>
import AppBox from 'src/components/app/AppBox.vue';
import useOptionStore from 'src/stores/options';
import SelectInput from 'src/components/shared/SelectInput.vue';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { formatNumberDecimal } from 'src/stores/utils';
import { watch } from 'vue';
withDefaults(
defineProps<{
readonly?: boolean;
totalPrice?: number;
}>(),
{
totalPrice: 0,
},
);
const optionStore = useOptionStore();
const paybackType = defineModel<'BankTransfer' | 'Cash'>('paybackType');
const paybackBank = defineModel<string>('paybackBank');
const paybackAccount = defineModel<string>('paybackAccount');
const paybackAccountName = defineModel<string>('paybackAccountName');
watch(
() => paybackType.value,
() => {
if (paybackType.value === 'Cash') {
paybackBank.value = paybackAccount.value = paybackAccountName.value = '';
}
},
);
</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="q-px-md q-py-sm surface-1">
<AppBox
no-padding
bordered
class="credit-note-color"
:class="{
row: $q.screen.gt.sm,
column: $q.screen.lt.md,
}"
>
<section class="col bordered-r">
<header
class="bordered-b q-px-md q-py-sm row bg-color-light items-center"
>
<div class="icon-wrapper bg-color q-mr-sm">
<q-icon name="mdi-bank-outline" />
</div>
<span class="text-weight-bold">
{{ $t('quotation.paymentCondition') }}
</span>
</header>
<div class="q-pa-sm">
<div class="row q-col-gutter-sm">
<SelectInput
for="select-credit-note-pay-type"
:label="$t('quotation.payType')"
dense
outlined
:readonly
class="col-md-4 col-12"
v-model="paybackType"
:option="[
{
label: $t('creditNote.label.Cash'),
value: 'Cash',
},
{
label: $t('creditNote.label.BankTransfer'),
value: 'BankTransfer',
},
]"
/>
<SelectInput
:disable="paybackType === 'Cash'"
for="select-credit-note-bank"
dense
outlined
fill-input
:hide-selected="false"
:readonly
class="col-md-8 col-12"
:label="$t('branch.form.bank')"
:option="optionStore.globalOption?.bankBook"
v-model="paybackBank"
clearable
>
<template #option="{ scope }">
<q-item
v-if="scope.opt"
v-bind="scope.itemProps"
class="row items-center"
>
<q-item-section avatar>
<q-img
:src="`/img/bank/${scope.opt.value}.png`"
class="bordered"
style="border-radius: 50%"
/>
</q-item-section>
<q-item-section>
{{ scope.opt.label }}
</q-item-section>
</q-item>
</template>
<template #selected-item="{ scope }">
<q-item-section v-if="scope.opt" avatar class="q-py-sm">
<q-img
:src="`/img/bank/${scope.opt.value}.png`"
class="bordered"
style="border-radius: 50%; width: 30px"
/>
</q-item-section>
</template>
</SelectInput>
<q-input
:disable="paybackType === 'Cash'"
for="input-credit-account-number"
v-model="paybackAccount"
dense
outlined
:readonly
class="col-md-4 col-12"
:label="$t('creditNote.label.accountNumber')"
/>
<q-input
:disable="paybackType === 'Cash'"
for="input-credit-account-name"
v-model="paybackAccountName"
dense
outlined
:readonly
class="col-md-8 col-12"
:label="$t('creditNote.label.accountName')"
/>
</div>
</div>
</section>
<section class="col column">
<header
class="bordered-b q-px-md q-py-sm row bg-color-light items-center"
>
<div class="icon-wrapper bg-color q-mr-sm">
<Icon icon="iconoir:coins" />
</div>
<span class="text-weight-bold">
{{ $t('quotation.summary') }}
</span>
</header>
<div class="q-pa-sm price-container col">
<div class="row">
{{ $t('general.total') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(totalPrice) }}
฿
</span>
</div>
</div>
<div class="q-pa-sm row surface-2 items-center text-weight-bold">
{{ $t('quotation.totalPriceBaht') }}
<span class="q-ml-auto" style="color: var(--brand-1)">
{{ formatNumberDecimal(totalPrice) }}
฿
</span>
</div>
</section>
</AppBox>
</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,379 @@
<script lang="ts" setup>
// NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue';
import { storeToRefs } from 'pinia';
// 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 TableReceipt from './TableReceipt.vue';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
// NOTE: Stores & Type
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 { usePayment, useReceipt } from 'src/stores/payment';
import { Receipt, PaymentDataStatus } from 'src/stores/payment/types';
import { Quotation } from 'src/stores/quotations';
import { useQuasar } from 'quasar';
const navigatorStore = useNavigator();
const flowStore = useFlowStore();
const receiptStore = useReceipt();
const { data, page, pageMax, pageSize } = storeToRefs(receiptStore);
// NOTE: Variable
const pageState = reactive({
hideStat: false,
statusFilter: 'None' as 'None' | PaymentDataStatus,
inputSearch: '',
fieldSelected: [...columns.map((v) => v.name)],
gridView: false,
total: 0,
});
const fieldSelectedOption = computed(() => {
return columns.map((v) => ({
label: v.label,
value: v.name,
}));
});
async function fetchList(opts?: { rotateFlowId?: boolean }) {
const ret = await receiptStore.getReceiptList({
page: page.value,
pageSize: pageSize.value,
query: pageState.inputSearch,
});
if (ret) {
data.value = ret.result;
pageState.total = ret.total;
pageMax.value = Math.ceil(ret.total / pageSize.value);
}
if (opts?.rotateFlowId) flowStore.rotate();
}
function triggerView(opts: { quotationId: string }) {
const url = new URL(`/quotation/view?tab=receipt`, window.location.origin);
localStorage.setItem(
'new-quotation',
JSON.stringify({
quotationId: opts.quotationId,
statusDialog: 'info',
}),
);
window.open(url.toString(), '_blank');
}
const paymentStore = usePayment();
async function viewDocExample(id: string) {
const url = await paymentStore.getFlowAccount(id);
if (url) {
window.open(url.data.link, '_blank');
}
}
onMounted(async () => {
navigatorStore.current.title = 'receipt.title';
navigatorStore.current.path = [{ text: 'receipt.caption', i18n: true }];
await fetchList({ rotateFlowId: true });
});
watch(
[
() => pageState.inputSearch,
() => pageState.statusFilter,
() => pageSize.value,
() => page.value,
],
() => fetchList({ rotateFlowId: true }),
);
</script>
<template>
<div class="column full-height no-wrap">
<!-- SEC: stat -->
<section class="text-body-2 q-mb-xs flex items-center">
{{ $t('receipt.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: 'fluent:receipt-money-16-regular',
count: pageState.total,
label: 'quotation.status.PaymentSuccess',
color: 'light-green',
},
]"
: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 bordered-b"
style="z-index: 1"
>
<div class="row q-py-sm q-px-md justify-between full-width">
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 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-12 col-md-5 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<q-select
v-model="pageState.statusFilter"
outlined
dense
option-value="value"
option-label="label"
class="col"
:class="{ 'offset-md-5': pageState.gridView }"
map-options
emit-value
:for="'field-select-status'"
:hide-dropdown-icon="$q.screen.lt.sm"
:options="[
{
label: $t('general.all'),
value: 'None',
},
]"
/>
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col 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>
</div>
</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">
<TableReceipt
:columns="columns"
:rows="data"
:grid="pageState.gridView"
@view="
(data) => triggerView({ quotationId: data.invoice.quotation.id })
"
@preview="(data) => viewDocExample(data.id)"
>
<template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
hide-action
:code="item.row.code"
:title="item.row.invoice.quotation.workName"
:status="$t(`invoice.status.${item.row.paymentStatus}`)"
:badge-color="hslaColors[item.row.paymentStatus] || ''"
:custom-data="[
{
label: $t('general.customer'),
value:
item.row.invoice.quotation.customerBranch
.registerName ||
`${item.row.invoice.quotation.customerBranch?.firstName || '-'} ${item.row.invoice.quotation.customerBranch?.lastName || ''}`,
},
{
label: $t('taskOrder.issueDate'),
value: new Date(
item.row.invoice.quotation.createdAt,
).toLocaleString('th-TH', {
hour12: false,
}),
},
{
label: $t('invoice.paymentDueDate'),
value: new Date(
item.row.invoice.quotation.dueDate,
).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: 'numeric',
}),
},
{
label: $t('quotation.payType'),
value: $t(
`quotation.type.${item.row.invoice.quotation.payCondition}`,
),
},
{
label: $t('preview.netValue'),
value: item.row.amount,
},
]"
@view="
() =>
triggerView({
quotationId: item.row.invoice.quotation.id,
})
"
@preview="
() => {
viewDocExample(item.row.invoice.quotation.id);
}
"
/>
</div>
</template>
</TableReceipt>
</article>
<!-- SEC: footer content -->
<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">
{{
$t('general.recordsPage', {
resultcurrentPage: 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="() => fetchList({ rotateFlowId: true })"
/>
</nav>
</footer>
</div>
</section>
</div>
</template>
<style scoped></style>

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { QTableSlots } from 'quasar';
import { columns } from './constants';
import { Receipt } from 'src/stores/payment/types';
import { useReceipt } from 'src/stores/payment';
const receiptStore = useReceipt();
type Data = Receipt;
withDefaults(
defineProps<{
rows: Data[];
readonly?: boolean;
flat?: boolean;
bordered?: boolean;
grid?: boolean;
hideHeader?: boolean;
buttonDownload?: boolean;
buttonDelete?: boolean;
hidePagination?: boolean;
inTable?: boolean;
hideView?: boolean;
btnSelected?: boolean;
imgColumn?: string;
customColumn?: string[];
}>(),
{
row: () => [],
flat: false,
bordered: false,
grid: false,
hideHeader: false,
buttonDownload: false,
imgColumn: '',
customColumn: () => [],
},
);
defineEmits<{
(e: 'preview' | 'view', data: Data): void;
(e: 'edit' | 'delete' | 'download', data: Data, index: number): void;
}>();
</script>
<template>
<q-table
:rows-per-page-options="[0]"
:rows="rows.map((data, i) => ({ ...data, _index: i }))"
:columns
:grid
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">
{{ $t(col.label) }}
</q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: Receipt & { _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('#')">
<span>
{{
typeof col.field === 'string'
? props.row[col.field as keyof Receipt]
: col.field(props.row)
}}
</span>
</template>
<template v-if="col.name === '#order'">
{{
col.field(props.row) +
(receiptStore.page - 1) * receiptStore.pageSize
}}
</template>
<template v-if="col.name === '#action'">
<q-btn
:id="`btn-preview-${props.row.invoice.quotation.workName}`"
flat
dense
rounded
icon="mdi-play-box-outline"
size="12px"
:title="$t('preview.doc')"
@click.stop="$emit('preview', props.row)"
/>
<q-btn
:id="`btn-eye-${props.row.invoice.quotation.workName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row)"
/>
</template>
</q-td>
</q-tr>
</template>
<template v-slot:item="props: { row: Receipt }">
<slot name="grid" :item="props" />
</template>
</q-table>
</template>
<style lang="scss" scoped>
:deep(i.q-icon.mdi.mdi-alert.q-table__bottom-nodata-icon) {
color: #ffc224 !important;
}
</style>

View file

@ -0,0 +1,95 @@
import { QTableProps } from 'quasar';
import { Invoice, Receipt } from 'src/stores/payment/types';
import { formatNumberDecimal } from 'src/stores/utils';
import { dateFormatJS } from 'src/utils/datetime';
export const columns = [
{
name: '#order',
align: 'center',
label: 'general.order',
field: (v: Receipt & { _index: number }) => v._index + 1,
},
{
name: 'quotation',
align: 'center',
label: 'requestList.quotationCode',
field: 'code',
},
{
name: 'workSheetName',
align: 'left',
label: 'receipt.workSheetName',
field: (v: Receipt) => v.invoice.quotation.workName,
},
{
name: 'issueDate',
align: 'center',
label: 'taskOrder.issueDate',
field: (v: Receipt) =>
dateFormatJS({
date: v.invoice.quotation.createdAt,
withTime: true,
}),
},
{
name: 'value',
align: 'center',
label: 'preview.value',
field: (v: Receipt) => formatNumberDecimal(v.amount, 2),
},
{
name: '#action',
align: 'center',
label: '',
field: (_) => '#action',
},
] as const satisfies QTableProps['columns'];
export const docColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'document',
align: 'left',
label: 'general.document',
field: 'document',
},
{
name: 'attachment',
align: 'left',
label: 'requestList.attachment',
field: 'attachment',
},
{
name: 'amount',
align: 'center',
label: 'general.amount',
field: 'amount',
},
{
name: 'documentInSystem',
align: 'center',
label: 'requestList.documentInSystem',
field: 'documentInSystem',
},
{
name: 'status',
align: 'center',
label: 'general.status',
field: 'status',
},
] as const satisfies QTableProps['columns'];
export const hslaColors: Record<string, string> = {
PaymentWait: '--orange-5-hsl',
PaymentSuccess: '--green-8-hsl',
};

View file

@ -105,6 +105,21 @@ const routes: RouteRecordRaw[] = [
name: 'TaskOrder', name: 'TaskOrder',
component: () => import('pages/09_task-order/MainPage.vue'), component: () => import('pages/09_task-order/MainPage.vue'),
}, },
{
path: '/credit-note',
name: 'CreditNote',
component: () => import('pages/11_credit-note/MainPage.vue'),
},
{
path: '/invoice',
name: '/Invoice',
component: () => import('pages/10_invoice/MainPage.vue'),
},
{
path: '/receipt',
name: 'receipt',
component: () => import('pages/13_receipt/MainPage.vue'),
},
], ],
}, },
@ -148,6 +163,21 @@ const routes: RouteRecordRaw[] = [
name: 'docOrder', name: 'docOrder',
component: () => import('pages/09_task-order/document_view/MainPage.vue'), component: () => import('pages/09_task-order/document_view/MainPage.vue'),
}, },
{
path: '/credit-note/add',
name: 'CreditNoteNew',
component: () => import('pages/11_credit-note/FormPage.vue'),
},
{
path: '/credit-note/:id',
name: 'CreditNoteView',
component: () => import('pages/11_credit-note/FormPage.vue'),
},
{
path: '/receipt/:id',
name: 'receiptform',
component: () => import('pages/13_receipt/MainPage.vue'),
},
// Always leave this as last one, // Always leave this as last one,
// but you can also remove it // but you can also remove it

View file

@ -0,0 +1,103 @@
import {
CreditNote as Data,
CreditNoteStatus as Status,
CreditNotePayload as Payload,
CreditNotePaybackStatus,
} from './types.ts';
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { api } from 'src/boot/axios.ts';
import { PaginationResult } from 'src/types.ts';
import { manageAttachment, manageFile } from '../utils/index.ts';
const ENDPOINT = 'credit-note';
export * from './types.ts';
export async function getCreditNoteStats() {
const res = await api.get<Record<Status, number>>(`/${ENDPOINT}/stats`);
if (res.status < 400) {
return res.data;
}
return null;
}
export async function getCreditNoteList(params?: {
page?: number;
pageSize?: number;
query?: string;
creditNoteStatus?: Status;
}) {
const res = await api.get<PaginationResult<Data>>(`/${ENDPOINT}`, {
params,
});
if (res.status < 400) return res.data;
return null;
}
export async function getCreditNote(id: string) {
const res = await api.get<Data>(`/${ENDPOINT}/${id}`);
if (res.status < 400) return res.data;
return null;
}
export async function createCreditNote(body: Payload) {
const res = await api.post<Data>(`/${ENDPOINT}`, body);
if (res.status < 400) return res.data;
return null;
}
export async function updateCreditNote(id: string, body: Payload) {
const res = await api.put<Data>(`/${ENDPOINT}/${id}`, body);
if (res.status < 400) return res.data;
return null;
}
export async function deleteCreditNote(id: string) {
const res = await api.delete<Data>(`/${ENDPOINT}/${id}`);
if (res.status < 400) return res.data;
return null;
}
export async function updatePaybackStatus(
creditNoteId: string,
status: CreditNotePaybackStatus,
) {
const res = await api.post(`/${ENDPOINT}/${creditNoteId}/payback-status`, {
paybackStatus: status,
});
if (res.status < 400) return true;
return null;
}
export const useCreditNote = defineStore('credit-note-store', () => {
const data = ref<Data[]>([]);
const page = ref<number>(1);
const pageMax = ref<number>(1);
const pageSize = ref<number>(30);
const stats = ref<Record<Status, number>>({
[Status.Pending]: 0,
[Status.Success]: 0,
});
return {
data,
page,
pageMax,
pageSize,
stats,
getCreditNoteStats,
getCreditNote,
getCreditNoteList,
createCreditNote,
updateCreditNote,
deleteCreditNote,
...manageAttachment(api, ENDPOINT),
...manageFile<'slip'>(api, ENDPOINT),
action: { updatePaybackStatus },
};
});

View file

@ -0,0 +1,53 @@
import { QuotationFull } from '../quotations';
import { RequestWork } from '../request-list';
import { CreatedBy } from '../types';
export type CreditNotePayload = {
quotationId: string;
requestWorkId: string[];
remark?: string;
reason: string;
detail: string;
paybackType: 'BankTransfer' | 'Cash';
paybackBank: string;
paybackAccount: string;
paybackAccountName: string;
};
export type CreditNote = {
id: string;
code: string;
quotationId: string;
quotation: QuotationFull;
requestWork: RequestWork[];
remark?: string;
reason: string;
detail: string;
paybackType: 'BankTransfer' | 'Cash';
paybackBank: string;
paybackAccount: string;
paybackAccountName: string;
paybackStatus: CreditNotePaybackStatus;
paybackDate?: string | null;
value: number;
createdAt: string;
createdBy?: CreatedBy;
createdByUserId?: string;
creditNoteStatus: CreditNoteStatus;
};
export enum CreditNoteStatus {
Pending = 'Pending',
Success = 'Success',
}
export enum CreditNotePaybackStatus {
Pending = 'Pending',
Verify = 'Verify',
Done = 'Done',
}

View file

@ -8,6 +8,7 @@ import {
Payment, Payment,
Receipt, Receipt,
PaymentFlowAccount, PaymentFlowAccount,
PaymentDataStatus,
} from './types'; } from './types';
import { manageAttachment } from '../utils'; import { manageAttachment } from '../utils';
@ -122,6 +123,19 @@ export const useInvoice = defineStore('invoice-store', () => {
const page = ref<number>(1); const page = ref<number>(1);
const pageMax = ref<number>(1); const pageMax = ref<number>(1);
const pageSize = ref<number>(30); const pageSize = ref<number>(30);
const stats = ref<Record<PaymentDataStatus, number>>({
[PaymentDataStatus.Success]: 0,
[PaymentDataStatus.Wait]: 0,
});
async function getInvoiceStats(params?: { quotationId?: string }) {
const res = await api.get<Record<PaymentDataStatus, number>>(
'/invoice/stats',
{ params },
);
if (res.status >= 400) return null;
return res.data;
}
async function getInvoice(id: string) { async function getInvoice(id: string) {
const res = await api.get<Invoice>(`/invoice/${id}`); const res = await api.get<Invoice>(`/invoice/${id}`);
@ -136,6 +150,7 @@ export const useInvoice = defineStore('invoice-store', () => {
pageSize?: number; pageSize?: number;
query?: string; query?: string;
quotationId?: string; quotationId?: string;
pay?: boolean;
}) { }) {
const res = await api.get<PaginationResult<Invoice>>('/invoice', { const res = await api.get<PaginationResult<Invoice>>('/invoice', {
params, params,
@ -171,7 +186,9 @@ export const useInvoice = defineStore('invoice-store', () => {
page, page,
pageSize, pageSize,
pageMax, pageMax,
stats,
getInvoiceStats,
getInvoice, getInvoice,
getInvoiceList, getInvoiceList,
createInvoice, createInvoice,

View file

@ -16,9 +16,14 @@ export type Invoice = {
createdBy: CreatedBy; createdBy: CreatedBy;
createdByUserId: string; createdByUserId: string;
payment?: Payment; payment: Payment;
}; };
export enum PaymentDataStatus {
Success = 'PaymentSuccess',
Wait = 'PaymentWait',
}
export type InvoicePayload = { export type InvoicePayload = {
quotationId: string; quotationId: string;
amount: number; amount: number;
@ -39,7 +44,16 @@ export type Payment = {
paymentStatus: string; paymentStatus: string;
date: Date; date: Date;
amount: number; amount: number;
invoice: { id: string; amount: number; installments: { no: number }[] }; invoice: {
id: string;
amount: number;
installments: { no: number }[];
quotation: Quotation;
};
}; };
export enum PaymentDataStatus {
Success = 'PaymentSuccess',
Wait = 'PaymentWait',
}
export type Receipt = Payment; export type Receipt = Payment;

View file

@ -69,6 +69,8 @@ export const useQuotationStore = defineStore('quotation-store', () => {
| 'Canceled'; | 'Canceled';
urgentFirst?: boolean; urgentFirst?: boolean;
query?: string; query?: string;
hasCancel?: boolean;
includeRegisteredBranch?: boolean;
}) { }) {
const res = await api.get<PaginationResult<Quotation>>('/quotation', { const res = await api.get<PaginationResult<Quotation>>('/quotation', {
params, params,

View file

@ -5,24 +5,26 @@ import { Invoice } from '../payment/types';
import { Employee } from '../employee/types'; import { Employee } from '../employee/types';
import { WorkflowTemplate } from '../workflow-template/types'; import { WorkflowTemplate } from '../workflow-template/types';
export type PayCondition = export enum PayCondition {
| 'Full' Full = 'Full',
| 'Split' Split = 'Split',
| 'SplitCustom' SplitCustom = 'SplitCustom',
| 'BillFull' BillFull = 'BillFull',
| 'BillSplit' BillSplit = 'BillSplit',
| 'BillSplitCustom'; BillSplitCustom = 'BillSplitCustom',
}
export type QuotationStatus = export enum QuotationStatus {
| 'Issued' Issued = 'Issued',
| 'Accepted' Accepted = 'Accepted',
| 'Expired' Expired = 'Expired',
| 'Invoice' Invoice = 'Invoice',
| 'PaymentPending' PaymentPending = 'PaymentPending',
| 'PaymentInProcess' PaymentInProcess = 'PaymentInProcess',
| 'PaymentSuccess' PaymentSuccess = 'PaymentSuccess',
| 'ProcessComplete' ProcessComplete = 'ProcessComplete',
| 'Canceled'; Canceled = 'Canceled',
}
export type CustomerBranchRelation = { export type CustomerBranchRelation = {
district: District; district: District;
@ -207,7 +209,7 @@ export type QuotationStats = {
}; };
export type Quotation = { export type Quotation = {
_count: { worker: number }; _count: { worker: number; canceledWork: number };
id: string; id: string;
finalPrice: number; finalPrice: number;
vat: number; vat: number;
@ -251,6 +253,7 @@ export type Quotation = {
| 'canceled'; | 'canceled';
registeredBranchId: string; registeredBranchId: string;
registeredBranch?: { id: string; name: string; nameEN: string; code: string };
customerBranchId: string; customerBranchId: string;
customerBranch: CustomerBranchRelation; customerBranch: CustomerBranchRelation;
@ -326,7 +329,7 @@ export type QuotationFull = {
customerBranchId: string; customerBranchId: string;
customerBranch: CustomerBranchRelation; customerBranch: CustomerBranchRelation;
registeredBranchId: string; registeredBranchId: string;
registeredBranch: { id: string; name: string }; registeredBranch: { id: string; name: string; nameEN: string; code: string };
createdByUserId: string; createdByUserId: string;
createdAt: string | Date; createdAt: string | Date;

View file

@ -223,6 +223,8 @@ export const useRequestList = defineStore('request-list', () => {
pageSize?: number; pageSize?: number;
workStatus?: RequestWorkStatus; workStatus?: RequestWorkStatus;
readyToTask?: boolean; readyToTask?: boolean;
quotationId?: string;
cancelOnly?: boolean;
}) { }) {
const res = await api.get<PaginationResult<RequestWork>>('/request-work', { const res = await api.get<PaginationResult<RequestWork>>('/request-work', {
params, params,

View file

@ -52,6 +52,8 @@ export type RequestWork = {
productServiceId: string; productServiceId: string;
request: RequestData; request: RequestData;
attributes?: Attributes; attributes?: Attributes;
creditNoteId?: string;
processByUserId?: string;
}; };
export type RowDocument = { export type RowDocument = {

View file

@ -355,13 +355,33 @@ export function manageFile<T extends string>(
parentId: string; parentId: string;
fileId: string; fileId: string;
file: File; file: File;
uploadUrl?: boolean;
onUploadProgress?: (e: AxiosProgressEvent) => void; onUploadProgress?: (e: AxiosProgressEvent) => void;
abortController?: AbortController; abortController?: AbortController;
}) => { }) => {
const res = await api.put( const res = opts.uploadUrl
`/${base}/${opts.parentId}/file-${opts.group}/${opts.fileId}`, ? await api.put(
opts.file, `/${base}/${opts.parentId}/file-${opts.group}/${opts.fileId}`,
{ )
: await api.put(
`/${base}/${opts.parentId}/file-${opts.group}/${opts.fileId}`,
opts.file,
{
headers: { 'Content-Type': opts.file.type },
onUploadProgress: opts.onUploadProgress
? opts.onUploadProgress
: option?.onUploadProgress
? option.onUploadProgress
: (e) => console.log(e),
signal: opts.abortController?.signal,
},
);
if (res.status >= 400) return false;
if (opts.uploadUrl && typeof res.data === 'string') {
// NOTE: Must use axios instance or else CORS error.
const uploadRes = await axios.put(res.data, opts.file, {
headers: { 'Content-Type': opts.file.type }, headers: { 'Content-Type': opts.file.type },
onUploadProgress: opts.onUploadProgress onUploadProgress: opts.onUploadProgress
? opts.onUploadProgress ? opts.onUploadProgress
@ -369,10 +389,12 @@ export function manageFile<T extends string>(
? option.onUploadProgress ? option.onUploadProgress
: (e) => console.log(e), : (e) => console.log(e),
signal: opts.abortController?.signal, signal: opts.abortController?.signal,
}, });
);
if (res.status < 400) return true; if (uploadRes.status >= 400) return true;
return false; }
return true;
}, },
delFile: async (opts: { group: T; parentId: string; fileId: string }) => { delFile: async (opts: { group: T; parentId: string; fileId: string }) => {
const res = await api.delete( const res = await api.delete(

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
test('Login', async () => { test('Login', async () => {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -20,7 +20,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -16,7 +16,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -16,7 +16,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -16,7 +16,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -19,7 +19,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -20,7 +20,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -19,7 +19,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -21,7 +21,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -19,7 +19,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -19,7 +19,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -20,7 +20,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -18,7 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -21,7 +21,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -22,7 +22,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -19,7 +19,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -17,7 +17,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -21,7 +21,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

View file

@ -22,7 +22,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
try { try {
// Login // Login
await page.goto('http://192.168.1.62:20101/'); await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /); await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin'); await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234'); await page.fill("input[name='password']", '1234');

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