feat: menu request list (#75)

* feat: i18n

* feat: request list

* refactor: hide stat transition on app.scss

* feat: request list i18n

* feat: request list => constants and main page

* feat: add store

* feat: add fetch data

* feat: add utilities fn

* feat: add store function / types

* refactor: request list type

* refactor: request list constants

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

* feat: avatar group components

* feat: request list group

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

* feat: send search query

* feat: add parameter

* refactor: remove unused function

* fix: rename component lits to list

* feat: show stats from api

* chore: cleanup

* refactor: make it type safe

* refactor: accept rotate flow id as parameter

* feat: use page size component

* feat: add component, data display & expansion product

* feat: i18n

* refactor: constants and request list table

* refactor: type code, createdAt, updatedAt

* refactor: utils function changThemeMode

* feat: request list => view page

* refactor: use type instead of infer from value

* fix: function getEmployeeName att type

* refactor: fetch work list

* refactor: loop work list

* feat: add i18n duty

* feat: add form issue component

* feat: add form issue section

* fix: store error

* refactor: edit by value

* refactor: accept basic info from outside instead

* feat: add status filter support on fetch

* refactor: remove delete button

* refactor: wording

* feat/fix: request list i18n & constant

* feat: document type

* feat/refactor: request list => document expansion

* refactor: doc expansion use FormGroupHead

* refactor: fetch data based on id from route param

* refactor: text area disable

* feat: properties expansion display (mocking)

* refactor: add document at product relation

* refactor: edit get value product

* feat: get workflow step to show on top

* refactor: add type

* refactor: add get attachment

* refactor: add view attachment

* refactor: edit file name

* refactor: define props get hide icon

* refactor: edit align row

* refactor: by value table document

* refactor: by value row table

* feat: add independent ocr dialog

* chore: clean up

* refactor: accept more props and small adjustment

* fix: error withDefault call

* feat: accept default metadata when open

* fix: typo

* feat: add override hook when finish ocr

* feat: reset state on open

* feat: detect reader result is actually string

* fix: variable name conflict

* feat: properties to input component

* feat: properties input in properties expansion

* feat: properties expansion data (temporary)

* refactor: add i18n status work

* refactor: edit type work status and add step status

* refactor: add edit status work

* refactor: edit step work

* refactor: properties data type

* refactor: filter selected product & specific properties

* feat: add emit event

* refactor: change variable name for better understanding

* refactor: hide step that no properties

* refactor: work status type to validate

* feat: work status color

* refactor: key for filename

* refactor: close expansion when change step

* refactor: responsive meta data

* refactor: product expansion responsive

* fix: dark mode step text color

* fix: document expansion table no data label

* refactor: main page body bordered and overflow hidden

* refactor: use utils function instead

* refactor: add process

* refactor: by value  name

* refactor: add upload file

* refactor: upload file

* refactor: by value

* fix: option worker type

* refactor: fetchRequestAttachment after edit

* fix: metadata display

* refactor: add class full-height

* refactor: edit type

* refactor: fetch file

* refactor: by value visa

* refactor: request list attributes type

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

* feat: request list properties function

* fix: error when no workflow

* docs: update comment to fix indent

* refactor: step type (attributes)

* refactor: add attributes payload on editStatusRequestWork function

* feat/refactor: functional form expansion/filter worklist

* refactor: set attributes properties after submit

* refactor: add request work ready status

* feat: request list => form employee component

* feat/refactor: form expansion select user/layout

* fix: properties readonly

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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