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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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';
urgentFirst?: boolean;
query?: string;
hasCancel?: boolean;
includeRegisteredBranch?: boolean;
}) {
const res = await api.get<PaginationResult<Quotation>>('/quotation', {
params,

View file

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

View file

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

View file

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

View file

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