feat: debit note (#172)

* feat: new file

* feat: function api debit

* feat: add route debit

* feat: new form page

* refactor: show menu debit

* refactor: add type debit note status

* feat: add i18n

* feat: add constants

* feat: add stores

* feat: layout

* feat: add function

* refactor: change name value

* feat: form select quotation

* refactor: change name url

* refactor: use form debit

* refactor: change src import

* refactor: move file form debit

* refactor: add i18n

* feat: add type debit note

* refactor: add columns

* refactor: bind value columns

* refactor: change name Table

* refactor: edit type

* refactor: bind type debit note

* refactor: bind value debit

* refactor: chame name function

* fix: calculate page

* refactor: delete table

* refactor: change name get list

* refactor: change i18n

* refactor: change name value

* refactor: bind navigate and trigger delete

* refactor: format number deciml

* refactor: add i18n

* feat: new page

* refactor: add color debit

* feat: Debit tab

#178

* feat: TableRequest

* refactor: edit type pay condition

* refactor: add i18n btn submit

* refactor: use type enum

* feat: edit layout product expansion

* refactor: bind function

* refactor: show code

* feat: add input search and select  status

* feat: paymentform

* refactor: edit type

* refactor: add manage file and edit end point

* feat: add form.ts

* refactor: send mode

* refactor: edit v-model of due date

* feat: submit create debit

* fix: status

* refactor: handle data not allow

* fix: call updateDebitNote in edit mode and simplify payload handling

* refactor: hide edit

* refactor: handle pay condition only full

* refactor: delete pay split

* refactor: add query

* refactor: handle is debit note

* refactor: handle is quotation

* refactor: add props hide

* refactor: tap payment and receipt

* refactor: add i18n

* feat: view document

* refactor: handle btn view doc

* refactor: use my remark

---------

Co-authored-by: Thanaphon Frappet <thanaphon@frappet.com>
Co-authored-by: nwpptrs <jay02499@gmail.com>
Co-authored-by: aif912752 <siripak@chamomind.com>
This commit is contained in:
Methapon Metanipat 2025-01-27 09:04:08 +07:00 committed by GitHub
parent e3c781f857
commit 79240f53b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 4172 additions and 12 deletions

View file

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

View file

@ -1125,6 +1125,8 @@ export default {
},
preview: {
dateAt: 'Date {msg}',
seller: 'Seller',
taskOrder: 'Work Order',
doc: 'View Document',
productList: 'Product List',
@ -1135,15 +1137,17 @@ export default {
vat: 'VAT',
value: 'Value',
netValue: 'Net Value',
dueDate: 'Due Date',
paymentMethods: 'Payment Methods',
title: {
creditNote: 'Credit Note',
quotation: 'Quotation',
invoice: 'Invoice',
payment: 'Payment',
receipt: 'Receipt',
debitNote: 'Debit Note',
},
},
address: {
subDistrict: 'Sub District',
subArea: 'Sub Area',
@ -1225,4 +1229,49 @@ export default {
dataSum: 'Summary of all receipts/tax invoices',
workSheetName: 'Worksheet name',
},
debitNote: {
title: 'Debit Note',
caption: 'All Debit Notes',
expire: 'Expired',
payment: 'Payment',
receipt: 'Receipt',
succeed: 'Completed',
downloadReceipt: 'Download Receipt',
downloadTaxInvoice: 'Download Tax Invoice',
label: {
additionalDetail: 'Additional Details',
specifyReasonForDebit: 'Specify Reason for Debit',
debitNoteInformation: 'Debit Note Information',
codeDebit: 'Debit Note Number',
codeQuotation: 'Quotation Number',
quotationWorkName: 'Work Name',
quotationPayment: 'Payment Method',
value: 'Net Value',
submit: 'Approve Debit Note',
},
stats: {
Pending: 'Debit Note',
Expire: 'Expired',
Payment: 'Payment',
Receipt: 'Receipt',
Succeed: 'Completed',
},
viewMode: {
payment: 'Payment',
receipt: 'Receipt/Tax Invoice',
processComplete: 'Completed',
},
status: {
Pending: 'Debit Note',
Expire: 'Expired',
Payment: 'Payment',
Receipt: 'Receipt',
Succeed: 'Completed',
},
},
};

View file

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

View file

@ -159,7 +159,7 @@ onMounted(async () => {
children: [
{ label: 'receipt', route: '/receipt' },
{ label: 'creditNote', route: '/credit-note', disabled: true },
{ label: 'debitNote', route: '', disabled: true },
{ label: 'debitNote', route: '/debit-note' },
],
},

View file

@ -21,12 +21,16 @@ import { dateFormatJS } from 'src/utils/datetime';
import { QFile, QMenu } from 'quasar';
import UploadFileCard from 'src/components/upload-file/UploadFileCard.vue';
import { onMounted } from 'vue';
import { DebitNote } from 'src/stores/debit-note';
const configStore = useConfigStore();
const quotationPayment = useQuotationPayment();
const { data: config } = storeToRefs(configStore);
const prop = defineProps<{ data?: Quotation | QuotationFull }>();
const prop = defineProps<{
data?: Quotation | QuotationFull | DebitNote;
isDebitNote?: boolean;
}>();
const refQFile = ref<InstanceType<typeof QFile>[]>([]);
const refQMenu = ref<InstanceType<typeof QMenu>[]>([]);
@ -194,7 +198,12 @@ async function triggerSubmit() {
onMounted(async () => {
if (!prop.data) return;
const ret = await quotationPayment.getQuotationPayment(prop.data.id);
const ret = await quotationPayment.getQuotationPayment({
quotationId: prop.isDebitNote === true ? undefined : prop.data.id,
debitNoteId: prop.isDebitNote === true ? prop.data.id : undefined,
quotationOnly: !!prop.isDebitNote ? false : true,
debitNoteOnly: !!prop.isDebitNote ? true : false,
});
if (ret) {
paymentData.value = ret.result;
slipFile.value = paymentData.value.map((v) => ({

View file

@ -443,6 +443,8 @@ async function fetchQuotation() {
async function fetchReceipt() {
const res = await useReceiptStore.getReceiptList({
quotationId: quotationFormData.value.id,
quotationOnly: true,
debitNoteOnly: false,
});
if (res) {
@ -856,7 +858,7 @@ function convertToTable(nodes: Node[]) {
} else {
quotationFormData.value.paySplit = [];
quotationFormData.value.paySplitCount = 0;
quotationFormData.value.payCondition = 'Full';
quotationFormData.value.payCondition = PayCondition.Full;
}
tempPaySplit.value = JSON.parse(

View file

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

View file

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

View file

@ -64,12 +64,11 @@ function goToRequestList(id: string) {
}))
"
:columns
hide-bottom
bordered
flat
hide-pagination
selection="multiple"
card-container-class="q-col-gutter-sm"
:no-data-label="$t('general.noDataTable')"
class="full-width"
>
<template v-slot:header="props">

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,67 @@
<script lang="ts" setup>
defineProps<{
readonly?: boolean;
}>();
const remark = defineModel<string>('remark', { default: '' });
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
>
<template #header>
<span>
{{ $t('general.remark') }}
</span>
</template>
<main class="surface-1 q-pa-md full-width">
<q-editor
dense
:readonly="readonly"
:model-value="remark"
min-height="5rem"
class="full-width"
toolbar-bg="input-border"
style="cursor: auto; color: var(--foreground)"
:content-class="readonly ? 'q-mt-sm' : 'bordered q-mt-sm rounded'"
:flat="!readonly"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
:toolbar="[['left', 'center', 'justify'], ['clip']]"
:toolbar-toggle-color="readonly ? 'disabled' : 'primary'"
:toolbar-color="readonly ? 'disabled' : $q.dark.isActive ? 'white' : ''"
:definitions="{
clip: {
icon: 'mdi-paperclip',
tip: 'Upload',
disable: readonly,
handler: () => console.log('upload'),
},
}"
@update:model-value="
(v) => {
remark = v;
}
"
/>
</main>
</q-expansion-item>
</template>
<style scoped>
:deep(.q-editor__toolbar-group):nth-child(2) {
margin-left: auto !important;
}
:deep(.q-editor__toolbar.row.no-wrap.scroll-x) {
background-color: var(--surface-2) !important;
}
:deep(.q-editor__toolbar) {
border-color: var(--surface-3) !important;
}
</style>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import { AddButton } from 'components/button';
import WorkerItem from 'src/components/05_quotation/WorkerItem.vue';
defineProps<{
readonly?: boolean;
hideBtnAddWorker?: boolean;
employeeAmount?: number;
rowWorker: {
foreignRefNo: string;
employeeName: string;
birthDate: string;
gender: string;
age: string;
nationality: string;
documentExpireDate: string;
imgUrl?: string;
status: string;
}[];
}>();
defineEmits<{
(e: 'addWorker'): void;
(e: 'edit'): void;
(e: 'update:employeeAmount', number: number): void;
(e: 'delete', index: number): void;
}>();
const toggleWorker = defineModel<boolean>('toggleWorker');
</script>
<template>
<q-expansion-item
for="item-up"
id="item-up"
dense
class="overflow-hidden"
switch-toggle-side
default-opened
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<section class="row items-center full-width">
<div class="row items-center q-pr-md q-py-sm">
<span class="q-mr-md" style="font-size: 18px">
{{ $t('quotation.employeeList') }}
</span>
<template v-if="!readonly">
<ToggleButton class="q-mr-sm" v-model="toggleWorker" />
{{ toggleWorker ? $t('general.specify') : $t('general.noSpecify') }}
</template>
</div>
<nav class="q-ml-auto">
<AddButton
v-if="!hideBtnAddWorker"
icon-only
@click.stop="$emit('addWorker')"
/>
</nav>
</section>
</template>
<div class="surface-1 q-pa-md full-width">
<WorkerItem
@update:employee-amount="(v) => $emit('update:employeeAmount', v)"
:employee-amount
:readonly="readonly"
fallback-img="/images/employee-avatar.png"
:rows="rowWorker"
@delete="(i) => $emit('delete', i)"
/>
</div>
</q-expansion-item>
</template>
<style scoped></style>

View file

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

View file

@ -120,6 +120,11 @@ const routes: RouteRecordRaw[] = [
name: 'receipt',
component: () => import('pages/13_receipt/MainPage.vue'),
},
{
path: '/debit-note',
name: 'debitNote',
component: () => import('pages/12_debit-note/MainPage.vue'),
},
],
},
@ -183,6 +188,22 @@ const routes: RouteRecordRaw[] = [
name: 'receiptform',
component: () => import('pages/13_receipt/MainPage.vue'),
},
{
path: '/debit-note/add',
name: 'DebitNoteNew',
component: () => import('pages/12_debit-note/FormPage.vue'),
},
{
path: '/debit-note/:id',
name: 'DebitNoteView',
component: () => import('pages/12_debit-note/FormPage.vue'),
},
{
path: '/debit-note/document-view',
name: 'DebitNoteDocumentView',
component: () => import('pages/12_debit-note/document-view/MainPage.vue'),
},
// Always leave this as last one,
// but you can also remove it

View file

@ -0,0 +1,94 @@
import {
DebitNote as Data,
DebitNoteStatus as Status,
DebitNotePayload as Payload,
} 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 = 'debit-note';
export * from './types.ts';
export async function getDebitNoteStats() {
const res = await api.get<Record<Status, number>>(`/${ENDPOINT}/stats`);
if (res.status < 400) {
return res.data;
}
return null;
}
export async function getDebitNoteList(params?: {
page?: number;
pageSize?: number;
query?: string;
deebitNoteStatus?: Status;
includeRegisteredBranch?: boolean;
}) {
const res = await api.get<PaginationResult<Data>>(`/${ENDPOINT}`, {
params,
});
if (res.status < 400) return res.data;
return null;
}
export async function getDebitNote(id: string) {
const res = await api.get<Data>(`/${ENDPOINT}/${id}`);
if (res.status < 400) return res.data;
return null;
}
export async function createDebitNote(body: Payload) {
const res = await api.post<Data>(`/${ENDPOINT}`, body);
if (res.status < 400) return res.data;
return null;
}
export async function updateDebitNote(body: Payload) {
const { id, quotationId, ...payload } = body;
const res = await api.put<Data>(`/${ENDPOINT}/${id}`, payload);
if (res.status < 400) return res.data;
return null;
}
export async function deleteDebitNote(id: string) {
const res = await api.delete<Data>(`/${ENDPOINT}/${id}`);
if (res.status < 400) return res.data;
return null;
}
export const useDebitNote = defineStore('debit-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.Expire]: 0,
[Status.Payment]: 0,
[Status.Receipt]: 0,
[Status.Succeed]: 0,
});
return {
data,
page,
pageMax,
pageSize,
stats,
getDebitNoteStats,
getDebitNote,
getDebitNoteList,
createDebitNote,
updateDebitNote,
deleteDebitNote,
...manageAttachment(api, ENDPOINT),
...manageFile<'slip'>(api, ENDPOINT),
};
});

View file

@ -0,0 +1,94 @@
import {
Quotation,
QuotationFull,
QuotationStatus,
QuotationPayload,
PayCondition,
} from '../quotations';
import { RequestWork } from '../request-list';
import { CreatedBy, UpdatedBy } from '../types';
import { CustomerBranch } from '../customer';
import { Branch } from '../branch/types';
export type DebitNotePayload = Omit<
QuotationPayload & {
id?: string;
quotationId: string;
debitNoteQuotationId?: string;
reason?: string;
detail?: string;
agentPrice: boolean;
},
| 'customerBranchId'
| 'registeredBranchId'
| 'contactName'
| 'contactTel'
| '_count'
| 'urgent'
| 'workName'
| 'workerMax'
| 'productServiceList'
| 'paySplit'
> & {
productServiceList: {
installmentNo: number;
workerIndex: number[];
discount: number;
amount: number;
productId: string;
workId: string;
serviceId: string;
}[];
};
export type DebitNote = {
debitNoteQuotation?: QuotationFull;
updatedBy: UpdatedBy;
createdBy: CreatedBy;
productServiceList: QuotationFull['productServiceList'];
worker: QuotationFull['worker'];
paySplit: QuotationFull['paySplit'];
registeredBranch: Branch;
customerBranch: CustomerBranch;
_count: { worker: number };
updatedByUserId: string;
updatedAt: string;
createdByUserId: string;
createdAt: string;
debitNoteQuotationId: string;
isDebitNote: boolean;
finalPrice: number;
discount: number;
vatExcluded: number;
vat: number;
totalDiscount: number;
totalPrice: number;
agentPrice: boolean;
urgent: boolean;
workerMax: number;
payBillDate: string;
paySplitCount: number;
payCondition: PayCondition;
date: string;
dueDate: string;
contactTel: string;
contactName: string;
workName: string;
code: string;
remark: string;
quotationStatus: QuotationStatus;
statusOrder: number;
status: string;
customerBranchId: string;
registeredBranchId: string;
id: string;
};
export enum DebitNoteStatus {
Pending = 'Pending',
Expire = 'Expire',
Payment = 'Payment',
Receipt = 'Receipt',
Succeed = 'Succeed',
}

View file

@ -98,6 +98,9 @@ export const useReceipt = defineStore('receipt-store', () => {
pageSize?: number;
query?: string;
quotationId?: string;
debitNoteId?: string;
debitNoteOnly?: boolean;
quotationOnly?: boolean;
}) {
const res = await api.get<PaginationResult<Receipt>>('/receipt', {
params: opts,

View file

@ -192,10 +192,15 @@ export const useQuotationStore = defineStore('quotation-store', () => {
* @deprecated Please use payment store instead.
*/
export const useQuotationPayment = defineStore('quotation-payment', () => {
async function getQuotationPayment(quotationId: string) {
async function getQuotationPayment(params: {
quotationId?: string;
debitNoteId?: string;
debitNoteOnly?: boolean;
quotationOnly?: boolean;
}) {
const res = await api.get<PaginationResult<QuotationPaymentData>>(
'/payment',
{ params: { quotationId } },
{ params },
);
if (res.status < 400) {
return res.data;

44
tests/institution.spec.ts Normal file
View file

@ -0,0 +1,44 @@
import { fakerEN, fakerTH } from '@faker-js/faker';
import test, { expect, Page } from '@playwright/test';
import { login } from './utils';
test.describe.configure({ mode: 'serial' });
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.afterAll(async () => {
await page.close();
});
test('JWS_INST_001 - Login', async () => {
await login(page);
});
test('JWS_INST_002 - Goto Institution', async () => {
await page.click('//span[text()="หน่วยงาน"]');
await expect(page).toHaveURL(/.*agencies-management/);
await page.waitForTimeout(5000);
});
test('JWS_INST_003 - Click New Institution', async () => {
await page.click('i.q-icon.mdi.mdi-plus');
await expect(page.locator('div.col.text-subtitle1')).toContainText(
'เพิ่มหน่วยงาน',
);
await page.waitForTimeout(5000);
});
test('JWS_INST_004 - Fill Form', async () => {
await page
.getByRole('textbox', { name: 'ชื่อหน่วยงาน' })
.fill(fakerTH.company.name());
await page
.getByRole('textbox', { name: 'Agencies Name' })
.fill(fakerEN.company.name());
fakerEN.location.buildingNumber();
await page.waitForTimeout(5000);
});

19
tests/utils/index.ts Normal file
View file

@ -0,0 +1,19 @@
import { expect, Page } from '@playwright/test';
let isLoggedIn = false;
export async function login(page: Page) {
if (!process.env.TEST_APP_URL) throw new Error('Expect TEST_APP_URL env.');
if (isLoggedIn) return;
await page.goto('/');
await expect(page).toHaveTitle(/^Sign in to /);
await page.fill("input[name='username']", 'admin');
await page.fill("input[name='password']", '1234');
await page.click('id=kc-login');
await page.waitForTimeout(2000);
await expect(page).toHaveURL(new RegExp('^' + process.env.TEST_APP_URL));
isLoggedIn = true;
}