feat: menu request list (#75)

* feat: i18n

* feat: request list

* refactor: hide stat transition on app.scss

* feat: request list i18n

* feat: request list => constants and main page

* feat: add store

* feat: add fetch data

* feat: add utilities fn

* feat: add store function / types

* refactor: request list type

* refactor: request list constants

* refactor: quotation card => add customData and badge color props

* feat: avatar group components

* feat: request list group

* refactor: request list => remove tab, add table data

* feat: send search query

* feat: add parameter

* refactor: remove unused function

* fix: rename component lits to list

* feat: show stats from api

* chore: cleanup

* refactor: make it type safe

* refactor: accept rotate flow id as parameter

* feat: use page size component

* feat: add component, data display & expansion product

* feat: i18n

* refactor: constants and request list table

* refactor: type code, createdAt, updatedAt

* refactor: utils function changThemeMode

* feat: request list => view page

* refactor: use type instead of infer from value

* fix: function getEmployeeName att type

* refactor: fetch work list

* refactor: loop work list

* feat: add i18n duty

* feat: add form issue component

* feat: add form issue section

* fix: store error

* refactor: edit by value

* refactor: accept basic info from outside instead

* feat: add status filter support on fetch

* refactor: remove delete button

* refactor: wording

* feat/fix: request list i18n & constant

* feat: document type

* feat/refactor: request list => document expansion

* refactor: doc expansion use FormGroupHead

* refactor: fetch data based on id from route param

* refactor: text area disable

* feat: properties expansion display (mocking)

* refactor: add document at product relation

* refactor: edit get value product

* feat: get workflow step to show on top

* refactor: add type

* refactor: add get attachment

* refactor: add view attachment

* refactor: edit file name

* refactor: define props get hide icon

* refactor: edit align row

* refactor: by value table document

* refactor: by value row table

* feat: add independent ocr dialog

* chore: clean up

* refactor: accept more props and small adjustment

* fix: error withDefault call

* feat: accept default metadata when open

* fix: typo

* feat: add override hook when finish ocr

* feat: reset state on open

* feat: detect reader result is actually string

* fix: variable name conflict

* feat: properties to input component

* feat: properties input in properties expansion

* feat: properties expansion data (temporary)

* refactor: add i18n status work

* refactor: edit type work status and add step status

* refactor: add edit status work

* refactor: edit step work

* refactor: properties data type

* refactor: filter selected product & specific properties

* feat: add emit event

* refactor: change variable name for better understanding

* refactor: hide step that no properties

* refactor: work status type to validate

* feat: work status color

* refactor: key for filename

* refactor: close expansion when change step

* refactor: responsive meta data

* refactor: product expansion responsive

* fix: dark mode step text color

* fix: document expansion table no data label

* refactor: main page body bordered and overflow hidden

* refactor: use utils function instead

* refactor: add process

* refactor: by value  name

* refactor: add upload file

* refactor: upload file

* refactor: by value

* fix: option worker type

* refactor: fetchRequestAttachment after edit

* fix: metadata display

* refactor: add class full-height

* refactor: edit type

* refactor: fetch file

* refactor: by value visa

* refactor: request list attributes type

* fix: properties to input props (placeholder, readonly, disable)

* feat: request list properties function

* fix: error when no workflow

* docs: update comment to fix indent

* refactor: step type (attributes)

* refactor: add attributes payload on editStatusRequestWork function

* feat/refactor: functional form expansion/filter worklist

* refactor: set attributes properties after submit

* refactor: add request work ready status

* feat: request list => form employee component

* feat/refactor: form expansion select user/layout

* fix: properties readonly

---------

Co-authored-by: puriphatt <puriphat@frappet.com>
Co-authored-by: Thanaphon Frappet <thanaphon@frappet.com>
This commit is contained in:
Methapon Metanipat 2024-11-22 18:02:03 +07:00 committed by GitHub
parent 9105dcf7fe
commit 972f6ba13e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3653 additions and 57 deletions

View file

@ -38,7 +38,7 @@
},
{
"label": "Nationality Verification Group",
"value": "ืืnvg"
"value": "nvg"
},
{
"label": "Border Pass Group",
@ -1111,10 +1111,10 @@
},
{
"label": "กลุ่ม พิสูจน์สัญชาติ",
"value": "ืืnvg"
"value": "nvg"
},
{
"label": "กลุ่ม Border pass ",
"label": "กลุ่ม Border pass",
"value": "bp"
}
],

View file

@ -423,7 +423,7 @@ watch(
class="col-12"
:label="$t('customer.table.fullnameEN')"
:disable="!readonly"
:model-value="`${(prefixNameOptions.find((v) => v.value === namePrefix) || {}).value || ''} ${firstName || ''} ${lastName || ''}`"
:model-value="`${(prefixNameOptions.find((v) => v.value === namePrefix) || {}).value || ''} ${firstNameEN || ''} ${lastNameEN || ''}`"
/>
<q-select

View file

@ -18,6 +18,14 @@ defineProps<{
reporter?: string;
totalPrice?: number;
urgent?: boolean;
hidePreview?: boolean;
badgeColor?: string;
customData?: {
label: string;
value: string | number | unknown;
slotName?: string;
}[];
}>();
defineEmits<{
@ -52,13 +60,14 @@ const rand = Math.random();
<div class="q-mr-sm" style="font-size: 90%">
<BadgeComponent
:title="status"
hsla-color="--blue-6-hsl"
:hsla-color="badgeColor || '--blue-6-hsl'"
:border="urgent"
/>
</div>
<nav class="col text-right">
<q-btn
v-if="!hidePreview"
flat
dense
rounded
@ -111,7 +120,28 @@ const rand = Math.random();
'surface-2': !urgent,
}"
>
<article class="row q-py-sm">
<article
class="q-py-sm"
:class="{
row: $q.screen.gt.sm,
column: $q.screen.lt.sm,
}"
v-if="customData && customData?.length > 0"
>
<template v-for="cData in customData" :key="cData.label">
<template v-if="cData.slotName">
<slot :name="cData.slotName" :props="cData" />
</template>
<template v-else>
<div class="col-4 app-text-muted q-pr-sm">
{{ cData.label || '-' }}
</div>
<div class="col-8">{{ cData.value || '-' }}</div>
</template>
</template>
</article>
<article v-else class="row q-py-sm">
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('quotation.customerName') }}
</div>
@ -125,6 +155,7 @@ const rand = Math.random();
</div>
<div class="col-8">
<BadgeComponent
:hsla-color="badgeColor"
icon="mdi-account-multiple-outline"
:title="[workerCount, workerMax].join(' / ')"
/>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
withDefaults(
defineProps<{
label: string;
value: string;
icon?: string;
iconSize?: string;
}>(),
{
label: '-',
value: '-',
},
);
</script>
<template>
<article class="row items-center">
<Icon
v-if="icon"
:icon
class="app-text-muted q-pr-sm"
:width="iconSize || '2rem'"
/>
<span class="column">
<span class="app-text-muted-2 text-caption">
{{ label }}
</span>
<span class="text-weight-medium">
{{ value }}
</span>
</span>
</article>
</template>

View file

@ -0,0 +1,120 @@
<script setup lang="ts">
import {
PropDate,
PropNumber,
PropOptions,
PropString,
} from 'src/stores/product-service/types';
import SelectInput from '../shared/SelectInput.vue';
import DatePicker from '../shared/DatePicker.vue';
defineProps<{
prop: PropString | PropNumber | PropDate | PropOptions;
placeholder?: string;
readonly?: boolean;
disable?: boolean;
}>();
const model = defineModel<string | number | null | undefined>();
function numberDisplay(prop: PropNumber, data: number): string {
if (isNaN(data)) data = 0;
let formattedNumber: string;
if (prop.comma && prop.decimal) {
formattedNumber = data.toLocaleString('en-US', {
minimumFractionDigits: prop.decimalPlace,
maximumFractionDigits: prop.decimalPlace,
});
} else if (prop.comma && !prop.decimal) {
formattedNumber = data.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
} else if (!prop.comma && prop.decimal) {
formattedNumber = data.toFixed(prop.decimalPlace);
} else {
formattedNumber = Math.round(data).toString();
}
return formattedNumber;
}
function numberParse(formatted: string, prop: PropNumber): number {
let parsedNumber: number;
let cleanedFormatted = formatted.replace(/,/g, '');
if (prop.decimal) {
parsedNumber = parseFloat(cleanedFormatted);
} else {
parsedNumber = parseInt(cleanedFormatted, 10);
}
if (isNaN(parsedNumber)) {
return 0;
}
return parsedNumber;
}
</script>
<template>
<q-input
v-if="prop.type === 'string'"
:readonly
:disable
class="col-7"
:model-value="readonly || disable ? model || '-' : model"
dense
outlined
:placeholder
:maxlength="prop.isPhoneNumber ? prop.phoneNumberLength : undefined"
@update:model-value="
(v) => {
model = v;
}
"
@focus="(e) => (e.target as HTMLInputElement).select()"
/>
<q-input
v-if="prop.type === 'number'"
:readonly
:disable
class="col-7"
debounce="500"
dense
outlined
:model-value="numberDisplay(prop, Number(model))"
@update:model-value="
(v) => {
let cleanedFormatted = v?.toString().replace(/,/g, '');
const x = numberParse(
numberDisplay(prop as PropNumber, Number(cleanedFormatted)),
prop as PropNumber,
);
model = x;
}
"
@focus="(e) => (e.target as HTMLInputElement).select()"
/>
<DatePicker
v-if="prop.type === 'date'"
:readonly
:disabled="disable"
class="col-7"
v-model="model as string"
/>
<SelectInput
v-if="prop.type === 'array'"
:readonly
:disable
:label="$t('form.selection')"
v-model="model as string"
class="col-7"
:option="prop.options.map((opt) => ({ label: opt, value: opt }))"
/>
</template>
<style scoped></style>

View file

@ -10,6 +10,7 @@ defineProps<{
border?: boolean;
solid?: boolean;
transparency?: number;
hideIcon?: boolean;
}>();
</script>
@ -24,8 +25,17 @@ defineProps<{
: undefined,
}"
>
<Icon :icon="icon || 'mdi-circle-medium'" style="margin-right: 0.25rem" />
{{ title || (!!titleI18n ? $t(titleI18n) : '-') }}
<Icon
v-if="!hideIcon"
:icon="icon || 'mdi-circle-medium'"
style="margin-right: 0.25rem"
/>
<slot name="label">
{{ title || (!!titleI18n ? $t(titleI18n) : '-') }}
</slot>
<slot name="append"></slot>
</div>
</template>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
withDefaults(
defineProps<{
data?: Record<string, unknown>[];
dataLabel?: string;
dataUrl?: string;
}>(),
{
dataLabel: 'name',
dataUrl: 'imgUrl',
data: () => [
{
name: 'Someone 1',
imgUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8cGVyc29ufGVufDB8fDB8fA%3D%3D&auto=format&fit=crop&w=500&q=60',
},
{
name: 'Someone 2',
imgUrl:
'https://images.unsplash.com/flagged/photo-1570612861542-284f4c12e75f?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8M3x8cGVyc29ufGVufDB8fDB8fA%3D%3D&auto=format&fit=crop&w=500&q=60',
},
{
name: 'Someone 3',
imgUrl:
'https://images.unsplash.com/photo-1547425260-76bcadfb4f2c?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8cGVyc29ufGVufDB8fDB8fA%3D%3D&auto=format&fit=crop&w=500&q=60',
},
{
name: 'Someone 4',
imgUrl:
'https://images.unsplash.com/photo-1499952127939-9bbf5af6c51c?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MTF8fHBlcnNvbnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=500&q=60',
},
{
name: 'Someone 5',
imgUrl:
'https://images.unsplash.com/photo-1504593811423-6dd665756598?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MTZ8fHBlcnNvbnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=500&q=60',
},
{
name: 'Someone 6',
imgUrl:
'https://images.unsplash.com/photo-1504593811423-6dd665756598?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MTZ8fHBlcnNvbnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=500&q=60',
},
],
},
);
</script>
<template>
<div class="avatar-group">
<div class="avatar" v-for="(person, i) in data.slice(0, 3)" :key="i">
<q-tooltip>
{{ person[dataLabel] }}
</q-tooltip>
<img
:src="
typeof person[dataUrl] === 'string' ? (person[dataUrl] as string) : ''
"
alt="Image"
/>
</div>
<div v-if="data.length > 3" class="avatar remaining-count">
<q-tooltip>
<div v-for="(person, i) in data.slice(3)" :key="i + 3">
{{ person.name }}
</div>
</q-tooltip>
<span>{{ `+${data.length - 3}` }}</span>
</div>
</div>
</template>
<style scoped>
.avatar-group {
display: flex;
align-items: center;
}
.avatar {
position: relative;
transition: 0.2s;
}
.avatar:not(:first-child) {
margin-left: -0.75rem;
}
.avatar:hover {
z-index: 1;
transform: translateY(-0.5rem);
}
.avatar img {
width: 30px;
height: 30px;
display: block;
object-fit: cover;
border-radius: 50%;
border: 2px solid var(--border-color);
}
.remaining-count {
color: hsl(var(--text-mute-2));
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background-color: var(--surface-2);
border: 2px solid var(--border-color);
border-radius: 50%;
font-size: 0.8rem;
margin-left: -0.75rem;
}
</style>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
import { ref } from 'vue';
import ShowAttachment from 'components/ShowAttachent.vue';
import DialogForm from 'components/DialogForm.vue';
const isOpen = ref(false);
const isEdit = ref(false);
const isRunning = ref(false);
const file = ref<File>();
const url = ref<string>();
const metadata = ref<Data>();
const splitRatio = ref(50);
type Data = Record<string, any>;
type Props = {
title?: string;
readonly?: boolean;
autoSave?: boolean;
data?: Data;
};
type HandleProps = {
ocr?: (file: File) => Promise<false | Data>;
save?: (file: File, meta?: Data) => void | Promise<boolean>;
override?: (before: Data, after: Data) => Data;
};
defineEmits<{
(e: 'submit', file: File, meta?: Data): void;
}>();
defineExpose({ isRunning });
const props = withDefaults(defineProps<Props & HandleProps>(), { title: '' });
const input = (() => {
const _element = document.createElement('input');
_element.type = 'file';
_element.accept = 'image/jpeg,image/png';
_element.addEventListener('change', change);
return _element;
})();
async function change(e: Event) {
const _element = e.target as HTMLInputElement | null;
const _file = _element?.files?.[0];
if (!_file) return;
file.value = _file;
url.value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(_file);
reader.onload = () => {
if (typeof reader.result === 'string') {
return resolve(reader.result);
}
return reject();
};
});
if (!props.ocr) return;
if (props.data) metadata.value = structuredClone(props.data);
isOpen.value = true;
isRunning.value = true;
const ocrResult = await props.ocr(_file);
isRunning.value = false;
if (!ocrResult) return;
if (!props.override || !metadata.value) {
return (metadata.value = ocrResult);
}
if (Object.entries(metadata.value).some(([k, v]) => ocrResult[k] !== v)) {
return (metadata.value = props.override(metadata.value, ocrResult));
}
}
</script>
<template>
<slot
name="trigger"
:browse="
() => {
file = undefined;
url = undefined;
metadata = undefined;
input?.click();
}
"
/>
<DialogForm
v-if="file && url"
v-model:modal="isOpen"
style="position: absolute"
height="100vh"
weight="90%"
hide-close-event
hide-delete
edit
:title
:is-edit
:readonly
:edit-data="() => (isEdit = true)"
:undo="() => (isEdit = false)"
:close="() => (isOpen = false)"
:submit="
() => {
if (!file) return;
$emit('submit', file, metadata);
if (autoSave) save?.(file, metadata);
isOpen = false;
}
"
>
<q-splitter class="full-height" v-model="splitRatio">
<template #before>
<div class="full-height">
<slot name="viewer" :url :file>
<ShowAttachment :url :file />
</slot>
</div>
</template>
<template #after>
<div class="q-pa-md full-height">
<slot name="body" :metadata :is-running :is-edit />
</div>
</template>
</q-splitter>
</DialogForm>
</template>

View file

@ -105,7 +105,7 @@ async function change(e: Event) {
const _file = _element?.files?.[0];
if (_file) {
const newFileName = `${selectedMenu.value?.group}-${dateFormat(new Date().toISOString())}`;
const newFileName = `${selectedMenu.value?.group}-${dateFormat(new Date().toISOString())}-${_file.name}`;
const renamedFile = new File([_file], newFileName, { type: _file.type });
if (!obj.value[currentIndex.value] && selectedMenu.value) {
@ -155,14 +155,12 @@ async function change(e: Event) {
}
if (resOcr.group === 'passport') {
const fullName = map['full_name'].split(' ');
obj.value[currentIndex.value]._meta = {
type: map['doc_type'],
number: map['doc_number'],
gender: map['sex'],
firstName: fullName[0],
lastName: fullName[1],
firstName: map['first_name'],
lastName: map['last_name'],
issueDate: map['issue_date'],
expireDate: map['expire_date'],
issuePlace: map['nationality'],
@ -195,7 +193,7 @@ async function fileList() {
.map((item) => {
return {
...item,
name: item.name?.split('-')[1],
name: `${item.name?.split('-')[1]}-${item.name?.split('-')[2] || ''}`,
};
});
}

View file

@ -248,3 +248,17 @@ html {
display: none;
}
}
.slide-enter-active {
transition: all 0.1s ease-out;
}
.slide-leave-active {
transition: all 0.1s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(-20px);
opacity: 0;
}

View file

@ -29,8 +29,8 @@ $separator-dark-color: var(--border-color);
background: var(--surface-1);
color: $primary;
&:has(input:disabled) {
background: var(--surface-tab);
&:has(:where(input:disabled, textarea:disabled)) {
background: var(--gray-1);
}
}
@ -149,15 +149,11 @@ div.fullscreen.q-drawer__backdrop {
color: hsl(var(--text-mute));
}
.q-tree__node-header.relative-position.row.no-wrap.items-center:has(
.clickable-node
):hover {
.q-tree__node-header.relative-position.row.no-wrap.items-center:has(.clickable-node):hover {
background-color: hsla(var(--info-bg) / 0.1);
}
.q-tree__node-header.relative-position.row.no-wrap.items-center:has(
.clickable-node.active-node
) {
.q-tree__node-header.relative-position.row.no-wrap.items-center:has(.clickable-node.active-node) {
background-color: hsla(var(--info-bg) / 0.1);
}
@ -165,8 +161,7 @@ div.fullscreen.q-drawer__backdrop {
padding-left: 0px !important;
}
.q-tree__node.relative-position.q-tree__node--child:not(:first-child)
.q-tree__node-header:before {
.q-tree__node.relative-position.q-tree__node--child:not(:first-child) .q-tree__node-header:before {
top: -32px !important;
}

View file

@ -131,6 +131,8 @@ export default {
enterToAdd: 'Press enter to add.',
forExample: 'eg. {example}',
importFromFile: 'Import From File {suffix}',
customer: 'Customer',
individual: 'Individual',
},
menu: {
@ -203,7 +205,7 @@ export default {
debitNote: 'Debit Note',
},
managDocumente: {
manageDocument: {
title: 'Docs',
document: 'Document',
},
@ -841,6 +843,48 @@ export default {
name: 'Agencies Name',
},
requestList: {
title: 'Request List',
caption: 'All Request List',
quotationCode: 'Quotation Code',
requestListCode: 'Request List Code',
invoiceCode: 'Invoice Code',
receiptCode: 'Receipt Code',
alienIdCard: 'Alien Identification Card"',
relatedDoc: 'Related Documents',
attachment: 'Documents',
documentInSystem: 'Documents in the System',
noDocumentYet: 'Document Not Found',
companyEmployee: 'Company Employee',
localEmployee: 'Local Employee',
nonLocalEmployee: 'Non Local Employee',
status: {
work: {
Pending: 'Await for order',
Ready: 'Ready for order',
Waiting: 'Work assigned, awaiting action',
InProgress: 'In progress',
Validate: 'Awaiting validation',
Ended: 'Completed',
Completed: 'Work finished',
},
Pending: 'Pending',
InProgress: 'In Progress',
Completed: 'Completed',
AwaitOrder: 'Awaiting Order',
ReadyOrder: 'Ready for Order',
EndOrder: 'Order Completed',
AwaitReview: 'Awaiting Review',
UploadedAwaitReview: 'Uploaded, Awaiting Review',
ReviewedAwaitUpload: 'Reviewed, Awaiting Upload',
Reviewed: 'Reviewed',
ApprovedReview: 'Approved',
},
},
dialog: {
title: {
incompleteDataEntry: 'Incomplete Data Entry',
@ -968,4 +1012,11 @@ export default {
district: 'District',
province: 'Province',
},
duty: {
text: '{subject} Duty',
notInclude: 'Not Include Duty',
include: 'Include Duty',
cost: 'Duty Cost (Baht)',
},
};

View file

@ -131,6 +131,8 @@ export default {
enterToAdd: 'กดปุ่ม Enter เพื่อเพิ่ม',
forExample: 'เช่น {example}',
importFromFile: 'นำเข้าจากไฟล์ {suffix}',
customer: 'ลูกค้า',
individual: 'รายบุคคล',
},
menu: {
@ -203,7 +205,7 @@ export default {
debitNote: 'ใบเพิ่มหนี้',
},
managDocumente: {
manageDocument: {
title: 'คลังเอกสาร',
document: 'เอกสาร',
},
@ -833,6 +835,47 @@ export default {
name: 'ชื่อหน่วยงาน',
},
requestList: {
title: 'ใบรายการคำขอ',
caption: 'ใบรายการคำขอทั้งหมด',
quotationCode: 'เลขที่ใบเสนอราคา',
requestListCode: 'เลขที่ใบรายการคำขอ',
invoiceCode: 'เลขที่ใบแจ้งหนี้',
receiptCode: 'เลขที่ใบเสร็จ/กำกับภาษี',
alienIdCard: 'บัตรประจำตัวต่างด้าว',
relatedDoc: 'เอกสารที่เกี่ยวข้อง',
attachment: 'ไฟล์เอกสาร',
documentInSystem: 'เอกสารในระบบ',
noDocumentYet: 'ยังไม่พบเอกสาร',
companyEmployee: 'พนักงานบริษัท',
localEmployee: 'พนักงานในพื้นที่',
nonLocalEmployee: 'พนักงานนอกพื้นที่',
status: {
work: {
Pending: 'รอสั่งงาน',
Ready: 'พร้อมสั่งงาน',
Waiting: 'สั่งงานเเล้วรอดำเนินการ',
InProgress: 'กำลังดำเนินการ',
Validate: 'รอตรวจสอบ',
Ended: 'เสร็จสิ้น',
Completed: 'จบงาน',
},
Pending: 'รอดำเนินการ',
InProgress: 'ดำเนินการ',
Completed: 'เสร็จสิ้น',
AwaitOrder: 'รอสั่งงาน',
ReadyOrder: 'พร้อมสั่งงาน',
EndOrder: 'จบงาน',
AwaitReview: 'รอตรวจสอบ',
UploadedAwaitReview: 'อัปโหลดแล้ว รอตรวจสอบ',
ReviewedAwaitUpload: 'ตรวจสอบแล้ว รอการอัปโหลด',
Reviewed: 'ตรวจสอบแล้ว',
ApprovedReview: 'ผ่านการตรวจสอบ',
},
},
dialog: {
title: {
incompleteDataEntry: 'กรอกข้อมูลไม่ครบ',
@ -955,4 +998,11 @@ export default {
district: 'อำเภอ',
province: 'จังหวัด',
},
duty: {
text: 'อากร{subject}',
notInclude: 'ไม่ติดอากร',
include: 'ติดอากร',
cost: 'จำนวนเงินอากร (บาท)',
},
};

View file

@ -142,14 +142,14 @@ onMounted(async () => {
{
label: 'menu.order',
icon: 'mdi-file-chart-outline',
disabled: true,
disabled: false,
children: [
{ label: 'requestList', route: '' },
{ label: 'documentCheck', route: '' },
{ label: 'workOrder', route: '' },
{ label: 'goodReceipt', route: '' },
{ label: 'workReceipt', route: '' },
{ label: 'workDelivery', route: '' },
{ label: 'requestList', route: '/request-list' },
{ label: 'documentCheck', route: '', hidden: true },
{ label: 'workOrder', route: '', hidden: true },
{ label: 'goodReceipt', route: '', hidden: true },
{ label: 'workReceipt', route: '', hidden: true },
{ label: 'workDelivery', route: '', hidden: true },
],
},
{
@ -165,8 +165,8 @@ onMounted(async () => {
},
{
label: 'menu.managDocumente',
icon: 'mdi-archive',
label: 'menu.manageDocument',
icon: 'mdi-archive-outline',
children: [
{
label: 'document',

View file

@ -3082,19 +3082,6 @@ const emptyCreateDialog = ref(false);
"
>
<template #form="{ mode, meta, isEdit }">
<FormCitizen
v-if="mode === 'citizen' && meta"
orc
ra
:readonly="!isEdit"
v-model:citizen-id="meta.citizenId"
v-model:birth-date="meta.birthDate"
v-model:first-name="meta.firstName"
v-model:first-name-en="meta.firstNameEN"
v-model:last-name="meta.lastName"
v-model:last-name-en="meta.lastNameEN"
v-model:address="meta.address"
/>
<FormEmployeePassport
v-if="mode === 'passport' && meta"
prefix-id="drawer-info-employee"

View file

@ -193,14 +193,14 @@ export const columnsAttachment = [
},
{
name: 'createdAt',
align: 'center',
align: 'left',
label: 'general.uploadDate',
field: 'attachmentName',
},
{
name: 'ัexpireDate',
align: 'center',
align: 'left',
label: 'general.expirationDate',
field: 'attachmentName',
},

View file

@ -888,8 +888,10 @@ async function assignFormDataProductService(id: string) {
currentService.value = JSON.parse(JSON.stringify(res));
const workflowRet = await getWorkflowTemplate(res.attributes.workflowId);
if (workflowRet) currWorkflow.value = workflowRet;
if (res.attributes && res.attributes.workflowId) {
const workflowRet = await getWorkflowTemplate(res.attributes.workflowId);
if (workflowRet) currWorkflow.value = workflowRet;
}
prevService.value = {
code: res.code,

View file

@ -0,0 +1,608 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import OcrDialog from 'src/components/upload-file/OcrDialog.vue';
import { parseResultMRZ, runOcr } from 'src/utils/ocr';
import { useRequestList } from 'src/stores/request-list';
import {
DocStatus,
RowDocument,
Attributes,
} from 'src/stores/request-list/types';
import MainButton from 'src/components/button/MainButton.vue';
import FormGroupHead from './FormGroupHead.vue';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import FormEmployeePassport from 'components/03_customer-management/FormEmployeePassport.vue';
import FormEmployeeVisa from 'components/03_customer-management/FormEmployeeVisa.vue';
import { CancelButton, EditButton } from 'src/components/button';
import { docColumn } from './constants';
import { computed } from 'vue';
import { QTableSlots } from 'quasar';
import {
EmployeePassportPayload,
EmployeeVisaPayload,
} from 'stores/employee/types';
type Data = {
id: string;
key: string;
type: 'customer' | 'employee';
data: RowDocument & { fileName: string };
};
defineEmits<{
(e: 'changeStatus', v: Partial<Data> & { status: DocStatus }): void;
(e: 'viewDoc', v: Data): void;
(
e: 'upload',
v: {
id: string;
file: File;
form?: EmployeePassportPayload | EmployeeVisaPayload;
group: string;
type: 'customer' | 'employee';
},
done: typeof fetchRequestAttachment,
): void;
(e: 'download', v: Data): void;
}>();
const group = ref('passport');
const requestListStore = useRequestList();
const props = withDefaults(
defineProps<{
listDocument: string[];
currentId: { customer: string; employee: string };
}>(),
{
listDocument: () => [],
},
);
const attributes = defineModel<Attributes>('attributes', {
default: {
customer: {},
employee: {},
},
});
const state = reactive<{
isEdit: boolean;
splitPercent: number;
tab: 'customer' | 'employee';
}>({
isEdit: false,
splitPercent: 15,
tab: 'customer',
});
const docStatus = [
DocStatus.AwaitReview,
DocStatus.UploadedAwaitReview,
DocStatus.ReviewedAwaitUpload,
DocStatus.Reviewed,
DocStatus.ApprovedReview,
];
const attachmentList = ref<
Awaited<ReturnType<typeof requestListStore.getAttachmentRequest>>
>({});
async function ocr(file: File): Promise<Record<string, any> | false> {
const result = await ocrGroup(file);
if (result) ocrResultToVariable(result);
return result;
}
async function ocrGroup(file: File) {
switch (group.value) {
case 'passport':
return ocrPassport(file);
case 'visa':
return ocrPassport(file);
default:
return false;
}
}
async function ocrPassport(file: File): Promise<Record<string, any> | false> {
const mrz = await runOcr(file, parseResultMRZ);
return mrz?.result || false;
}
function ocrResultToVariable(
data: Record<string, any>,
): EmployeePassportPayload | EmployeeVisaPayload {
// TODO: assign ocr result to variable
let form: EmployeePassportPayload | EmployeeVisaPayload;
if (group.value === 'passport') {
form = {
birthCountry: data.birthCountry,
previousPassportRef: data.previousPassportRef,
issuePlace: data.nationality,
issueCountry: data.country,
issueDate: new Date(data.issue_date),
type: data.doc_type,
expireDate: new Date(data.expire_date),
birthDate: new Date(data.birthDate),
workerStatus: data.workerStatus,
nationality: data.nationality,
gender: data.sex,
lastNameEN: data.lastNameEN,
lastName: data.last_name,
middleNameEN: data.middleNameEN,
middleName: data.middleName,
firstNameEN: data.firstNameEN,
firstName: data.first_name,
namePrefix: data.namePrefix,
number: data.doc_number,
};
} else {
form = {
arrivalAt: data.arrivalAt,
arrivalTMNo: data.arrivalTMNo,
arrivalTM: data.arrivalTM,
mrz: data.mrz,
entryCount: data.entryCount,
issuePlace: data.issue_place,
issueCountry: data.country,
issueDate: new Date(data.valid_until),
type: data.visa_type || '',
expireDate: new Date(data.expire_date),
remark: data.remark,
workerType: data.workerType,
number: data.doc_number,
};
}
return form;
}
defineExpose({
fetchRequestAttachment,
});
async function fetchRequestAttachment(type: 'customer' | 'employee') {
const res = await requestListStore.getAttachmentRequest(
{
customer: props.currentId.customer,
employee: props.currentId.employee,
}[type] || '',
type,
);
if (res) {
attachmentList.value = res;
}
}
const currentListDocument = computed(() => {
return props.listDocument.map((v, i) => {
const fileIds = attachmentList.value[v]
? Object.values(attachmentList.value[v])
: [];
const fileNames = fileIds.map((id) => String(id));
return {
no: i + 1,
documentType: v,
fileName: fileNames,
amount: fileIds.length,
documentInSystem: fileIds.length > 0,
status: attributes.value
? (attributes.value[state.tab]?.[v] ?? docStatus[0])
: docStatus[0],
};
});
});
function triggerCancel() {
state.isEdit = false;
}
function triggerEdit() {
state.isEdit = true;
}
function changeCustomerTab(opts: { tab: 'customer' | 'employee' }) {
state.tab = opts.tab;
}
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered"
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="fetchRequestAttachment(state.tab)"
>
<template #header>
<span>
{{ $t('menu.order.documentCheck') }}
</span>
<nav class="q-ml-auto row">
<CancelButton
v-if="state.isEdit"
id="btn-info-basic-undo"
icon-only
type="button"
@click.stop="triggerCancel"
/>
<EditButton
v-if="!state.isEdit"
id="btn-info-basic-edit"
icon-only
@click.stop="triggerEdit"
type="button"
/>
</nav>
</template>
<FormGroupHead>
{{ $t('requestList.relatedDoc') }}
</FormGroupHead>
<main>
<q-splitter
v-model="state.splitPercent"
:limits="[0, 100]"
style="width: 100%"
class="col"
before-class="overflow-hidden"
after-class="overflow-hidden"
>
<template v-slot:before>
<section class="q-px-md q-py-sm column" style="gap: var(--size-1)">
<q-item
v-for="tab in ['customer', 'employee'] as const"
v-close-popup
clickable
:key="tab"
dense
class="no-padding items-center rounded full-width"
active-class="active"
:active="state.tab === tab"
@click="
changeCustomerTab({ tab });
fetchRequestAttachment(tab);
"
:id="`btn-${tab}`"
>
<span class="q-px-md ellipsis">
{{
`${$t('general.document')}${tab === 'customer' ? $t('customer.employer') : $t('customer.employee')}`
}}
</span>
</q-item>
</section>
</template>
<template v-slot:after>
<section class="q-px-md q-py-sm">
<q-table
:columns="docColumn"
:rows="currentListDocument"
bordered
flat
hide-pagination
:rows-per-page-options="[0]"
:no-data-label="$t('general.noDataTable')"
class="full-width"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th
v-for="col in props.cols"
:class="`text-${col.align}`"
: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: (typeof currentListDocument.value)[number];
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{
dark: $q.dark.isActive,
}"
class="text-center"
>
<q-td>
{{ props.rowIndex + 1 }}
</q-td>
<q-td class="text-left">
{{
$t(`customerEmployee.fileType.${props.row.documentType}`)
}}
</q-td>
<q-td
class="text-left q-gutter-xs"
style="
max-width: 400px;
white-space: normal;
word-break: break-word;
"
>
<span
:class="{
'app-text-muted': !props.row.fileName,
}"
>
<div class="q-gutter-y-xs">
<BadgeComponent
hide-icon
v-for="v in props.row.fileName"
:key="v"
:title="v ? v : $t('requestList.noDocumentYet')"
hsla-color="--gray-8-hsl"
>
<template #label>
<span
:class="{
'cursor-pointer': props.row.fileName,
}"
@click.stop="
$emit('viewDoc', {
key: props.row.documentType,
data: { ...props.row, fileName: v },
id:
{
customer: currentId.customer,
employee: currentId.employee,
}[state.tab] || '',
type: state.tab,
})
"
>
{{ v ? $t(v) : $t('requestList.noDocumentYet') }}
</span>
</template>
<template #append>
<q-icon
:class="{
'cursor-pointer': props.row.fileName,
}"
style="color: hsla(207% 96% 32%)"
name="mdi-tray-arrow-down"
size="xs"
class="app-text-muted q-ml-xs"
@click.stop="
$emit('download', {
key: props.row.documentType,
data: { ...props.row, fileName: v },
id:
{
customer: currentId.customer,
employee: currentId.employee,
}[state.tab] || '',
type: state.tab,
})
"
/>
</template>
</BadgeComponent>
</div>
</span>
</q-td>
<q-td>
{{ props.row.amount }}
</q-td>
<q-td>
<q-icon
:name="`mdi-${props.row.documentInSystem ? 'check' : 'close'}-circle`"
size="sm"
:class="{
'app-text-positive': props.row.documentInSystem,
'app-text-negative': !props.row.documentInSystem,
}"
/>
</q-td>
<q-td class="text-left">
<q-btn-dropdown
dense
unelevated
:label="
$t(
`requestList.status.${props.row.status || 'AwaitReview'}`,
)
"
class="text-capitalize text-weight-regular doc-status"
:class="{
await: props.row.status === DocStatus.AwaitReview,
'in-progress':
props.row.status === DocStatus.ReviewedAwaitUpload ||
props.row.status === DocStatus.UploadedAwaitReview,
completed:
props.row.status === DocStatus.Reviewed ||
props.row.status === DocStatus.ApprovedReview,
}"
:menu-offset="[0, 8]"
dropdown-icon="mdi-chevron-down"
content-class="bordered rounded"
@click.stop
>
<q-list dense>
<template v-for="s in docStatus" :key="s">
<q-item
v-if="s !== props.row.status"
clickable
v-close-popup
@click="
$emit('changeStatus', {
key: props.row.documentType,
status: s,
type: state.tab,
})
"
>
{{ $t(`requestList.status.${s}`) }}
</q-item>
</template>
</q-list>
</q-btn-dropdown>
</q-td>
<q-td>
<span class="row justify-end no-wrap">
<OcrDialog
@submit="
(file, meta) => {
$emit(
'upload',
{
id: {
customer: currentId.customer,
employee: currentId.employee,
}[state.tab] as string,
file,
form:
meta !== undefined
? ocrResultToVariable(meta)
: undefined,
group: props.row.documentType,
type: state.tab,
},
fetchRequestAttachment,
);
}
"
:ocr
>
<template #trigger="{ browse }">
<MainButton
v-if="!!state.isEdit"
iconOnly
icon="mdi-tray-arrow-up"
color="var(--positive-bg)"
:title="$t('general.upload')"
@click.stop="
group = props.row.documentType;
browse();
"
/>
</template>
<template #body="{ metadata, isRunning }">
<FormEmployeePassport
v-if="group === 'passport' && metadata"
:title="$t('customerEmployee.form.group.passport')"
dense
outlined
separator
ocr
prefix-id="ocr"
v-model:birth-country="metadata.birthCountry"
v-model:previous-passportRef="
metadata.previousPassportRef
"
v-model:issue-place="metadata.nationality"
v-model:issue-country="metadata.country"
v-model:issue-date="metadata.issue_date"
v-model:type="metadata.doc_type"
v-model:expire-date="metadata.expire_date"
v-model:birth-date="metadata.birthDate"
v-model:worker-status="metadata.workerStatus"
v-model:nationality="metadata.nationality"
v-model:gender="metadata.sex"
v-model:last-name-en="metadata.lastNameEN"
v-model:last-name="metadata.last_name"
v-model:middle-name-en="metadata.middleNameEN"
v-model:middle-name="metadata.middleName"
v-model:first-name-en="metadata.firstNameEN"
v-model:first-name="metadata.first_name"
v-model:name-prefix="metadata.namePrefix"
v-model:passport-number="metadata.doc_number"
/>
<FormEmployeeVisa
v-if="group === 'visa' && metadata"
:title="$t('customerEmployee.form.group.visa')"
ocr
dense
outlined
prefix-id="ocr"
v-model:arrival-at="metadata.arrivalAt"
v-model:arrival-tm-no="metadata.arrivalTMNo"
v-model:arrival-tm="metadata.arrivalTM"
v-model:mrz="metadata.mrz"
v-model:entry-count="metadata.entryCount"
v-model:issue-place="metadata.issue_place"
v-model:issue-country="metadata.country"
v-model:visa-issue-date="metadata.valid_until"
v-model:visa-type="metadata.visa_type"
v-model:expire-date="metadata.expire_date"
v-model:visa-expiry-date="metadata.expireDate"
v-model:remark="metadata.remark"
v-model:worker-type="metadata.workerType"
v-model:visa-number="metadata.doc_number"
/>
<div
v-if="isRunning"
class="full-height flex flex-center"
>
<q-circular-progress
indeterminate
rounded
size="50px"
color="light-blue"
class="q-ma-md"
/>
</div>
</template>
</OcrDialog>
</span>
</q-td>
</q-tr>
</template>
</q-table>
</section>
</template>
</q-splitter>
</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,44 @@
<script setup lang="ts">
const duty = defineModel<boolean>('duty', { required: true });
const dutyCost = defineModel<number>('dutyCost', { required: false });
defineProps<{
readonly?: boolean;
cost?: boolean;
}>();
</script>
<template>
<div class="row items-center q-px-md q-py-xs">
<q-radio
v-model="duty"
:val="false"
:label="$t('duty.notInclude')"
:disable="readonly"
class="col"
/>
<q-radio
v-model="duty"
:val="true"
:label="$t('duty.include')"
:disable="readonly"
class="col"
/>
<q-select
:options="[30]"
:disable="!duty"
:label="$t('duty.cost')"
:readonly
:hide-dropdown-icon="readonly"
:model-value="dutyCost || 0"
@update:model-value="(value) => (dutyCost = value)"
v-if="dutyCost !== undefined || cost"
class="col"
dense
outlined
use-input
/>
<!-- NOTE: For spacing only -->
<div v-else class="col"></div>
<div class="offset-md-7"></div>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import SelectUser from 'src/components/shared/select/SelectUser.vue';
const localEmployee = defineModel<boolean>('localEmployee', { required: true });
const employeeId = defineModel<string>('employeeId', { required: false });
defineProps<{
readonly?: boolean;
cost?: boolean;
}>();
</script>
<template>
<div class="row items-center q-px-md q-py-xs">
<div class="col-12 row">
<q-radio
v-model="localEmployee"
:val="true"
:label="$t('requestList.nonLocalEmployee')"
:disable="readonly"
class="col"
/>
<q-radio
v-model="localEmployee"
:val="false"
:label="$t('requestList.localEmployee')"
:disable="readonly"
class="col"
/>
<div class="col" />
<div class="offset-md-7"></div>
</div>
<div class="col-12 row">
<SelectUser
class="col-md-5 col-12"
v-model:value="employeeId"
:readonly
:label="$t('general.select', { msg: $t('personnel.MESSENGER') })"
/>
</div>
</div>
</template>

View file

@ -0,0 +1,166 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import FormGroupHead from './FormGroupHead.vue';
import FormDuty from './FormDuty.vue';
import FormIssue from './FormIssue.vue';
import FormEmployee from './FormEmployee.vue';
import { UndoButton, SaveButton, EditButton } from 'src/components/button';
import { AttributesForm, Step } from 'src/stores/request-list/types';
import { useRequestList } from 'src/stores/request-list';
const props = defineProps<{
step: Step;
}>();
const requestListStore = useRequestList();
const state = reactive({
isEdit: false,
});
const defaultForm = {
customerDuty: false,
customerDutyCost: 30,
companyDuty: false,
companyDutyCost: 30,
individualDuty: false,
localEmployee: true,
employeeId: '',
};
const attributesForm = defineModel<AttributesForm>('attributesForm', {
default: {
customerDuty: false,
customerDutyCost: 30,
companyDuty: false,
companyDutyCost: 30,
individualDuty: false,
localEmployee: true,
employeeId: '',
},
});
const formData = ref<AttributesForm>(defaultForm);
function triggerUndo() {
assignToForm();
state.isEdit = false;
}
async function triggerSubmit() {
const payload = {
...props.step,
attributes: { form: formData.value },
};
const res = await requestListStore.editStatusRequestWork(payload);
if (res) {
attributesForm.value = JSON.parse(JSON.stringify(formData.value));
state.isEdit = false;
}
}
function triggerEdit() {
state.isEdit = true;
}
function assignToForm() {
formData.value = JSON.parse(JSON.stringify(attributesForm.value));
}
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered q-my-sm"
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"
@before-show="assignToForm"
>
<template #header>
<span>
{{ $t('general.designForm') }}
</span>
<nav class="q-ml-auto row">
<UndoButton
v-if="state.isEdit"
id="btn-info-basic-undo"
icon-only
type="button"
@click.stop="triggerUndo"
/>
<SaveButton
v-if="state.isEdit"
id="btn-info-basic-save"
icon-only
type="submit"
@click.stop="triggerSubmit"
/>
<EditButton
v-if="!state.isEdit"
id="btn-info-basic-edit"
icon-only
@click.stop="triggerEdit"
type="button"
/>
</nav>
</template>
<main class="row">
<section class="col-12">
<FormGroupHead>
{{ $t('duty.text', { subject: $t('general.customer') }) }}
</FormGroupHead>
<FormDuty
class="surface-1"
:readonly="!state.isEdit"
v-model:duty="formData.customerDuty"
v-model:duty-cost="formData.customerDutyCost"
cost
/>
</section>
<section class="col-12">
<FormGroupHead>
{{ $t('duty.text', { subject: $t('general.company') }) }}
</FormGroupHead>
<FormDuty
class="surface-1"
:readonly="!state.isEdit"
v-model:duty="formData.companyDuty"
v-model:duty-cost="formData.companyDutyCost"
cost
/>
</section>
<section class="col-12">
<FormGroupHead>
{{ $t('quotation.templateForm') }}
</FormGroupHead>
<FormIssue :readonly="!state.isEdit" />
</section>
<section class="col-12">
<FormGroupHead>
{{ $t('general.select', { msg: $t('requestList.companyEmployee') }) }}
</FormGroupHead>
<FormEmployee
:readonly="!state.isEdit"
class="surface-1"
v-model:local-employee="formData.localEmployee"
v-model:employee-id="formData.employeeId"
/>
</section>
<section class="col-12">
<FormGroupHead>
{{ $t('duty.text', { subject: $t('general.individual') }) }}
</FormGroupHead>
<FormDuty
:readonly="!state.isEdit"
class="surface-1"
v-model:duty="formData.individualDuty"
/>
</section>
</main>
</q-expansion-item>
</template>
<style></style>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
</script>
<template>
<div
class="surface-3 row items-center q-px-md q-py-xs"
style="color: hsla(var(--text-mute-2)); background: hsla(var(--gray-2-hsl))"
>
<Icon icon="mdi-circle-medium" class="q-mr-xs" />
<slot />
</div>
</template>

View file

@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue';
import { MainButton } from 'components/button';
import SelectInput from 'src/components/shared/SelectInput.vue';
defineProps<{
readonly?: boolean;
}>();
const templateForm = defineModel<string>();
const templateFormOption = ref<
{ label: string; labelEN: string; value: string }[]
>([]);
</script>
<template>
<div
class="surface-1 q-pa-md"
:class="{
['template-container']: $q.screen.gt.sm,
['column']: !$q.screen.gt.sm,
}"
style="gap: var(--size-2)"
>
<SelectInput
id="quotation-branch"
style="grid-column: span 4"
incremental
v-model="templateForm"
class="full-width"
:readonly
:option="templateFormOption"
:label="$t('quotation.templateForm')"
:option-label="$i18n.locale === 'eng' ? 'labelEN' : 'label'"
/>
<MainButton
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
class="full-width"
style="grid-column: span 2"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
<MainButton
solid
icon="mdi-pencil-outline"
color="207 96% 32%"
class="full-width"
style="grid-column: span 2"
>
{{ $t('general.designForm') }}
</MainButton>
</div>
</template>
<style scoped>
.template-container {
display: grid;
grid-template-columns: repeat(12, 1fr);
}
</style>

View file

@ -0,0 +1,323 @@
<script setup lang="ts">
// NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue';
import { storeToRefs } from 'pinia';
// NOTE: Components
import StatCardComponent from 'src/components/StatCardComponent.vue';
import NoData from 'src/components/NoData.vue';
import TableRequestList from './TableRequestList.vue';
import PaginationComponent from 'src/components/PaginationComponent.vue';
import PaginationPageSize from 'src/components/PaginationPageSize.vue';
// NOTE: Stores & Type
import { useNavigator } from 'src/stores/navigator';
import { column } from './constants';
import useFlowStore from 'src/stores/flow';
import { useRequestList } from 'src/stores/request-list';
import { RequestData, RequestDataStatus } from 'src/stores/request-list/types';
const navigatorStore = useNavigator();
const flowStore = useFlowStore();
const requestListStore = useRequestList();
const { data, stats, page, pageMax, pageSize } = storeToRefs(requestListStore);
// NOTE: Variable
const pageState = reactive({
hideStat: false,
statusFilter: RequestDataStatus.Pending,
inputSearch: '',
fieldSelected: [...column.map((v) => v.name)],
gridView: false,
total: 0,
});
const fieldSelectedOption = computed(() => {
return column.map((v) => ({
label: v.label,
value: v.name,
}));
});
// NOTE: Function
async function fetchList(opts?: { rotateFlowId?: boolean }) {
const ret = await requestListStore.getRequestDataList({
query: pageState.inputSearch,
page: page.value,
pageSize: pageSize.value,
requestDataStatus: pageState.statusFilter,
});
if (ret) {
data.value = ret.result;
pageState.total = ret.total;
pageMax.value = Math.ceil(ret.total / pageSize.value);
}
if (opts?.rotateFlowId) flowStore.rotate();
}
async function fetchStats() {
const ret = await requestListStore.getRequestDataStats();
if (ret) stats.value = ret;
}
function triggerView(opts: { requestData: RequestData }) {
const url = new URL(
`/request-list/${opts.requestData.id}`,
window.location.origin,
);
window.open(url.toString(), '_blank');
}
onMounted(async () => {
navigatorStore.current.title = 'requestList.title';
navigatorStore.current.path = [{ text: 'requestList.caption', i18n: true }];
await fetchStats();
await fetchList({ rotateFlowId: true });
});
watch([() => pageState.inputSearch, () => pageState.statusFilter], () =>
fetchList({ rotateFlowId: true }),
);
</script>
<template>
<div class="column full-height no-wrap">
<!-- SEC: stat -->
<section class="text-body-2 q-mb-xs flex items-center">
{{ $t('general.dataSum') }}
<q-badge
rounded
class="q-ml-sm"
style="
background-color: hsla(var(--info-bg) / 0.15);
color: hsl(var(--info-bg));
"
>
{{ 0 }}
</q-badge>
<q-btn
class="q-ml-sm"
icon="mdi-pin-outline"
color="primary"
size="sm"
flat
dense
rounded
@click="pageState.hideStat = !pageState.hideStat"
:style="pageState.hideStat ? 'rotate: 90deg' : ''"
style="transition: 0.1s ease-in-out"
/>
</section>
<transition name="slide">
<div v-if="!pageState.hideStat" class="scroll q-mb-md">
<div style="display: inline-block">
<StatCardComponent
labelI18n
:branch="[
{
icon: 'icon-park-outline:loading-one',
count: stats[RequestDataStatus.Pending],
label: 'requestList.status.Pending',
color: 'orange',
},
{
icon: 'mdi-timer-sand',
count: stats[RequestDataStatus.InProgress],
label: 'requestList.status.InProgress',
color: 'blue',
},
{
icon: 'mdi-check-decagram-outline',
count: stats[RequestDataStatus.Completed],
label: 'requestList.status.Completed',
color: 'light-green',
},
]"
:dark="$q.dark.isActive"
/>
</div>
</div>
</transition>
<section class="col surface-1 rounded bordered overflow-hidden">
<div class="column full-height">
<!-- SEC: header content -->
<header
class="row surface-3 justify-between full-width items-center bordered-b"
style="z-index: 1"
>
<div class="row q-py-sm q-px-md justify-between full-width">
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="q-mr-md col-12 col-md-3"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="pageState.inputSearch"
debounce="200"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
<div
class="row col-12 col-md-5 justify-end"
:class="{ 'q-pt-xs': $q.screen.lt.md }"
style="white-space: nowrap"
>
<q-select
v-model="pageState.statusFilter"
outlined
dense
option-value="value"
option-label="label"
class="col"
:class="{ 'offset-md-5': pageState.gridView }"
map-options
emit-value
:for="'field-select-status'"
:hide-dropdown-icon="$q.screen.lt.sm"
:options="[
{
label: $t('requestList.status.Pending'),
value: RequestDataStatus.Pending,
},
{
label: $t('requestList.status.InProgress'),
value: RequestDataStatus.InProgress,
},
{
label: $t('requestList.status.Completed'),
value: RequestDataStatus.Completed,
},
]"
/>
<q-select
v-if="!pageState.gridView"
id="select-field"
for="select-field"
class="col q-ml-sm"
:options="
fieldSelectedOption.map((v) => ({
...v,
label: v.label && $t(v.label),
}))
"
:display-value="$t('general.displayField')"
:hide-dropdown-icon="$q.screen.lt.sm"
v-model="pageState.fieldSelected"
option-label="label"
option-value="value"
map-options
emit-value
outlined
multiple
dense
/>
<q-btn-toggle
id="btn-mode"
v-model="pageState.gridView"
dense
class="no-shadow bordered rounded surface-1 q-ml-sm"
:toggle-color="$q.dark.isActive ? 'grey-9' : 'grey-2'"
size="xs"
:options="[
{ value: true, slot: 'folder' },
{ value: false, slot: 'list' },
]"
>
<template v-slot:folder>
<q-icon
name="mdi-view-grid-outline"
size="16px"
class="q-px-sm q-py-xs rounded"
:style="{
color: $q.dark.isActive
? pageState.gridView
? '#C9D3DB '
: '#787B7C'
: pageState.gridView
? '#787B7C'
: '#C9D3DB',
}"
/>
</template>
<template v-slot:list>
<q-icon
name="mdi-format-list-bulleted"
class="q-px-sm q-py-xs rounded"
size="16px"
:style="{
color: $q.dark.isActive
? pageState.gridView === false
? '#C9D3DB'
: '#787B7C'
: pageState.gridView === false
? '#787B7C'
: '#C9D3DB',
}"
/>
</template>
</q-btn-toggle>
</div>
</div>
</header>
<!-- SEC: body content -->
<article
v-if="data.length === 0"
class="col surface-2 flex items-center justify-center"
>
<NoData :not-found="!!pageState.inputSearch" />
</article>
<article v-else class="col surface-2 full-width scroll q-pa-md">
<TableRequestList
:columns="column"
:rows="data"
:grid="pageState.gridView"
:visible-columns="pageState.fieldSelected"
@view="(data) => triggerView({ requestData: data })"
/>
</article>
<!-- SEC: footer content -->
<footer
class="row justify-between items-center q-px-md q-py-sm surface-2"
v-if="pageMax > 0"
>
<div class="col-4">
<div class="row items-center no-wrap">
<div class="app-text-muted q-mr-sm" v-if="$q.screen.gt.sm">
{{ $t('general.recordPerPage') }}
</div>
<div><PaginationPageSize v-model="pageSize" /></div>
</div>
</div>
<div class="col-4 row justify-center app-text-muted">
{{
$t('general.recordsPage', {
resultcurrentPage: data.length,
total: pageState.inputSearch ? data.length : pageState.total,
})
}}
</div>
<nav class="col-4 row justify-end">
<PaginationComponent
v-model:current-page="page"
v-model:max-page="pageMax"
:fetch-data="() => fetchList({ rotateFlowId: true })"
/>
</nav>
</footer>
</div>
</section>
</div>
</template>
<style></style>

View file

@ -0,0 +1,241 @@
<script setup lang="ts">
import { baseUrl } from 'src/stores/utils';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import { ProductRelation } from 'src/stores/quotations/types';
import { Step, RequestWorkStatus } from 'src/stores/request-list/types';
const workStatus = [
RequestWorkStatus.Ready,
RequestWorkStatus.Waiting,
RequestWorkStatus.InProgress,
RequestWorkStatus.Validate,
RequestWorkStatus.Ended,
RequestWorkStatus.Completed,
];
const productId = defineModel<string>('productId', { required: true });
defineEmits<{
(
e: 'changeStatus',
v: { step?: Step; requestWorkStatus: RequestWorkStatus },
): void;
}>();
defineProps<{
product: ProductRelation;
name: string;
code: string;
status?: Step;
}>();
// NOTE: Function
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1"
>
<template v-slot:header>
<header class="row items-center q-py-sm no-wrap full-width">
<div
class="img-frame rounded q-mr-md"
:class="{ dark: $q.dark.isActive }"
style="width: 50px; height: 50px"
>
<q-img
:src="`${baseUrl}/product/${productId}/image/`"
style="object-fit: cover; width: 100%; height: 100%"
>
<template #error>
<span
class="flex items-center justify-center full-height full-width"
>
<q-img src="/shop-image.png" style="width: 80%"></q-img>
</span>
</template>
</q-img>
</div>
<div class="row col">
<span class="row items-center col-12 no-wrap">
<span class="ellipsis-2-lines">
{{ product?.name || name }}
</span>
</span>
<div class="rounded q-px-xs app-text-muted surface-3">
{{ product?.code || code }}
</div>
</div>
<div class="q-ml-auto q-gutter-y-xs">
<div class="justify-end flex">
<q-btn-dropdown
:disable="
status?.workStatus === 'Waiting' ||
status?.workStatus === 'InProgress'
"
dense
unelevated
:label="
$q.screen.lt.sm
? undefined
: $t(
`requestList.status.work.${status?.workStatus ?? RequestWorkStatus.Pending}`,
)
"
class="text-capitalize text-weight-regular product-status rounded"
:class="{
disable:
$q.screen.gt.xs &&
(status?.workStatus === RequestWorkStatus.Waiting ||
status?.workStatus === RequestWorkStatus.InProgress),
pending:
($q.screen.gt.xs && !status?.workStatus) ||
status?.workStatus === RequestWorkStatus.Pending ||
status?.workStatus === RequestWorkStatus.Ready,
progress:
$q.screen.gt.xs &&
status?.workStatus === RequestWorkStatus.Validate,
complete:
$q.screen.gt.xs &&
(status?.workStatus === RequestWorkStatus.Ended ||
status?.workStatus === RequestWorkStatus.Completed),
}"
:style="
$q.screen.xs &&
(status?.workStatus === RequestWorkStatus.Waiting ||
status?.workStatus === RequestWorkStatus.InProgress)
? `opacity: 30% !important`
: ''
"
:menu-offset="[0, 8]"
dropdown-icon="mdi-chevron-down"
content-class="bordered rounded"
@click.stop
>
<q-list dense>
<q-item
v-for="(value, index) in workStatus"
:key="index"
clickable
v-close-popup
@click="
$emit('changeStatus', {
step: status,
requestWorkStatus: workStatus[index],
})
"
>
{{ $t(`requestList.status.work.${value}`) }}
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="text-caption">
<span
class="q-pr-xs"
style="border-right: 1px solid #ccc"
v-if="$q.screen.gt.xs"
>
{{ $t('general.status') }}
</span>
<span
class="q-pl-xs product-status-text"
:class="{
pending:
!status?.workStatus ||
status.workStatus === RequestWorkStatus.Pending ||
status.workStatus === RequestWorkStatus.Ready,
wait: status?.workStatus === 'Waiting',
progress:
status?.workStatus === RequestWorkStatus.InProgress ||
status?.workStatus === RequestWorkStatus.Validate,
complete:
status?.workStatus === RequestWorkStatus.Ended ||
status?.workStatus === RequestWorkStatus.Completed,
}"
>
{{
$t(`requestList.status.work.${status?.workStatus ?? 'Pending'}`)
}}
</span>
</div>
</div>
</header>
</template>
<slot :product="product"></slot>
</q-expansion-item>
</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);
}
:deep(
.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;
}
.img-frame {
background: hsla(var(--teal-10-hsl) / 0.15);
&.dark {
background: hsla(var(--teal-8-hls) / 0.15);
}
}
.product-status {
padding-left: 8px;
border-radius: 20px;
color: hsl(var(--_color));
background: hsla(var(--_color) / 0.15);
&.disable {
--_color: var(--stone-7-hsl);
}
&.pending {
--_color: var(--yellow-6-hsl);
}
&.wait {
--_color: var(--blue-6-hsl);
}
&.progress {
--_color: var(--orange-5-hsl);
}
&.complete {
--_color: var(--green-5-hsl);
}
}
.product-status-text {
color: hsl(var(--_color));
&.pending {
--_color: var(--yellow-6-hsl);
}
&.wait {
--_color: var(--blue-6-hsl);
}
&.progress {
--_color: var(--orange-5-hsl);
}
&.complete {
--_color: var(--green-5-hsl);
}
}
</style>

View file

@ -0,0 +1,166 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import PropertiesToInput from 'src/components/08_request-list/PropertiesToInput.vue';
import { UndoButton, SaveButton, EditButton } from 'src/components/button';
import {
PropDate,
PropNumber,
PropOptions,
PropString,
} from 'src/stores/product-service/types';
import { Attributes } from 'src/stores/request-list/types';
import useOptionStore from 'src/stores/options';
import { useRequestList } from 'src/stores/request-list';
const optionStore = useOptionStore();
const requestListStore = useRequestList();
const props = withDefaults(
defineProps<{
id: string;
propertiesToShow: (PropString | PropNumber | PropDate | PropOptions)[];
}>(),
{
id: '',
properties: () => [],
},
);
const formRemark = ref<string>('');
const formData = ref<{
[field: string]: string | number | null | undefined;
}>({});
const state = reactive({
isEdit: false,
});
const attributes = defineModel<Attributes>('attributes', {
default: {
remark: '',
properties: {},
},
});
function triggerUndo() {
assignToForm();
state.isEdit = false;
}
async function triggerSubmit() {
const res = await requestListStore.editRequestWork({
id: props.id,
attributes: {
...attributes.value,
properties: formData.value,
remark: formRemark.value,
},
});
if (res) {
attributes.value.remark = formRemark.value || '';
attributes.value.properties = JSON.parse(
JSON.stringify(formData.value || {}),
);
state.isEdit = false;
}
}
function triggerEdit() {
state.isEdit = true;
}
function assignToForm() {
formRemark.value = attributes.value?.remark || '';
formData.value = JSON.parse(
JSON.stringify(attributes.value?.properties || {}),
);
}
defineEmits<{
(e: 'save', v: { [field: string]: string | number | null | undefined }): void;
}>();
</script>
<template>
<q-expansion-item
dense
class="overflow-hidden bordered"
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"
@before-show="assignToForm"
>
<template #header>
<span>
{{ $t('general.properties') }}
</span>
<nav class="q-ml-auto row">
<UndoButton
v-if="state.isEdit"
id="btn-info-basic-undo"
icon-only
type="button"
@click.stop="triggerUndo"
/>
<SaveButton
v-if="state.isEdit"
id="btn-info-basic-save"
icon-only
type="submit"
@click.stop="triggerSubmit"
/>
<EditButton
v-if="!state.isEdit"
id="btn-info-basic-edit"
icon-only
@click.stop="triggerEdit"
type="button"
/>
</nav>
</template>
<main class="q-px-md q-py-sm" :class="{ row: $q.screen.gt.sm }">
<section class="col-7" :class="{ 'q-pr-sm': $q.screen.gt.sm }">
<span
v-for="(prop, i) in propertiesToShow"
:key="i"
class="row items-center q-pb-sm"
>
<article class="col-5">
{{ i + 1 }} {{ optionStore.mapOption(prop.fieldName) }}
</article>
<PropertiesToInput
:readonly="!state.isEdit"
:prop="prop"
:placeholder="optionStore.mapOption(prop.fieldName)"
v-model="formData[prop.fieldName]"
/>
</span>
</section>
<section class="col-5">
<q-input
class="full-height"
:model-value="formRemark || '-'"
dense
:readonly="!state.isEdit"
outlined
type="textarea"
:label="$t('general.remark')"
stack-label
@update:model-value="(v) => (formRemark = v as string)"
/>
</section>
</main>
</q-expansion-item>
</template>
<style scoped>
:deep(.q-textarea .q-field__control) {
height: 100%;
}
</style>

View file

@ -0,0 +1,576 @@
<script setup lang="ts">
// NOTE: Library
import { onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
// NOTE: Components
import DataDisplay from 'src/components/08_request-list/DataDisplay.vue';
import DocumentExpansion from './DocumentExpansion.vue';
import FormExpansion from './FormExpansion.vue';
import PropertiesExpansion from './PropertiesExpansion.vue';
// NOTE: Store
import { dateFormatJS } from 'src/utils/datetime';
import { useRequestList } from 'src/stores/request-list';
import {
RequestData,
RequestWork,
Attributes,
DocStatus,
Step,
} from 'src/stores/request-list/types';
import useOptionStore from 'src/stores/options';
import ProductExpansion from './ProductExpansion.vue';
import { useRoute } from 'vue-router';
import { useWorkflowTemplate } from 'src/stores/workflow-template';
import { WorkflowTemplate } from 'src/stores/workflow-template/types';
import { initLang, initTheme, Lang } from 'src/utils/ui';
import {
EmployeePassportPayload,
EmployeeVisaPayload,
} from 'stores/employee/types';
const { locale } = useI18n();
// NOTE: Variable
const route = useRoute();
const optionStore = useOptionStore();
const requestListStore = useRequestList();
const flowTemplateStore = useWorkflowTemplate();
const workList = ref<RequestWork[]>();
const statusFile = ref<Attributes>({
customer: {},
employee: {},
});
const refDocumentExpansion = ref<InstanceType<typeof DocumentExpansion>[]>([]);
const data = ref<RequestData>();
const flow = ref<WorkflowTemplate>();
const pageState = reactive({
hideMetaData: false,
currentStep: 1,
});
// NOTE: Function
async function fetchRequestWorkList(opts: { requestDataId: string }) {
const res = await requestListStore.getRequestWorkList({
requestDataId: opts.requestDataId,
pageSize: 9999,
});
if (res) {
workList.value = res.result;
}
}
function getCustomerName(
record: RequestData,
opts?: {
locale?: string;
noCode?: boolean;
},
) {
const customer = record.quotation.customerBranch;
return (
{
['CORP']: {
[Lang.English]: customer.registerNameEN,
[Lang.Thai]: customer.registerName,
}[opts?.locale || 'eng'],
['PERS']:
{
[Lang.English]: `${optionStore.mapOption(customer.namePrefix)} ${customer.firstNameEN} ${customer.lastNameEN}`,
[Lang.Thai]: `${optionStore.mapOption(customer.namePrefix)} ${customer.firstName} ${customer.lastName}`,
}[opts?.locale || Lang.English] || '-',
}[customer.customer.customerType] +
(opts?.noCode ? '' : ' ' + `(${customer.code})`)
);
}
function getEmployeeName(
record: RequestData,
opts?: {
locale?: string;
},
) {
const employee = record.employee;
return (
{
[Lang.English]: `${optionStore.mapOption(employee.namePrefix)} ${employee.firstNameEN} ${employee.lastNameEN}`,
[Lang.Thai]: `${optionStore.mapOption(employee.namePrefix)} ${employee.firstName} ${employee.lastName}`,
}[opts?.locale || Lang.English] || '-'
);
}
async function getData() {
const current = route.params['requestListId'];
if (typeof current === 'string') {
const res = await requestListStore.getRequestData(current);
if (res) {
data.value = res;
await fetchRequestWorkList({ requestDataId: current });
await getFlow();
}
}
}
async function getFlow() {
if (!workList.value) return;
const attr = workList.value.find((v) => !!v.productService.work?.attributes)
?.productService.work?.attributes;
if (attr && Object.hasOwn(attr, 'workflowId')) {
const workflowId = attr['workflowId'];
const res = await flowTemplateStore.getWorkflowTemplate(workflowId);
if (res) flow.value = res;
}
}
onMounted(async () => {
initTheme();
initLang();
// get data
await getData();
});
watch(() => route.params['requestListId'], getData);
async function triggerChangeStatusWork(step: Step) {
const res = await requestListStore.editStatusRequestWork(step);
if (res) {
const indexWork = workList.value?.findIndex(
(v) => v.id === step.requestWorkId,
);
if (indexWork === -1 || indexWork === undefined) return;
if (workList.value === undefined) return;
const indexStep = workList.value[indexWork].stepStatus.findIndex(
(v) => v.step === step.step,
);
if (indexStep === -1) {
workList.value[indexWork].stepStatus.push(res);
}
if (indexStep !== -1) {
workList.value[indexWork].stepStatus[indexStep].workStatus =
res.workStatus;
}
}
}
async function triggerChangeStatusFile(opt: {
index: number;
id: string;
documentType: string;
status: DocStatus;
type: 'customer' | 'employee';
}) {
if (!workList.value) return;
const workItem = workList.value[opt.index];
if (!workItem || !workItem.attributes) {
if (workItem) {
workItem.attributes = { customer: {}, employee: {} };
} else {
return;
}
}
const attributes = workItem.attributes;
statusFile.value = attributes;
if (!statusFile.value[opt.type]) {
statusFile.value[opt.type] = {};
}
statusFile.value[opt.type]![opt.documentType] = opt.status;
const res = await requestListStore.editRequestWork({
id: opt.id,
attributes: statusFile.value,
});
if (res) {
workList.value[opt.index].attributes = res.attributes;
}
}
async function triggerUpload(opt: {
id: string;
type: 'customer' | 'employee';
group: string;
file: File;
form?: EmployeePassportPayload | EmployeeVisaPayload;
}) {
const newName = `${opt.group}-${Date.now()}-${opt.file.name}`;
const res = await requestListStore.uploadAttachmentRequest({
...opt,
name: newName,
});
return !!res;
}
async function triggerViewFile(opt: {
id: string;
fileName: string;
type: 'customer' | 'employee';
group: string;
download?: boolean;
}) {
let url;
url = await requestListStore.viewAttachmentRequest({
id: opt.id,
name: opt.fileName,
type: opt.type,
group: opt.group,
download: opt.download,
});
if (!opt.download) window.open(url, '_blank');
}
</script>
<template>
<div class="column surface-0 fullscreen" v-if="data">
<!-- SEC: Header -->
<header class="row q-px-md q-py-sm items-center full justify-between">
<div style="flex: 1" class="row items-center">
<RouterLink to="/quotation">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('requestList.title') }}
{{ data.code || '' }}
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg: dateFormatJS({ date: data.createdAt }),
})
}}
</span>
</span>
</div>
</header>
<!-- SEC: Body -->
<main 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" :key="pageState.currentStep">
<!-- step -->
<nav
v-if="flow"
class="surface-1 q-pa-sm row no-wrap full-width scroll rounded"
style="gap: 10px"
>
<template v-for="(value, i) in flow.step" :key="value.id">
<button
v-if="
workList?.every(
(v) =>
v.productService.work?.attributes.workflowStep[i]
.attributes.properties.length > 0,
)
"
class="status-color q-pa-sm bordered row items-center cursor-pointer no-wrap"
style="text-wrap: nowrap"
:class="{
[`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>
</template>
</nav>
<!-- meta data -->
<article class="surface-1 rounded q-pa-sm">
<div
class="text-weight-bold row items-center no-wrap"
style="gap: 16px"
>
<q-img src="/images/quotation-avatar.png" width="42px" />
<span class="ellipsis" style="font-size: 18px">
{{ data.quotation.workName || '-' }}
</span>
<q-btn
class="q-ml-sm"
icon="mdi-pin-outline"
color="primary"
size="sm"
flat
dense
rounded
@click="pageState.hideMetaData = !pageState.hideMetaData"
:style="pageState.hideMetaData ? 'rotate: 90deg' : ''"
style="transition: 0.1s ease-in-out"
/>
</div>
<transition name="slide">
<section
v-if="!pageState.hideMetaData"
class="q-pt-md"
:class="{ row: $q.screen.gt.sm, column: $q.screen.lt.md }"
>
<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.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"
:label="$t('requestList.quotationCode')"
: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.invoiceCode')"
:value="'-'"
/>
<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"
:label="$t('requestList.receiptCode')"
:value="'-'"
/>
<DataDisplay
class="col"
icon="mdi-passport"
:label="$t('customerEmployee.form.passportNo')"
:value="'-'"
/>
</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) =>
v.productService.work?.attributes.workflowStep[
pageState.currentStep - 1
].productsId.includes(v.productService.productId),
)"
:key="value"
>
<ProductExpansion
:status="
value.stepStatus.find((v) => v.step === pageState.currentStep)
"
v-model:product-id="value.productService.productId"
:name="value.productService.product.name"
:code="value.productService.product.code"
:product="value.productService.product"
@change-status="
(v) => {
triggerChangeStatusWork({
workStatus: v.requestWorkStatus,
step:
v.step === undefined
? pageState.currentStep
: v.step.step,
requestWorkId: value.id || '',
});
}
"
>
<template v-slot="{ product }">
<section class="column surface-1 q-pa-sm bordered-t">
<DocumentExpansion
ref="refDocumentExpansion"
:attributes="value.attributes"
@change-status="
(opt) => {
triggerChangeStatusFile({
index,
id: value.id || '',
documentType: opt.key || '',
status: opt.status,
type: opt.type || 'customer',
});
}
"
@view-doc="
(opt) => {
triggerViewFile({
id: opt.id,
fileName: opt.data.fileName,
type: opt.type,
group: opt.data.documentType,
});
}
"
@upload="
async (opt, done) => {
await triggerUpload({ ...opt });
await done(opt.type || 'customer');
}
"
@download="
(opt) => {
console.log(opt);
triggerViewFile({
id: opt.id,
fileName: opt.data.fileName,
type: opt.type,
group: opt.data.documentType,
download: true,
});
}
"
:current-id="{
customer: value.request.quotation.customerBranchId,
employee: value.request.employeeId,
}"
:listDocument="product?.document"
/>
<FormExpansion
:step="{
step: pageState.currentStep,
requestWorkId: value.id || '',
}"
:id="value.id"
:attributes-form="
value.stepStatus[pageState.currentStep - 1]?.attributes
?.form
"
/>
<PropertiesExpansion
:id="value.id"
:properties-to-show="
value.productService.work?.attributes.workflowStep[
pageState.currentStep - 1
].attributes.properties
"
:attributes="value.attributes"
/>
</section>
</template>
</ProductExpansion>
</template>
</div>
</section>
</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-done {
--_color: var(--green-5-hsl);
}
&.status-color-doing {
--_color: var(--blue-5-hsl);
color: var(--foreground);
}
&.status-color-waiting {
--_color: var(--gray-4-hsl);
color: hsla(var(--_color));
}
}
.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,270 @@
<script setup lang="ts">
import { QTable, QTableProps, QTableSlots } from 'quasar';
import KebabAction from 'components/shared/KebabAction.vue';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import AvatarGroup from 'src/components/shared/AvatarGroup.vue';
import { RequestData } from 'src/stores/request-list/types';
import { RequestDataStatus } from 'src/stores/request-list/types';
import useOptionStore from 'src/stores/options';
const props = withDefaults(
defineProps<{
rows: QTableProps['rows'];
columns: QTableProps['columns'];
grid?: boolean;
visibleColumns?: string[];
}>(),
{
row: () => [],
column: () => [],
grid: false,
visibleColumns: () => [],
},
);
defineEmits<{
(e: 'view', data: RequestData): void;
(e: 'edit', id: string): void;
(e: 'delete', id: string): void;
}>();
function getCustomerName(
record: RequestData,
opts?: {
locale?: string;
noCode?: boolean;
},
) {
const customer = record.quotation.customerBranch;
return (
{
['CORP']: {
['eng']: customer.registerNameEN,
['tha']: customer.registerName,
}[opts?.locale || 'eng'],
['PERS']:
{
['eng']: `${useOptionStore().mapOption(customer.namePrefix)} ${customer.firstNameEN} ${customer.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(customer.namePrefix)} ${customer.firstName} ${customer.lastName}`,
}[opts?.locale || 'eng'] || '-',
}[customer.customer.customerType] +
(opts?.noCode ? '' : ' ' + `(${customer.code})`)
);
}
function getEmployeeName(
record: RequestData,
opts?: {
locale?: string;
},
) {
const employee = record.employee;
return (
{
['eng']: `${useOptionStore().mapOption(employee.namePrefix)} ${employee.firstNameEN} ${employee.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(employee.namePrefix)} ${employee.firstName} ${employee.lastName}`,
}[opts?.locale || 'eng'] || '-'
);
}
</script>
<template>
<q-table
v-bind="props"
bordered
flat
hide-pagination
card-container-class="q-col-gutter-sm"
:rows-per-page-options="[0]"
class="full-width"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in 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: RequestData;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{ urgent: props.row.quotation.urgent, dark: $q.dark.isActive }"
class="text-center"
>
<q-td v-if="visibleColumns.includes('order')">
{{ props.rowIndex + 1 }}
</q-td>
<q-td v-if="visibleColumns.includes('requestList')" class="text-left">
{{ props.row.quotation.workName || '-' }}
<div class="text-caption app-text-muted">
{{ props.row.code || '-' }}
</div>
</q-td>
<q-td v-if="visibleColumns.includes('employer')" class="text-left">
{{
getCustomerName(props.row, {
noCode: true,
locale: $i18n.locale,
}) || '-'
}}
</q-td>
<q-td v-if="visibleColumns.includes('employee')" class="text-left">
{{ getEmployeeName(props.row, { locale: $i18n.locale }) || '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('quotationCode')">
{{ props.row.quotation.code || '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('responsiblePerson')">
{{ '-' }}
</q-td>
<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'
: '--orange-5-hsl'
"
:title="
$t(`requestList.status.${props.row.requestDataStatus}`) || '-'
"
/>
</q-td>
<q-td class="text-right">
<q-btn
:id="`btn-eye-${props.row.quotation.workName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row)"
/>
<KebabAction
:idName="`btn-kebab-${props.row.quotation.workName}`"
status="'ACTIVE'"
hide-toggle
@view="$emit('view', props.row)"
@edit="$emit('edit', props.row.id)"
@delete="$emit('delete', props.row.id)"
/>
</q-td>
</q-tr>
</template>
<template
v-slot:item="props: {
row: RequestData;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
:badge-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'
: '--orange-5-hsl'
"
hidePreview
:urgent="props.row.quotation.urgent"
:code="props.row.code"
:title="props.row.quotation.workName"
:status="$t(`requestList.status.${props.row.requestDataStatus}`)"
:custom-data="[
{
label: $t('customer.employer'),
value:
getCustomerName(props.row, {
noCode: true,
locale: $i18n.locale,
}) || '-',
},
{
label: $t('customer.employee'),
value:
getEmployeeName(props.row, { locale: $i18n.locale }) || '-',
},
{
label: $t('requestList.quotationCode'),
value: props.row.quotation.code || '-',
},
{
label: $t('flow.responsiblePerson'),
value: '',
slotName: 'responsiblePerson',
},
]"
@view="$emit('view', props.row)"
>
<template v-slot:responsiblePerson="{ props }">
<div class="col-4 app-text-muted q-pr-sm self-center">
{{ props.label }}
</div>
<div class="col-8">
<AvatarGroup />
</div>
</template>
</QuotationCard>
</div>
</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);
}
}
</style>

View file

@ -0,0 +1,91 @@
import { QTableProps } from 'quasar';
export const column = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'requestList',
align: 'center',
label: 'requestList.title',
field: 'requestList',
},
{
name: 'employer',
align: 'center',
label: 'customer.employer',
field: 'employer',
},
{
name: 'employee',
align: 'center',
label: 'customer.employee',
field: 'employee',
},
{
name: 'quotationCode',
align: 'center',
label: 'requestList.quotationCode',
field: 'quotationCode',
},
{
name: 'responsiblePerson',
align: 'center',
label: 'flow.responsiblePerson',
field: 'responsiblePerson',
},
{
name: 'status',
align: 'center',
label: 'general.status',
field: 'status',
},
] as const satisfies QTableProps['columns'];
export const docColumn = [
{
name: 'order',
align: 'center',
label: 'general.order',
field: 'no',
},
{
name: 'document',
align: 'left',
label: 'general.document',
field: 'document',
},
{
name: 'attachment',
align: 'left',
label: 'requestList.attachment',
field: 'attachment',
},
{
name: 'amount',
align: 'center',
label: 'general.amount',
field: 'amount',
},
{
name: 'documentInSystem',
align: 'center',
label: 'requestList.documentInSystem',
field: 'documentInSystem',
},
{
name: 'status',
align: 'center',
label: 'general.status',
field: 'status',
},
] as const satisfies QTableProps['columns'];

View file

@ -0,0 +1,17 @@
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
// NOTE: Import types
import {
EmployeePassportPayload,
EmployeeVisaPayload,
} from 'stores/employee/types';
// NOTE: Import stores
const DEFAULT_DATA_META_OCR: EmployeePassportPayload | EmployeeVisaPayload =
{} as EmployeePassportPayload | EmployeeVisaPayload;
export const useRequestForm = defineStore('form-request', () => {
return {};
});

View file

@ -94,6 +94,11 @@ const routes: RouteRecordRaw[] = [
name: 'agencies-management',
component: () => import('pages/07_agencies-management/MainPage.vue'),
},
{
path: '/request-list',
name: 'RequestList',
component: () => import('pages/08_request-list/MainPage.vue'),
},
],
},
@ -112,6 +117,11 @@ const routes: RouteRecordRaw[] = [
name: 'QuotationDocumentView',
component: () => import('pages/05_quotation/preview/ViewForm.vue'),
},
{
path: '/request-list/:requestListId',
name: 'requestListView',
component: () => import('pages/08_request-list/RequestListView.vue'),
},
// Always leave this as last one,
// but you can also remove it

View file

@ -121,7 +121,7 @@ type EmployeeRelation = {
id: string;
};
type ProductRelation = {
export type ProductRelation = {
id: string;
code: string;
name: string;
@ -144,6 +144,7 @@ type ProductRelation = {
updatedAt: string;
updatedByUserId: string;
productGroup?: ProductGroup;
document?: string[];
};
type WorkRelation = {

View file

@ -0,0 +1,273 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import {
RequestData,
RequestDataStatus,
RequestWork,
RequestWorkStatus,
Step,
} from './types';
import { api } from 'src/boot/axios';
import { PaginationResult } from 'src/types';
import {
EmployeePassportPayload,
EmployeeVisaPayload,
} from 'stores/employee/types';
import { manageAttachment, manageFile, manageMeta } from '../utils';
export const useRequestList = defineStore('request-list', () => {
const data = ref<RequestData[]>([]);
const page = ref<number>(1);
const pageMax = ref<number>(1);
const pageSize = ref<number>(30);
const stats = ref<Record<RequestDataStatus, number>>({
[RequestDataStatus.Pending]: 0,
[RequestDataStatus.InProgress]: 0,
[RequestDataStatus.Completed]: 0,
});
type TypeFile =
| 'passport'
| 'visa'
| 'citizen'
| 'house-registration'
| 'commercial-registration'
| 'vat-registration'
| 'power-of-attorney';
async function uploadAttachmentRequest(opt: {
id: string;
type: 'customer' | 'employee';
group: string;
file: File;
form?: EmployeePassportPayload | EmployeeVisaPayload;
name?: string;
}) {
const base = { customer: 'customer-branch', employee: 'employee' }[
opt.type
];
const attachmentManag = manageAttachment(api, base);
const metaManager = manageMeta<TypeFile>(api, base);
let res;
const group = [
'passport',
'visa',
'citizen',
'house-registration',
'commercial-registration',
'vat-registration',
'power-of-attorney',
];
console.log(opt.group);
if (group.includes(opt.group)) {
res = await metaManager.postMeta({
group: opt.group as TypeFile,
parentId: opt.id,
meta: opt.form,
file: opt.file,
});
} else {
res = await attachmentManag.putAttachment({
parentId: opt.id,
name: opt.name || '',
file: opt.file,
});
}
return res;
}
async function viewAttachmentRequest(opt: {
id: string;
name: string;
type: 'customer' | 'employee';
group: string;
download?: boolean;
}) {
const base = { customer: 'customer-branch', employee: 'employee' }[
opt.type
];
const attachmentManag = manageAttachment(api, base);
const fileManager = manageFile<TypeFile>(api, base);
let res;
const group = [
'passport',
'visa',
'citizen',
'house-registration',
'commercial-registration',
'vat-registration',
'power-of-attorney',
];
if (group.includes(opt.group)) {
res = await fileManager.getFile({
parentId: opt.id,
group: opt.group as TypeFile,
fileId: opt.name,
download: opt.download,
});
}
if (!group.includes(opt.group)) {
res = await attachmentManag.getAttachment({
parentId: opt.id,
name: opt.name,
download: opt.download,
});
}
if (res) return res;
}
async function getAttachmentRequest(
id: string,
type: 'customer' | 'employee',
) {
const base = { customer: 'customer-branch', employee: 'employee' }[type];
const attachmentManag = manageAttachment(api, base);
const fileManager = manageFile<TypeFile>(api, base);
const resFiles: Partial<Record<string, any>> = {};
if (type === 'employee') {
const resPassport = await fileManager.listFile({
group: 'passport',
parentId: id,
});
const resVisa = await fileManager.listFile({
group: 'visa',
parentId: id,
});
resFiles['passport'] = { ...resPassport };
resFiles['visa'] = { ...resVisa };
} else if (type === 'customer') {
const groups = [
'citizen',
'house-registration',
'commercial-registration',
'vat-registration',
'power-of-attorney',
] as const;
for (const group of groups) {
const res = await fileManager.listFile({
group,
parentId: id,
});
resFiles[group] = { ...res };
}
}
const resAttachment = await attachmentManag.listAttachment({
parentId: id,
});
if (resAttachment)
for (const item of resAttachment) {
const [key] = item.split('-').map((s) => s.trim());
if (key) {
if (!resFiles[key]) {
resFiles[key] = [];
}
if (!resFiles[key].includes(item)) {
resFiles[key].push(item);
}
}
}
console.log(resFiles);
return resFiles;
}
async function getRequestDataStats() {
const res = await api.get<typeof stats.value>('/request-data/stats');
if (res.status < 400) return res.data;
return null;
}
async function getRequestData(id: string) {
const res = await api.get<RequestData>(`/request-data/${id}`);
if (res.status < 400) return res.data;
return null;
}
async function getRequestDataList(params?: {
query?: string;
page?: number;
pageSize?: number;
requestDataStatus?: RequestDataStatus;
}) {
const res = await api.get<PaginationResult<RequestData>>('/request-data', {
params,
});
if (res.status < 400) return res.data;
return null;
}
async function getRequestWorkList(params?: {
page?: number;
pageSize?: number;
requestDataId?: string;
}) {
const res = await api.get<PaginationResult<RequestWork>>('/request-work', {
params,
});
if (res.status < 400) return res.data;
return null;
}
async function editRequestWork(body: Partial<RequestWork>) {
const res = await api.put(`/request-work/${body.id}`, {
...body,
id: undefined,
});
if (res.status < 400) return res.data;
return null;
}
async function editStatusRequestWork(body: Step) {
const res = await api.put<Step>(
`/request-work/${body.requestWorkId}/step-status/${body.step}`,
{
attributes: body.attributes,
requestWorkStatus: body.workStatus,
requestWorkId: undefined,
step: undefined,
},
);
if (res.status < 400) return res.data;
return null;
}
return {
data,
page,
pageMax,
pageSize,
stats,
viewAttachmentRequest,
getAttachmentRequest,
uploadAttachmentRequest,
getRequestDataStats,
getRequestData,
getRequestDataList,
getRequestWorkList,
editRequestWork,
editStatusRequestWork,
};
});

View file

@ -0,0 +1,89 @@
import { Employee } from '../employee/types';
import { QuotationFull, Quotation } from '../quotations/types';
export type RequestData = {
id: string;
employee: Employee;
employeeId: string;
code: string;
createdAt: string;
updatedAt: string;
quotation: QuotationFull;
quotationId: string;
flow: Record<string, any>;
requestWork: RequestWork[];
requestDataStatus: RequestDataStatus;
};
export enum RequestDataStatus {
Pending = 'Pending',
InProgress = 'InProgress',
Completed = 'Completed',
}
export enum RequestWorkStatus {
Pending = 'Pending',
Ready = 'Ready',
Waiting = 'Waiting',
InProgress = 'InProgress',
Validate = 'Validate',
Ended = 'Ended',
Completed = 'Completed',
}
export enum DocStatus {
AwaitReview = 'AwaitReview',
UploadedAwaitReview = 'UploadedAwaitReview',
ReviewedAwaitUpload = 'ReviewedAwaitUpload',
Reviewed = 'Reviewed',
ApprovedReview = 'ApprovedReview',
}
export type RequestWork = {
id?: string;
stepStatus: Step[];
requestDataId: string;
productService: QuotationFull['productServiceList'][number];
productServiceId: string;
request: RequestData;
attributes?: Attributes;
};
export type RowDocument = {
no: number;
documentType: string;
amount: number;
documentInSystem: boolean;
status: string;
};
export type Attributes = {
customer?: {
[key: string]: string;
};
employee?: {
[key: string]: string;
};
properties?: { [field: string]: string | number | null | undefined };
remark?: string;
};
export type AttributesForm = {
customerDuty: boolean;
customerDutyCost?: number;
companyDuty: boolean;
companyDutyCost?: number;
individualDuty: boolean;
localEmployee: boolean;
employeeId?: string;
};
export type Step = {
attributes?: { form?: AttributesForm };
requestWorkId: string;
workStatus?: RequestWorkStatus;
step: number;
};

View file

@ -1,4 +1,11 @@
import { Dialog, QSelect, Notify, QNotifyCreateOptions } from 'quasar';
import {
Dialog,
QSelect,
Notify,
QNotifyCreateOptions,
QVueGlobals,
useQuasar,
} from 'quasar';
import { Ref, ref } from 'vue';
import axios, { AxiosInstance, AxiosProgressEvent } from 'axios';
import { ComposerTranslation } from 'vue-i18n';
@ -498,3 +505,31 @@ export function createDataRefBase<T>(
pageSize: ref<number>(defaultPageSize),
};
}
export function changeMode(mode: string) {
const $q = useQuasar();
if (mode === 'light') {
localStorage.setItem('currentTheme', 'light');
$q.dark.set(false);
return;
}
if (mode === 'dark') {
localStorage.setItem('currentTheme', 'dark');
$q.dark.set(true);
return;
}
if (mode === 'baseOnDevice') {
localStorage.setItem('currentTheme', 'baseOnDevice');
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
$q.dark.set(true);
} else {
$q.dark.set(false);
}
return;
}
}

View file

@ -7,9 +7,26 @@ type MRZ = {
type Field = {
field: string;
format?: (value: string, obj?: Record<string, string>) => string;
/**
* Post process value after format.
* Useful for extract one field to multiple fields .
*
* @example
* Convert fullname into firstname and lastname
*
* ```ts
* (value) => [
* { field: 'first_name', value: value.split(' ').at(0) || '' },
* { field: 'last_name', value: value.split(' ').slice(1).join(' ') },
* ]
* ```
*/
process?: (value: string) => { field: string; value: string }[];
};
type FieldList = Record<string, Field>;
type FieldList = Record<string, Field | Field[]>;
const DEFAULT_FIELD = {
documentType: { field: 'doc_type' },
@ -18,6 +35,10 @@ const DEFAULT_FIELD = {
name: {
field: 'full_name',
format: (value, _) => value.replace(/0/, 'O'),
process: (value) => [
{ field: 'first_name', value: value.split(' ').at(0) || '' },
{ field: 'last_name', value: value.split(' ').slice(1).join(' ') },
],
},
country: {
field: 'country',
@ -132,8 +153,18 @@ function mrzCleanResult(obj: Record<string, string>) {
const original = structuredClone(obj);
for (const value of Object.values(DEFAULT_FIELD)) {
if (obj[value.field] && 'format' in value) {
obj[value.field] = value.format(obj[value.field], original);
if (obj[value.field]) {
if ('format' in value) {
obj[value.field] = value.format(obj[value.field], original);
}
if ('process' in value) {
value.process(obj[value.field]).forEach((result) => {
obj[result.field] = result.value;
});
delete obj[value.field];
continue;
}
}
}