feat: task order (#141)

* feat: task order => routes

* feat: Page

* refactor: pagination

* refactor: taskOrder => table, card and constants

* feat: add structure select request list comp

* fix: re-export type

* refactor: edit path route of task order

* feat: trigger task order

* refactor: edit type task statss

* feat: table select request list

* feat: i18n

* refactor: quasar expansion chevron color

* refactor: type

* refactor: state btn status done

* feat: task order => order view layout

* feat: task order => remark expansion

* fix: task order => rename attachment to additional file

* feat: upload file section optional layout

* feat: task order => additional file expansion

* feat: task order => payment expansion

* feat: conditionally add urgent

* feat: send id together with link

* refactor: edit type

* feat: new form.ts

* refactor: edit url

* refactor: edit id trigger

* feat: select institution component

* feat: task order code i18n

* feat: task order => document expansion form

* feat: fallback address on null

* refactor: add type for table

* feat: add filter parameter

* refactor: edit name routes

* refactor: add type of task order payload

* refactor: by value form

* refactor: responsive quotation form info

* refactor: submit form

* refactor: add i18n

* refactor: status canceled

* refactor: handle task status

* refactor: handle mode view

* refactor: addtaskstatus

* refactor: i18n & constants

* refactor: table employee

* refactor: select ready request work

* refactor: handle save form

* refactor: edit layout btn

* feat: undo()

* cleanup delete import

* feat: closetab

* refactor: handle readonly

* fix: body edit

* refactor: handle readonly uploadfile

* feat: import manage attachment

* refactor: quotation/task-order => type

* refactor: select ready request work

* refactor: i18n & constants

* chore: clean duplicate i18n

* refactor: type according to backend relation

* refactor: edit base url

* feat: upload file

* feat: fetch file list

* feat: get url file

* refactor: set default opened

* refactor: type

* feat: removefile

* feat: task order => select product

* feat: add parameter only active branch is selectable

* refactor: add i18n

* feat: set layout

* feat: add info product expansion

* refactor: new info messenger

* refactor: add slot name value

* refactor: add i18n

* refactor: edit type task status

* refactor: use date format

* refactor: value can null

* refactor: add i18n

* cleanup

* feat: productlistinput

* refactor: edit i18n

* refactor: edit redo

* refactor: add slot

* feat: task order => i18n

* refactor: task order => constant

* refactor: taskOrder => status type and index

* feat: taskOrder => ReceiveDialog

* refactor: wording

* refactor: table employee due date

* refactor: receive task i18n

* feat: trigger receive & task stat in receive page

* refactor: receive dialog task in cart  i18n

* fix: remove task-order/receive/add

* feat: receivetabletaskorder

* refactor: fetch task on receive dialog

* feat: add separate api get user task

* refactor: receive fetch (messenger)

* refactor: edit layout table

* refactor: task order i18n & constant

* refactor: task order change tab and stat (messenger and !messenger)

* fix: task order status display & receive badge color (card)

* refactor: trigger receive view

* fix: add receive task condition

* feat: total count

* feat: prepare information

* fix: i18n error task order not found

* refacor: value

* feat: select worker

* refactor: status i18n & constant

* refactor: table employee props (check box, step)

* fix: order => select ready task

* refactor: order => toggle status

* refactor: receive => receive dialog

* feat: featch value

* refactor: task status display components

* refactor: status active can is null

* feat: update status tab

* refactor: data display

* refactor: i18n & fullTaskOrder variable

* refactor: task receive view

* refactor: add type responsible user

* refactor: set group messenger

* cleanup:

* refactor: i18n / clone full task order / service => workflow type

* refactor: receive view

* refactor: show info messenger

* refactor: handle flow step

* refactor: receive view => opacity when pending

* feat: add workflow template name and step name

* feat: display workflow data on table

* feat: add template step identifier

* fix: edit does not change workflow id if changed

* feat: detect if same template and step

* refactor: handle template

* refactor: add slot name product

* refactor: map step in list product

* refactor: bind data messenger list group

* refactor: change endpoint name

* chore: add helper package

* feat: changetaskstatus

* refactor: update type

* refactor: set color btn

* refactor: add step

* refactor: add resposible institution

* feat: disabled

* refactor: map responsible institution

* fix: order view => readonly

* chore: clean

* refactor: edit url api

* refactor: edit name type

* refactor: add slots action

* refactor: add type row

* refactor: add opts of task status

* refactor: add select status

* refactor: handle btn

* refactor: add btn change task status

* refactor: edit i18n redo th

* refactor: sort status opts

* feat: receive & order banner img

* refactor: fetch status after submit

* refactor: handle create only

* refactor: task order status type Accept (messenger only)

* feat: receive messenger profile

* refactor: receive toggle status (display only)

* fix: document expansion readonly

* feat: confirmsendingbtn

* refactor: constant and task order status

* feat: receive task list count

* refactor: post or get

* refactor: define props institution group

* refactor: fetch status after submit

* refactor: handle create

* refactor: handle query

* refactor: update endpoint to support accept multiple order

* refactor: change function name

* feat: receive => functional accept task order

* feat: task status count

* feat: receive stat card count

* refactor: order messenger profile

* refactor: edit value to be task status

* refactor: handle status of type order

* refactor: use componet task status

* refactor: handle show btn saving status

* refactor: order => task status

* refactor: edit selectStatus => changeStatus

* refactor: edit @click btn confirmssending

* refactor: add i18n

* refactor: add function get template data

* refactor: add change status

* refactor: handle type receive

* feat: order => auto change tab by status

* refactor: fetch task after change status

* feat: fail remark dialog

* refactor: display step order (table employee)

* refactor: fail remark dialog

* refactor: order => open ready request dialog map selected

* refactor: task list type & change status param

* refactor: table task order, td background when selected

* refactor: order => change status param

* refactor: order => selectedEmployee variable type

* refactor: task status component => shield btn

* refactor: receive => change status

* refactor: order => step btn waiting

* fix: step btn waiting condition

* refactor: filter selectable task (Failed)

* refactor: find index condition on check

* refactor: no request list available

* refactor: fail btn no-wrap

* refactor: fail dialog readonly

* fix: reset state on open dialog

* fix: wrong title position

* refactor: hide task status drop down icon

* fix: handle check condition

* refactor: add userTask type and status

* feat: submit task order function

* refactor: table employee checkbox display condition

* refactor: main layout

* fix: task order validate i18n

* refactor: table task order add submit status

* refactor: status list

* refactor: info product => user task status

* feat: receive => submit task & step

* refactor: i18n

* feat: complete task oder function

* refactor: task status component no action props

* refactor: info messenger status

* refactor: receive and order view

* refactor: order complete view

* refactor: order => complete color and title

* refactor: calc price on table

* refactor: quotation table i18n + product image

* refactor: remove urgent checkbox

* refactor: task status color

* feat: calc summary price

* fix: data is not available

* feat: add doc view structure

* refactor: format address text

* feat: fetch document data from api

* fix: value is null

* fix: regression cannot edit package

* feat: add document view for task order

* feat: add view document button

* feat: update type add discount

* feat: readonly on cancel

* feat: add discount from relation

* refactor: add taskProduct on submit order

* refactor: order => task product discount

* refactor: order => date, task status count, view example

* refactor: receive date

* refactor: receive task status count

---------

Co-authored-by: puriphatt <puriphat@frappet.com>
Co-authored-by: nwpptrs <jay02499@gmail.com>
Co-authored-by: Thanaphon Frappet <thanaphon@frappet.com>
Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com>
Co-authored-by: oat_dev <nattapon@frappet.com>
This commit is contained in:
Methapon Metanipat 2024-12-25 11:59:49 +07:00 committed by GitHub
parent cd0831bac1
commit 9eff614dbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 6981 additions and 361 deletions

View file

@ -54,7 +54,8 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.27.0",
"prettier": "^3.3.3",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"vue-component-type-helpers": "^2.1.10"
},
"engines": {
"node": "^24 || ^22 || ^20 || ^18",

8
pnpm-lock.yaml generated
View file

@ -123,6 +123,9 @@ importers:
typescript:
specifier: ^5.5.4
version: 5.5.4
vue-component-type-helpers:
specifier: ^2.1.10
version: 2.1.10
packages:
@ -3516,6 +3519,9 @@ packages:
vm-browserify@1.1.2:
resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==}
vue-component-type-helpers@2.1.10:
resolution: {integrity: sha512-lfgdSLQKrUmADiSV6PbBvYgQ33KF3Ztv6gP85MfGaGaSGMTXORVaHT1EHfsqCgzRNBstPKYDmvAV9Do5CmJ07A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@ -7445,6 +7451,8 @@ snapshots:
vm-browserify@1.1.2: {}
vue-component-type-helpers@2.1.10: {}
vue-demi@0.14.10(vue@3.4.38(typescript@5.5.4)):
dependencies:
vue: 3.4.38(typescript@5.5.4)

View file

@ -2,6 +2,7 @@
import { ref, watch } from 'vue';
import { QTableProps, QTableSlots } from 'quasar';
import { storeToRefs } from 'pinia';
import { baseUrl } from 'stores/utils';
import WorkerItem from './WorkerItem.vue';
import DeleteButton from '../button/DeleteButton.vue';
@ -81,19 +82,19 @@ const columns = [
{
name: 'code',
align: 'left',
label: 'general.code',
label: 'productService.product.code',
field: (v) => v.product.code,
},
{
name: 'name',
align: 'center',
label: 'productService.service.list',
label: 'quotation.productList',
field: (v) => v.product.name,
},
{
name: 'amount',
align: 'center',
label: 'general.amount',
label: 'taskOrder.amountOfEmployee',
field: 'amount',
},
{
@ -115,15 +116,15 @@ const columns = [
field: 'priceBeforeVat',
},
{
name: 'tax',
name: 'vat',
align: 'center',
label: 'general.vat',
field: 'tax',
field: 'vat',
},
{
name: 'sumPrice',
align: 'right',
label: 'quotation.sumPrice',
label: 'quotation.totalPriceBaht',
field: 'sumPrice',
},
] satisfies QTableProps['columns'];
@ -335,14 +336,22 @@ watch(
"
></q-input>
</q-td>
<q-td>{{ props.row.product.code }}</q-td>
<q-td class="text-center">{{ props.row.product.code }}</q-td>
<q-td style="width: 100%">
<q-avatar class="q-mr-sm" size="md">
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${props.row.product.id}/image/${props.row.product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
{{ props.row.product.name }}
</q-td>
@ -366,7 +375,6 @@ watch(
/>
</q-td>
<q-td align="right">
<!-- TODO: -->
{{
formatNumberDecimal(
props.row.pricePerUnit +

View file

@ -22,6 +22,7 @@ defineProps<{
badgeColor?: string;
hideKebabView?: boolean;
hideKebabEdit?: boolean;
hideAction?: boolean;
customData?: {
label: string;
@ -88,6 +89,7 @@ const rand = Math.random();
@click.stop="$emit('view')"
/>
<KebabAction
v-if="!hideAction"
:idName="code"
status="ACTIVE"
hide-toggle

View file

@ -4,7 +4,7 @@ import { Icon } from '@iconify/vue/dist/iconify.js';
withDefaults(
defineProps<{
label: string;
value: string;
value?: string;
icon?: string;
iconSize?: string;
tooltip?: boolean;
@ -17,7 +17,7 @@ withDefaults(
</script>
<template>
<article class="row items-center full-width">
<article class="row items-center">
<Icon
v-if="icon"
:icon
@ -25,12 +25,14 @@ withDefaults(
:width="iconSize || '2rem'"
/>
<span class="row col">
<span class="col-12 app-text-muted-2 text-caption">
<span class="col-12 app-text-muted-2" style="font-size: 10px">
{{ label }}
</span>
<span class="col-12 text-weight-medium ellipsis">
{{ value }}
<q-tooltip v-if="tooltip" :delay="500">{{ value }}</q-tooltip>
<span class="col-12 ellipsis">
<slot name="value">
{{ value }}
<q-tooltip v-if="tooltip" :delay="500">{{ value }}</q-tooltip>
</slot>
</span>
</span>
</article>

View file

@ -7,7 +7,7 @@ defineProps<{
}>();
</script>
<template>
<div>
<div class="text-center">
<q-img
src="/no-data.png"
:style="{

View file

@ -0,0 +1,7 @@
<script lang="ts" setup>
defineProps<{}>();
const emit = defineEmits<{}>();
</script>
<template></template>
<style scoped></style>

View file

@ -1,7 +1,83 @@
<script setup lang="ts">
withDefaults(defineProps<{}>(), {});
import { RequestWork, RequestWorkStatus } from 'src/stores/request-list/types';
withDefaults(
defineProps<{
statusDone?: boolean;
statusActive?: boolean;
statusWaiting?: boolean;
label: string;
}>(),
{
statusDone: false,
statusActive: false,
label: '',
},
);
</script>
<template>
<button
class="status-color q-pa-sm bordered row items-center cursor-pointer no-wrap"
style="text-wrap: nowrap"
:class="{
['status-color-done']: statusDone,
['status-color-doing']: true,
['status-color-waiting']: statusWaiting,
['step-status-active']: statusActive,
}"
>
<div class="q-px-sm">
<q-icon
class="icon-color quotation-status"
style="border-radius: 50%"
:name="`${statusActive ? 'mdi-circle-slice-8' : statusDone ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'}`"
/>
</div>
<div class="text-left">{{ label }}</div>
</button>
</template>
<style scoped>
.status-color {
--_color: var(--gray-0);
border-color: hsla(var(--_color));
background: hsla(var(--_color) / 0.05);
border-radius: 4px;
<template></template>
.icon-color {
color: hsla(var(--_color));
}
<style scoped></style>
&.status-color-doing {
--_color: var(--blue-5-hsl);
color: var(--foreground);
}
&.status-color-done {
--_color: var(--green-5-hsl);
color: var(--foreground);
}
&.status-color-waiting {
--_color: var(--gray-4-hsl);
color: var(--foreground);
}
}
.step-status-active {
opacity: 1;
font-weight: 600;
transition: 1s box-shadow ease-in-out;
animation: status 1s infinite;
}
@keyframes status {
0% {
box-shadow: 0x 0px 0px hsla(var(--_color) / 0.5);
}
50% {
box-shadow: 0px 0px 1px 4px hsla(var(--_color) / 0.2);
}
100% {
box-shadow: 0px 0px 4px 7px hsla(var(--_color) / 0);
}
}
</style>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import { Institution } from 'src/stores/institution/types';
import { useInstitution } from 'src/stores/institution';
type SelectOption = Institution;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { getInstitutionList: getList, getInstitution: getById } =
useInstitution();
defineEmits<{
(e: 'create'): void;
(e: 'updateValue', val: string): void;
}>();
type ExclusiveProps = {
selectFirstValue?: boolean;
};
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: query === '' ? undefined : query,
...props.params,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
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"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="
selectOptions.map((v) => {
const ret = {
label: v.name,
value: v.id,
};
return ret;
})
"
:hide-selected="false"
:fill-input="false"
:rules="
required ? [(v: string) => !!v || $t('form.error.required')] : undefined
"
@filter="filter"
@update:model-value="(v) => $emit('updateValue', v as string)"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { ref } from 'vue';
import { QFile } from 'quasar';
import { baseUrl } from 'stores/utils';
@ -11,9 +11,11 @@ const props = withDefaults(
readonly?: boolean;
label?: string;
multiple?: boolean;
layout?: 'row' | 'column';
transformUrl?: (url: string) => string | Promise<string>;
}>(),
{
layout: 'row',
label: 'Upload',
readonly: false,
multiple: false,
@ -50,9 +52,9 @@ function pickFile() {
}
</script>
<template>
<div>
<div :class="{ row: layout === 'column' }">
<div
class="upload-section column rounded q-py-md full-height items-center justify-center no-wrap surface-2"
class="upload-section column rounded q-py-md items-center justify-center no-wrap surface-2 col"
>
<q-img src="/images/upload.png" width="150px" />
{{ label }}
@ -74,13 +76,13 @@ function pickFile() {
</div>
<!-- upload card -->
<section class="row">
<section class="row col" :class="{ 'q-pl-md': layout === 'column' }">
<div
v-for="(d, j) in fileData"
:key="j"
class="col-12"
:class="{
'q-pt-md': j === 0,
'q-pt-md': layout === 'row' && j === 0,
'q-pt-sm': j > 0,
}"
>

View file

@ -181,3 +181,16 @@ div.fullscreen.q-drawer__backdrop {
i.q-icon.mdi.mdi-alert.q-table__bottom-nodata-icon {
color: #ffc224 !important;
}
i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon {
color: hsl(var(--text-mute));
}
i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon.q-expansion-item__toggle-icon--rotated {
color: var(--brand-1);
}
.q-item.q-item-type.row.no-wrap.q-item--dense.q-item--clickable.q-link.cursor-pointer.q-focusable.q-hoverable.surface-1
.q-focus-helper {
visibility: hidden;
}

View file

@ -137,6 +137,8 @@ export default {
selected: 'Selected {number} {msg}',
next: 'Next',
import: 'Import',
numberOfDay: 'Number of days',
other: 'Other',
},
menu: {
@ -905,6 +907,7 @@ export default {
InProgress: 'In Progress',
Completed: 'Completed',
Canceled: 'Canceled',
AwaitOrder: 'Awaiting Order',
ReadyOrder: 'Ready for Order',
EndOrder: 'Order Completed',
@ -919,17 +922,62 @@ export default {
taskOrder: {
title: 'Task Order',
receive: 'Job Receipt ',
caption: 'All Task Order',
code: 'Task Order Code',
receiveTaskOrder: 'Receive Task Order',
tasktobeDone: 'Task to be Done',
inProgress: 'In Progress',
completed: 'Completed',
sendTaskOrder: 'Send Task Order',
payment: 'Payment',
goodReceipt: 'Good Receipt',
canceled: 'Canceled',
issueBranch: 'Issue Branch',
issueDate: 'Issue Date',
madeBy: 'Made By',
contactName: 'Contact Name',
workOrderCode: 'Work Order Code',
workOrderName: 'Work Order Name',
telephone: 'Telephone',
productList: 'Product List',
amountOfEmployee: 'Number of workers',
recipientOrSender: 'Recipient/Sender',
workStartDate: 'Work Start Date & Time',
workSubmissionDate: 'Work Submission Date & Time',
status: {
Pending: 'Pending',
InProgress: 'InProgress',
Success: 'Success',
Failed: 'Failed',
Redo: 'Redo',
Validate: 'Validate',
Complete: 'Complete',
Canceled: 'Canceled',
},
receiveTask: 'Receive Task',
receiveScan: 'Receive Task (Scan)',
receiveCustom: 'Receive Task (Custom)',
allProduct: 'All Product',
alreadySentTask: 'Already Sent Task',
sentTask: 'Sent Task',
taskInCart: 'Task in Cart',
waitReceive: 'Waiting to accept task',
failTaskOrderCode: 'Task order with issue',
describeIssue: 'Describe issue',
documentSubmitFailed: 'Document submission failed',
taskNotFullyCompleted: 'Task was not fully completed',
noRequestAvailable: 'There is no request list available for processing',
validate: 'Validate',
done: 'Done',
confirmValidate: 'Confirm Validate',
},
dialog: {
@ -957,8 +1005,11 @@ export default {
headquartersNotEstablished: 'Headoffice not established',
warningClose: 'Incomplte edit data, Do you want to close?',
close: 'Do you want to close this window?',
confirmChangeStatus: 'Do you want to change your status?',
confirmSavingStatus:
'Do you want to confirm the saving of the status change data?',
confirmSending: 'Do you confirm the submission of the task?',
confirmValidate: 'Do you confirm the validation?',
},
action: {
ok: 'OK',
@ -1036,6 +1087,7 @@ export default {
installmentsValidateFailed:
'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.',
},
},

View file

@ -137,6 +137,8 @@ export default {
selected: '{number} {msg}ถูกเลือก',
import: 'นำเข้า',
next: 'ถัดไป',
numberOfDay: 'จำนวนวัน',
other: 'อื่นๆ',
},
menu: {
@ -894,6 +896,7 @@ export default {
InProgress: 'ดำเนินการ',
Completed: 'เสร็จสิ้น',
Canceled: 'ยกเลิก',
AwaitOrder: 'รอสั่งงาน',
ReadyOrder: 'พร้อมสั่งงาน',
EndOrder: 'จบงาน',
@ -908,17 +911,60 @@ export default {
taskOrder: {
title: 'ใบสั่งงาน',
receive: 'ใบรับงาน',
caption: 'ใบสั่งงานทั้งหมด',
code: 'เลขใบสั่งงาน',
receiveTaskOrder: 'รับใบสั่งงาน',
tasktobeDone: 'งานที่ต้องทำ',
inProgress: 'ดำเนินการ',
completed: 'เรียบร้อย',
sendTaskOrder: 'ส่งใบสั่งงาน',
payment: 'ขำระเงิน',
goodReceipt: 'ใบรับสินค้า',
canceled: 'ยกเลิก',
issueBranch: 'สาขาที่ออก',
issueDate: 'วันที่ออก',
madeBy: 'ผู้ที่ทำรายการ',
contactName: 'ชื่อผู้ติดต่อ',
workOrderCode: 'รหัสใบสั่งงาน',
workOrderName: 'ชื่อใบสั่งงาน',
telephone: 'เบอร์โทร',
productList: 'รายการสินค้า',
amountOfEmployee: 'จำนวนแรงงาน (คน)',
recipientOrSender: 'คนรับ/ส่งงาน',
workStartDate: 'วันที่เวลารับงาน',
workSubmissionDate: 'วันที่เวลาส่งงาน',
status: {
Pending: 'รอดำเนินการ',
InProgress: 'กำลังดำเนินการ ',
Success: 'ดำเนินการสำเร็จ',
Failed: 'ดำเนินการไม่สำเร็จ',
Redo: 'ทำใหม่',
Validate: 'ตรวจสอบความถูกต้อง',
Complete: 'ดำเนินการเสร็จสิ้น',
Canceled: ' ยกเลิกรายการคำขอ',
},
receiveTask: 'รับงาน',
receiveScan: 'รับงานแบบสแกน',
receiveCustom: 'รับงานแบบเลือกเอง',
allProduct: 'สินค้าทั้งหมด',
alreadySentTask: 'ส่งงานแล้ว',
sentTask: 'ส่งงาน',
taskInCart: 'งานในตะกร้า',
waitReceive: 'รอรับงาน',
failTaskOrderCode: 'เลขที่ใบรายการที่พบปัญหา',
describeIssue: 'ระบุปัญหา',
documentSubmitFailed: 'ยื่นเอกสารไม่ผ่าน',
taskNotFullyCompleted: 'ทำรายการไม่ครบ',
noRequestAvailable: 'ไม่มีใบรายการคำขอที่สามารถดำเนินการได้',
validate: 'ตรวจสอบสินค้า',
done: 'ดำเนินการแล้ว',
confirmValidate: 'ยืนยันการตรวจสอบ',
},
dialog: {
@ -946,6 +992,10 @@ export default {
warningClose: 'มีการแก้ไขที่ยังไม่ได้บันทึก คุณต้องการปิดใช่หรือไม่',
close: 'คุณต้องการปิดหน้าต่างนี้ใช่หรือไม่',
confirmChangeStatus: 'คุณต้องการเปลี่ยนสถานะใช่หรือไม่',
confirmSavingStatus:
'คุณต้องการยืนยันการบันทึกข้อมูลการเปลี่ยนสถานะใช่หรือไม่',
confirmSending: 'ยืนยันการส่งงานใช่หรือไม่',
confirmValidate: 'ยืนยันการตรวจสอบใช่หรือไม่',
},
action: {
ok: 'ยืนยัน',
@ -1020,6 +1070,7 @@ export default {
installmentsValidateFailed:
'ข้อมูลงวดไม่ถูกต้อง กรุณาตรวจสอบและยืนยันว่าแต่ละงวดมีสินค้าอย่างน้อยหนึ่งรายการ',
flowTemplateNotFound: 'ไม่พบขั้นตอนการทำงาน',
taskOrderNotFound: 'ไม่พบใบสั่งงาน',
},
},

View file

@ -6,7 +6,7 @@ import { Icon } from '@iconify/vue';
import { BranchContact } from 'stores/branch-contact/types';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import type { QTableProps } from 'quasar';
import type { QTableProps, QTableSlots } from 'quasar';
import { resetScrollBar } from 'src/stores/utils';
import useBranchStore from 'stores/branch';
import useFlowStore from 'stores/flow';
@ -1330,7 +1330,11 @@ watch(currentHq, () => {
</q-tr>
</template>
<template v-slot:body="props">
<template
v-slot:body="props: {
row: (typeof treeData.value)[number];
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
@ -1470,34 +1474,14 @@ watch(currentHq, () => {
>
{{
formatAddress({
address: props.row.address,
addressEN: props.row.addressEN,
moo: props.row.moo,
mooEN: props.row.mooEN,
soi: props.row.soi,
soiEN: props.row.soiEN,
street: props.row.street,
streetEN: props.row.streetEN,
province: props.row.province,
district: props.row.district,
subDistrict: props.row.subDistrict,
...props.row,
en: $i18n.locale === 'eng',
})
}}
<q-tooltip>
{{
formatAddress({
address: props.row.address,
addressEN: props.row.addressEN,
moo: props.row.moo,
mooEN: props.row.mooEN,
soi: props.row.soi,
soiEN: props.row.soiEN,
street: props.row.street,
streetEN: props.row.streetEN,
province: props.row.province,
district: props.row.district,
subDistrict: props.row.subDistrict,
...props.row,
en: $i18n.locale === 'eng',
})
}}

View file

@ -1158,6 +1158,7 @@ async function submitService(notClose = false) {
if (dialogServiceEdit.value) {
const res = await editService(currentIdService.value, {
...formService.value,
workflowId: currWorkflow.value?.id || '',
status: statusToggle.value ? formService.value.status : 'INACTIVE',
});
if (!res) return;

View file

@ -34,6 +34,8 @@ const props = defineProps<{
totalVatIncluded: number;
totalAfterDiscount: number;
};
taskOrder?: boolean;
taskOrderComplete?: boolean;
}>();
const { t } = useI18n();
@ -184,10 +186,13 @@ function calculateInstallments(param: {
<AppBox
no-padding
bordered
class="row main-color"
class="main-color"
:class="{
row: $q.screen.gt.sm,
column: $q.screen.lt.md,
'invoice-color': view === View.Invoice,
'receipt-color': view === View.Receipt,
'task-order-color': taskOrder && !taskOrderComplete,
}"
>
<div class="col bordered-r">
@ -203,9 +208,13 @@ function calculateInstallments(param: {
<div class="row q-col-gutter-sm">
<section class="row q-col-gutter-sm col-12 items-center">
<SelectInput
class="col-6"
class="col-md-6 col-12"
:label="$t('quotation.payType')"
:option="payTypeOption"
:option="
taskOrder
? payTypeOption.filter((v) => v.value === 'Full')
: payTypeOption
"
:readonly
id="pay-type"
:model-value="payType"
@ -218,7 +227,8 @@ function calculateInstallments(param: {
/>
<div
class="col-6"
class="col-md-6 col-12"
:class="{ 'q-pl-md': $q.screen.lt.md }"
v-if="payType === 'Split' || payType === 'SplitCustom'"
>
<div class="row full-width items-center justify-between q-py-xs">
@ -485,6 +495,10 @@ function calculateInstallments(param: {
--_color: var(--green-6-hsl);
}
.task-order-color {
--_color: var(--pink-7-hsl);
}
.bg-color {
color: white;
background: hsla(var(--_color));

View file

@ -493,6 +493,7 @@ watch(
<div class="row items-center q-gutter-x-sm">
<q-btn
color="primary"
padding="4px"
flat
rounded

View file

@ -158,6 +158,7 @@ function clean() {
workerList.value = [];
workerSelected.value = [];
open.value = false;
state.step = 1;
}
function selectedIndex(item: Employee) {
@ -300,6 +301,7 @@ watch(() => state.search, getWorkerList);
@click="state.importWorker = true"
:label="$t('quotation.importWorker')"
/>
<span v-else class="q-ml-auto"></span>
</template>
</DialogHeader>
</template>

View file

@ -151,6 +151,12 @@ watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
label: 'requestList.status.Completed',
color: 'light-green',
},
// {
// icon: 'mdi-close-circle-outline',
// count: 0,
// label: 'requestList.status.Canceled',
// color: 'red',
// },
]"
:dark="$q.dark.isActive"
/>

View file

@ -35,6 +35,7 @@ defineProps<{
installmentNo?: number;
paySuccess: boolean;
payCondition: PayCondition;
readonly?: boolean;
}>();
// NOTE: Function
@ -85,6 +86,7 @@ defineProps<{
<div class="justify-end flex">
<q-btn-dropdown
:disable="
readonly ||
status?.workStatus === 'Waiting' ||
status?.workStatus === 'InProgress'
"
@ -103,27 +105,33 @@ defineProps<{
:class="{
disable:
$q.screen.gt.xs &&
(status?.workStatus === RequestWorkStatus.Waiting ||
(readonly ||
status?.workStatus === RequestWorkStatus.Waiting ||
status?.workStatus === RequestWorkStatus.InProgress),
pending:
($q.screen.gt.xs && !status?.workStatus) ||
status?.workStatus === RequestWorkStatus.Pending ||
status?.workStatus === RequestWorkStatus.Ready,
$q.screen.gt.xs &&
!readonly &&
(!status?.workStatus ||
status?.workStatus === RequestWorkStatus.Pending ||
status?.workStatus === RequestWorkStatus.Ready),
progress:
$q.screen.gt.xs &&
!readonly &&
status?.workStatus === RequestWorkStatus.Validate,
complete:
$q.screen.gt.xs &&
!readonly &&
(status?.workStatus === RequestWorkStatus.Ended ||
status?.workStatus === RequestWorkStatus.Completed),
canceled:
$q.screen.gt.xs &&
!readonly &&
status?.workStatus === RequestWorkStatus.Canceled,
}"
:style="
$q.screen.xs &&
(status?.workStatus === RequestWorkStatus.Waiting ||
status?.workStatus === RequestWorkStatus.InProgress)
readonly ||
status?.workStatus === RequestWorkStatus.Waiting ||
status?.workStatus === RequestWorkStatus.InProgress
? `opacity: 30% !important`
: ''
"

View file

@ -10,6 +10,7 @@ import FormExpansion from './FormExpansion.vue';
import PropertiesExpansion from './PropertiesExpansion.vue';
import FormGroupHead from './FormGroupHead.vue';
import AvatarGroup from 'src/components/shared/AvatarGroup.vue';
import { StateButton } from 'components/button';
// NOTE: Store
import { dateFormatJS } from 'src/utils/datetime';
@ -21,6 +22,7 @@ import {
DocStatus,
Step,
RequestWorkStatus,
RequestDataStatus,
} from 'src/stores/request-list/types';
import useOptionStore from 'src/stores/options';
import ProductExpansion from './ProductExpansion.vue';
@ -32,12 +34,7 @@ import {
EmployeePassportPayload,
EmployeeVisaPayload,
} from 'stores/employee/types';
import {
PropDate,
PropNumber,
PropOptions,
PropString,
} from 'src/stores/product-service/types';
import { PropVariant } from 'src/stores/product-service/types';
import { Invoice } from 'src/stores/payment/types';
const { locale } = useI18n();
@ -382,7 +379,7 @@ function isInstallmentPaySuccess(installmentNo: number) {
v-if="
workList?.every((v) =>
v.productService.work?.attributes?.workflowStep?.every(
(s) => {
(s: any) => {
s.attributes?.properties.length === 0;
},
),
@ -393,18 +390,27 @@ function isInstallmentPaySuccess(installmentNo: number) {
{{ $t('requestList.noWorkflowTemplate') }}
</span>
<template v-for="(value, i) in flow.step" :key="value.id">
<button
v-if="
workList?.some(
(v) =>
v.productService.work?.attributes?.workflowStep?.[i]
?.attributes.properties.length > 0,
)
<StateButton
@click="() => (pageState.currentStep = value.order)"
:status-waiting="
!workList
?.filter((v) => {
return v.productService.work?.attributes.workflowStep?.[
i - 1
]?.productsId.includes(v.productService.productId);
})
.every((v) => {
const status = v.stepStatus.find(
({ step }) => step === i,
)?.workStatus;
return (
status === RequestWorkStatus.Completed ||
status === RequestWorkStatus.Ended
);
})
"
class="status-color q-pa-sm bordered row items-center cursor-pointer no-wrap"
style="text-wrap: nowrap"
:class="{
['status-color-done']: workList
:status-done="
workList
?.filter((v) => {
return v.productService.work?.attributes.workflowStep?.[
i
@ -414,28 +420,17 @@ function isInstallmentPaySuccess(installmentNo: number) {
const status = v.stepStatus.find(
({ step }) => step === i + 1,
)?.workStatus;
return (
status === RequestWorkStatus.Completed ||
status === RequestWorkStatus.Ended
);
}),
['status-color-doing']: true,
['step-status-active']: pageState.currentStep === value.order,
}"
@click="() => (pageState.currentStep = value.order)"
>
<!-- 'quotation-status-active': value.active?.(), -->
<!-- @click="'waiting' !== 'waiting' && value.handler()" -->
<div class="q-px-sm">
<q-icon
class="icon-color quotation-status"
style="border-radius: 50%"
:name="`${pageState.currentStep === value.order ? 'mdi-circle-slice-8' : 'mdi-checkbox-blank-circle-outline'}`"
/>
</div>
<div class="text-left">{{ value.name }}</div>
</button>
})
"
:status-active="pageState.currentStep === value.order"
:label="value.name"
/>
<!-- 'quotation-status-active': value.active?.(), -->
<!-- @click="'waiting' !== 'waiting' && value.handler()" -->
</template>
</nav>
@ -543,106 +538,10 @@ function isInstallmentPaySuccess(installmentNo: number) {
/>
<div v-if="$q.screen.gt.sm" class="col"></div>
</div>
<!-- <div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
:label="$t('requestList.requestListCode')"
:value="data.code || '-'"
/>
<DataDisplay
class="col"
icon="mdi-account-settings-outline"
:label="$t('customer.employer')"
:value="
getCustomerName(data, { locale: locale, noCode: true }) ||
'-'
"
/>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
:label="$t('requestList.quotationCode')"
:value="data.quotation.code || '-'"
/>
<DataDisplay
class="col"
icon="mdi-account-settings-outline"
:label="$t('customer.employee')"
:value="
getEmployeeName(data, { locale: $i18n.locale }) || '-'
"
/>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
tooltip
:label="$t('requestList.invoiceCode')"
:value="
data.quotation?.invoice
?.map((i: Invoice) => i.code)
.join(', ') || '-'
"
/>
<span class="col"></span>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
tooltip
:label="$t('requestList.receiptCode')"
:value="
data.quotation?.invoice
?.flatMap((i: Invoice) => i.payment?.code || [])
.join(', ') || '-'
"
/>
<DataDisplay
class="col"
icon="mdi-passport"
:label="$t('customerEmployee.form.passportNo')"
:value="data.employee.employeePassport?.[0]?.number || '-'"
/>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-card-account-details-outline"
:label="$t('requestList.alienIdCard')"
:value="data.employee.nrcNo"
/>
<DataDisplay
class="col"
icon="mdi-account-settings-outline"
:label="$t('flow.responsiblePerson')"
:value="'-'"
/>
</div> -->
</section>
</transition>
</article>
<!-- product -->
<template
v-for="(value, index) in workList
?.filter((v) =>
@ -650,6 +549,21 @@ function isInstallmentPaySuccess(installmentNo: number) {
pageState.currentStep - 1
]?.productsId.includes(v.productService.productId),
)
.map((v) => {
const _props =
v.productService.work?.attributes?.workflowStep[
pageState.currentStep - 1
]?.attributes?.properties;
return Object.assign(v, {
_documentExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'documentCheck',
),
_formExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'designForm',
),
});
})
.sort(
(lhs, rhs) =>
lhs.productService.installmentNo -
@ -658,6 +572,7 @@ function isInstallmentPaySuccess(installmentNo: number) {
:key="value"
>
<ProductExpansion
:readonly="data.requestDataStatus === RequestDataStatus.Canceled"
:installment-info="getInstallmentInfo()"
:pay-success="
isInstallmentPaySuccess(value.productService.installmentNo)
@ -689,14 +604,7 @@ function isInstallmentPaySuccess(installmentNo: number) {
class="column surface-1 q-px-sm bordered-t q-pb-sm q-gutter-y-sm"
>
<DocumentExpansion
v-if="
value.productService.work?.attributes?.workflowStep[
pageState.currentStep - 1
].attributes?.properties.some(
(v: PropString | PropNumber | PropDate | PropOptions) =>
v.fieldName === 'documentCheck',
)
"
v-if="value._documentExpansion"
ref="refDocumentExpansion"
:attributes="value.attributes"
@change-status="
@ -744,14 +652,7 @@ function isInstallmentPaySuccess(installmentNo: number) {
:listDocument="product?.document"
/>
<FormExpansion
v-if="
value.productService.work?.attributes?.workflowStep[
pageState.currentStep - 1
].attributes?.properties.some(
(v: PropString | PropNumber | PropDate | PropOptions) =>
v.fieldName === 'designForm',
)
"
v-if="value._formExpansion"
:step="{
step: pageState.currentStep,
requestWorkId: value.id || '',
@ -782,47 +683,4 @@ function isInstallmentPaySuccess(installmentNo: number) {
</main>
</div>
</template>
<style scoped>
.status-color {
--_color: var(--gray-0);
border-color: hsla(var(--_color));
background: hsla(var(--_color) / 0.05);
border-radius: 4px;
.icon-color {
color: hsla(var(--_color));
}
&.status-color-waiting {
--_color: var(--gray-4-hsl);
color: var(--foreground);
}
&.status-color-doing {
--_color: var(--blue-5-hsl);
color: var(--foreground);
}
&.status-color-done {
--_color: var(--green-5-hsl);
color: var(--foreground);
}
}
.step-status-active {
opacity: 1;
font-weight: 600;
transition: 1s box-shadow ease-in-out;
animation: status 1s infinite;
}
@keyframes status {
0% {
box-shadow: 0x 0px 0px hsla(var(--_color) / 0.5);
}
50% {
box-shadow: 0px 0px 1px 4px hsla(var(--_color) / 0.2);
}
100% {
box-shadow: 0px 0px 4px 7px hsla(var(--_color) / 0);
}
}
</style>
<style scoped></style>

View file

@ -48,8 +48,8 @@ function getCustomerName(
}[opts?.locale || 'eng'],
['PERS']:
{
['eng']: `${useOptionStore().mapOption(customer?.namePrefix)} ${customer?.firstNameEN} ${customer?.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(customer?.namePrefix)} ${customer?.firstName} ${customer?.lastName}`,
['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})`)
@ -66,8 +66,8 @@ function getEmployeeName(
return (
{
['eng']: `${useOptionStore().mapOption(employee?.namePrefix)} ${employee?.firstNameEN} ${employee?.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(employee?.namePrefix)} ${employee?.firstName} ${employee?.lastName}`,
['eng']: `${useOptionStore().mapOption(employee?.namePrefix || '')} ${employee?.firstNameEN} ${employee?.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(employee?.namePrefix || '')} ${employee?.firstName} ${employee?.lastName}`,
}[opts?.locale || 'eng'] || '-'
);
}
@ -132,13 +132,12 @@ function getEmployeeName(
<q-td v-if="visibleColumns.includes('status')">
<BadgeComponent
:hsla-color="
props.row.requestDataStatus === RequestDataStatus.Pending
? '--orange-5-hsl'
: props.row.requestDataStatus === RequestDataStatus.InProgress
? '--blue-6-hsl'
: props.row.requestDataStatus === RequestDataStatus.Completed
? '--green-8-hsl'
: '--red-5-hsl'
{
[RequestDataStatus.Pending]: '--orange-5-hsl',
[RequestDataStatus.InProgress]: '--blue-6-hsl',
[RequestDataStatus.Completed]: '--green-8-hsl',
[RequestDataStatus.Canceled]: '--red-5-hsl',
}[props.row.requestDataStatus]
"
:title="
$t(`requestList.status.${props.row.requestDataStatus}`) || '-'

View file

@ -1,6 +1,6 @@
<script lang="ts" setup>
// NOTE: Library
import { computed, onMounted, reactive } from 'vue';
import { computed, onMounted, reactive, watch, ref } from 'vue';
import { storeToRefs } from 'pinia';
// NOTE: Components
@ -8,26 +8,47 @@ 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 TableTaskOrder from './TableTaskOrder.vue';
import FloatingActionButton from 'components/FloatingActionButton.vue';
import ReceiveDialog from './receive_view/ReceiveDialog.vue';
// NOTE: Stores & Type
import {
TaskOrder,
TaskOrderStatus,
UserTaskStatus,
} from 'src/stores/task-order/types';
import { useNavigator } from 'src/stores/navigator';
import { useTaskOrderStore } from 'src/stores/task-order';
import { useTaskOrderForm } from './form';
import useFlowStore from 'src/stores/flow';
import { pageTabs, column } from './constants';
import { pageTabs, column, pageTabsReceive } from './constants';
import { isRoleInclude } from 'src/stores/utils';
import { PaginationResult } from 'src/types';
const taskOrderFormStore = useTaskOrderForm();
const navigatorStore = useNavigator();
const flowStore = useFlowStore();
const taskOrderStore = useTaskOrderStore();
const { stats, pageMax, page, data, pageSize } = storeToRefs(taskOrderStore);
// NOTE: Variable
const pageState = reactive({
currentTab: 'title',
currentTab: 'Pending',
hideStat: false,
statusFilter: 'None',
inputSearch: '',
fieldSelected: [...column.map((v) => v.name)],
gridView: false,
total: 0,
isMessenger: isRoleInclude(['messenger']),
receiveDialog: false,
isReceiveScan: false,
});
const taskOrderList = ref<TaskOrder[]>([]);
const fieldSelectedOption = computed(() => {
return column.map((v) => ({
label: v.label,
@ -36,12 +57,118 @@ const fieldSelectedOption = computed(() => {
});
// NOTE: Function
async function fetchTaskOrderList(opts?: { page?: number; pageSize?: number }) {
let res: PaginationResult<TaskOrder> | null;
if (pageState.isMessenger) {
res = await taskOrderStore.getUserTaskOrderList({
page: opts?.page || page.value,
pageSize: opts?.pageSize || pageSize.value,
query: pageState.inputSearch === '' ? undefined : pageState.inputSearch,
userTaskStatus: pageState.currentTab as UserTaskStatus,
});
} else {
res = await taskOrderStore.getTaskOrderList({
page: opts?.page || page.value,
pageSize: opts?.pageSize || pageSize.value,
query: pageState.inputSearch === '' ? undefined : pageState.inputSearch,
taskOrderStatus: pageState.currentTab as TaskOrderStatus | undefined,
});
}
if (res) {
data.value = res.result;
pageState.total = res.total;
pageMax.value = Math.ceil(res.total / pageSize.value);
taskOrderList.value = res.result;
}
}
async function triggerTaskOrder(opts: {
statusDialog: 'info' | 'edit' | 'create';
id?: string;
}) {
const url = new URL(
`/task-order/order/${opts.statusDialog === 'create' ? 'add' : opts.id}`,
window.location.origin,
);
window.open(url.toString(), '_blank');
}
async function triggerTaskReceive(opts: {
statusDialog: 'info' | 'edit';
id?: string;
}) {
const url = new URL(`/task-order/receive/${opts.id}`, window.location.origin);
window.open(url.toString(), '_blank');
}
async function submitReceiveTask(selectedTask: TaskOrder[]) {
const arrId = selectedTask.map((t) => t.id);
await taskOrderStore.acceptTaskOrder(arrId);
fetchTaskOrderList();
pageState.receiveDialog = false;
}
function openReceiveDialog(scan?: boolean) {
if (scan) pageState.isReceiveScan = scan;
pageState.receiveDialog = true;
}
onMounted(async () => {
navigatorStore.current.title = 'taskOrder.title';
navigatorStore.current.path = [{ text: 'taskOrder.caption', i18n: true }];
fetchTaskOrderList();
taskOrderStore.getTaskOrderStats();
});
watch(
[
() => pageState.currentTab,
() => pageState.inputSearch,
() => pageSize.value,
() => pageState.statusFilter,
],
() => {
fetchTaskOrderList();
},
);
</script>
<template>
<FloatingActionButton
style="z-index: 999"
:hide-icon="!pageState.isMessenger"
@click.stop="
() => {
if (!pageState.isMessenger) {
triggerTaskOrder({ statusDialog: 'create' });
}
}
"
>
<q-fab-action
id="add-customer-natural-person"
:label="$t('taskOrder.receiveCustom')"
external-label
label-position="left"
style="color: white; background-color: var(--brand-1)"
padding="xs"
icon="mdi-account-arrow-left-outline"
@click="openReceiveDialog()"
/>
<q-fab-action
id="add-customer-legal-entity"
style="color: white; background-color: var(--brand-1)"
padding="xs"
icon="mdi-barcode-scan"
:label="$t('taskOrder.receiveScan')"
external-label
label-position="left"
@click="openReceiveDialog(true)"
/>
</FloatingActionButton>
<div class="column full-height no-wrap">
<!-- SEC: stat -->
<section class="text-body-2 q-mb-xs flex items-center">
@ -54,9 +181,7 @@ onMounted(async () => {
color: hsl(var(--info-bg));
"
>
<!-- TODO: replace stat -->
0
<!-- {{ Object.values(stats).reduce((a, c) => a + c, 0) }} -->
{{ pageState.total }}
</q-badge>
<q-btn
class="q-ml-sm"
@ -75,43 +200,50 @@ onMounted(async () => {
<transition name="slide">
<div v-if="!pageState.hideStat" class="scroll q-mb-md">
<div style="display: inline-block">
<!-- TODO: replace count -->
<StatCardComponent
v-if="!pageState.isMessenger"
labelI18n
:branch="[
{
icon: 'material-symbols-light:receipt-long',
count: 0,
count: stats[TaskOrderStatus.Pending],
label: 'taskOrder.title',
color: 'blue',
},
{
icon: 'material-symbols-light:receipt-long',
count: 0,
label: 'taskOrder.receiveTaskOrder',
count: stats[TaskOrderStatus.InProgress],
label: 'taskOrder.inProgress',
color: 'orange',
},
{
icon: 'mdi:email-fast-outline',
count: 0,
label: 'taskOrder.sendTaskOrder',
color: 'pink',
},
{
icon: 'tabler:cash-register',
count: 0,
label: 'taskOrder.payment',
color: 'purple',
},
{
icon: 'fluent:receipt-bag-24-regular',
count: 0,
count: stats[TaskOrderStatus.Complete],
label: 'taskOrder.goodReceipt',
color: 'light-green',
},
]"
:dark="$q.dark.isActive"
/>
<!-- TODO: stat count (messenger) -->
<StatCardComponent
v-else
labelI18n
:branch="[
{
icon: 'material-symbols-light:receipt-long',
count: pageState.total,
label:
pageState.currentTab === UserTaskStatus.Pending
? 'taskOrder.taskInCart'
: pageState.currentTab === UserTaskStatus.Accept
? 'taskOrder.receiveTask'
: 'taskOrder.sentTask',
color: 'blue',
},
]"
:dark="$q.dark.isActive"
/>
</div>
</div>
</transition>
@ -142,12 +274,11 @@ onMounted(async () => {
</q-input>
<div
class="row col-12 col-md-5 justify-end"
class="row col-12 col-md-3 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<!-- TODO: replace status -->
<q-select
<!-- <q-select
v-model="pageState.statusFilter"
outlined
dense
@ -166,23 +297,23 @@ onMounted(async () => {
},
{
label: $t('requestList.status.Pending'),
value: '',
value: 'Pending',
},
{
label: $t('requestList.status.InProgress'),
value: '',
value: 'InProgress',
},
{
label: $t('requestList.status.Completed'),
value: '',
value: 'Complete',
},
]"
/>
/> -->
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col q-ml-sm"
class="col"
:options="
fieldSelectedOption.map((v) => ({
...v,
@ -260,7 +391,9 @@ onMounted(async () => {
active-color="info"
>
<q-tab
v-for="tab in pageTabs"
v-for="tab in pageState.isMessenger
? pageTabsReceive
: pageTabs"
:name="tab.value"
:key="tab.value"
@click="
@ -289,19 +422,29 @@ onMounted(async () => {
<!-- SEC: body content -->
<article
v-if="true"
v-if="taskOrderList.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"
></article>
<article v-else class="col surface-2 full-width scroll q-pa-md">
<TableTaskOrder
:receive="pageState.isMessenger"
:rows="taskOrderList"
:columns="column"
:grid="pageState.gridView"
:visible-columns="pageState.fieldSelected"
@view="
(v) => {
pageState.isMessenger
? triggerTaskReceive({ statusDialog: 'info', id: v.id })
: triggerTaskOrder({ statusDialog: 'info', id: v.id });
}
"
/>
</article>
<!-- TODO: footer -->
<!-- SEC: footer content -->
<!-- <footer
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="pageMax > 0"
>
@ -316,8 +459,10 @@ onMounted(async () => {
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch ? data.length : pageState.total,
resultcurrentPage: taskOrderList.length,
total: pageState.inputSearch
? taskOrderList.length
: pageState.total,
})
}}
</div>
@ -325,12 +470,18 @@ onMounted(async () => {
<PaginationComponent
v-model:current-page="page"
v-model:max-page="pageMax"
:fetch-data="() => fetchList({ rotateFlowId: true })"
:fetch-data="() => fetchTaskOrderList()"
/>
</nav>
</footer> -->
</footer>
</div>
</section>
</div>
<ReceiveDialog
@submit="submitReceiveTask"
v-model:open="pageState.receiveDialog"
:scan="pageState.isReceiveScan"
/>
</template>
<style scoped></style>

View file

@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, ref, reactive, watch, onMounted } from 'vue';
import {
useRequestList,
RequestWork,
RequestWorkStatus,
} from 'src/stores/request-list';
import DialogHeader from 'src/components/dialog/DialogHeader.vue';
import CancelButton from 'src/components/button/CancelButton.vue';
import SaveButton from 'src/components/button/SaveButton.vue';
import DialogFormContainer from 'src/components/dialog/DialogFormContainer.vue';
import TableEmployee from './TableEmployee.vue';
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';
const emit = defineEmits<{
(e: 'select', value: RequestWork[]): void;
(e: 'afterSubmit'): void;
}>();
const requestListStore = useRequestList();
const taskList = defineModel<
{
step: number;
requestWorkId: string;
requestWorkStep?: Task;
}[]
>('taskList', {
default: [],
});
const open = defineModel<boolean>('open', { default: false });
const selectedEmployee = ref<
(RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
})[]
>([]);
let data = ref<RequestWork[]>([]);
let group = computed(() =>
data.value.reduce<
{
product: RequestWork['productService']['product'];
list: RequestWork[];
}[]
>((acc, curr) => {
let exist = acc.find(
(item) => curr.productService.productId == item.product.id,
);
if (exist) exist.list.push(curr);
else acc.push({ product: curr.productService.product, list: [curr] });
return acc;
}, []),
);
let state = reactive({
search: '',
});
onMounted(getList);
watch(() => state.search, getList);
async function getList() {
let res = await requestListStore.getRequestWorkList({
page: 1,
pageSize: 99999,
query: state.search,
readyToTask: true,
});
if (!res) return;
data.value = res.result;
}
// function toggle(item: RequestWork) {
// switch (selected(item)) {
// case true:
// return deselect(item);
// case false:
// return select(item);
// }
// }
// function select(item: RequestWork) {
// if (selected(item)) return;
// selectedEmployee.value = selectedEmployee.value
// ? selectedEmployee.value.concat(item)
// : [item];
// }
// function deselect(item: RequestWork) {
// const idx = selectedEmployee.value?.findIndex((v) => v.id === item.id);
// if (idx !== -1) selectedEmployee.value?.splice(idx, 1);
// }
// function selected(item: RequestWork): boolean {
// return !!selectedEmployee.value?.some((v) => v.id === item.id);
// }
//
function getStep(requestWork: RequestWork) {
const target = requestWork.stepStatus.find(
(v) => v.workStatus === RequestWorkStatus.Ready,
);
return target?.step || 0;
}
function getTemplateData(requestWork: RequestWork) {
const target = getStep(requestWork);
if (!target) return null;
const flow = requestWork.productService.service?.workflow;
if (!flow) return null;
const step = flow.step.find((v) => v.order === target);
if (!step) return null;
return {
id: step.id,
step: step.order,
templateName: flow.name,
templateStepName: step.name || '-',
};
}
function submit() {
let selected: {
step: number;
requestWorkId: string;
requestWorkStep?: Task;
}[] = [];
selectedEmployee.value.forEach((v, i) => {
const curr = v.stepStatus.find(
(s) => s.workStatus === RequestWorkStatus.Ready,
);
if (curr) {
const task: Task = {
...curr,
attributes: curr.attributes,
workStatus: RequestWorkStatus.Ready,
taskOrderId: '',
requestWork: selectedEmployee.value[i],
};
selected.push({
step: task.step,
requestWorkId: task.requestWorkId,
requestWorkStep: task,
});
}
});
if (selected) {
taskList.value = selected;
emit('afterSubmit');
}
open.value = false;
}
function close() {
open.value = false;
}
function onDialogOpen() {
selectedEmployee.value = [];
if (taskList.value.length === 0) return;
const matchingItems = group.value
.flatMap((g) => g.list)
.filter((l) =>
l.stepStatus.some((s) =>
taskList.value.some((t) => s.requestWorkId === t.requestWorkId),
),
);
selectedEmployee.value = JSON.parse(JSON.stringify(matchingItems));
}
</script>
<template>
<DialogFormContainer v-model="open" v-on:open="onDialogOpen">
<template #header>
<DialogHeader
:title="$t('general.list', { msg: $t('productService.title') })"
/>
</template>
<section class="col column full-width no-wrap surface-2 scroll">
<template v-if="group.length > 0">
<div
v-for="{ product, list } in group"
:key="product.id"
class="bordered-b"
>
<q-expansion-item
dense
class="overflow-hidden"
switch-toggle-side
style="border-radius: var(--radius-2)"
:default-opened="
list.some((v) => selectedEmployee.some((s) => s.id === v.id))
"
expand-icon="mdi-chevron-down-circle"
header-class="q-py-sm text-medium text-body items-center rounded q-mx-md q-my-sm"
>
<template #header>
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
</div>
</span>
<span class="q-ml-auto">
<div
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.15)"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ list.length }}
</div>
</span>
</template>
<div>
<FormGroupHead>
{{ $t('quotation.employeeList') }}
</FormGroupHead>
<div class="q-pa-md full-width">
<TableEmployee
checkbox-on
step-on
:rows="
list.map((v) =>
Object.assign(v, { _template: getTemplateData(v) }),
)
"
v-model:selected-employee="selectedEmployee"
/>
</div>
</div>
</q-expansion-item>
</div>
</template>
<div v-else class="row items-center justify-center full-height">
<NoData :text="$t('taskOrder.noRequestAvailable')" />
</div>
</section>
<template #footer>
<CancelButton class="q-ml-auto" outlined @click="close" />
<SaveButton
:label="$t('general.select')"
class="q-ml-sm"
icon="mdi-check"
solid
@click="submit"
/>
</template>
</DialogFormContainer>
</template>
<style scoped>
:deep(i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon) {
color: hsl(var(--text-mute));
}
:deep(
i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon.q-expansion-item__toggle-icon--rotated
) {
color: var(--brand-1);
}
.active {
background: red;
}
</style>

View file

@ -0,0 +1,464 @@
<script lang="ts" setup>
import { QTable, QTableProps, QTableSlots } from 'quasar';
import { Icon } from '@iconify/vue/dist/iconify.js';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import ExpirationDate from 'src/components/03_customer-management/ExpirationDate.vue';
import { baseUrl } from 'src/stores/utils';
import { employeeColumn } from './constants';
import useOptionStore from 'src/stores/options';
import { RequestWork } from 'src/stores/request-list';
import { QuotationFull } from 'src/stores/quotations/types';
import { dateFormatJS, calculateAge } from 'src/utils/datetime';
import { TaskStatus } from 'src/stores/task-order/types';
const props = withDefaults(
defineProps<{
checkboxOn?: boolean;
checkAll?: boolean;
stepOn?: boolean;
rows: QTableProps['rows'];
grid?: boolean;
}>(),
{
rows: () => [],
grid: false,
},
);
const selectedEmployee = defineModel<
(RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
})[]
>('selectedEmployee', {
default: [],
});
defineEmits<{
(
e: 'changeAllStatus',
payload: {
data: (RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
})[];
status: TaskStatus;
},
): void;
}>();
function getEmployeeName(
record: RequestWork,
opts?: {
locale?: string;
},
) {
const employee = record.request.employee;
return (
{
['eng']: `${useOptionStore().mapOption(employee?.namePrefix || '')} ${employee?.firstNameEN} ${employee?.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(employee?.namePrefix || '')} ${employee?.firstName} ${employee?.lastName}`,
}[opts?.locale || 'eng'] || '-'
);
}
function goToQuotation(quotation: QuotationFull) {
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');
}
function goToRequestList(id: string) {
const url = new URL(`/request-list/${id}`, window.location.origin);
window.open(url.toString(), '_blank');
}
function handleCheckAll() {
const arr = JSON.parse(JSON.stringify(props.rows));
const selectableTasks = arr.filter(
(
t: RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
taskStatus: TaskStatus;
},
) =>
t.taskStatus !== TaskStatus.Failed &&
t.taskStatus !== TaskStatus.Success &&
t.taskStatus !== TaskStatus.Complete &&
t.taskStatus !== TaskStatus.Redo,
);
if (selectedEmployee.value.length !== selectableTasks.length) {
selectedEmployee.value = selectableTasks;
} else {
selectedEmployee.value = [];
}
}
function handleCheck(
row: RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
};
taskStatus: TaskStatus;
},
) {
if (
row.taskStatus === TaskStatus.Failed ||
row.taskStatus === TaskStatus.Success ||
row.taskStatus === TaskStatus.Complete ||
row.taskStatus === TaskStatus.Redo
)
return;
const index = selectedEmployee.value.findIndex((data) => data.id === row.id);
if (index !== -1) {
selectedEmployee.value.splice(index, 1);
} else {
selectedEmployee.value.push(row);
}
}
</script>
<template>
<q-table
flat
bordered
row-key="id"
v-bind="props"
hide-pagination
class="full-width"
:selection="!checkboxOn ? undefined : 'multiple'"
:columns="
stepOn
? [
...employeeColumn.slice(0, 2),
{
name: 'periodNo',
align: 'center',
label: 'flow.step',
field: (v) => v.product.code,
},
...employeeColumn.slice(2),
]
: employeeColumn
"
:rows-per-page-options="[0]"
:no-data-label="$t('general.noDataTable')"
v-model:selected="selectedEmployee"
hide-selected-banner
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-if="checkboxOn" class="relative-position">
<q-checkbox
v-if="checkAll"
:model-value="
selectedEmployee.length > 0 &&
selectedEmployee.length ===
rows.filter(
(t) =>
t.taskStatus !== TaskStatus.Complete &&
t.taskStatus !== TaskStatus.Success &&
t.taskStatus !== TaskStatus.Failed,
).length
"
@click="handleCheckAll"
size="sm"
/>
<div v-if="checkAll" class="absolute-right row items-center">
<q-btn
flat
dense
rounded
icon="mdi-chevron-down"
size="sm"
class=""
>
<q-menu :offset="[0, 4]">
<q-list dense>
<q-item
clickable
v-close-popup
class="items-center"
@click="
$emit('changeAllStatus', {
data: selectedEmployee,
status: TaskStatus.Success,
})
"
>
<q-icon
style="color: hsl(var(--positive-bg))"
name="mdi-file-check-outline"
class="q-pr-sm"
size="xs"
></q-icon>
{{ $t(`taskOrder.status.Success`) }}
</q-item>
<q-item
clickable
v-close-popup
class="items-center"
@click="
$emit('changeAllStatus', {
data: selectedEmployee,
status: TaskStatus.Failed,
})
"
>
<q-icon
style="color: hsl(var(--negative-bg))"
name="mdi-file-remove-outline"
class="q-pr-sm"
size="xs"
></q-icon>
{{ $t(`taskOrder.status.Failed`) }}
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</q-th>
<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="$slots.append"></q-th>
<q-th v-if="$slots.action"></q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
};
taskStatus: TaskStatus;
};
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{
urgent: props.row.request.quotation.urgent,
dark: $q.dark.isActive,
['disabled-row']:
selectedEmployee.length > 0 &&
selectedEmployee.some(
(v) => v._template?.id !== props.row._template?.id,
),
}"
class="text-center"
>
<q-td v-if="checkboxOn">
<q-checkbox
:model-value="selectedEmployee.some((t) => t.id === props.row.id)"
@click="handleCheck(props.row)"
size="sm"
:disable="
props.row.taskStatus === TaskStatus.Failed ||
props.row.taskStatus === TaskStatus.Success ||
props.row.taskStatus === TaskStatus.Complete ||
props.row.taskStatus === TaskStatus.Redo ||
(selectedEmployee.length > 0 &&
selectedEmployee.some(
(v) => v._template?.id !== props.row._template?.id,
))
"
/>
</q-td>
<q-td>
{{ props.rowIndex + 1 }}
</q-td>
<q-td>
<span
class="cursor-pointer link"
@click="goToRequestList(props.row.request.id)"
>
{{ props.row.request.code }}
</span>
</q-td>
<q-td v-if="stepOn" class="text-left">
<div v-if="props.row._template" class="column text-left">
<span>{{ props.row._template.templateName }}</span>
<span class="app-text-muted text-caption">
{{ $t('flow.stepNo', { msg: props.row._template.step }) }}
{{ props.row._template.templateStepName }}
</span>
</div>
<span v-else>-</span>
</q-td>
<q-td>
<div class="row items-center no-wrap">
<q-avatar class="q-mr-sm" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/employee/${props.row.request.employee.id}/image/${props.row.request.employee.selectedImage}`"
>
<template #error>
<span class="full-width full-height">
<q-img src="/images/employee-avatar.png" />
</span>
</template>
</q-img>
</q-avatar>
<div class="column text-left q-ml-sm">
<div>
{{ getEmployeeName(props.row, { locale: $i18n.locale }) }}
</div>
<div class="app-text-muted">
{{ 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}`"
width="24px"
/>
</div>
</q-td>
<q-td>{{ calculateAge(props.row.request.employee.dateOfBirth) }}</q-td>
<q-td>
{{
useOptionStore().mapOption(props.row.request.employee.nationality)
}}
</q-td>
<q-td>
{{
dateFormatJS({
date: props.row.request.quotation.dueDate,
locale: $i18n.locale,
dayStyle: '2-digit',
monthStyle: 'short',
})
}}
</q-td>
<q-td>
<ExpirationDate
:expiration-date="new Date(props.row.request.quotation.dueDate)"
/>
</q-td>
<q-td>
<span
class="cursor-pointer link"
@click="goToQuotation(props.row.request.quotation)"
>
{{ props.row.request.quotation.code }}
</span>
</q-td>
<q-td>
<BadgeComponent
v-if="props.row.request.quotation.urgent"
icon="mdi-fire"
:title="$t('general.urgent2')"
hsla-color="--gray-1-hsl"
hsla-background="--red-8-hsl"
solid
/>
</q-td>
<q-td v-if="$slots.append">
<slot name="append" :props="props"></slot>
</q-td>
<q-td v-if="$slots.action">
<slot name="action" :props="props"></slot>
</q-td>
</q-tr>
</template>
<!-- <template v-slot:item="props"></template> -->
</q-table>
</template>
<style scoped>
:deep(tr:nth-child(2n)) {
background: #f9fafc;
&.dark {
background: hsl(var(--gray-11-hsl) / 0.2);
}
}
.q-table tr.urgent {
background: hsla(var(--red-6-hsl) / 0.03);
}
.q-table tr.urgent td:first-child {
&::after {
content: ' ';
display: block;
position: absolute;
left: 0;
top: 15%;
bottom: 15%;
background: var(--red-8);
width: 4px;
border-radius: 99rem;
animation: blink 1s infinite;
}
}
@keyframes blink {
0% {
background: var(--red-8);
}
50% {
background: var(--red-3);
}
100% {
background: var(--red-8);
}
}
.link {
color: hsl(var(--info-bg));
text-decoration: underline;
}
.disabled-row {
opacity: 0.3;
filter: grayscale(1);
}
:deep(.q-table tbody td:after) {
background: transparent;
}
</style>

View file

@ -0,0 +1,301 @@
<script lang="ts" setup>
import { QTableProps, QTableSlots } from 'quasar';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import KebabAction from 'src/components/shared/KebabAction.vue';
import useOptionStore from 'src/stores/options';
import { dateFormatJS, dateFormat } from 'src/utils/datetime';
import { TaskOrder, TaskOrderStatus } from 'src/stores/task-order/types';
import { column } from './constants';
import { ref } from 'vue';
const selectedTask = defineModel<TaskOrder[]>('selectedTask', { default: [] });
const props = withDefaults(
defineProps<{
rows: QTableProps['rows'];
grid?: boolean;
visibleColumns?: string[];
selection?: 'single' | 'multiple' | 'none';
receive?: boolean;
}>(),
{
rows: () => [],
grid: false,
receive: false,
selection: 'none',
visibleColumns: () => [
'createdAt',
'order',
'taskName',
'issueBranch',
'institution',
'createdBy',
'contactTel',
'contactName',
'taskStatus',
],
},
);
const currentBtnOpen = ref<boolean[]>([]);
function taskOrderStatus(value: TaskOrderStatus, type: 'status' | 'color') {
const mappings: Record<string, Record<'status' | 'color', string>> = {
Pending: {
status: props.receive ? 'taskOrder.taskInCart' : 'taskOrder.title',
color: '--blue-6-hsl',
},
InProgress: {
status: 'taskOrder.inProgress',
color: props.receive ? '--blue-6-hsl' : '--orange-5-hsl',
},
Validate: {
status: 'taskOrder.inProgress',
color: props.receive ? '--blue-6-hsl' : '--orange-5-hsl',
},
Complete: {
status: props.receive ? 'taskOrder.sentTask' : 'taskOrder.goodReceipt',
color: props.receive ? '--blue-6-hsl' : '--green-8-hsl',
},
Accept: {
status: 'taskOrder.receiveTask',
color: '--blue-6-hsl',
},
Submit: {
status: 'taskOrder.sentTask',
color: '--blue-6-hsl',
},
};
return mappings[value]?.[type] || '';
}
function getCreatedByName(
record: TaskOrder,
opts?: {
locale?: string;
},
) {
const createdBy = record.createdBy;
return (
{
['eng']: `${useOptionStore().mapOption(createdBy?.namePrefix) || ''} ${createdBy?.firstNameEN} ${createdBy?.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(createdBy?.namePrefix) || ''} ${createdBy?.firstName} ${createdBy?.lastName}`,
}[opts?.locale || 'eng'] || '-'
);
}
function openList(index: number, data: TaskOrder) {
if (!currentBtnOpen.value[index]) {
// currentBtnOpen.value.map((v, i) => {
// if (i !== index) {
// currentBtnOpen.value[i] = false;
// }
// });
emit('clickSubRow', index, data);
}
currentBtnOpen.value[index] = !currentBtnOpen.value[index];
}
const emit = defineEmits<{
(e: 'view', data: TaskOrder): void;
(e: 'clickSubRow', index: number, data: TaskOrder): void;
}>();
</script>
<template>
<q-table
v-bind="props"
:columns="column"
bordered
flat
hide-pagination
card-container-class="q-col-gutter-sm"
:rows-per-page-options="[0]"
class="full-width"
:no-data-label="$t('general.noDataTable')"
row-key="id"
v-model:selected="selectedTask"
hide-selected-banner
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-if="selection !== 'none'">
<q-checkbox v-model="props.selected" size="sm" />
</q-th>
<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-tr>
</template>
<template
v-slot:body="props: {
row: TaskOrder;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr class="text-center">
<q-td v-if="selection !== 'none'">
<q-checkbox v-model="props.selected" size="sm" />
</q-td>
<q-td v-if="visibleColumns.includes('order')">
{{ props.rowIndex + 1 }}
</q-td>
<q-td v-if="visibleColumns.includes('taskName')" class="text-left">
<div>
{{ props.row.taskName || '-' }}
<q-tooltip :delay="300">
{{ props.row.taskName || '-' }}
</q-tooltip>
</div>
<div class="text-caption app-text-muted">
{{ props.row.code || '-' }}
</div>
</q-td>
<q-td v-if="visibleColumns.includes('issueBranch')">-</q-td>
<q-td v-if="visibleColumns.includes('institution')">
{{
$i18n.locale === 'eng'
? props.row.institution.nameEN || '-'
: props.row.institution.name || '-'
}}
</q-td>
<q-td v-if="visibleColumns.includes('createdAt')">
{{
dateFormatJS({
date: props.row.createdAt,
dayStyle: '2-digit',
monthStyle: '2-digit',
}) || '-'
}}
{{ dateFormat(props.row.createdAt, false, true) }}
</q-td>
<q-td v-if="visibleColumns.includes('createdBy')" class="text-left">
{{ getCreatedByName(props.row, $i18n) }}
</q-td>
<q-td v-if="visibleColumns.includes('contactTel')">
{{ props.row.contactTel || '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('contactName')" class="text-left">
{{ props.row.contactName || '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('taskStatus')">
<BadgeComponent
hide-icon
:hsla-color="taskOrderStatus(props.row.taskOrderStatus, 'color')"
:title="$t(taskOrderStatus(props.row.taskOrderStatus, 'status'))"
/>
</q-td>
<q-td v-if="selection === 'none'">
<q-btn
:id="`btn-eye-${props.row.taskName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row)"
/>
<KebabAction
v-if="false"
:idName="`btn-kebab-${props.row.taskName}`"
status="'ACTIVE'"
hide-toggle
:hide-edit="true"
@view="$emit('view', props.row)"
/>
</q-td>
<q-td v-else>
<q-btn
dense
flat
class="rounded"
@click.stop="
() => {
openList(props.rowIndex, props.row);
}
"
>
<div class="row items-center no-wrap">
<q-icon name="mdi-account-group-outline" />
<q-icon
class="btn-arrow-right"
:class="{
active: currentBtnOpen[props.rowIndex],
}"
size="xs"
:name="`mdi-chevron-${currentBtnOpen[props.rowIndex] ? 'down' : 'up'}`"
/>
</div>
</q-btn>
</q-td>
</q-tr>
<q-tr v-show="currentBtnOpen[props.rowIndex]" :props="props">
<q-td colspan="100%" style="padding: 16px">
<slot name="subRow" :props="props"></slot>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<div class="col-md-4 col-sm-6 col-12">
<!-- TODO: status -->
<QuotationCard
:status="$t(taskOrderStatus(props.row.taskOrderStatus, 'status'))"
:badge-color="taskOrderStatus(props.row.taskOrderStatus, 'color')"
hide-action
hidePreview
:code="props.row.code"
:title="props.row.taskName"
:custom-data="[
{
label: $t('taskOrder.issueBranch'),
value: props.row.issueBranch,
},
{
label: $t('general.agencies'),
value:
$i18n.locale === 'eng'
? props.row.institution.nameEN
: props.row.institution.name,
},
{
label: $t('taskOrder.issueDate'),
value: `${dateFormatJS({
date: props.row.createdAt,
dayStyle: '2-digit',
monthStyle: '2-digit',
})} ${dateFormat(props.row.createdAt, false, true)}`,
},
{
label: $t('taskOrder.madeBy'),
value: getCreatedByName(props.row, $i18n),
},
{
label: $t('general.telephone'),
value: props.row.contactTel,
},
{
label: $t('taskOrder.contactName'),
value: props.row.contactName,
},
]"
@view="$emit('view', props.row)"
/>
</div>
</template>
</q-table>
</template>
<style scoped>
:deep(.q-table tbody td:after) {
background: transparent;
}
</style>

View file

@ -0,0 +1,176 @@
<script lang="ts" setup>
import { TaskStatus } from 'src/stores/task-order/types';
import { taskStatusReceiveToggle, taskStatusOrderToggle } from './constants';
import { computed } from 'vue';
const props = defineProps<{
readonly?: boolean;
noAction?: boolean;
type?: 'order' | 'receive';
status: string | TaskStatus | undefined;
}>();
defineEmits<{
(e: 'changeStatus', status: TaskStatus): void;
(e: 'clickFailed'): void;
}>();
const statusList = [
...taskStatusReceiveToggle,
...taskStatusOrderToggle,
{
value: TaskStatus.Validate,
icon: 'mdi-file-check-outline',
color: 'positive',
},
];
const currStatus = computed(() =>
statusList.find((v) => v.value === props.status),
);
function hideIcon() {
if (props.noAction) return props.noAction;
if (props.type === 'order') {
return (
currStatus.value?.value === TaskStatus.InProgress ||
currStatus.value?.value === TaskStatus.Redo ||
currStatus.value?.value === TaskStatus.Success ||
currStatus.value?.value === TaskStatus.Complete ||
currStatus.value?.value === TaskStatus.Canceled
);
}
if (props.type === 'receive') {
return (
currStatus.value?.value === TaskStatus.Failed ||
currStatus.value?.value === TaskStatus.Success ||
currStatus.value?.value === TaskStatus.Complete ||
currStatus.value?.value === TaskStatus.Redo ||
currStatus.value?.value === TaskStatus.Validate ||
currStatus.value?.value === TaskStatus.Canceled
);
}
}
</script>
<template>
<div
v-if="readonly"
class="row rounded bordered surface-2 items-center justify-center q-pa-xs no-wrap"
:style="`color: hsl(var(--${currStatus?.color}-bg))`"
>
<q-icon :name="currStatus?.icon" class="q-pr-xs" size="xs" />
{{ $t(`taskOrder.status.${status}`) }}
</div>
<div v-else class="row items-center justify-center no-wrap">
<q-btn-dropdown
dense
unelevated
:label="
currStatus?.value === TaskStatus.Validate && type === 'order'
? $t('taskOrder.done')
: $t(`taskOrder.status.${status}`)
"
class="text-capitalize text-weight-regular product-status rounded"
:class="{
'hide-icon q-pr-md': hideIcon(),
warning:
currStatus?.value === TaskStatus.Pending ||
currStatus?.value === TaskStatus.InProgress,
positive:
currStatus?.value === TaskStatus.Complete ||
currStatus?.value === TaskStatus.Success ||
currStatus?.value === TaskStatus.Validate,
negative:
currStatus?.value === TaskStatus.Failed ||
currStatus?.value === TaskStatus.Redo ||
currStatus?.value === TaskStatus.Canceled,
'pointer-events-none': {
order: !['Success', 'Failed', 'Validate'].includes(status || ''),
receive: status !== TaskStatus.InProgress,
}[type ?? 'order'],
}"
:menu-offset="[0, 8]"
dropdown-icon="mdi-chevron-down"
content-class="bordered rounded"
@click.stop
:icon="currStatus?.icon"
>
<q-list v-if="!noAction" dense>
<q-item
v-for="(v, index) in type === 'order'
? {
Success: taskStatusOrderToggle.filter(
(v) => v.value === TaskStatus.Complete,
),
Failed: taskStatusOrderToggle.filter(
(v) => v.value === TaskStatus.Redo,
),
Validate: taskStatusOrderToggle.filter(
(v) => v.value !== TaskStatus.InProgress,
),
}[status ?? '']
: {
InProgress: taskStatusReceiveToggle.filter((v) =>
[TaskStatus.Success, TaskStatus.Failed].includes(v.value),
),
}[status ?? '']"
:key="index"
clickable
v-close-popup
@click="$emit('changeStatus', v.value)"
class="items-center"
>
<q-icon
:style="`color: hsl(var(--${v.color}-bg))`"
:name="v.icon"
class="q-pr-sm"
size="xs"
></q-icon>
{{ $t(`taskOrder.status.${v.value}`) }}
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="currStatus?.value === TaskStatus.Failed"
flat
dense
rounded
color="negative"
icon="mdi-shield-alert-outline"
@click="$emit('clickFailed')"
/>
<div style="width: 31.94px" v-else></div>
</div>
</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);
}
}
.pointer-events-none {
pointer-events: none;
}
:deep(
.hide-icon
i.q-icon.mdi.mdi-chevron-down.q-btn-dropdown__arrow.q-btn-dropdown__arrow-container
) {
display: none;
}
</style>

View file

@ -1,11 +1,99 @@
import { QTableProps } from 'quasar';
import { TaskStatus } from 'src/stores/task-order/types';
export const taskStatusOpts = [
{
status: TaskStatus.Pending,
name: 'taskOrder.status.Pending',
},
{
status: TaskStatus.InProgress,
name: 'taskOrder.status.InProgress',
},
{
status: TaskStatus.Success,
name: 'taskOrder.status.Success',
},
{
status: TaskStatus.Failed,
name: 'taskOrder.status.Failed',
},
{
status: TaskStatus.Redo,
name: 'taskOrder.status.Redo',
},
{
status: TaskStatus.Validate,
name: 'taskOrder.status.Validate',
},
{
status: TaskStatus.Complete,
name: 'taskOrder.status.Complete',
},
];
export const pageTabs = [
{ label: 'title', value: 'title' },
{ label: 'receiveTaskOrder', value: 'receiveTaskOrder' },
{ label: 'sendTaskOrder', value: 'sendTaskOrder' },
{ label: 'payment', value: 'payment' },
{ label: 'goodReceipt', value: 'goodReceipt' },
{ label: 'title', value: 'Pending' },
{ label: 'inProgress', value: 'InProgress' },
{ label: 'goodReceipt', value: 'Complete' },
];
export const pageTabsReceive = [
{ label: 'taskInCart', value: 'Pending' },
{ label: 'receiveTask', value: 'Accept' },
{ label: 'sentTask', value: 'Submit' },
];
export enum Status {
taskOrder = 'taskOrder',
receiveTaskOrder = 'receiveTaskOrder',
sendTaskOrder = 'sendTaskOrder',
payment = 'payment',
goodReceipt = 'goodReceipt',
}
export const taskStatusReceiveToggle = [
{
value: TaskStatus.Pending,
icon: 'mdi-file-move-outline',
color: 'warning',
},
{
value: TaskStatus.Success,
icon: 'mdi-file-check-outline',
color: 'positive',
},
{
value: TaskStatus.Failed,
icon: 'mdi-file-remove-outline',
color: 'negative',
},
];
export const taskStatusOrderToggle = [
{
value: TaskStatus.InProgress,
icon: 'mdi-file-move-outline',
color: 'positive',
},
{
value: TaskStatus.Complete,
icon: 'mdi-file-check-outline',
color: 'positive',
},
{
value: TaskStatus.Redo,
icon: 'mdi-file-remove-outline',
color: 'negative',
},
{
value: TaskStatus.Canceled,
icon: 'mdi-file-remove-outline',
color: 'negative',
},
];
export const column = [
@ -16,40 +104,40 @@ export const column = [
field: 'no',
},
{
name: 'taskOrder',
align: 'left',
name: 'taskName',
align: 'center',
label: 'taskOrder.title',
field: 'taskOrder',
field: 'taskName',
},
{
name: 'issueBranch',
align: 'left',
align: 'center',
label: 'taskOrder.issueBranch',
field: 'issueBranch',
},
{
name: 'agencies',
align: 'left',
name: 'institution',
align: 'center',
label: 'general.agencies',
field: 'agencies',
field: 'institution',
},
{
name: 'issueDate',
align: 'left',
name: 'createdAt',
align: 'center',
label: 'taskOrder.issueDate',
field: 'issueDate',
field: 'createdAt',
},
{
name: 'madeBy',
align: 'left',
name: 'createdBy',
align: 'center',
label: 'taskOrder.madeBy',
field: 'madeBy',
field: 'createdBy',
},
{
name: 'telephone',
align: 'left',
name: 'contactTel',
align: 'center',
label: 'general.telephone',
field: 'telephone',
field: 'contactTel',
},
{
name: 'contactName',
@ -58,9 +146,174 @@ export const column = [
field: 'contactName',
},
{
name: 'status',
align: 'left',
name: 'taskStatus',
align: 'center',
label: 'general.status',
field: 'status',
field: 'taskStatus',
},
];
] as const satisfies QTableProps['columns'];
export const employeeColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'code',
align: 'center',
label: 'requestList.requestListCode',
field: 'code',
},
{
name: 'fullName',
align: 'center',
label: 'quotation.employeeName',
field: 'fullName',
},
{
name: 'dateOfBirth',
align: 'center',
label: 'general.age',
field: 'dateOfBirth',
},
{
name: 'nationality',
align: 'center',
label: 'general.nationality',
field: 'nationality',
},
{
name: 'dueDate',
align: 'center',
label: 'quotation.documentExpireDate',
field: 'dueDate',
},
{
name: 'day',
align: 'center',
label: 'general.numberOfDay',
field: 'day',
},
{
name: 'quotationCode',
align: 'center',
label: 'requestList.quotationCode',
field: 'quotationCode',
},
] as const satisfies QTableProps['columns'];
export const productColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'code',
align: 'center',
label: 'productService.product.code',
field: 'code',
},
{
name: 'productList',
align: 'center',
label: 'taskOrder.productList',
field: 'productList',
},
{
name: 'amountOfEmployee',
align: 'center',
label: 'taskOrder.amountOfEmployee',
field: 'amountOfEmployee',
},
{
name: 'pricePerUnit',
align: 'center',
label: 'quotation.pricePerUnit',
field: 'pricePerUnit',
},
{
name: 'discount',
align: 'center',
label: 'general.discount',
field: 'discount',
},
{
name: 'priceBeforeVat',
align: 'center',
label: 'quotation.priceBeforeVat',
field: 'priceBeforeVat',
},
{
name: 'vat',
align: 'center',
label: 'general.vat',
field: 'vat',
},
{
name: 'totalPriceBaht',
align: 'center',
label: 'quotation.totalPriceBaht',
field: 'totalPriceBaht',
},
] as const satisfies QTableProps['columns'];
export const receiveProductColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'order',
},
{
name: 'requestListCode',
align: 'center',
label: 'requestList.requestListCode',
field: 'requestListCode',
},
{
name: 'flow',
align: 'center',
label: 'flow.step',
field: 'flow',
},
{
name: 'quotation',
align: 'center',
label: 'quotation.employeeName',
field: 'quotation',
},
{
name: 'age',
align: 'center',
label: 'general.age',
field: 'age',
},
{
name: 'nationality',
align: 'center',
label: 'general.nationality',
field: 'nationality',
},
{
name: 'documentExpireDate',
align: 'center',
label: 'quotation.documentExpireDate',
field: 'documentExpireDate',
},
{
name: 'numberOfDay',
align: 'center',
label: 'general.numberOfDay',
field: 'numberOfDay',
},
{
name: 'quotationCode',
align: 'center',
label: 'requestList.quotationCode',
field: 'quotationCode',
},
] as const satisfies QTableProps['columns'];

View file

@ -0,0 +1,519 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia';
import { onMounted, nextTick, ref } from 'vue';
import ThaiBahtText from 'thai-baht-text';
// NOTE: Import stores
import { formatNumberDecimal } from 'stores/utils';
import { useConfigStore } from 'stores/config';
import { precisionRound } from 'src/utils/arithmetic';
// NOTE Import Types
import { Branch } from 'stores/branch/types';
// NOTE: Import Components
import ViewHeader from './ViewHeader.vue';
import ViewFooter from './ViewFooter.vue';
import PrintButton from 'src/components/button/PrintButton.vue';
import {
TaskOrder,
TaskOrderStatus,
TaskStatus,
} from 'src/stores/task-order/types';
import { useRoute } from 'vue-router';
import { useTaskOrderStore } from 'src/stores/task-order';
import { RequestWork } from 'src/stores/request-list';
const route = useRoute();
const taskOrder = useTaskOrderStore();
const configStore = useConfigStore();
const config = storeToRefs(configStore).data;
type Data = TaskOrder;
type ProductItem = {
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 branch = ref<Branch>();
const product = ref<ProductItem[]>([]);
const elements = ref<HTMLElement[]>([]);
const chunks = ref<ProductItem[][]>([[]]);
const data = ref<Data>();
const summaryPrice = ref<SummaryPrice>({
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
});
async function assignData() {
for (let i = 0; i < product.value.length; i++) {
let el = elements.value.at(-1);
if (!el) return;
if (getHeight(el) < 500) {
chunks.value.at(-1)?.push(product.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 STORAGE_KEY = 'task-order-preview';
onMounted(async () => {
if (route.params['id'] && typeof route.params['id'] === 'string') {
const jsonObject = await taskOrder.getTaskOrderById(route.params['id']);
if (!jsonObject) return;
data.value = jsonObject;
branch.value = jsonObject.registeredBranch;
} else {
let jsonString: string | null;
jsonString = localStorage.getItem(STORAGE_KEY);
jsonString = jsonString || sessionStorage.getItem(STORAGE_KEY);
if (!jsonString) return;
localStorage.removeItem(STORAGE_KEY);
const jsonObject: Data = JSON.parse(jsonString);
if (jsonObject) sessionStorage.setItem(STORAGE_KEY, jsonString);
data.value = jsonObject;
branch.value = jsonObject.registeredBranch;
}
const taskListGroup = data.value?.taskList.reduce<
{
product: RequestWork['productService']['product'];
list: RequestWork[];
}[]
>((acc, curr) => {
if (
data.value?.taskOrderStatus === TaskOrderStatus.Complete &&
curr.taskStatus !== TaskStatus.Complete
) {
return acc;
}
const task = curr.requestWorkStep;
if (!task) return acc;
if (task.requestWork) {
let exist = acc.find(
(item) => task.requestWork.productService.productId == item.product.id,
);
if (exist) {
exist.list.push(task.requestWork);
} else {
acc.push({
product: task.requestWork.productService.product,
list: [task.requestWork],
});
}
}
return acc;
}, []);
product.value = [];
summaryPrice.value = taskListGroup
.map((v) => ({
product: v.product,
pricePerUnit: v.product.serviceCharge,
discount:
data.value?.taskProduct.find(
({ productId }) => productId === v.product.id,
)?.discount || 0,
amount: v.list.length,
}))
.reduce(
(a, c) => {
const price = precisionRound(c.pricePerUnit * c.amount);
const vat =
precisionRound(
(c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) *
(config.value?.vat || 0.07),
) * (!c.discount ? c.amount : 1);
product.value.push({
id: c.product.id,
code: c.product.code,
detail: c.product.name,
priceUnit: c.pricePerUnit,
amount: c.amount,
discount: c.discount,
vat,
value: price * c.amount + vat,
});
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);
return a;
},
{
totalPrice: 0,
totalDiscount: 0,
vat: 0,
vatExcluded: 0,
finalPrice: 0,
},
);
assignData();
});
function print() {
window.print();
}
</script>
<template>
<div class="toolbar">
<PrintButton solid @click="print" />
</div>
<div class="row justify-between container color-task">
<section class="content" v-for="chunk in chunks">
<ViewHeader
v-if="!!branch && data"
:branch="branch"
:institution="data.institution"
:details="{
code: data.code,
name: data.taskName,
contactName: data.contactName,
contactTel: data.contactTel,
}"
/>
<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('taskOrder.goodReceipt') }}
</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>
</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" v-if="data">
<ViewHeader
v-if="!!branch"
:branch="branch"
:institution="data.institution"
:details="{
code: data.code,
name: data.taskName,
contactName: data.contactName,
contactTel: data.contactTel,
}"
/>
<article style="height: 5.8in"></article>
<ViewFooter
:data="{
name: '',
company: branch?.name || '',
buyer: '',
buyDate: '',
approveDate: '',
approver: '',
}"
/>
</section>
</div>
</template>
<style scoped>
.color-task {
--main: var(--yellow-6);
--main-hsl: var(--yellow-6-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);
}
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,148 @@
<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 { Institution } from 'src/stores/institution/types';
// NOTE: Import Components
defineProps<{
branch: Branch;
institution: Institution;
details: {
code: string;
name: string;
contactName: string;
contactTel: string;
};
}>();
</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('taskOrder.goodReceipt') }}
</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: institution.address,
addressEN: institution.addressEN,
moo: institution.moo || '-',
mooEN: institution.mooEN || '-',
soi: institution.soi || '-',
soiEN: institution.soiEN || '-',
street: institution.street || '-',
streetEN: institution.streetEN || '-',
province: institution.province,
district: institution.district,
subDistrict: institution.subDistrict,
})
}}
</span>
</article>
</section>
<section class="detail-quotation-info">
<div>
<div>
{{ $t('general.itemNo', { msg: `${$t('preview.taskOrder')}` }) }}
</div>
<div>{{ details.code }}</div>
</div>
<div>
<div>องาน</div>
<div>{{ details.name }}</div>
</div>
<div>
<div>ดต</div>
<div>{{ details.contactName }}</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,55 @@
<script lang="ts" setup>
import UploadFileSection from 'src/components/upload-file/UploadFileSection.vue';
defineProps<{
readonly?: boolean;
transformUrl?: (url: string) => string | Promise<string>;
}>();
const emit = defineEmits<{
(e: 'fetchFileList'): 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: [] });
</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"
@after-show="() => $emit('fetchFileList')"
>
<template #header>
<span>
{{ $t('quotation.additionalFile') }}
</span>
</template>
<main class="q-px-md q-py-sm surface-1">
<UploadFileSection
multiple
:layout="$q.screen.gt.sm ? 'column' : 'row'"
:readonly
:label="$t('general.upload', { msg: $t('general.attachment') })"
:transform-url="transformUrl"
v-model:file-data="fileData"
@update:file="(f) => $emit('upload', f as unknown as FileList)"
@close="(v) => $emit('remove', v)"
/>
</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 SelectInstitution from 'src/components/shared/select/SelectInstitution.vue';
import DatePicker from 'src/components/shared/DatePicker.vue';
import { getName } from 'src/services/keycloak';
defineProps<{
readonly?: boolean;
taskListGroup: boolean;
institutionGroup: string[];
}>();
const registeredBranchId = defineModel<string>('registeredBranchId');
const institutionId = defineModel<string>('institutionId');
const issueDate = defineModel<string>('issueDate');
const code = defineModel<string>('code');
const taskName = defineModel<string>('taskName');
const contactName = defineModel<string>('contactName');
const contactTel = defineModel<string>('contactTel');
</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('taskOrder.issueBranch')}${$i18n.locale === 'tha' ? $t('taskOrder.title') : ''}`"
v-model:value="registeredBranchId"
/>
<SelectInstitution
:readonly
required
class="col-md-4 col-12"
:label="`${$t('general.agencies')}`"
v-model:value="institutionId"
:disabled="taskListGroup"
:params="{ payload: { group: institutionGroup } }"
/>
<DatePicker
:label="$t('taskOrder.issueDate')"
class="col-md-2 col-6"
:model-value="issueDate || new Date(Date.now())"
:readonly
:disabled="!readonly"
/>
<q-input
:label="$t('taskOrder.code')"
outlined
dense
class="col-md-2 col-6"
:readonly
:disable="!readonly"
:model-value="code"
/>
<q-input
:readonly
:label="$t('general.name', { msg: $t('taskOrder.title') })"
outlined
dense
class="col-md-4 col-6"
v-model="taskName"
/>
<q-input
:readonly
:label="$t('taskOrder.contactName')"
outlined
dense
class="col-md col-6"
v-model="contactName"
/>
<q-input
:readonly
:label="$t('general.telephone')"
outlined
dense
class="col"
v-model="contactTel"
/>
<q-input
:readonly
:label="$t('taskOrder.madeBy')"
outlined
dense
class="col"
:disable="!readonly"
:model-value="getName()"
/>
</main>
</q-expansion-item>
</template>
<style scoped></style>

View file

@ -0,0 +1,63 @@
<script lang="ts" setup>
import QuotationFormInfo from 'src/pages/05_quotation/QuotationFormInfo.vue';
defineProps<{
complete?: boolean;
}>();
const payType = defineModel<
| 'Full'
| 'Split'
| 'SplitCustom'
| 'BillFull'
| 'BillSplit'
| 'BillSplitCustom'
>('payType', { default: 'Full' });
const paySplit = defineModel<{ no: number; name?: string; amount: number }[]>(
'paySplit',
{ default: [] },
);
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,
},
});
</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">
<QuotationFormInfo
task-order
:task-order-complete="complete"
v-model:pay-type="payType"
v-model:pay-split="paySplit"
v-model:summary-price="summaryPrice"
/>
</main>
</q-expansion-item>
</template>
<style scoped></style>

View file

@ -0,0 +1,310 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { QTableSlots } from 'quasar';
import { AddButton } from 'src/components/button';
import TableEmployee from '../TableEmployee.vue';
import { RequestWork } from 'src/stores/request-list';
import { productColumn } from '../constants';
import { baseUrl, formatNumberDecimal, commaInput } from 'src/stores/utils';
import { precisionRound } from 'src/utils/arithmetic';
import { useConfigStore } from 'stores/config';
import { storeToRefs } from 'pinia';
const currentBtnOpen = ref<boolean[]>([]);
const configStore = useConfigStore();
const { data: config } = storeToRefs(configStore);
// TODO: replace discount
const discount4Show = ref<string[]>([]);
const taskProduct = defineModel<{ productId: string; discount?: number }[]>(
'taskProduct',
{
default: [],
},
);
defineProps<{
readonly?: boolean;
taskList: {
product: RequestWork['productService']['product'];
list: RequestWork[];
}[];
}>();
defineEmits<{
(e: 'addProduct'): void;
}>();
defineExpose({
calcPricePerUnit,
});
function openList(index: number) {
if (!currentBtnOpen.value[index]) {
currentBtnOpen.value.map((v, i) => {
if (i !== index) {
currentBtnOpen.value[i] = false;
}
});
}
currentBtnOpen.value[index] = !currentBtnOpen.value[index];
}
function calcPricePerUnit(product: RequestWork['productService']['product']) {
return product.vatIncluded
? precisionRound(product.serviceCharge / (1 + (config.value?.vat || 0.07)))
: product.serviceCharge;
}
function calcPrice(
product: RequestWork['productService']['product'],
amount: number,
) {
const disc =
taskProduct.value.find((v) => v.productId === product.id)?.discount || 0;
const pricePerUnit = calcPricePerUnit(product);
return precisionRound(
pricePerUnit * amount -
disc +
precisionRound(
product.calcVat
? (pricePerUnit * (disc ? amount : 1) - disc) *
(config.value?.vat || 0.07)
: 0,
) *
(!disc ? amount : 1),
);
}
</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">
<q-table
:columns="productColumn"
:rows="taskList"
bordered
flat
hide-pagination
card-container-class="q-col-gutter-sm"
class="full-width"
:rows-per-page-options="[0]"
:no-data-label="$t('general.noDataTable')"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }}
{{ col.label === 'quotation.vat' ? '%' : '' }}
</q-th>
<q-th></q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: {
product: RequestWork['productService']['product'];
list: RequestWork[];
};
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr class="text-center">
<q-td>
{{ props.rowIndex + 1 }}
</q-td>
<q-td>
{{ props.row.product.code }}
</q-td>
<q-td style="width: 100%" class="text-left">
<q-avatar class="q-mr-sm" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${props.row.product.id}/image/${props.row.product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
{{ props.row.product.name }}
</q-td>
<q-td>
{{ props.row.list.length }}
</q-td>
<q-td class="text-right">
{{
formatNumberDecimal(
calcPricePerUnit(props.row.product) +
(props.row.product.calcVat
? calcPricePerUnit(props.row.product) *
(config?.vat || 0.07)
: 0),
2,
)
}}
</q-td>
<!-- TODO: display price detail -->
<q-td align="center">
<q-input
:readonly
:bg-color="readonly ? 'transparent' : ''"
dense
min="0"
outlined
input-class="text-right"
style="width: 90px"
debounce="500"
:model-value="
commaInput(
taskProduct
.find((v) => v.productId === props.row.product.id)
?.discount?.toString() || '0',
)
"
@update:model-value="
(v) => {
if (typeof v === 'string')
discount4Show[props.rowIndex] = commaInput(v);
const x = parseFloat(
discount4Show[props.rowIndex] &&
typeof discount4Show[props.rowIndex] === 'string'
? discount4Show[props.rowIndex].replace(/,/g, '')
: '',
);
const product = taskProduct.find(
(v) => v.productId === props.row.product.id,
);
if (product) {
product.discount = x;
}
}
"
/>
</q-td>
<!-- before vat -->
<q-td class="text-right">
{{ formatNumberDecimal(calcPricePerUnit(props.row.product), 2) }}
</q-td>
<!-- vat -->
<q-td class="text-right">
{{
formatNumberDecimal(
props.row.product.calcVat
? precisionRound(
(calcPricePerUnit(props.row.product) *
props.row.list.length -
(taskProduct.find(
(v) => v.productId === props.row.product.id,
)?.discount || 0)) *
(config?.vat || 0.07),
)
: 0,
2,
)
}}
</q-td>
<!-- total -->
<q-td class="text-right">
{{
formatNumberDecimal(
calcPrice(props.row.product, props.row.list.length),
2,
)
}}
</q-td>
<q-td>
<q-btn
dense
flat
class="rounded"
@click.stop="openList(props.rowIndex)"
>
<div class="row items-center no-wrap">
<q-icon name="mdi-account-group-outline" />
<q-icon
class="btn-arrow-right"
:class="{
active: currentBtnOpen[props.rowIndex],
}"
size="xs"
:name="`mdi-chevron-${currentBtnOpen[props.rowIndex] ? 'down' : 'up'}`"
/>
</div>
</q-btn>
</q-td>
</q-tr>
<q-tr v-show="currentBtnOpen[props.rowIndex]" :props="props">
<q-td colspan="100%" style="padding: 16px">
<TableEmployee step-on :rows="props.row.list" />
</q-td>
</q-tr>
</template>
</q-table>
<div class="q-pt-md row items-center">
<span class="q-ml-auto q-mr-sm">
{{
$t('general.numberOf', {
msg: $t('productService.product.product'),
})
}}
</span>
<div class="surface-3 q-px-sm rounded">{{ taskList.length }}</div>
</div>
</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,149 @@
<script lang="ts" setup>
// NOTE: Import stores
import { dateFormat } from 'src/utils/datetime';
// NOTE Import Types
import {
TaskOrderStatus,
TaskStatus,
UserTaskStatus,
} from 'src/stores/task-order/types';
// NOTE: Import Components
import BadgeComponent from 'src/components/BadgeComponent.vue';
import DataDisplay from 'src/components/08_request-list/DataDisplay.vue';
defineProps<{
gender?: string;
contactName?: string;
contactTel?: string;
contactUrl?: string;
email?: string;
receivedDate?: Date | string;
deliveryDate?: Date | string;
status?: UserTaskStatus;
}>();
</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('personnel.MESSENGER') }) }}
</span>
</template>
<main
class="surface-1"
:class="{ muted: status === UserTaskStatus.Pending }"
>
<div class="row q-col-gutter-sm q-px-md q-py-sm">
<DataDisplay
class="col-md col-6"
:label="$t('taskOrder.recipientOrSender')"
>
<template #value>
<q-avatar size="md" class="q-mr-xs">
<q-img class="text-center" :ratio="1" :src="contactUrl">
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
:style="`${gender ? 'background: white' : 'background: linear-gradient(135deg,rgba(43, 137, 223, 1) 0%, rgba(230, 51, 81, 1) 100%);'}`"
>
<q-img
v-if="gender"
:src="
gender === 'male'
? '/no-img-man.png'
: '/no-img-female.png'
"
/>
<q-icon
v-else
size="sm"
name="mdi-account-outline"
style="color: white"
/>
</div>
</template>
</q-img>
</q-avatar>
{{ contactName }}
</template>
</DataDisplay>
<DataDisplay
class="col-md col-6"
:label="$t('general.telephone')"
:value="contactTel || '-'"
/>
<DataDisplay
class="col-md col-6"
:label="$t('form.email')"
:value="email || '-'"
/>
<DataDisplay
class="col-md col-6"
:label="$t('taskOrder.workStartDate')"
>
<template #value>
{{ receivedDate ? dateFormat(receivedDate) : '-' }}
</template>
</DataDisplay>
<DataDisplay
class="col-md col-6"
:label="$t('taskOrder.workSubmissionDate')"
>
<template #value>
{{ deliveryDate ? dateFormat(deliveryDate) : '-' }}
</template>
</DataDisplay>
<DataDisplay class="col-md col-6" :label="$t('general.status')">
<template #value>
<BadgeComponent
v-if="status"
hide-icon
:title="
$t(
`taskOrder.${
{
Pending: 'waitReceive',
Accept: 'receiveTask',
Submit: 'sentTask',
}[status] ?? 'waitReceive'
}`,
)
"
:hsla-color="
{
Pending: '--info-bg',
Accept: '--info-bg',
Submit: '--positive-bg',
}[status] ?? '--info-bg'
"
/>
</template>
</DataDisplay>
</div>
<slot name="product"></slot>
</main>
</q-expansion-item>
</template>
<style scoped>
.muted {
& .col-md {
opacity: 50%;
}
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts" setup>
// NOTE: Import stores
// NOTE Import Types
import { ProductRelation } from 'src/stores/quotations/types';
// NOTE: Import Components
import FormGroupHead from 'src/pages/08_request-list/FormGroupHead.vue';
import DataDisplay from 'src/components/08_request-list/DataDisplay.vue';
import SelectBranch from 'src/components/shared/select/SelectBranch.vue';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import { TaskOrderStatus, UserTaskStatus } from 'src/stores/task-order/types';
// TODO: defineProps
defineProps<{
code?: string;
taskName?: string;
issueBranch?: string;
agencies?: string;
madeBy?: string;
contactTel?: string;
contactName?: string;
userTaskStatus?: UserTaskStatus;
product?: ProductRelation;
}>();
function mapStatus(value: string, type: 'status' | 'color') {
const mappings: Record<string, Record<'status' | 'color', string>> = {
Pending: {
status: 'taskOrder.taskInCart',
color: '--blue-6-hsl',
},
Accept: {
status: 'taskOrder.inProgress',
color: '--blue-6-hsl',
},
Submit: {
status: 'taskOrder.sentTask',
color: '--blue-6-hsl',
},
InProgress: {
status: 'taskOrder.inProgress',
color: '--blue-6-hsl',
},
Validate: {
status: 'taskOrder.inProgress',
color: '--blue-6-hsl',
},
Complete: {
status: 'taskOrder.sentTask',
color: '--blue-6-hsl',
},
};
return mappings[value]?.[type] || '';
}
</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('taskOrder.productList', { msg: $t('personnel.MESSENGER') }) }}
</span>
</template>
<main class="surface-1">
<!-- TODO: เอา DataDisplay มาใสตรงน -->
<div class="row q-col-gutter-sm q-px-md q-py-sm">
<DataDisplay
class="col-md-2 col-6"
:label="$t('taskOrder.workOrderCode')"
:value="code || '-'"
/>
<DataDisplay
class="col-md-2 col-6"
:label="$t('taskOrder.workOrderName')"
:value="taskName || '-'"
/>
<SelectBranch
class="col-md-2 col-6 fix-padding"
:label="$t('taskOrder.workOrderName')"
:value="issueBranch"
readonly
/>
<DataDisplay
class="col-md-2 col-6"
:label="$t('general.agencies')"
:value="agencies || '-'"
/>
<DataDisplay
class="col-md-2 col-6"
:label="$t('taskOrder.madeBy')"
:value="madeBy || '-'"
/>
<DataDisplay
class="col-md-2 col-6"
:label="$t('taskOrder.telephone')"
:value="contactTel || '-'"
/>
<DataDisplay
class="col-md-2 col-6"
:label="$t('branch.form.contactName')"
:value="contactName || '-'"
/>
<DataDisplay class="col-md-2 col-6" :label="$t('general.status')">
<template #value>
<BadgeComponent
v-if="userTaskStatus"
hide-icon
:hsla-color="mapStatus(userTaskStatus, 'color')"
:title="$t(mapStatus(userTaskStatus, 'status'))"
/>
</template>
</DataDisplay>
</div>
<FormGroupHead>
{{ $t('productService.title') }}
</FormGroupHead>
<slot :product="product"></slot>
</main>
</q-expansion-item>
</template>
<style scoped>
:deep(.fix-padding.q-field--outlined .q-field__control) {
padding: 0;
}
</style>

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { ProductRelation } from 'src/stores/quotations/types';
import { receiveProductColumn } from 'src/pages/09_task-order/constants';
import { computed } from 'vue';
import { QTableSlots } from 'quasar';
const datarows = [
{
order: 1,
requestListCode: 'RL-20231201',
flow: 'Step 1: Review',
quotation: 'John Doe',
age: '30 ปี',
nationality: 'Thai',
documentExpireDate: '2024-12-31',
numberOfDay: '12',
quotationCode: 'QT-987654',
},
];
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden 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"
@click=""
>
<template #header>
<div class="row items-center">
<q-avatar class="q-mr-sm" size="md">
<q-icon
class="full-width full-height"
name="mdi-shopping"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</q-avatar>
<div class="colum">
<div class="col">
<span>
าบรการและคาดำเนนงานยนขอบญชรายช (Name list)
ญชาตเมยนมา
</span>
</div>
<div class="col app-text-muted">AC103</div>
</div>
</div>
</template>
<main><!-- TODO add table --></main>
</q-expansion-item>
</template>
<style scoped>
:deep(tr:nth-child(2n)) {
background: #f9fafc;
&.dark {
background: hsl(var(--gray-11-hsl) / 0.2);
}
}
.active {
background-color: hsla(var(--info-bg) / 0.1);
color: hsl(var(--info-bg));
}
.doc-status {
padding-left: 8px;
border-radius: 20px;
&.await {
color: var(--yellow-6);
background: hsla(var(--yellow-6-hsl) / 0.15);
}
&.in-progress {
color: var(--orange-5);
background: hsla(var(--orange-5-hsl) / 0.15);
}
&.completed {
color: var(--green-5);
background: hsla(var(--green-5-hsl) / 0.15);
}
}
</style>

View file

@ -0,0 +1,164 @@
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { dialog } from 'stores/utils';
// NOTE: Import types
import {
SetTaskStatusPayload,
TaskOrder,
TaskOrderPayload,
TaskStatus,
} from 'stores/task-order/types';
import { RequestWork } from 'src/stores/request-list';
// NOTE: Import stores
import { useTaskOrderStore } from 'src/stores/task-order';
export const useTaskOrderForm = defineStore('task-order-form', () => {
const { t } = useI18n();
const taskOrderStore = useTaskOrderStore();
const DEFAULT_DATA: TaskOrderPayload = {
taskList: [],
institutionId: '',
contactTel: '',
contactName: '',
taskName: '',
};
const state = ref<{
mode: null | 'info' | 'create' | 'edit';
setTaskStatusList: SetTaskStatusPayload[];
}>({
mode: null,
setTaskStatusList: [],
});
let resetFormData = structuredClone(DEFAULT_DATA);
const currentFormData = ref<TaskOrderPayload>(structuredClone(resetFormData));
const fullTaskOrder = ref<TaskOrder>();
function resetForm(clean = false) {
if (clean) {
currentFormData.value = structuredClone(DEFAULT_DATA);
resetFormData = structuredClone(DEFAULT_DATA);
return;
}
currentFormData.value = structuredClone(resetFormData);
state.value.mode = 'info';
}
async function assignFormData(
id: string,
mode: 'info' | 'edit' | 'assign' = 'info',
userId?: string,
) {
const res = await taskOrderStore.getTaskOrderById(id, {
taskAssignedUserId: userId,
});
if (!res) return;
fullTaskOrder.value = structuredClone(res);
resetFormData = Object.assign(res, {
requestWork: res.taskList.map((v) => ({
step: v.step,
requestWorkId: v.requestWorkId,
})),
});
currentFormData.value = structuredClone(resetFormData);
if (mode === 'assign') return;
state.value.mode = mode;
}
async function submitTask(opt?: {
taskProduct?: {
productId: string;
discount?: number;
}[];
}) {
let succeed = false;
if (state.value.mode === 'create') {
const mapTaskList = currentFormData.value.taskList.map((v) => ({
step: v.step,
requestWorkId: v.requestWorkId,
}));
const res = await taskOrderStore.createTaskOrder({
...currentFormData.value,
taskProduct: opt?.taskProduct,
taskList: mapTaskList,
});
if (res) {
currentFormData.value.id = res.id;
succeed = true;
}
}
if (state.value.mode === 'edit' && !!currentFormData.value.id) {
const res = await taskOrderStore.editTaskOrder(currentFormData.value);
if (res) {
succeed = true;
}
}
return succeed;
}
async function changeStatus(
value: {
requestWorkId: string;
step: number;
failedComment?: string;
failedType?: string;
}[],
status: TaskStatus,
setRowStatus: () => void,
) {
dialog({
color: 'warning',
icon: 'mdi-alert',
title: t('dialog.title.confirmChangeStatus'),
actionText: t('general.confirm'),
persistent: true,
message: t('dialog.message.confirmChangeStatus'),
action: async () => {
if (!currentFormData.value.id) return;
const records = value.map((item) => ({
failedComment: item.failedComment,
failedType: item.failedType,
taskStatus: status,
requestWorkId: item.requestWorkId,
step: item.step,
}));
const res = await taskOrderStore.changeTaskStatus(
currentFormData.value.id,
records,
);
if (res) setRowStatus();
},
cancel: () => {},
});
}
return {
currentFormData,
state,
fullTaskOrder,
resetForm,
assignFormData,
submitTask,
changeStatus,
};
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,299 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Lang } from 'src/utils/ui';
import { TaskStatus } from 'src/stores/task-order/types';
import { RequestData, RequestWork } from 'src/stores/request-list';
import useOptionStore from 'src/stores/options';
import { CancelButton, SaveButton } from 'src/components/button';
import SelectInput from 'src/components/shared/SelectInput.vue';
import DialogFormContainer from 'src/components/dialog/DialogFormContainer.vue';
import DialogHeader from 'src/components/dialog/DialogHeader.vue';
const emit = defineEmits<{
(e: 'submit'): void;
(e: 'close'): void;
}>();
const open = defineModel<boolean>('open', { default: false });
const taskStatusList = defineModel<
{
code?: string;
requestWorkId: string;
step: number;
failedComment?: string;
failedType?: string;
}[]
>('setTaskStatusList', { default: [] });
const failedType = ref<string>('');
const failedComment = ref<string>('');
const props = defineProps<{
readonly?: boolean;
failTaskOption: (RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
taskStatus?: TaskStatus;
})[];
}>();
function onDialogOpen() {
failedType.value = taskStatusList.value[0].failedType || '';
failedComment.value = taskStatusList.value[0].failedComment || '';
}
function onDialogClose() {
emit('close');
}
function getEmployeeName(
record?: RequestData,
opts?: {
locale?: string;
},
) {
if (!record) return;
const employee = record.employee;
return (
{
[Lang.English]: `${useOptionStore().mapOption(employee.namePrefix)} ${employee.firstNameEN} ${employee.lastNameEN}`,
[Lang.Thai]: `${useOptionStore().mapOption(employee.namePrefix)} ${employee.firstName} ${employee.lastName}`,
}[opts?.locale || Lang.English] || '-'
);
}
function submit() {
taskStatusList.value.forEach((v) => {
v.failedComment = failedComment.value;
v.failedType = failedType.value;
});
emit('submit');
open.value = false;
}
function tooltip(id: string) {
const currTask = props.failTaskOption.find((t) => t.id === id);
return currTask;
}
</script>
<template>
<DialogFormContainer
v-on:open="onDialogOpen"
v-on:close="onDialogClose"
v-model="open"
height="330px"
width="60vw"
>
<template #header>
<DialogHeader :title="$t('general.remark')" />
</template>
<main class="q-py-sm q-px-md scroll">
<q-select
:readonly
dense
outlined
multiple
use-chips
:model-value="taskStatusList"
:label="$t('taskOrder.failTaskOrderCode')"
hide-dropdown-icon
class="q-mb-sm"
:options="
failTaskOption.filter(
(v) => v.taskStatus !== TaskStatus.Success && TaskStatus.Complete,
)
"
@remove="(v) => taskStatusList.splice(v.index, 1)"
@add="
(v) => {
const index = taskStatusList.findIndex(
(item) => item.requestWorkId === v.value.id,
);
if (index !== -1) {
taskStatusList.splice(index, 1);
return;
}
taskStatusList.push({
code: `${v.value.productService.product.code}-${v.value.request.code}`,
requestWorkId: v.value.id,
step: v.value._template.step,
});
}
"
>
<template #selected-item="scope">
<q-chip
dense
:removable="!readonly"
@remove="scope.removeAtIndex(scope.index)"
>
{{ taskStatusList[scope.index].code }}
<q-tooltip :delay="300">
<section class="column">
<div>
{{ $t('productService.title') }}
</div>
<div class="text-caption">
{{
tooltip(scope.opt.requestWorkId)?.productService.product
.name
}}
({{
tooltip(scope.opt.requestWorkId)?.productService.product
.code
}})
</div>
<div class="q-pt-xs">
{{ $t('quotation.employeeName') }}
</div>
<div class="text-caption">
{{
getEmployeeName(tooltip(scope.opt.requestWorkId)?.request, {
locale: $i18n.locale,
})
}}
({{ tooltip(scope.opt.requestWorkId)?.request.code }})
</div>
</section>
</q-tooltip>
</q-chip>
</template>
<template #option="scope">
<q-item
clickable
v-bind="scope.itemProps"
class="row items-start col-12 no-padding"
:active="
scope.opt.id === taskStatusList[scope.index]?.requestWorkId
"
active-class="text-brand1"
>
<div class="q-ma-sm">
<q-icon
name="mdi-shopping-outline"
style="color: var(--teal-10)"
/>
</div>
<div class="q-mt-sm">
<div>
<span>
<span style="font-weight: 600">
{{ $t('productService.title') }}:
</span>
{{ scope.opt.productService.product.name }}
({{ scope.opt.productService.product.code }})
</span>
</div>
<div class="text-caption app-text-muted-2 q-mb-xs">
{{ $t('quotation.employeeName') }}:
{{
getEmployeeName(scope.opt.request, { locale: $i18n.locale })
}}
({{ scope.opt.request.code }})
</div>
</div>
<!-- <section class="column">
<div class="text-caption app-text-muted">
{{ $t('productService.title') }}
</div>
<div>
{{ scope.opt.productService.product.name }} ({{
scope.opt.productService.product.code
}})
</div>
<div class="text-caption app-text-muted q-pt-xs">
{{ $t('quotation.employeeName') }}
</div>
<div>
{{
getEmployeeName(scope.opt.request, { locale: $i18n.locale })
}}
({{ scope.opt.request.code }})
</div>
</section> -->
</q-item>
<q-separator class="q-mx-sm" />
</template>
</q-select>
<SelectInput
:readonly
:option="[
{
label: $t('taskOrder.documentSubmitFailed'),
value: 'documentSubmitFailed',
},
{
label: $t('taskOrder.taskNotFullyCompleted'),
value: 'taskNotFullyCompleted',
},
{
label: $t('general.other'),
value: 'other',
},
]"
:label="$t('taskOrder.describeIssue')"
v-model="failedType"
>
<template #option="{ opt, scope }">
<q-item
class="items-center"
v-bind="scope.itemProps"
:class="{ 'bordered-t': opt.value === 'other' }"
>
{{ opt.label }}
</q-item>
</template>
</SelectInput>
<div v-if="failedType === 'other'" class="q-mt-sm rounded">
<q-editor
dense
flat
v-model="failedComment"
min-height="5rem"
class="q-mt-sm q-mb-xs"
:toolbar="[['left', 'center', 'justify']]"
:toolbar-color="$q.dark.isActive ? 'white' : ''"
:toolbar-toggle-color="'primary'"
style="
cursor: auto;
color: var(--foreground);
border-color: var(--surface-3);
"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
/>
</div>
</main>
<template #footer v-if="!readonly">
<CancelButton class="q-ml-auto" outlined @click="$emit('close')" />
<SaveButton class="q-ml-sm" solid @click="submit" />
</template>
</DialogFormContainer>
</template>
<style scoped>
.q-editor__toolbar {
border-bottom: none !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,774 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { getUserId } from 'src/services/keycloak';
// NOTE: Import Components
import { SaveButton } from 'src/components/button';
import { StateButton } from 'components/button';
import InfoMessengerExpansion from '../expansion/receive/InfoMessengerExpansion.vue';
import InfoProductExpansion from '../expansion/receive/InfoProductExpansion.vue';
import FormGroupHead from 'src/pages/08_request-list/FormGroupHead.vue';
import TableEmployee from '../TableEmployee.vue';
import TaskStatusComponent from '../TaskStatusComponent.vue';
import FailRemarkDialog from './FailRemarkDialog.vue';
// NOTE: Import Types and Store
import { dateFormatJS, dateFormat } from 'src/utils/datetime';
import { useTaskOrderForm } from '../form';
import { initLang, initTheme, Lang } from 'src/utils/ui';
import { RequestWork } from 'src/stores/request-list';
import { baseUrl, dialogWarningClose } from 'src/stores/utils';
import {
TaskOrder,
TaskOrderStatus,
TaskStatus,
UserTaskStatus,
} from 'src/stores/task-order/types';
import useOptionStore from 'src/stores/options';
import { useTaskOrderStore } from 'src/stores/task-order';
const route = useRoute();
const taskOrderFormStore = useTaskOrderForm();
const { currentFormData, state, fullTaskOrder } =
storeToRefs(taskOrderFormStore);
const statusTabForm = ref<
{
title: string;
status: 'done' | 'doing' | 'waiting';
handler: () => void;
active?: () => boolean;
}[]
>([
{
title: 'receive',
status: 'doing',
active: () => view.value === UserTaskStatus.Pending,
handler: () => (
(view.value = UserTaskStatus.Pending), console.log(view.value)
),
},
{
title: 'sendTaskOrder',
status: 'waiting',
active: () => view.value === UserTaskStatus.Submit,
handler: () => (
(view.value = UserTaskStatus.Submit), console.log(view.value)
),
},
]);
const failedDialog = ref(false);
const taskStatusRecords = ref<
{
requestWorkId: string;
step: number;
failedComment?: string;
failedType?: string;
code?: string;
}[]
>([]);
const selectedEmployee = ref<
(RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
})[][]
>([]);
const view = ref<UserTaskStatus>(UserTaskStatus.Pending);
const TAB_STATUS = ['Pending', 'Accept', 'Submit'];
function getStatus(
status: UserTaskStatus,
doneIndex: number,
doingIndex: number,
) {
return TAB_STATUS.findIndex((v) => v === status) >= doneIndex
? 'done'
: TAB_STATUS.findIndex((v) => v === status) >= doingIndex
? 'doing'
: 'waiting';
}
async function fetchStatus() {
if (!fullTaskOrder.value) return;
statusTabForm.value = [
{
title: 'receive',
status: getStatus(
fullTaskOrder.value?.userTask[0]?.userTaskStatus ||
UserTaskStatus.Pending,
2,
-1,
),
active: () => view.value === UserTaskStatus.Pending,
handler: () => (
(view.value = UserTaskStatus.Pending), console.log(view.value)
),
},
{
title: 'sendTaskOrder',
status: getStatus(
fullTaskOrder.value?.userTask[0]?.userTaskStatus ||
UserTaskStatus.Pending,
2,
2,
),
active: () => view.value === UserTaskStatus.Submit,
handler: () => (
(view.value = UserTaskStatus.Submit), console.log(view.value)
),
},
];
}
function getTemplateData(
requestWork: RequestWork,
targetStep: number,
): {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null {
const flow = requestWork.productService.service?.workflow;
if (!flow) return null;
const step = flow.step.find((v) => v.order === targetStep);
if (!step) return null;
return {
id: step.id,
templateName: flow.name,
templateStepName: step.name || '-',
step: step.order,
};
}
let taskListGroup = computed(() => {
const cacheData = currentFormData.value.taskList.reduce<
{
product: RequestWork['productService']['product'];
list: (RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
taskStatus?: TaskStatus;
})[];
}[]
>((acc, curr) => {
const taskStatus = curr.taskStatus;
const task = curr.requestWorkStep;
const step = curr.step;
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, {
_template: getTemplateData(task.requestWork, step),
taskStatus: taskStatus ?? TaskStatus.Pending,
});
if (exist) {
exist.list.push(task.requestWork);
} else {
acc.push({
product: task.requestWork.productService.product,
list: [record],
});
if (selectedEmployee.value.length < acc.length) {
selectedEmployee.value.push([]);
}
}
}
return acc;
}, []);
return cacheData;
});
// NOTE: Function
async function sendTask() {
if (!fullTaskOrder.value) return;
if (
!fullTaskOrder.value.taskList.every(
(t) =>
t.taskStatus === TaskStatus.Success ||
t.taskStatus === TaskStatus.Failed,
)
)
return;
await useTaskOrderStore().submitTaskOrder(fullTaskOrder.value.id);
await taskOrderFormStore.assignFormData(
fullTaskOrder.value.id,
'info',
getUserId(),
);
await fetchStatus();
view.value = UserTaskStatus.Submit;
}
function getMessengerName(
record: TaskOrder,
opts?: {
gender?: boolean;
url?: boolean;
locale?: string;
},
) {
const user = record.taskList[0].requestWorkStep.responsibleUser;
if (!user) return '-';
const url = `${baseUrl}/user/${user.id}/profile-image/${user.selectedImage}`;
return opts?.gender
? user.gender
: opts?.url
? url
: {
[Lang.English]: `${useOptionStore().mapOption(user.namePrefix as string)} ${user.firstNameEN} ${user.lastNameEN}`,
[Lang.Thai]: `${useOptionStore().mapOption(user.namePrefix as string)} ${user.firstName} ${user.lastName}`,
}[opts?.locale || Lang.English] || '-';
}
function handleChangeStatus(
records: {
data: (RequestWork & {
_template?: {
id: string;
templateName: string;
templateStepName: string;
step: number;
} | null;
})[];
status: TaskStatus;
},
index: number,
) {
const { data, status } = records;
if (data.length === 0) return;
taskStatusRecords.value = data.map((v) => ({
code: `${v.productService.product.code}-${v.request.code}`,
requestWorkId: v.id || '',
step: v._template?.step || 0,
}));
if (status === TaskStatus.Failed) {
failedDialog.value = true;
return;
}
taskOrderFormStore.changeStatus(taskStatusRecords.value, status, () => {
if (!currentFormData.value.id) return;
selectedEmployee.value[index] = [];
taskOrderFormStore.assignFormData(
currentFormData.value.id,
'info',
getUserId(),
);
});
}
function taskStatusCount(index: number, id: string) {
const task = fullTaskOrder.value?.taskList.filter(
(t) => t.requestWorkStep.requestWork.productService.productId === id,
);
if (index === 1) {
return task?.filter((t) => t.taskStatus === TaskStatus.InProgress).length;
} else if (index === 2) {
return task?.filter(
(t) =>
t.taskStatus === TaskStatus.Success ||
t.taskStatus === TaskStatus.Complete ||
t.taskStatus === TaskStatus.Validate,
).length;
} else {
return task?.filter(
(t) =>
t.taskStatus === TaskStatus.Canceled ||
t.taskStatus === TaskStatus.Redo ||
t.taskStatus === TaskStatus.Failed,
).length;
}
}
onMounted(async () => {
initTheme();
initLang();
const currentId = route.params['id'];
state.value.mode = 'info';
if (currentId !== undefined && typeof currentId === 'string') {
await taskOrderFormStore.assignFormData(
currentId,
state.value.mode,
getUserId(),
);
await fetchStatus();
}
});
watch([currentFormData.value.taskStatus], () => {
fetchStatus();
});
</script>
<template>
<div v-if="fullTaskOrder" class="column surface-0 fullscreen">
<div class="color-bar" :class="{ dark: $q.dark.isActive }">
<div class="pink-segment"></div>
<div class="light-pink-segment"></div>
<div class="gray-segment"></div>
</div>
<!-- SEC: Header -->
<header
class="row q-px-md q-py-sm items-center full justify-between relative-position"
>
<section class="banner" :class="{ dark: $q.dark.isActive }"></section>
<div style="flex: 1" class="row items-center">
<RouterLink to="/task-order">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('taskOrder.receive') }}
<!-- {{ code || '' }} -->
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg: dateFormatJS({
date: fullTaskOrder
? fullTaskOrder.createdAt
: new Date(Date.now()),
dayStyle: 'numeric',
monthStyle: 'long',
locale: $i18n.locale === 'tha' ? 'th-Th' : 'en-US',
}),
})
}}
{{
dateFormat(
fullTaskOrder ? fullTaskOrder.createdAt : new Date(Date.now()),
false,
true,
)
}}
</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"
>
<!-- TODO: replace step and status -->
<StateButton
v-for="i in statusTabForm"
:key="i.title"
:label="$t(`taskOrder.${i.title}`)"
:statusActive="i.active?.()"
:statusDone="i.status === 'done'"
:status-waiting="i.status === 'waiting'"
@click="i.handler()"
/>
</nav>
<article
v-if="
fullTaskOrder.taskOrderStatus !== TaskOrderStatus.Pending &&
view !== UserTaskStatus.Submit
"
class="row items-center surface-1 q-pa-md rounded gradient-stat"
>
<span
class="row col rounded q-px-sm q-py-md info"
style="border: 1px solid hsl(var(--info-bg))"
>
{{ $t('taskOrder.allProduct') }}
<span class="q-ml-auto">{{ fullTaskOrder.taskList.length }}</span>
</span>
<span
class="row col rounded q-px-sm q-py-md q-mx-md positive"
style="border: 1px solid hsl(var(--positive-bg))"
>
{{ $t('taskOrder.alreadySentTask') }}
<span class="q-ml-auto">
{{
fullTaskOrder.taskList.filter(
(t) =>
t.taskStatus === TaskStatus.Complete ||
t.taskStatus === TaskStatus.Success ||
t.taskStatus === TaskStatus.Validate ||
t.taskStatus === TaskStatus.Redo ||
t.taskStatus === TaskStatus.Failed,
).length
}}
</span>
</span>
<span
class="row col rounded q-px-sm q-py-md warning"
style="border: 1px solid hsl(var(--warning-bg))"
>
{{ $t('taskOrder.status.Pending') }}
<span class="q-ml-auto">
{{
fullTaskOrder.taskList.filter(
(t) => t.taskStatus === TaskStatus.InProgress,
).length
}}
</span>
</span>
</article>
<InfoMessengerExpansion
:gender="getMessengerName(fullTaskOrder, { gender: true })"
:contact-url="getMessengerName(fullTaskOrder, { url: true })"
:contact-name="
getMessengerName(fullTaskOrder, { locale: $i18n.locale })
"
:contact-tel="
fullTaskOrder.taskList[0].requestWorkStep.responsibleUser
?.telephoneNo
"
:email="
fullTaskOrder.taskList[0].requestWorkStep.responsibleUser?.email
"
:status="
fullTaskOrder.userTask[0]?.userTaskStatus ||
UserTaskStatus.Pending
"
/>
<InfoProductExpansion
:code="currentFormData.code"
:task-name="currentFormData.taskName"
:issue-branch="currentFormData.registeredBranchId"
:agencies="
$i18n.locale === 'eng'
? fullTaskOrder.institution?.nameEN
: fullTaskOrder.institution?.name
"
:made-by="
$i18n.locale === 'eng'
? `${fullTaskOrder?.createdBy.firstNameEN} ${fullTaskOrder?.createdBy.lastNameEN}`
: `${fullTaskOrder?.createdBy.firstName} ${fullTaskOrder?.createdBy.lastName}`
"
:contact-tel="currentFormData.contactTel"
:contact-name="currentFormData.contactName"
:userTaskStatus="
fullTaskOrder?.userTask[0]?.userTaskStatus ||
UserTaskStatus.Pending
"
>
<div
v-for="({ product, list }, i) in taskListGroup"
:key="product.id"
class="bordered-b"
>
<q-expansion-item
dense
class="overflow-hidden"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="q-py-sm text-medium text-body items-center rounded q-mx-md q-my-sm"
>
<template #header>
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
</div>
</span>
<span
v-if="
fullTaskOrder.taskOrderStatus === TaskOrderStatus.Pending
"
class="q-ml-auto"
>
<div
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.15)"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ list.length }}
</div>
</span>
<span v-else class="q-ml-auto row items-center q-gutter-x-sm">
<div
v-for="v in 3"
:key="v"
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.1)"
:style="`color: hsl(var(--${
v === 1 ? 'warning' : v === 2 ? 'positive' : 'negative'
}-bg))`"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ taskStatusCount(v, product.id) }}
</div>
</span>
</template>
<div>
<FormGroupHead>
{{ $t('quotation.employeeList') }}
</FormGroupHead>
<div class="q-pa-md full-width">
<TableEmployee
:checkbox-on="
fullTaskOrder.taskOrderStatus !==
TaskOrderStatus.Pending &&
fullTaskOrder.userTask.every(
(v) => v.userTaskStatus !== UserTaskStatus.Submit,
)
"
:check-all="
fullTaskOrder.taskOrderStatus !==
TaskOrderStatus.Pending &&
fullTaskOrder.userTask.every(
(v) => v.userTaskStatus !== UserTaskStatus.Submit,
)
"
step-on
:rows="list"
@change-all-status="(v) => handleChangeStatus(v, i)"
v-model:selected-employee="selectedEmployee[i]"
>
<template #append="{ props: subProps }">
<TaskStatusComponent
type="receive"
:readonly="
fullTaskOrder.taskOrderStatus ===
TaskOrderStatus.Pending
"
:status="subProps.row.taskStatus"
@click-failed="
() => {
taskStatusRecords = [
{
code: `${subProps.row.productService.product.code}-${subProps.row.request.code}`,
requestWorkId: subProps.row.id || '',
step: subProps.row._template?.step || 0,
failedComment:
fullTaskOrder?.taskList[subProps.rowIndex]
.failedComment || '',
failedType:
fullTaskOrder?.taskList[subProps.rowIndex]
.failedType || '',
},
];
failedDialog = true;
}
"
@change-status="
(status) => {
handleChangeStatus(
{
data: [subProps.row],
status: status,
},
i,
);
}
"
/>
</template>
</TableEmployee>
</div>
</div>
</q-expansion-item>
<FailRemarkDialog
:fail-task-option="list"
v-model:open="failedDialog"
v-model:set-task-status-list="taskStatusRecords"
@submit="
async () => {
await taskOrderFormStore.changeStatus(
taskStatusRecords,
TaskStatus.Failed,
() => {
if (!currentFormData.id) return;
taskOrderFormStore.assignFormData(
currentFormData.id,
'info',
getUserId(),
);
},
);
selectedEmployee = [];
}
"
@close="
() => {
failedDialog = false;
taskStatusRecords = [];
}
"
/>
</div>
</InfoProductExpansion>
</div>
</section>
</article>
<!-- SEC: footer -->
<footer
v-if="fullTaskOrder.taskOrderStatus !== TaskOrderStatus.Pending"
class="surface-1 q-pa-md full-width"
>
<nav class="row justify-end">
<SaveButton
:disabled="
!fullTaskOrder.taskList.every(
(t) =>
t.taskStatus === TaskStatus.Success ||
t.taskStatus === TaskStatus.Failed,
)
"
@click="
dialogWarningClose($t, {
message: $t('dialog.message.confirmSending'),
action: async () => {
await sendTask();
},
cancel: () => {},
})
"
:label="$t('taskOrder.sentTask')"
icon="mdi-check"
solid
/>
</nav>
</footer>
</div>
</template>
<style scoped>
.color-bar {
width: 100%;
height: 1vh;
background: linear-gradient(
90deg,
rgb(214, 51, 108) 0%,
rgba(255, 255, 255, 1) 77%,
rgba(204, 204, 204, 1) 100%
);
display: flex;
overflow: hidden;
}
.color-bar.dark {
opacity: 0.7;
}
.pink-segment {
background-color: var(--pink-7);
flex-grow: 4;
}
.light-pink-segment {
background-color: hsla(var(--pink-7-hsl) / 0.2);
flex-grow: 0.5;
}
.gray-segment {
background-color: #ccc;
flex-grow: 1;
}
.pink-segment,
.light-pink-segment,
.gray-segment {
transform: skewX(-60deg);
}
.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)
);
}
}
.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,250 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import DialogFormContainer from 'src/components/dialog/DialogFormContainer.vue';
import DialogHeader from 'src/components/dialog/DialogHeader.vue';
import TableTaskOrder from '../TableTaskOrder.vue';
import FormGroupHead from 'src/pages/08_request-list/FormGroupHead.vue';
import TableEmployee from '../TableEmployee.vue';
import { SaveButton, CancelButton } from 'src/components/button';
import TaskStatusComponent from '../TaskStatusComponent.vue';
import { baseUrl } from 'src/stores/utils';
import { TaskOrder, UserTaskStatus } from 'src/stores/task-order/types';
import { useTaskOrderStore } from 'src/stores/task-order';
import { RequestWork } from 'src/stores/request-list';
const taskOrderStore = useTaskOrderStore();
withDefaults(
defineProps<{
scan: boolean;
}>(),
{
scan: false,
},
);
const emit = defineEmits<{
(e: 'submit', data: TaskOrder[]): void;
}>();
const open = defineModel<boolean>('open', { default: false });
const rows = ref<TaskOrder[]>([]);
const selectedTask = ref<TaskOrder[]>([]);
const currTaskOrder = ref<(TaskOrder | null)[]>([]);
let taskListGroup = computed(() => {
let acc: {
taskId: string;
list: {
product: RequestWork['productService']['product'];
list: RequestWork[];
}[];
}[] = [];
let list = [];
currTaskOrder.value.forEach((v, i) => {
if (!v) {
acc[i] = {
taskId: '',
list: [],
};
return;
}
list = v.taskList.reduce<
{
product: RequestWork['productService']['product'];
list: RequestWork[];
}[]
>((acc2, curr2) => {
const task = curr2.requestWorkStep;
if (!task) return acc2;
if (task.requestWork) {
let exist = acc2.find(
(item) =>
task.requestWork.productService.productId == item.product.id,
);
if (exist) {
exist.list.push(task.requestWork);
} else {
acc2.push({
product: task.requestWork.productService.product,
list: [task.requestWork],
});
}
}
return acc2;
}, []);
acc[i] = {
taskId: v.id,
list: list,
};
});
return acc;
});
async function fetchTaskOrderList() {
const res = await taskOrderStore.getUserTaskOrderList({
pageSize: 999,
userTaskStatus: UserTaskStatus.Pending,
});
if (res) {
rows.value = res.result;
}
}
async function onDialogOpen() {
await fetchTaskOrderList();
currTaskOrder.value = Array(rows.value.length).fill(null);
}
function submit() {
emit('submit', selectedTask.value);
clearState();
}
function close() {
open.value = false;
clearState();
}
function clearState() {
selectedTask.value = [];
currTaskOrder.value = [];
}
async function handleSubRow(index: number, data: TaskOrder) {
const ret = await taskOrderStore.getTaskOrderById(data.id);
if (ret) {
currTaskOrder.value[index] = ret;
}
}
</script>
<template>
<DialogFormContainer
v-model="open"
v-on:open="onDialogOpen"
v-on:close="close"
>
<template #header>
<DialogHeader
:title="$t(`taskOrder.${scan ? 'receiveScan' : 'receiveCustom'}`)"
/>
</template>
<section class="q-pa-sm full-width scroll">
<TableTaskOrder
receive
:rows="rows"
:selection="'multiple'"
v-model:selected-task="selectedTask"
@click-sub-row="handleSubRow"
>
<template #subRow="{ props }">
<FormGroupHead>
{{ $t('productService.title') }}
</FormGroupHead>
<div
v-if="taskListGroup?.[props.rowIndex]?.list.length === 0"
class="app-text-muted q-pt-xs"
>
{{ $t('productService.product.noProduct') }}
</div>
<template v-if="taskListGroup">
<div
v-for="{ product, list } in taskListGroup?.[props.rowIndex]?.list"
:key="product.id"
:class="{ 'bordered-b': taskListGroup.length > 1 }"
>
<q-expansion-item
dense
class="overflow-hidden surface-1"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="q-py-sm text-medium text-body items-center q-mb-sm surface-1"
header-style="margin: 0"
>
<template #header>
<q-avatar class="q-mr-md" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
<span>
{{ product.name }}
<div class="app-text-muted text-caption">
{{ product.code }}
</div>
</span>
<span class="q-ml-auto">
<div
class="rounded q-px-sm row items-center"
style="background: hsl(var(--text-mute) / 0.15)"
>
<q-icon
name="mdi-account-group-outline"
size="xs"
class="q-pr-sm"
/>
{{ list.length }}
</div>
</span>
</template>
<div>
<FormGroupHead>
{{ $t('quotation.employeeList') }}
</FormGroupHead>
<div class="q-pa-md full-width surface-1">
<TableEmployee step-on :rows="list">
<template #append="{ props: subProps }">
<TaskStatusComponent
readonly
:status="
currTaskOrder[props.rowIndex]?.taskList[
subProps.rowIndex
].taskStatus
"
/>
</template>
</TableEmployee>
</div>
</div>
</q-expansion-item>
</div>
</template>
</template>
</TableTaskOrder>
</section>
<template #footer>
<CancelButton class="q-ml-auto" outlined @click="close" />
<SaveButton
:label="$t('taskOrder.receiveTask')"
class="q-ml-sm"
icon="mdi-check"
solid
@click="submit"
/>
</template>
</DialogFormContainer>
</template>
<style scoped></style>

View file

@ -128,6 +128,27 @@ const routes: RouteRecordRaw[] = [
name: 'requestListView',
component: () => import('pages/08_request-list/RequestListView.vue'),
},
{
path: '/task-order/order/add',
name: 'TaskOrderAdd',
component: () => import('pages/09_task-order/order_view/MainPage.vue'),
},
{
path: '/task-order/doc/:id',
name: 'TaskOrderAdd',
component: () => import('pages/09_task-order/document_view/MainPage.vue'),
},
{
path: '/task-order/order/:id',
name: 'TaskOrderView',
component: () => import('pages/09_task-order/order_view/MainPage.vue'),
},
{
path: '/task-order/receive/:id',
name: 'TaskReceiveView',
component: () => import('pages/09_task-order/receive_view/MainPage.vue'),
},
// Always leave this as last one,
// but you can also remove it

View file

@ -85,6 +85,7 @@ const useCustomerStore = defineStore('api-customer', () => {
query?: string;
page?: number;
pageSize?: number;
activeRegisBranchOnly?: boolean;
},
Data extends Pagination<
(CustomerBranch &

View file

@ -21,15 +21,29 @@ export const useInstitution = defineStore('institution-store', () => {
return null;
}
async function getInstitutionList(params?: {
async function getInstitutionList(opts?: {
page?: number;
pageSize?: number;
query?: string;
group?: string;
payload?: { group?: string[] };
}) {
const res = await api.get<PaginationResult<Institution>>('/institution', {
params,
});
const { payload, ...params } = opts || {};
console.log(params.query);
const res = payload
? await api.post<PaginationResult<Institution>>(
'/institution/list',
payload,
{
params,
},
)
: await api.get<PaginationResult<Institution>>(`/institution`, {
params,
});
if (res.status < 400) {
return res.data;
}

View file

@ -1,6 +1,9 @@
import { Status } from '../types';
import { UpdatedBy, CreatedBy } from 'stores/types';
import { WorkFlowPayloadStep } from '../workflow-template/types';
import {
WorkFlowPayloadStep,
WorkflowTemplate,
} from '../workflow-template/types';
export interface TreeProduct {
name: string;
@ -27,6 +30,8 @@ export interface Service {
work: Work[];
imageUrl: string;
registeredBranchId: string;
workflowId?: string;
workflow: WorkflowTemplate;
}
export interface WorkCreate {
@ -80,6 +85,8 @@ export interface Attributes {
workflowStep: (WorkFlowPayloadStep & { productsId: string[] })[];
}
export type PropVariant = PropString | PropNumber | PropDate | PropOptions;
export type PropString = {
type: 'string';
fieldName: string;

View file

@ -3,6 +3,7 @@ import { District, Province, SubDistrict } from '../address';
import { CreatedBy, Status, UpdatedBy } from '../types';
import { Invoice } from '../payment/types';
import { Employee } from '../employee/types';
import { WorkflowTemplate } from '../workflow-template/types';
export type PayCondition =
| 'Full'
@ -184,6 +185,7 @@ type ServiceRelation = {
createdByUserId: string;
updatedAt: string;
updatedByUserId: string;
workflow?: WorkflowTemplate;
work: (WorkRelation & {
productOnWork: {
@ -331,6 +333,8 @@ export type QuotationFull = {
updatedByUserId: string;
updatedAt: string | Date;
updatedBy: UpdatedBy;
agentPrice?: boolean;
};
export type QuotationPayload = {

View file

@ -215,9 +215,12 @@ export const useRequestList = defineStore('request-list', () => {
}
async function getRequestWorkList(params?: {
requestDataId?: string;
query?: string;
page?: number;
pageSize?: number;
requestDataId?: string;
workStatus?: RequestWorkStatus;
readyToTask?: boolean;
}) {
const res = await api.get<PaginationResult<RequestWork>>('/request-work', {
params,
@ -290,3 +293,5 @@ export const useRequestList = defineStore('request-list', () => {
cancelRequest,
};
});
export * from './types.ts';

View file

@ -22,6 +22,7 @@ export enum RequestDataStatus {
Pending = 'Pending',
InProgress = 'InProgress',
Completed = 'Completed',
Canceled = 'Canceled',
}
export enum RequestWorkStatus {

View file

@ -0,0 +1,189 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { api } from 'src/boot/axios';
// NOTE: Stores
// NOTE: Type
import {
SetTaskStatusPayload,
TaskOrder,
TaskOrderPayload,
TaskOrderStatus,
UserTaskStatus,
} from './types';
import { PaginationResult } from 'src/types';
import { manageAttachment } from '../utils';
export const useTaskOrderStore = defineStore('taskorder-store', () => {
const data = ref<TaskOrder[]>([]);
const page = ref<number>(1);
const pageMax = ref<number>(1);
const pageSize = ref<number>(30);
const stats = ref<Record<TaskOrderStatus, number>>({
[TaskOrderStatus.Pending]: 0,
[TaskOrderStatus.InProgress]: 0,
[TaskOrderStatus.Validate]: 0,
[TaskOrderStatus.Complete]: 0,
[TaskOrderStatus.Accept]: 0,
[TaskOrderStatus.Submit]: 0,
});
const fileManager = manageAttachment(api, 'task-order');
async function getTaskOrderStats() {
const res =
await api.get<Record<TaskOrderStatus, number>>('/task-order/stats');
if (res.status < 400) {
stats.value = res.data;
return res.data;
}
return null;
}
async function getTaskOrderList(params?: {
page?: number;
pageSize?: number;
query?: string;
taskOrderStatus?: TaskOrderStatus;
assignedUserId?: boolean;
}) {
const res = await api.get<PaginationResult<TaskOrder>>('/task-order', {
params,
});
if (res.status < 400) {
return res.data;
}
return null;
}
async function getTaskOrderById(
id: string,
params?: { taskAssignedUserId?: string },
) {
const res = await api.get<TaskOrder>(`/task-order/${id}`, { params });
if (res.status < 400) {
return res.data;
}
return null;
}
async function createTaskOrder(body: TaskOrderPayload) {
const res = await api.post<TaskOrder>('/task-order', body);
if (res.status < 400) {
return res.data;
}
return null;
}
async function changeTaskStatus(id: string, body: SetTaskStatusPayload[]) {
const res = await api.post<TaskOrder>(
`/task-order/${id}/set-task-status`,
body,
);
if (res.status < 400) {
return res.data;
}
return null;
}
async function acceptTaskOrder(id: string | string[]) {
const res = await api.post<TaskOrder>('/user-task-order/accept', {
taskOrderId: Array.isArray(id) ? id : [id],
});
if (res.status < 400) {
return res.data;
}
return null;
}
async function editTaskOrder(body: TaskOrderPayload) {
const res = await api.put<TaskOrder>(`/task-order/${body.id}`, {
taskList: body.taskList.map((v) => ({
step: v.step,
requestWorkId: v.requestWorkId,
})),
institutionId: body.institutionId,
contactTel: body.contactTel,
contactName: body.contactName,
taskStatus: body.taskStatus,
taskName: body.taskName,
});
if (res.status < 400) {
return res.data;
}
return null;
}
async function deleteTaskOrder(id: string) {
const res = await api.delete<TaskOrder>(`/task-order/${id}`);
if (res.status < 400) {
return true;
}
return null;
}
async function submitTaskOrder(id: string) {
const res = await api.post<TaskOrder>(`/task-order/${id}/submit`);
if (res.status < 400) {
return true;
}
return null;
}
async function completeTaskOrder(id: string) {
const res = await api.post<TaskOrder>(`/task-order/${id}/complete`);
if (res.status < 400) {
return true;
}
return null;
}
async function getUserTaskOrderList(params?: {
page?: number;
pageSize?: number;
query?: string;
userTaskStatus?: UserTaskStatus;
}) {
const res = await api.get<PaginationResult<TaskOrder>>('/user-task-order', {
params,
});
if (res.status < 400) {
return res.data;
}
return null;
}
return {
data,
page,
pageMax,
pageSize,
stats,
getTaskOrderStats,
getTaskOrderList,
getTaskOrderById,
createTaskOrder,
acceptTaskOrder,
editTaskOrder,
deleteTaskOrder,
changeTaskStatus,
submitTaskOrder,
completeTaskOrder,
// for receive user (messenger role)
getUserTaskOrderList,
...fileManager,
};
});

View file

@ -0,0 +1,207 @@
import { strict } from 'node:assert';
import { Branch } from '../branch/types';
import { Product, Service, Work } from '../product-service/types';
import { RequestWork } from '../request-list/types';
import { CreatedBy } from '../types';
import { User } from '../user';
export enum TaskOrderStatus {
Pending = 'Pending',
InProgress = 'InProgress',
Validate = 'Validate',
Complete = 'Complete',
Accept = 'Accept', // messenger only
Submit = 'Submit', // messenger only
}
export enum TaskStatus {
Pending = 'Pending',
InProgress = 'InProgress',
Success = 'Success',
Failed = 'Failed',
Redo = 'Redo',
Validate = 'Validate',
Complete = 'Complete',
Canceled = 'Canceled',
}
export interface TaskOrder {
createdBy: CreatedBy;
registeredBranch: Branch;
taskProduct: {
productId: string;
discount?: number;
}[];
taskList: {
step: number;
requestWorkId: string;
requestWorkStep: Task;
taskStatus: TaskStatus;
taskOrderId: string;
id: string;
failedType?: string;
failedComment?: string;
}[];
institution: Institution;
createdByUserId: string;
createdAt: string;
registeredBranchId: string;
institutionId: string;
contactTel: string;
contactName: string;
taskOrderStatus: TaskOrderStatus;
taskName: string;
code: string;
id: string;
userTask: UserTask[];
}
export interface UserTask {
id: string;
taskOrderId: string;
userId: string;
userTaskStatus: UserTaskStatus;
}
export interface Institution {
selectedImage: string;
subDistrictId: string;
districtId: string;
provinceId: string;
streetEN: string;
street: string;
mooEN: string;
moo: string;
soiEN: string;
soi: string;
addressEN: string;
address: string;
nameEN: string;
group: string;
name: string;
code: string;
id: string;
}
export interface AcceptedBy {
updatedByUserId: string;
updatedAt: string;
statusOrder: number;
status: string;
birthDate: string;
trainingPlace: string;
importNationality: string;
sourceNationality: string;
licenseExpireDate: string;
licenseIssueDate: string;
licenseNo: string;
discountCondition: string;
citizenExpire: string;
citizenIssue: string;
citizenId: string;
userRole: string;
userType: string;
checkpointEN: string;
checkpoint: string;
retireDate: string;
startDate: string;
registrationNo: string;
telephoneNo: string;
email: string;
gender: string;
username: string;
lastNameEN: string;
lastName: string;
middleNameEN: string;
middleName: string;
firstNameEN: string;
firstName: string;
namePrefix: string;
selectedImage: string;
subDistrictId: string;
districtId: string;
provinceId: string;
streetEN: string;
street: string;
mooEN: string;
moo: string;
soiEN: string;
soi: string;
addressEN: string;
address: string;
createdByUserId: string;
createdAt: string;
code: string;
id: string;
}
export interface Task {
step: number;
workStatus: string;
requestWorkId: string;
attributes: any;
customerDuty?: boolean;
customerDutyCost?: number;
companyDuty?: boolean;
companyDutyCost?: number;
individualDuty?: boolean;
individualDutyCost?: number;
responsibleUserLocal?: boolean;
responsibleUserId?: string;
responsibleUser?: User;
taskOrderId: string;
requestWork: RequestWork;
}
export interface TaskOrderPayload {
taskList: {
step: number;
requestWorkId: string;
requestWorkStep?: Task;
taskStatus?: TaskStatus;
}[];
taskProduct?: {
productId: string;
discount?: number;
}[];
institutionId: string;
contactTel: string;
contactName: string;
taskStatus?: TaskOrderStatus;
taskName: string;
registeredBranchId?: string;
id?: string;
code?: string;
}
export interface ProductService {
id: string;
quotationId: string;
order: number;
vat: number;
amount: number;
discount: number;
pricePerUnit: number;
installmentNo: number;
productId: string;
workId: string;
serviceId: string;
attributes: any;
product: Product;
service: Service;
work: Work;
}
export enum UserTaskStatus {
Pending = 'Pending',
Accept = 'Accept',
Submit = 'Submit',
}
export interface SetTaskStatusPayload {
failedComment?: string;
failedType?: string;
taskStatus: TaskStatus;
requestWorkId: string;
step: number;
}

View file

@ -117,7 +117,7 @@ export function deleteItem(items: unknown[], index: number) {
}
}
export function formatNumberDecimal(num: number, point: number): string {
export function formatNumberDecimal(num: number, point: number = 2): string {
return num.toLocaleString('eng', {
minimumFractionDigits: point,
maximumFractionDigits: point,

View file

@ -21,7 +21,7 @@ export type WorkflowStep = {
userId: string;
user: CreatedBy;
}[];
responsibleInstitution: string[];
responsibleInstitution: (string | { group: string })[];
attributes: WorkFlowAttributes;
};

View file

@ -10,9 +10,9 @@ export function formatAddress(opt: {
soiEN?: string;
street?: string;
streetEN?: string;
province: Province;
district: District;
subDistrict: SubDistrict;
province?: Province | null;
district?: District | null;
subDistrict?: SubDistrict | null;
en?: boolean;
}) {
const { t } = useI18n();
@ -24,23 +24,29 @@ export function formatAddress(opt: {
if (opt.soiEN) addressParts.push(`Soi ${opt.soiEN},`);
if (opt.streetEN) addressParts.push(`${opt.streetEN} Rd.`);
addressParts.push(`${opt.subDistrict.nameEN} sub-district,`);
addressParts.push(`${opt.district.nameEN} district,`);
addressParts.push(`${opt.province.nameEN},`);
if (opt.subDistrict) {
addressParts.push(`${opt.subDistrict.nameEN} sub-district,`);
}
if (opt.district) addressParts.push(`${opt.district.nameEN} district,`);
if (opt.province) addressParts.push(`${opt.province.nameEN},`);
} else {
// th
addressParts = [`${opt.address},`];
if (opt.moo) addressParts.push(`หมู่ ${opt.moo},`);
if (opt.soi) addressParts.push(`ซอย${opt.soi},`);
if (opt.soi) addressParts.push(`ซอย ${opt.soi},`);
if (opt.street) addressParts.push(`ถนน${opt.street},`);
addressParts.push(
`${opt.province.id === '10' ? 'แขวง' : 'ตำบล'}${opt.subDistrict.name},`,
);
addressParts.push(
`${opt.province.id === '10' ? 'เขต' : 'อำเภอ'}${opt.district.name},`,
);
addressParts.push(`จังหวัด${opt.province.name},`);
if (opt.subDistrict) {
addressParts.push(
`${opt.province?.id === '10' ? 'แขวง' : 'ตำบล'}${opt.subDistrict.name},`,
);
}
if (opt.district) {
addressParts.push(
`${opt.province?.id === '10' ? 'เขต' : 'อำเภอ'}${opt.district.name},`,
);
}
if (opt.province) addressParts.push(`จังหวัด${opt.province.name},`);
// addressParts.push(
// `${opt.province.id === '10' ? t('address.subArea') : t('address.subDistrict')}${opt.subDistrict.name},`,
// );
@ -52,7 +58,7 @@ export function formatAddress(opt: {
// );
}
addressParts.push(`${opt.subDistrict.zipCode}`);
if (opt.subDistrict) addressParts.push(`${opt.subDistrict.zipCode}`);
return addressParts.join(' ');
}