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:
Methapon Metanipat 2025-01-14 09:08:31 +07:00 committed by GitHub
parent 0c694dee5d
commit 5e2100eb8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2897 additions and 77 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1100,6 +1100,7 @@ export default {
'Validation failed. Each installment must include at least one product. Please review and update the installments accordingly.', 'Validation failed. Each installment must include at least one product. Please review and update the installments accordingly.',
flowTemplateNotFound: 'Workflow template cannot be found', flowTemplateNotFound: 'Workflow template cannot be found',
taskOrderNotFound: 'Task order cannot be found.', taskOrderNotFound: 'Task order cannot be found.',
quotationNotFound: 'Quotation cannot be found.',
}, },
}, },
@ -1135,4 +1136,54 @@ export default {
include: 'Include Duty', include: 'Include Duty',
cost: 'Duty Cost (Baht)', cost: 'Duty Cost (Baht)',
}, },
creditNote: {
title: 'Credit Note',
caption: 'All Credit Notes',
label: {
code: 'Credit Note Code',
quotationCode: 'Quotation Code',
quotationWorkName: 'Work Name',
quotationPayment: 'Payment Method',
value: 'Value',
servicePoint: 'Service Point',
quotationRegisteredBranch: 'Service Point Issuing The Quotation',
quotationCreatedBy: 'Made By',
customer: 'Customer',
BankTransfer: 'Bank Transfer',
Cash: 'Cash',
accountNumber: 'Bank Account Number',
accountName: 'Bank Account Name',
creditNoteInformation: 'Credit Note Information',
additionalDetail: 'Detail',
specifyReason: 'Specify Reason',
reasonReturn:
'The customer returned all or part of the goods because they did not meet their requirements.',
reasonCanceled:
'The customer canceled certain items or services listed on the invoice.',
submit: 'Approve the credit note',
refund: 'Refund',
refundMethod: 'Refund Method',
totalRefund: 'Total refund amount',
totalAmount: 'Total',
paid: 'Paid',
remain: 'Remaining',
refundDocs: 'Refund Documents',
refundSuccess: 'Refund Success',
},
status: {
Pending: 'Pending Refund',
Success: 'Refund Completed',
Canceled: 'Canceled',
payback: {
Pending: 'Pending',
Verify: 'Verify',
Done: 'Done',
},
},
stats: {
Pending: 'Pending Refund',
Success: 'Refund Completed',
},
},
}; };

View file

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

View file

@ -152,12 +152,12 @@ onMounted(async () => {
{ {
label: 'menu.account', label: 'menu.account',
icon: 'mdi-bank-outline', icon: 'mdi-bank-outline',
disabled: true, disabled: false,
children: [ children: [
{ label: 'uploadSlip', route: '' }, { label: 'uploadSlip', route: '', disabled: true },
{ label: 'receipt', route: '' }, { label: 'receipt', route: '', disabled: true },
{ label: 'creditNote', route: '' }, { label: 'creditNote', route: '/credit-note' },
{ label: 'debitNote', route: '' }, { label: 'debitNote', route: '', disabled: true },
], ],
}, },

View file

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

View file

@ -1865,7 +1865,7 @@ watch(
<div <div
v-if="view === View.Complete" v-if="view === View.Complete"
class="surface-1 q-pa-md full-width q-gutter-y-md" class="surface-1 q-pa-md full-width q-gutter-y-md rounded"
> >
<div class="row justify-between items-center"> <div class="row justify-between items-center">
<q-input <q-input

View file

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

View file

@ -219,6 +219,8 @@ function getEmployeeName(
hide-preview hide-preview
hide-kebab-view hide-kebab-view
hide-kebab-edit hide-kebab-edit
hide-kebab-delete
use-cancel
:badge-color=" :badge-color="
props.row.requestDataStatus === RequestDataStatus.Pending props.row.requestDataStatus === RequestDataStatus.Pending
? '--orange-5-hsl' ? '--orange-5-hsl'
@ -259,6 +261,7 @@ function getEmployeeName(
}, },
]" ]"
@view="$emit('view', props.row)" @view="$emit('view', props.row)"
@cancel="$emit('delete', props.row)"
> >
<template v-slot:responsiblePerson="{ props: subProps }"> <template v-slot:responsiblePerson="{ props: subProps }">
<div class="col-4 app-text-muted q-pr-sm self-center"> <div class="col-4 app-text-muted q-pr-sm self-center">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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>

View 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>

View file

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

View file

@ -0,0 +1,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>

View file

@ -0,0 +1,78 @@
import { QTableProps } from 'quasar';
import { CreditNote, CreditNoteStatus } from 'src/stores/credit-note';
import { formatNumberDecimal } from 'src/stores/utils';
export const taskStatusOpts = [
{
status: CreditNoteStatus.Pending,
name: `creditNote.status.${CreditNoteStatus.Pending}`,
},
{
status: CreditNoteStatus.Success,
name: `creditNote.status.${CreditNoteStatus.Success}`,
},
];
export const pageTabs = [
{ label: CreditNoteStatus.Pending, value: CreditNoteStatus.Pending },
{ label: CreditNoteStatus.Success, value: CreditNoteStatus.Success },
];
export enum Status {
taskOrder = 'taskOrder',
receiveTaskOrder = 'receiveTaskOrder',
sendTaskOrder = 'sendTaskOrder',
payment = 'payment',
goodReceipt = 'goodReceipt',
}
export const columns = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: (data: CreditNote & { _index: number; _page: number }) =>
data._page * (data._index + 1),
},
{
name: 'code',
align: 'center',
label: 'creditNote.label.code',
field: (data: CreditNote) => data.code,
},
{
name: 'quotationCode',
align: 'center',
label: 'creditNote.label.quotationCode',
field: (data: CreditNote) => data.quotation.code,
},
{
name: 'quotationWorkName',
align: 'center',
label: 'creditNote.label.quotationWorkName',
field: (data: CreditNote) => data.quotation.workName,
},
{
name: 'quotationPayment',
align: 'center',
label: 'creditNote.label.quotationPayment',
field: (data: CreditNote) => data.quotation.payCondition,
},
{
name: 'creditNoteValue',
align: 'center',
label: 'creditNote.label.value',
field: (data: CreditNote) => formatNumberDecimal(data.value),
},
{
name: '#action',
align: 'center',
label: '',
field: (_) => '#action',
},
] as const satisfies QTableProps['columns'];
export const hslaColors: Record<string, string> = {
Pending: '--blue-6-hsl',
Success: '--red-6-hsl',
};

View file

@ -0,0 +1,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>

View file

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

View file

@ -0,0 +1,235 @@
<script lang="ts" setup>
import AppBox from 'src/components/app/AppBox.vue';
import useOptionStore from 'src/stores/options';
import SelectInput from 'src/components/shared/SelectInput.vue';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { formatNumberDecimal } from 'src/stores/utils';
import { watch } from 'vue';
withDefaults(
defineProps<{
readonly?: boolean;
totalPrice?: number;
}>(),
{
totalPrice: 0,
},
);
const optionStore = useOptionStore();
const paybackType = defineModel<'BankTransfer' | 'Cash'>('paybackType');
const paybackBank = defineModel<string>('paybackBank');
const paybackAccount = defineModel<string>('paybackAccount');
const paybackAccountName = defineModel<string>('paybackAccountName');
watch(
() => paybackType.value,
() => {
if (paybackType.value === 'Cash') {
paybackBank.value = paybackAccount.value = paybackAccountName.value = '';
}
},
);
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
>
<template #header>
<span>
{{ $t('general.payment') }}
</span>
</template>
<main class="q-px-md q-py-sm surface-1">
<AppBox
no-padding
bordered
class="credit-note-color"
:class="{
row: $q.screen.gt.sm,
column: $q.screen.lt.md,
}"
>
<section class="col bordered-r">
<header
class="bordered-b q-px-md q-py-sm row bg-color-light items-center"
>
<div class="icon-wrapper bg-color q-mr-sm">
<q-icon name="mdi-bank-outline" />
</div>
<span class="text-weight-bold">
{{ $t('quotation.paymentCondition') }}
</span>
</header>
<div class="q-pa-sm">
<div class="row q-col-gutter-sm">
<SelectInput
for="select-credit-note-pay-type"
:label="$t('quotation.payType')"
dense
outlined
:readonly
class="col-md-4 col-12"
v-model="paybackType"
:option="[
{
label: $t('creditNote.label.Cash'),
value: 'Cash',
},
{
label: $t('creditNote.label.BankTransfer'),
value: 'BankTransfer',
},
]"
/>
<SelectInput
:disable="paybackType === 'Cash'"
for="select-credit-note-bank"
dense
outlined
fill-input
:hide-selected="false"
:readonly
class="col-md-8 col-12"
:label="$t('branch.form.bank')"
:option="optionStore.globalOption?.bankBook"
v-model="paybackBank"
clearable
>
<template #option="{ scope }">
<q-item
v-if="scope.opt"
v-bind="scope.itemProps"
class="row items-center"
>
<q-item-section avatar>
<q-img
:src="`/img/bank/${scope.opt.value}.png`"
class="bordered"
style="border-radius: 50%"
/>
</q-item-section>
<q-item-section>
{{ scope.opt.label }}
</q-item-section>
</q-item>
</template>
<template #selected-item="{ scope }">
<q-item-section v-if="scope.opt" avatar class="q-py-sm">
<q-img
:src="`/img/bank/${scope.opt.value}.png`"
class="bordered"
style="border-radius: 50%; width: 30px"
/>
</q-item-section>
</template>
</SelectInput>
<q-input
:disable="paybackType === 'Cash'"
for="input-credit-account-number"
v-model="paybackAccount"
dense
outlined
:readonly
class="col-md-4 col-12"
:label="$t('creditNote.label.accountNumber')"
/>
<q-input
:disable="paybackType === 'Cash'"
for="input-credit-account-name"
v-model="paybackAccountName"
dense
outlined
:readonly
class="col-md-8 col-12"
:label="$t('creditNote.label.accountName')"
/>
</div>
</div>
</section>
<section class="col column">
<header
class="bordered-b q-px-md q-py-sm row bg-color-light items-center"
>
<div class="icon-wrapper bg-color q-mr-sm">
<Icon icon="iconoir:coins" />
</div>
<span class="text-weight-bold">
{{ $t('quotation.summary') }}
</span>
</header>
<div class="q-pa-sm price-container col">
<div class="row">
{{ $t('general.total') }}
<span class="q-ml-auto">
{{ formatNumberDecimal(totalPrice) }}
฿
</span>
</div>
</div>
<div class="q-pa-sm row surface-2 items-center text-weight-bold">
{{ $t('quotation.totalPriceBaht') }}
<span class="q-ml-auto" style="color: var(--brand-1)">
{{ formatNumberDecimal(totalPrice) }}
฿
</span>
</div>
</section>
</AppBox>
</main>
</q-expansion-item>
</template>
<style scoped>
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1/1;
font-size: 1.5rem;
padding: var(--size-1);
border-radius: var(--radius-2);
}
:deep(.price-tag .q-field__control) {
display: flex;
align-items: center;
width: 90px;
height: 25px;
}
.credit-note-color {
--_color: var(--indigo-10-hsl);
}
.bg-color {
color: white;
background: hsla(var(--_color));
}
.dark .bg-color {
--_color: var(--orange-6-hsl);
}
.bg-color-light {
background: hsla(var(--_color) / 0.1);
}
.dark .bg-color-light {
--_color: var(--orange-6-hsl / 0.2);
}
.price-container > * {
padding: var(--size-1);
}
</style>

View file

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

View file

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

View file

@ -0,0 +1,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',
}

View file

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

View file

@ -209,7 +209,7 @@ export type QuotationStats = {
}; };
export type Quotation = { export type Quotation = {
_count: { worker: number }; _count: { worker: number; canceledWork: number };
id: string; id: string;
finalPrice: number; finalPrice: number;
vat: number; vat: number;
@ -253,6 +253,7 @@ export type Quotation = {
| 'canceled'; | 'canceled';
registeredBranchId: string; registeredBranchId: string;
registeredBranch?: { id: string; name: string; nameEN: string; code: string };
customerBranchId: string; customerBranchId: string;
customerBranch: CustomerBranchRelation; customerBranch: CustomerBranchRelation;
@ -328,7 +329,7 @@ export type QuotationFull = {
customerBranchId: string; customerBranchId: string;
customerBranch: CustomerBranchRelation; customerBranch: CustomerBranchRelation;
registeredBranchId: string; registeredBranchId: string;
registeredBranch: { id: string; name: string }; registeredBranch: { id: string; name: string; nameEN: string; code: string };
createdByUserId: string; createdByUserId: string;
createdAt: string | Date; createdAt: string | Date;

View file

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

View file

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

View file

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