feat: credit note (#171)
* feat: add main page credit note * feat: enable credit note route and update menu item states * refactor: add i18n * refactor: edit i18n status * feat: add action column * feat: add empty form page * feat: add get data * feat: add type credit note status * refactor: add type name en * refactor: add type credit note status in type credit note * feat: add hsla colors * refactor: add slot grid * refactor: handle hide kebab edit show only tab tssued * feat: show grid card * feat: i18n * feat: add credit note form and dialog * refactor: add props hide kebab deelete * refactor: hide kebab * style: update color segments to indigo theme * feat: i18n * fix: update labels for credit note fields * refactor: add type * feat: new select quotation * refactor: use new select quotation * feat: navigate to * refactor: function trigger and navigate to * feat: i18n bank * feat: add payment expansion component and integrate into credit note form * refactor: bind i18n pay condition * refactor: navigate to get quotation id * feat: i18n * fix: update label for createdBy field in credit note form * feat: add credit note information expansion component * feat: add Credit Note expansion component and update form layout * refactor: bind quotation id and send * refactor: deelete duplicate type * refactor: show state button * refactor: handle show status * feat: add function update payback status * feat: add return and canceled reasons to credit note translations * feat: enhance SelectReadyRequestWork component with credit note handling and fetch parameters * feat: type * feat: add status handling and optional display for employee table * refactor: rename selectedQuotationId to quotationId in FormCredit component * feat: set default opened state for CreditNoteExpansion and add reason options * feat: update PaymentExpansion to handle payback type selection and clear fields for cash payments * feat: enhance ProductExpansion to support credit note handling and adjust price calculations * feat: implement product handling and price calculation in CreditNote form * feat: add manage attachment function to store * refactor: bind delete credit note * feat: add credit note status and reference fields to types * refactor: update task step handling and simplify request work structure in credit note form * feat: add navigation to quotation from credit note form * feat: enhance upload section layout based on file data * feat: add readonly functionality to credit note form and related components * refactor: remove console log * feat: update i18n * style: add rounded corners to complete view container in quotation form * feat: add RefundInformation component and update credit note form status handling * feat: i18n * feat: update payback status endpoint and add paybackStatus to CreditNote type * feat: enhance QuotationFormReceipt component with optional props and slot support * feat: integrate payback status handling in RefundInformation and FormPage components * feat: add external file group * feat: update API endpoint paths for credit note operations * feat: improve layout and styling in UploadFile components * feat: implement file upload and management in Credit Note * refactor: update upload to check if it is redirect or not * feat: upload file slips * feat: add payback date dispaly * refactor: change module no * fix: icon link to main page instead * feat: add file dialog with image download functionality * fix: view slip * feat: add download button to image viewer * feat: handle after submit * feat: conditionally render bank transfer information * feat: handle upload file on create * feat: handle change payback status * feat: payback type in credit note form * fix: correct reference to quotation data in goToQuotation function --------- Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Co-authored-by: puriphatt <puriphat@frappet.com> Co-authored-by: Thanaphon Frappet <thanaphon@frappet.com>
This commit is contained in:
parent
0c694dee5d
commit
5e2100eb8d
34 changed files with 2897 additions and 77 deletions
|
|
@ -22,7 +22,9 @@ defineProps<{
|
|||
badgeColor?: string;
|
||||
hideKebabView?: boolean;
|
||||
hideKebabEdit?: boolean;
|
||||
hideKebabDelete?: boolean;
|
||||
hideAction?: boolean;
|
||||
useCancel?: boolean;
|
||||
|
||||
customData?: {
|
||||
label: string;
|
||||
|
|
@ -39,6 +41,7 @@ defineEmits<{
|
|||
(e: 'delete'): void;
|
||||
(e: 'example'): void;
|
||||
(e: 'preview'): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const rand = Math.random();
|
||||
|
|
@ -93,7 +96,8 @@ const rand = Math.random();
|
|||
:idName="code"
|
||||
status="ACTIVE"
|
||||
hide-toggle
|
||||
hide-delete
|
||||
:use-cancel
|
||||
:hide-delete="hideKebabDelete"
|
||||
:hide-view="hideKebabView"
|
||||
:hide-edit="hideKebabEdit"
|
||||
@view="$emit('view')"
|
||||
|
|
@ -101,6 +105,7 @@ const rand = Math.random();
|
|||
@link="$emit('link')"
|
||||
@upload="$emit('upload')"
|
||||
@delete="$emit('delete')"
|
||||
@cancel="$emit('cancel')"
|
||||
/>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
|||
39
src/components/11_credit-note/FormCredit.vue
Normal file
39
src/components/11_credit-note/FormCredit.vue
Normal 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>
|
||||
|
|
@ -6,29 +6,57 @@ import DialogHeader from './DialogHeader.vue';
|
|||
import MainButton from '../button/MainButton.vue';
|
||||
import NoData from '../NoData.vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
hideTab?: boolean;
|
||||
download?: boolean;
|
||||
transformUrl?: (url: string) => string | Promise<string>;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ default: false });
|
||||
|
||||
const state = reactive({
|
||||
imageZoom: 100,
|
||||
transformedUrl: props.url,
|
||||
});
|
||||
|
||||
function openDialog() {
|
||||
async function openDialog() {
|
||||
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>
|
||||
<template>
|
||||
<DialogFormContainer v-model="open" v-on:open="openDialog">
|
||||
<template #header>
|
||||
<DialogHeader :title="title" />
|
||||
<DialogHeader :title="title || ''" />
|
||||
</template>
|
||||
|
||||
<main class="column full-height">
|
||||
<section
|
||||
v-if="!hideTab"
|
||||
style="background: var(--gray-3)"
|
||||
class="q-py-sm row justify-center"
|
||||
>
|
||||
|
|
@ -74,18 +102,32 @@ function openDialog() {
|
|||
</section>
|
||||
|
||||
<div
|
||||
:class="{ 'cursor-pointer': state.imageZoom > 100 }"
|
||||
class="full-height full-width flex justify-center items-center col scroll q-pa-md"
|
||||
:class="{ 'cursor-pointer': state.imageZoom > 100 }"
|
||||
v-dragscroll
|
||||
>
|
||||
<q-img
|
||||
v-if="url"
|
||||
v-if="state.transformedUrl"
|
||||
class="full-height"
|
||||
:src="url"
|
||||
:src="state.transformedUrl"
|
||||
fit="contain"
|
||||
:style="{ transform: `scale(${state.imageZoom / 100})` }"
|
||||
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 />
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
212
src/components/shared/select/SelectQuotation.vue
Normal file
212
src/components/shared/select/SelectQuotation.vue
Normal 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>
|
||||
|
||||
{{ 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>
|
||||
|
||||
{{ 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>
|
||||
|
|
@ -14,6 +14,7 @@ const props = withDefaults(
|
|||
url?: string;
|
||||
progress?: number;
|
||||
uploading?: { loaded: number; total: number };
|
||||
idle?: boolean;
|
||||
|
||||
clickable?: boolean;
|
||||
closeable?: boolean;
|
||||
|
|
@ -53,7 +54,7 @@ onMounted(() => {
|
|||
@click="$emit('click')"
|
||||
>
|
||||
<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 class="text-caption app-text-muted-2">
|
||||
{{
|
||||
|
|
@ -68,8 +69,13 @@ onMounted(() => {
|
|||
color="primary"
|
||||
size="1.5em"
|
||||
/>
|
||||
<q-icon v-else name="mdi-check-circle" color="positive" size="1rem" />
|
||||
{{ progress !== 1 ? `Uploading...` : 'Completed' }}
|
||||
<q-icon
|
||||
v-if="progress === 1 && !idle"
|
||||
name="mdi-check-circle"
|
||||
color="positive"
|
||||
size="1rem"
|
||||
/>
|
||||
{{ idle ? '' : progress !== 1 ? `Uploading...` : 'Completed' }}
|
||||
</span>
|
||||
</article>
|
||||
<q-btn
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const fileData = defineModel<
|
|||
loaded: number;
|
||||
total: number;
|
||||
url?: string;
|
||||
placeholder?: boolean;
|
||||
}[]
|
||||
>('fileData', { required: true });
|
||||
|
||||
|
|
@ -54,7 +55,8 @@ function pickFile() {
|
|||
<template>
|
||||
<div :class="{ row: layout === 'column' }">
|
||||
<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" />
|
||||
{{ label }}
|
||||
|
|
@ -76,11 +78,10 @@ function pickFile() {
|
|||
</div>
|
||||
|
||||
<!-- upload card -->
|
||||
<section class="row col" :class="{ 'q-pl-md': layout === 'column' }">
|
||||
<section class="column col" :class="{ 'q-pl-md': layout === 'column' }">
|
||||
<div
|
||||
v-for="(d, j) in fileData"
|
||||
:key="j"
|
||||
class="col-12"
|
||||
:class="{
|
||||
'q-pt-md': layout === 'row' && j === 0,
|
||||
'q-pt-sm': j > 0,
|
||||
|
|
@ -91,6 +92,7 @@ function pickFile() {
|
|||
:progress="d.progress"
|
||||
:uploading="{ loaded: d.loaded, total: d.total }"
|
||||
:url="d.url"
|
||||
:idle="d.placeholder"
|
||||
icon="mdi-file-image-outline"
|
||||
color="hsl(var(--text-mute))"
|
||||
clickable
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,7 @@ export default {
|
|||
'Validation failed. Each installment must include at least one product. Please review and update the installments accordingly.',
|
||||
flowTemplateNotFound: 'Workflow template cannot be found',
|
||||
taskOrderNotFound: 'Task order cannot be found.',
|
||||
quotationNotFound: 'Quotation cannot be found.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -1135,4 +1136,54 @@ export default {
|
|||
include: 'Include Duty',
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { title } from 'process';
|
||||
|
||||
export default {
|
||||
general: {
|
||||
ok: 'ตกลง',
|
||||
|
|
@ -1001,7 +1003,8 @@ export default {
|
|||
confirmSavingStatus:
|
||||
'คุณต้องการยืนยันการบันทึกข้อมูลการเปลี่ยนสถานะใช่หรือไม่',
|
||||
confirmSending: 'ยืนยันการส่งงานใช่หรือไม่',
|
||||
confirmEndWorkWarning: `ท่านต้องการให้ดำเนินการจบงานในขณะนี้หรือไม่? สถานะปัจจุบันที่แสดงว่า 'รอดำเนินการ' 'กำลังดำเนินการ' 'ไปดำเนินการใหม่' จะถูกเปลี่ยนเป็น 'ทำใหม่ทั้งหมด'`,
|
||||
confirmEndWorkWarning:
|
||||
"ท่านต้องการให้ดำเนินการจบงานในขณะนี้หรือไม่? สถานะปัจจุบันที่แสดงว่า 'รอดำเนินการ' 'กำลังดำเนินการ' 'ไปดำเนินการใหม่' จะถูกเปลี่ยนเป็น 'ทำใหม่ทั้งหมด'",
|
||||
confirmEndWork: 'ท่านต้องการจบงานใช่หรือไม่',
|
||||
},
|
||||
action: {
|
||||
|
|
@ -1078,6 +1081,7 @@ export default {
|
|||
'ข้อมูลงวดไม่ถูกต้อง กรุณาตรวจสอบและยืนยันว่าแต่ละงวดมีสินค้าอย่างน้อยหนึ่งรายการ',
|
||||
flowTemplateNotFound: 'ไม่พบขั้นตอนการทำงาน',
|
||||
taskOrderNotFound: 'ไม่พบใบสั่งงาน',
|
||||
quotationNotFound: 'ไม่พบใบเสนอราคา',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -1113,4 +1117,54 @@ export default {
|
|||
include: 'ติดอากร',
|
||||
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: 'คืนเงินเสร็จสิ้น',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,12 +152,12 @@ onMounted(async () => {
|
|||
{
|
||||
label: 'menu.account',
|
||||
icon: 'mdi-bank-outline',
|
||||
disabled: true,
|
||||
disabled: false,
|
||||
children: [
|
||||
{ label: 'uploadSlip', route: '' },
|
||||
{ label: 'receipt', route: '' },
|
||||
{ label: 'creditNote', route: '' },
|
||||
{ label: 'debitNote', route: '' },
|
||||
{ label: 'uploadSlip', route: '', disabled: true },
|
||||
{ label: 'receipt', route: '', disabled: true },
|
||||
{ label: 'creditNote', route: '/credit-note' },
|
||||
{ label: 'debitNote', route: '', disabled: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useQuotationForm } from './form';
|
|||
import { hslaColors } from './constants';
|
||||
|
||||
// NOTE Import Types
|
||||
import { CustomerBranchCreate } from 'stores/customer/types';
|
||||
import { CustomerBranchCreate, CustomerType } from 'stores/customer/types';
|
||||
|
||||
// NOTE: Import Components
|
||||
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();
|
||||
customerFormState.value.dialogType = 'create';
|
||||
customerFormData.value.customerType = opts?.type;
|
||||
|
|
@ -656,6 +656,8 @@ async function storeDataLocal(id: string) {
|
|||
<template #grid="{ item }">
|
||||
<div class="col-md-4 col-sm-6 col-12">
|
||||
<QuotationCard
|
||||
hide-kebab-delete
|
||||
:hide-kebab-edit="!(pageState.currentTab === 'Issued')"
|
||||
:urgent="item.row.urgent"
|
||||
:code="item.row.code"
|
||||
:title="item.row.workName"
|
||||
|
|
|
|||
|
|
@ -1865,7 +1865,7 @@ watch(
|
|||
|
||||
<div
|
||||
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">
|
||||
<q-input
|
||||
|
|
|
|||
|
|
@ -11,31 +11,40 @@ defineEmits<{
|
|||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
payType: string;
|
||||
amount: number;
|
||||
date: string | Date;
|
||||
index: number;
|
||||
paySplitCount: number;
|
||||
title?: string;
|
||||
hideExampleBtn?: boolean;
|
||||
successLabel?: string;
|
||||
|
||||
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>
|
||||
<template>
|
||||
<section class="surface-1 rounded row">
|
||||
<aside class="column col bordered-r q-py-md q-pl-md">
|
||||
<span class="text-weight-medium text-body1">
|
||||
{{ $t('quotation.receiptDialog.PaymentReceive') }}
|
||||
{{ title || $t('quotation.receiptDialog.PaymentReceive') }}
|
||||
</span>
|
||||
<span class="app-text-muted-2 q-pt-md">
|
||||
{{ $t('quotation.receiptDialog.paymentMethod') }}
|
||||
</span>
|
||||
<span class="row items-center">
|
||||
{{ $t(`quotation.type.${payType}`) }}
|
||||
{{ 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 v-if="$slots.payType" class="row items-center">
|
||||
<slot name="payType"></slot>
|
||||
</div>
|
||||
<div v-else class="row items-center">
|
||||
{{ $t(`quotation.type.${payType}`) }}
|
||||
{{ 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">
|
||||
฿ {{ formatNumberDecimal(amount, 2) }}
|
||||
</article>
|
||||
|
|
@ -45,6 +54,7 @@ withDefaults(
|
|||
<aside class="column col q-py-md text-right self-center q-px-md">
|
||||
<div class="q-gutter-x-xs">
|
||||
<MainButton
|
||||
v-if="!hideExampleBtn"
|
||||
icon="mdi-play-box-outline"
|
||||
color="207 96% 32%"
|
||||
@click="() => $emit('example', index)"
|
||||
|
|
@ -53,7 +63,7 @@ withDefaults(
|
|||
<ViewButton icon-only @click="() => $emit('view', index)" />
|
||||
</div>
|
||||
<span class="app-text-positive text-weight-bold text-body1">
|
||||
{{ $t('quotation.receiptDialog.receiptIssued') }}
|
||||
{{ successLabel || $t('quotation.receiptDialog.receiptIssued') }}
|
||||
</span>
|
||||
<div class="app-text-muted-2">
|
||||
<q-icon name="mdi-calendar-blank-outline" />
|
||||
|
|
@ -62,6 +72,7 @@ withDefaults(
|
|||
date: date,
|
||||
dayStyle: '2-digit',
|
||||
monthStyle: '2-digit',
|
||||
withTime: true,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ function getEmployeeName(
|
|||
hide-preview
|
||||
hide-kebab-view
|
||||
hide-kebab-edit
|
||||
hide-kebab-delete
|
||||
use-cancel
|
||||
:badge-color="
|
||||
props.row.requestDataStatus === RequestDataStatus.Pending
|
||||
? '--orange-5-hsl'
|
||||
|
|
@ -259,6 +261,7 @@ function getEmployeeName(
|
|||
},
|
||||
]"
|
||||
@view="$emit('view', props.row)"
|
||||
@cancel="$emit('delete', props.row)"
|
||||
>
|
||||
<template v-slot:responsiblePerson="{ props: subProps }">
|
||||
<div class="col-4 app-text-muted q-pr-sm self-center">
|
||||
|
|
|
|||
|
|
@ -14,20 +14,25 @@ import FormGroupHead from '../08_request-list/FormGroupHead.vue';
|
|||
import NoData from 'src/components/NoData.vue';
|
||||
|
||||
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<{
|
||||
(e: 'select', value: RequestWork[]): void;
|
||||
(e: 'afterSubmit'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
creditNote?: boolean;
|
||||
fetchParams?: Parameters<typeof requestListStore.getRequestWorkList>[0];
|
||||
}>();
|
||||
|
||||
const requestListStore = useRequestList();
|
||||
|
||||
const taskList = defineModel<
|
||||
{
|
||||
step: number;
|
||||
step?: number;
|
||||
requestWorkId: string;
|
||||
requestWorkStep?: Task;
|
||||
requestWorkStep?: Task | { requestWork: RequestWork };
|
||||
}[]
|
||||
>('taskList', {
|
||||
default: [],
|
||||
|
|
@ -36,6 +41,7 @@ const open = defineModel<boolean>('open', { default: false });
|
|||
|
||||
const selectedEmployee = ref<
|
||||
(RequestWork & {
|
||||
taskStatus: TaskStatus;
|
||||
_template?: {
|
||||
id: string;
|
||||
templateName: string;
|
||||
|
|
@ -76,7 +82,7 @@ async function getList() {
|
|||
page: 1,
|
||||
pageSize: 99999,
|
||||
query: state.search,
|
||||
readyToTask: true,
|
||||
...props.fetchParams,
|
||||
});
|
||||
|
||||
if (!res) return;
|
||||
|
|
@ -141,13 +147,19 @@ function submit() {
|
|||
|
||||
selectedEmployee.value.forEach((v, i) => {
|
||||
const curr = v.stepStatus.find(
|
||||
(s) => s.workStatus === RequestWorkStatus.Ready,
|
||||
(s) =>
|
||||
s.workStatus ===
|
||||
(props.creditNote
|
||||
? RequestWorkStatus.Canceled
|
||||
: RequestWorkStatus.Ready),
|
||||
);
|
||||
if (curr) {
|
||||
const task: Task = {
|
||||
...curr,
|
||||
attributes: curr.attributes,
|
||||
workStatus: RequestWorkStatus.Ready,
|
||||
workStatus: props.creditNote
|
||||
? RequestWorkStatus.Canceled
|
||||
: RequestWorkStatus.Ready,
|
||||
taskOrderId: '',
|
||||
requestWork: selectedEmployee.value[i],
|
||||
};
|
||||
|
|
@ -254,7 +266,8 @@ function onDialogOpen() {
|
|||
<div class="q-pa-md full-width">
|
||||
<TableEmployee
|
||||
checkbox-on
|
||||
step-on
|
||||
:step-on="!creditNote"
|
||||
:statusOn="creditNote"
|
||||
:rows="
|
||||
list.map((v) =>
|
||||
Object.assign(v, { _template: getTemplateData(v) }),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const props = withDefaults(
|
|||
checkboxOn?: boolean;
|
||||
checkAll?: boolean;
|
||||
stepOn?: boolean;
|
||||
statusOn?: boolean;
|
||||
rows: QTableProps['rows'];
|
||||
grid?: boolean;
|
||||
}>(),
|
||||
|
|
@ -180,7 +181,23 @@ function handleCheck(
|
|||
},
|
||||
...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]"
|
||||
: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">
|
||||
{{ col.label && $t(col.label) }}
|
||||
</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.action"></q-th>
|
||||
|
|
@ -338,7 +355,7 @@ function handleCheck(
|
|||
>
|
||||
<q-tr
|
||||
:class="{
|
||||
urgent: props.row.request.quotation.urgent,
|
||||
urgent: props.row.request.quotation?.urgent,
|
||||
dark: $q.dark.isActive,
|
||||
['disabled-row']:
|
||||
selectedEmployee.length > 0 &&
|
||||
|
|
@ -392,7 +409,7 @@ function handleCheck(
|
|||
<q-img
|
||||
class="text-center"
|
||||
: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>
|
||||
<span class="full-width full-height">
|
||||
|
|
@ -407,27 +424,27 @@ function handleCheck(
|
|||
</div>
|
||||
|
||||
<div class="app-text-muted">
|
||||
{{ props.row.request.employee.code }}
|
||||
{{ props.row.request.employee?.code }}
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
class="q-ml-md"
|
||||
:class="`app-text-${props.row.request.employee.gender}`"
|
||||
:icon="`material-symbols:${props.row.request.employee.gender}`"
|
||||
:class="`app-text-${props.row.request.employee?.gender}`"
|
||||
:icon="`material-symbols:${props.row.request.employee?.gender}`"
|
||||
width="24px"
|
||||
/>
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td>{{ calculateAge(props.row.request.employee.dateOfBirth) }}</q-td>
|
||||
<q-td>{{ calculateAge(props.row.request.employee?.dateOfBirth) }}</q-td>
|
||||
<q-td>
|
||||
{{
|
||||
useOptionStore().mapOption(props.row.request.employee.nationality)
|
||||
useOptionStore().mapOption(props.row.request.employee?.nationality)
|
||||
}}
|
||||
</q-td>
|
||||
<q-td>
|
||||
{{
|
||||
dateFormatJS({
|
||||
date: props.row.request.quotation.dueDate,
|
||||
date: props.row.request.quotation?.dueDate,
|
||||
locale: $i18n.locale,
|
||||
dayStyle: '2-digit',
|
||||
monthStyle: 'short',
|
||||
|
|
@ -436,7 +453,7 @@ function handleCheck(
|
|||
</q-td>
|
||||
<q-td>
|
||||
<ExpirationDate
|
||||
:expiration-date="new Date(props.row.request.quotation.dueDate)"
|
||||
:expiration-date="new Date(props.row.request.quotation?.dueDate)"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td>
|
||||
|
|
@ -444,12 +461,12 @@ function handleCheck(
|
|||
class="cursor-pointer link"
|
||||
@click="goToQuotation(props.row.request.quotation)"
|
||||
>
|
||||
{{ props.row.request.quotation.code }}
|
||||
{{ props.row.request.quotation?.code }}
|
||||
</span>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<BadgeComponent
|
||||
v-if="props.row.request.quotation.urgent"
|
||||
v-if="props.row.request.quotation?.urgent"
|
||||
icon="mdi-fire"
|
||||
:title="$t('general.urgent2')"
|
||||
hsla-color="--gray-1-hsl"
|
||||
|
|
@ -457,6 +474,12 @@ function handleCheck(
|
|||
solid
|
||||
/>
|
||||
</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">
|
||||
<slot name="append" :props="props"></slot>
|
||||
</q-td>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const fileData = defineModel<
|
|||
loaded: number;
|
||||
total: number;
|
||||
url?: string;
|
||||
placeholder?: boolean;
|
||||
}[]
|
||||
>('fileData', { default: [] });
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ const taskProduct = defineModel<{ productId: string; discount?: number }[]>(
|
|||
},
|
||||
);
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
readonly?: boolean;
|
||||
agentPrice?: boolean;
|
||||
taskList: {
|
||||
product: RequestWork['productService']['product'];
|
||||
list: RequestWork[];
|
||||
}[];
|
||||
creditNote?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
|
|
@ -55,15 +57,28 @@ function openList(index: number) {
|
|||
|
||||
function calcPricePerUnit(product: RequestWork['productService']['product']) {
|
||||
return product.vatIncluded
|
||||
? product.serviceCharge / (1 + (config.value?.vat || 0.07))
|
||||
: product.serviceCharge;
|
||||
? (props.creditNote
|
||||
? props.agentPrice
|
||||
? product.agentPrice
|
||||
: product.price
|
||||
: product.serviceCharge) /
|
||||
(1 + (config.value?.vat || 0.07))
|
||||
: props.creditNote
|
||||
? props.agentPrice
|
||||
? product.agentPrice
|
||||
: product.price
|
||||
: product.serviceCharge;
|
||||
}
|
||||
|
||||
function calcPrice(
|
||||
product: RequestWork['productService']['product'],
|
||||
amount: number,
|
||||
) {
|
||||
const pricePerUnit = product.serviceCharge;
|
||||
const pricePerUnit = props.creditNote
|
||||
? props.agentPrice
|
||||
? product.agentPrice
|
||||
: product.price
|
||||
: product.serviceCharge;
|
||||
const discount =
|
||||
taskProduct.value.find((v) => v.productId === product.id)?.discount || 0;
|
||||
const priceNoVat = product.vatIncluded
|
||||
|
|
@ -102,7 +117,16 @@ function calcPrice(
|
|||
|
||||
<main class="q-px-md q-py-sm surface-1">
|
||||
<q-table
|
||||
:columns="productColumn"
|
||||
:columns="
|
||||
creditNote
|
||||
? productColumn.filter(
|
||||
(v) =>
|
||||
v.name !== 'discount' &&
|
||||
v.name !== 'priceBeforeVat' &&
|
||||
v.name !== 'vat',
|
||||
)
|
||||
: productColumn
|
||||
"
|
||||
:rows="taskList"
|
||||
bordered
|
||||
flat
|
||||
|
|
@ -174,7 +198,7 @@ function calcPrice(
|
|||
}}
|
||||
</q-td>
|
||||
<!-- TODO: display price detail -->
|
||||
<q-td align="center">
|
||||
<q-td align="center" v-if="!creditNote">
|
||||
<q-input
|
||||
:readonly
|
||||
:bg-color="readonly ? 'transparent' : ''"
|
||||
|
|
@ -213,11 +237,11 @@ function calcPrice(
|
|||
/>
|
||||
</q-td>
|
||||
<!-- before vat -->
|
||||
<q-td class="text-right">
|
||||
<q-td class="text-right" v-if="!creditNote">
|
||||
{{ formatNumberDecimal(calcPricePerUnit(props.row.product), 2) }}
|
||||
</q-td>
|
||||
<!-- vat -->
|
||||
<q-td class="text-right">
|
||||
<q-td class="text-right" v-if="!creditNote">
|
||||
{{
|
||||
formatNumberDecimal(
|
||||
props.row.product.calcVat
|
||||
|
|
@ -267,7 +291,11 @@ function calcPrice(
|
|||
|
||||
<q-tr v-show="currentBtnOpen[props.rowIndex]" :props="props">
|
||||
<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-tr>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1130,6 +1130,7 @@ watch([currentFormData.value.taskStatus], () => {
|
|||
|
||||
<!-- SEC: Dialog -->
|
||||
<SelectReadyRequestWork
|
||||
:fetch-params="{ readyToTask: true }"
|
||||
v-model:open="pageState.productDialog"
|
||||
v-model:task-list="currentFormData.taskList"
|
||||
@after-submit="
|
||||
|
|
|
|||
822
src/pages/11_credit-note/FormPage.vue
Normal file
822
src/pages/11_credit-note/FormPage.vue
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
<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';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const creditNote = useCreditNote();
|
||||
const quotation = useQuotationStore();
|
||||
const configStore = useConfigStore();
|
||||
const { data: config } = storeToRefs(configStore);
|
||||
const { t } = useI18n();
|
||||
|
||||
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 currentFormData = ref<CreditNotePayload>({
|
||||
quotationId: '',
|
||||
requestWorkId: [],
|
||||
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;
|
||||
|
||||
currentFormData.value = {
|
||||
quotationId: creditNoteData.value.quotationId,
|
||||
requestWorkId: creditNoteData.value.requestWork.map((v) => v.id || ''),
|
||||
reason: creditNoteData.value.reason,
|
||||
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 = currentFormData.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"
|
||||
/>
|
||||
|
||||
<CreditNoteExpansion
|
||||
v-if="view === null"
|
||||
:readonly="readonly"
|
||||
v-model:reason="currentFormData.reason"
|
||||
v-model:detail="currentFormData.detail"
|
||||
/>
|
||||
<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="currentFormData.paybackType"
|
||||
v-model:payback-bank="currentFormData.paybackBank"
|
||||
v-model:payback-account="currentFormData.paybackAccount"
|
||||
v-model:payback-account-name="currentFormData.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="currentFormData.paybackType"
|
||||
v-model:payback-bank="currentFormData.paybackBank"
|
||||
v-model:payback-account="currentFormData.paybackAccount"
|
||||
v-model:payback-account-name="currentFormData.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);
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- TODO: bind additional file -->
|
||||
<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(({ name }) => {
|
||||
attachmentData.push({
|
||||
name: name,
|
||||
progress: 1,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
placeholder: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (!creditNoteData) return;
|
||||
|
||||
await uploadFile(creditNoteData.id, f);
|
||||
}
|
||||
"
|
||||
@remove="
|
||||
async (n) => {
|
||||
if (!creditNoteData) return;
|
||||
await remove(creditNoteData.id, n);
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- TODO: bind remark -->
|
||||
<RemarkExpansion
|
||||
v-if="view !== CreditNoteStatus.Success"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<SaveButton
|
||||
v-if="!readonly"
|
||||
@click="submit"
|
||||
: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>
|
||||
468
src/pages/11_credit-note/MainPage.vue
Normal file
468
src/pages/11_credit-note/MainPage.vue
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
<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 { ref } from 'vue';
|
||||
import { dialogWarningClose } from 'src/stores/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const flow = useFlowStore();
|
||||
const navigator = useNavigator();
|
||||
const creditNote = useCreditNote();
|
||||
const selectedQuotationId = ref<string>('');
|
||||
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"
|
||||
@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>
|
||||
280
src/pages/11_credit-note/RefundInformation.vue
Normal file
280
src/pages/11_credit-note/RefundInformation.vue
Normal 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>
|
||||
87
src/pages/11_credit-note/TableCreditNote.vue
Normal file
87
src/pages/11_credit-note/TableCreditNote.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<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[] }>();
|
||||
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 in visible" :align="col.align">
|
||||
<!-- NOTE: custom column will starts with # -->
|
||||
<template v-if="!col.name.startsWith('#')">
|
||||
<span v-if="col.name !== 'quotationPayment'">
|
||||
{{
|
||||
typeof col.field === 'string'
|
||||
? props.row[col.field as keyof 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
|
||||
@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>
|
||||
78
src/pages/11_credit-note/constants.ts
Normal file
78
src/pages/11_credit-note/constants.ts
Normal 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',
|
||||
};
|
||||
51
src/pages/11_credit-note/expansion/CreditNoteExpansion.vue
Normal file
51
src/pages/11_credit-note/expansion/CreditNoteExpansion.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import SelectInput from 'src/components/shared/SelectInput.vue';
|
||||
|
||||
defineProps<{
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
|
||||
const reason = defineModel<string>('reason');
|
||||
const detail = defineModel<string>('detail');
|
||||
</script>
|
||||
<template>
|
||||
<q-expansion-item
|
||||
dense
|
||||
:default-opened="true"
|
||||
class="overflow-hidden bordered full-width"
|
||||
switch-toggle-side
|
||||
style="border-radius: var(--radius-2)"
|
||||
expand-icon="mdi-chevron-down-circle"
|
||||
header-class="surface-1 q-py-sm text-medium text-body1"
|
||||
>
|
||||
<template #header>
|
||||
<span>
|
||||
{{ $t('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' },
|
||||
]"
|
||||
></SelectInput>
|
||||
<q-input
|
||||
:readonly
|
||||
for="input-credit-note-additional-detail"
|
||||
:label="$t('creditNote.label.additionalDetail')"
|
||||
outlined
|
||||
dense
|
||||
class="col"
|
||||
v-model="detail"
|
||||
></q-input>
|
||||
</main>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
108
src/pages/11_credit-note/expansion/DocumentExpansion.vue
Normal file
108
src/pages/11_credit-note/expansion/DocumentExpansion.vue
Normal 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>
|
||||
235
src/pages/11_credit-note/expansion/PaymentExpansion.vue
Normal file
235
src/pages/11_credit-note/expansion/PaymentExpansion.vue
Normal 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>
|
||||
|
|
@ -105,6 +105,11 @@ const routes: RouteRecordRaw[] = [
|
|||
name: 'TaskOrder',
|
||||
component: () => import('pages/09_task-order/MainPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/credit-note',
|
||||
name: 'CreditNote',
|
||||
component: () => import('pages/11_credit-note/MainPage.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -148,6 +153,16 @@ const routes: RouteRecordRaw[] = [
|
|||
name: 'docOrder',
|
||||
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'),
|
||||
},
|
||||
|
||||
// Always leave this as last one,
|
||||
// but you can also remove it
|
||||
|
|
|
|||
103
src/stores/credit-note/index.ts
Normal file
103
src/stores/credit-note/index.ts
Normal 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 },
|
||||
};
|
||||
});
|
||||
51
src/stores/credit-note/types.ts
Normal file
51
src/stores/credit-note/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { QuotationFull } from '../quotations';
|
||||
import { RequestWork } from '../request-list';
|
||||
import { CreatedBy } from '../types';
|
||||
|
||||
export type CreditNotePayload = {
|
||||
quotationId: string;
|
||||
requestWorkId: 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[];
|
||||
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',
|
||||
}
|
||||
|
|
@ -69,6 +69,8 @@ export const useQuotationStore = defineStore('quotation-store', () => {
|
|||
| 'Canceled';
|
||||
urgentFirst?: boolean;
|
||||
query?: string;
|
||||
hasCancel?: boolean;
|
||||
includeRegisteredBranch?: boolean;
|
||||
}) {
|
||||
const res = await api.get<PaginationResult<Quotation>>('/quotation', {
|
||||
params,
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export type QuotationStats = {
|
|||
};
|
||||
|
||||
export type Quotation = {
|
||||
_count: { worker: number };
|
||||
_count: { worker: number; canceledWork: number };
|
||||
id: string;
|
||||
finalPrice: number;
|
||||
vat: number;
|
||||
|
|
@ -253,6 +253,7 @@ export type Quotation = {
|
|||
| 'canceled';
|
||||
|
||||
registeredBranchId: string;
|
||||
registeredBranch?: { id: string; name: string; nameEN: string; code: string };
|
||||
|
||||
customerBranchId: string;
|
||||
customerBranch: CustomerBranchRelation;
|
||||
|
|
@ -328,7 +329,7 @@ export type QuotationFull = {
|
|||
customerBranchId: string;
|
||||
customerBranch: CustomerBranchRelation;
|
||||
registeredBranchId: string;
|
||||
registeredBranch: { id: string; name: string };
|
||||
registeredBranch: { id: string; name: string; nameEN: string; code: string };
|
||||
|
||||
createdByUserId: string;
|
||||
createdAt: string | Date;
|
||||
|
|
|
|||
|
|
@ -223,6 +223,8 @@ export const useRequestList = defineStore('request-list', () => {
|
|||
pageSize?: number;
|
||||
workStatus?: RequestWorkStatus;
|
||||
readyToTask?: boolean;
|
||||
quotationId?: string;
|
||||
cancelOnly?: boolean;
|
||||
}) {
|
||||
const res = await api.get<PaginationResult<RequestWork>>('/request-work', {
|
||||
params,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export type RequestWork = {
|
|||
productServiceId: string;
|
||||
request: RequestData;
|
||||
attributes?: Attributes;
|
||||
creditNoteId?: string;
|
||||
processByUserId?: string;
|
||||
};
|
||||
|
||||
export type RowDocument = {
|
||||
|
|
|
|||
|
|
@ -355,13 +355,33 @@ export function manageFile<T extends string>(
|
|||
parentId: string;
|
||||
fileId: string;
|
||||
file: File;
|
||||
uploadUrl?: boolean;
|
||||
onUploadProgress?: (e: AxiosProgressEvent) => void;
|
||||
abortController?: AbortController;
|
||||
}) => {
|
||||
const res = await api.put(
|
||||
`/${base}/${opts.parentId}/file-${opts.group}/${opts.fileId}`,
|
||||
opts.file,
|
||||
{
|
||||
const res = opts.uploadUrl
|
||||
? await api.put(
|
||||
`/${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 },
|
||||
onUploadProgress: opts.onUploadProgress
|
||||
? opts.onUploadProgress
|
||||
|
|
@ -369,10 +389,12 @@ export function manageFile<T extends string>(
|
|||
? option.onUploadProgress
|
||||
: (e) => console.log(e),
|
||||
signal: opts.abortController?.signal,
|
||||
},
|
||||
);
|
||||
if (res.status < 400) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (uploadRes.status >= 400) return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
delFile: async (opts: { group: T; parentId: string; fileId: string }) => {
|
||||
const res = await api.delete(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue