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:
parent
9105dcf7fe
commit
972f6ba13e
36 changed files with 3653 additions and 57 deletions
|
|
@ -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"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(' / ')"
|
||||
/>
|
||||
|
|
|
|||
35
src/components/08_request-list/DataDisplay.vue
Normal file
35
src/components/08_request-list/DataDisplay.vue
Normal 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>
|
||||
120
src/components/08_request-list/PropertiesToInput.vue
Normal file
120
src/components/08_request-list/PropertiesToInput.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
107
src/components/shared/AvatarGroup.vue
Normal file
107
src/components/shared/AvatarGroup.vue
Normal 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>
|
||||
140
src/components/upload-file/OcrDialog.vue
Normal file
140
src/components/upload-file/OcrDialog.vue
Normal 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>
|
||||
|
|
@ -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] || ''}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: 'จำนวนเงินอากร (บาท)',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
608
src/pages/08_request-list/DocumentExpansion.vue
Normal file
608
src/pages/08_request-list/DocumentExpansion.vue
Normal 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>
|
||||
44
src/pages/08_request-list/FormDuty.vue
Normal file
44
src/pages/08_request-list/FormDuty.vue
Normal 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>
|
||||
42
src/pages/08_request-list/FormEmployee.vue
Normal file
42
src/pages/08_request-list/FormEmployee.vue
Normal 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>
|
||||
166
src/pages/08_request-list/FormExpansion.vue
Normal file
166
src/pages/08_request-list/FormExpansion.vue
Normal 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>
|
||||
12
src/pages/08_request-list/FormGroupHead.vue
Normal file
12
src/pages/08_request-list/FormGroupHead.vue
Normal 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>
|
||||
61
src/pages/08_request-list/FormIssue.vue
Normal file
61
src/pages/08_request-list/FormIssue.vue
Normal 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>
|
||||
323
src/pages/08_request-list/MainPage.vue
Normal file
323
src/pages/08_request-list/MainPage.vue
Normal 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>
|
||||
241
src/pages/08_request-list/ProductExpansion.vue
Normal file
241
src/pages/08_request-list/ProductExpansion.vue
Normal 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>
|
||||
166
src/pages/08_request-list/PropertiesExpansion.vue
Normal file
166
src/pages/08_request-list/PropertiesExpansion.vue
Normal 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>
|
||||
576
src/pages/08_request-list/RequestListView.vue
Normal file
576
src/pages/08_request-list/RequestListView.vue
Normal 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>
|
||||
270
src/pages/08_request-list/TableRequestList.vue
Normal file
270
src/pages/08_request-list/TableRequestList.vue
Normal 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>
|
||||
91
src/pages/08_request-list/constants.ts
Normal file
91
src/pages/08_request-list/constants.ts
Normal 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'];
|
||||
17
src/pages/08_request-list/form.ts
Normal file
17
src/pages/08_request-list/form.ts
Normal 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 {};
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
273
src/stores/request-list/index.ts
Normal file
273
src/stores/request-list/index.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
89
src/stores/request-list/types.ts
Normal file
89
src/stores/request-list/types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue