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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
const duty = defineModel<boolean>('duty', { required: true });
const dutyCost = defineModel<number>('dutyCost', { required: false });
defineProps<{
readonly?: boolean;
cost?: boolean;
}>();
</script>
<template>
<div class="row items-center q-px-md q-py-xs">
<q-radio
v-model="duty"
:val="false"
:label="$t('duty.notInclude')"
:disable="readonly"
class="col"
/>
<q-radio
v-model="duty"
:val="true"
:label="$t('duty.include')"
:disable="readonly"
class="col"
/>
<q-select
:options="[30]"
:disable="!duty"
:label="$t('duty.cost')"
:readonly
:hide-dropdown-icon="readonly"
:model-value="dutyCost || 0"
@update:model-value="(value) => (dutyCost = value)"
v-if="dutyCost !== undefined || cost"
class="col"
dense
outlined
use-input
/>
<!-- NOTE: For spacing only -->
<div v-else class="col"></div>
<div class="offset-md-7"></div>
</div>
</template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,576 @@
<script setup lang="ts">
// NOTE: Library
import { onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
// NOTE: Components
import DataDisplay from 'src/components/08_request-list/DataDisplay.vue';
import DocumentExpansion from './DocumentExpansion.vue';
import FormExpansion from './FormExpansion.vue';
import PropertiesExpansion from './PropertiesExpansion.vue';
// NOTE: Store
import { dateFormatJS } from 'src/utils/datetime';
import { useRequestList } from 'src/stores/request-list';
import {
RequestData,
RequestWork,
Attributes,
DocStatus,
Step,
} from 'src/stores/request-list/types';
import useOptionStore from 'src/stores/options';
import ProductExpansion from './ProductExpansion.vue';
import { useRoute } from 'vue-router';
import { useWorkflowTemplate } from 'src/stores/workflow-template';
import { WorkflowTemplate } from 'src/stores/workflow-template/types';
import { initLang, initTheme, Lang } from 'src/utils/ui';
import {
EmployeePassportPayload,
EmployeeVisaPayload,
} from 'stores/employee/types';
const { locale } = useI18n();
// NOTE: Variable
const route = useRoute();
const optionStore = useOptionStore();
const requestListStore = useRequestList();
const flowTemplateStore = useWorkflowTemplate();
const workList = ref<RequestWork[]>();
const statusFile = ref<Attributes>({
customer: {},
employee: {},
});
const refDocumentExpansion = ref<InstanceType<typeof DocumentExpansion>[]>([]);
const data = ref<RequestData>();
const flow = ref<WorkflowTemplate>();
const pageState = reactive({
hideMetaData: false,
currentStep: 1,
});
// NOTE: Function
async function fetchRequestWorkList(opts: { requestDataId: string }) {
const res = await requestListStore.getRequestWorkList({
requestDataId: opts.requestDataId,
pageSize: 9999,
});
if (res) {
workList.value = res.result;
}
}
function getCustomerName(
record: RequestData,
opts?: {
locale?: string;
noCode?: boolean;
},
) {
const customer = record.quotation.customerBranch;
return (
{
['CORP']: {
[Lang.English]: customer.registerNameEN,
[Lang.Thai]: customer.registerName,
}[opts?.locale || 'eng'],
['PERS']:
{
[Lang.English]: `${optionStore.mapOption(customer.namePrefix)} ${customer.firstNameEN} ${customer.lastNameEN}`,
[Lang.Thai]: `${optionStore.mapOption(customer.namePrefix)} ${customer.firstName} ${customer.lastName}`,
}[opts?.locale || Lang.English] || '-',
}[customer.customer.customerType] +
(opts?.noCode ? '' : ' ' + `(${customer.code})`)
);
}
function getEmployeeName(
record: RequestData,
opts?: {
locale?: string;
},
) {
const employee = record.employee;
return (
{
[Lang.English]: `${optionStore.mapOption(employee.namePrefix)} ${employee.firstNameEN} ${employee.lastNameEN}`,
[Lang.Thai]: `${optionStore.mapOption(employee.namePrefix)} ${employee.firstName} ${employee.lastName}`,
}[opts?.locale || Lang.English] || '-'
);
}
async function getData() {
const current = route.params['requestListId'];
if (typeof current === 'string') {
const res = await requestListStore.getRequestData(current);
if (res) {
data.value = res;
await fetchRequestWorkList({ requestDataId: current });
await getFlow();
}
}
}
async function getFlow() {
if (!workList.value) return;
const attr = workList.value.find((v) => !!v.productService.work?.attributes)
?.productService.work?.attributes;
if (attr && Object.hasOwn(attr, 'workflowId')) {
const workflowId = attr['workflowId'];
const res = await flowTemplateStore.getWorkflowTemplate(workflowId);
if (res) flow.value = res;
}
}
onMounted(async () => {
initTheme();
initLang();
// get data
await getData();
});
watch(() => route.params['requestListId'], getData);
async function triggerChangeStatusWork(step: Step) {
const res = await requestListStore.editStatusRequestWork(step);
if (res) {
const indexWork = workList.value?.findIndex(
(v) => v.id === step.requestWorkId,
);
if (indexWork === -1 || indexWork === undefined) return;
if (workList.value === undefined) return;
const indexStep = workList.value[indexWork].stepStatus.findIndex(
(v) => v.step === step.step,
);
if (indexStep === -1) {
workList.value[indexWork].stepStatus.push(res);
}
if (indexStep !== -1) {
workList.value[indexWork].stepStatus[indexStep].workStatus =
res.workStatus;
}
}
}
async function triggerChangeStatusFile(opt: {
index: number;
id: string;
documentType: string;
status: DocStatus;
type: 'customer' | 'employee';
}) {
if (!workList.value) return;
const workItem = workList.value[opt.index];
if (!workItem || !workItem.attributes) {
if (workItem) {
workItem.attributes = { customer: {}, employee: {} };
} else {
return;
}
}
const attributes = workItem.attributes;
statusFile.value = attributes;
if (!statusFile.value[opt.type]) {
statusFile.value[opt.type] = {};
}
statusFile.value[opt.type]![opt.documentType] = opt.status;
const res = await requestListStore.editRequestWork({
id: opt.id,
attributes: statusFile.value,
});
if (res) {
workList.value[opt.index].attributes = res.attributes;
}
}
async function triggerUpload(opt: {
id: string;
type: 'customer' | 'employee';
group: string;
file: File;
form?: EmployeePassportPayload | EmployeeVisaPayload;
}) {
const newName = `${opt.group}-${Date.now()}-${opt.file.name}`;
const res = await requestListStore.uploadAttachmentRequest({
...opt,
name: newName,
});
return !!res;
}
async function triggerViewFile(opt: {
id: string;
fileName: string;
type: 'customer' | 'employee';
group: string;
download?: boolean;
}) {
let url;
url = await requestListStore.viewAttachmentRequest({
id: opt.id,
name: opt.fileName,
type: opt.type,
group: opt.group,
download: opt.download,
});
if (!opt.download) window.open(url, '_blank');
}
</script>
<template>
<div class="column surface-0 fullscreen" v-if="data">
<!-- SEC: Header -->
<header class="row q-px-md q-py-sm items-center full justify-between">
<div style="flex: 1" class="row items-center">
<RouterLink to="/quotation">
<q-img src="/icons/favicon-512x512.png" width="3rem" />
</RouterLink>
<span class="column text-h6 text-bold q-ml-md">
{{ $t('requestList.title') }}
{{ data.code || '' }}
<span class="text-caption text-regular app-text-muted">
{{
$t('quotation.processOn', {
msg: dateFormatJS({ date: data.createdAt }),
})
}}
</span>
</span>
</div>
</header>
<!-- SEC: Body -->
<main class="col full-width q-pa-md" style="flex-grow: 1; overflow-y: auto">
<section class="col-sm col-12">
<div class="col q-gutter-y-md" :key="pageState.currentStep">
<!-- step -->
<nav
v-if="flow"
class="surface-1 q-pa-sm row no-wrap full-width scroll rounded"
style="gap: 10px"
>
<template v-for="(value, i) in flow.step" :key="value.id">
<button
v-if="
workList?.every(
(v) =>
v.productService.work?.attributes.workflowStep[i]
.attributes.properties.length > 0,
)
"
class="status-color q-pa-sm bordered row items-center cursor-pointer no-wrap"
style="text-wrap: nowrap"
:class="{
[`status-color-${'doing'}`]: true,
'step-status-active': pageState.currentStep === value.order,
}"
@click="() => (pageState.currentStep = value.order)"
>
<!-- 'quotation-status-active': value.active?.(), -->
<!-- @click="'waiting' !== 'waiting' && value.handler()" -->
<div class="q-px-sm">
<q-icon
class="icon-color quotation-status"
style="border-radius: 50%"
:name="`${pageState.currentStep === value.order ? 'mdi-circle-slice-8' : 'mdi-checkbox-blank-circle-outline'}`"
/>
</div>
<div class="text-left">{{ value.name }}</div>
</button>
</template>
</nav>
<!-- meta data -->
<article class="surface-1 rounded q-pa-sm">
<div
class="text-weight-bold row items-center no-wrap"
style="gap: 16px"
>
<q-img src="/images/quotation-avatar.png" width="42px" />
<span class="ellipsis" style="font-size: 18px">
{{ data.quotation.workName || '-' }}
</span>
<q-btn
class="q-ml-sm"
icon="mdi-pin-outline"
color="primary"
size="sm"
flat
dense
rounded
@click="pageState.hideMetaData = !pageState.hideMetaData"
:style="pageState.hideMetaData ? 'rotate: 90deg' : ''"
style="transition: 0.1s ease-in-out"
/>
</div>
<transition name="slide">
<section
v-if="!pageState.hideMetaData"
class="q-pt-md"
:class="{ row: $q.screen.gt.sm, column: $q.screen.lt.md }"
>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
:label="$t('requestList.requestListCode')"
:value="data.quotation.code || '-'"
/>
<DataDisplay
class="col"
icon="mdi-account-settings-outline"
:label="$t('customer.employee')"
:value="
getEmployeeName(data, { locale: $i18n.locale }) || '-'
"
/>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
:label="$t('requestList.quotationCode')"
:value="data.code || '-'"
/>
<DataDisplay
class="col"
icon="mdi-account-settings-outline"
:label="$t('customer.employer')"
:value="
getCustomerName(data, { locale: locale, noCode: true }) ||
'-'
"
/>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
:label="$t('requestList.invoiceCode')"
:value="'-'"
/>
<span class="col"></span>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-file-document-outline"
:label="$t('requestList.receiptCode')"
:value="'-'"
/>
<DataDisplay
class="col"
icon="mdi-passport"
:label="$t('customerEmployee.form.passportNo')"
:value="'-'"
/>
</div>
<div
class="col q-gutter-y-md"
:class="{ column: $q.screen.gt.sm, row: $q.screen.lt.md }"
>
<DataDisplay
class="col"
icon="mdi-card-account-details-outline"
:label="$t('requestList.alienIdCard')"
:value="data.employee.nrcNo"
/>
<DataDisplay
class="col"
icon="mdi-account-settings-outline"
:label="$t('flow.responsiblePerson')"
:value="'-'"
/>
</div>
</section>
</transition>
</article>
<!-- product -->
<template
v-for="(value, index) in workList?.filter((v) =>
v.productService.work?.attributes.workflowStep[
pageState.currentStep - 1
].productsId.includes(v.productService.productId),
)"
:key="value"
>
<ProductExpansion
:status="
value.stepStatus.find((v) => v.step === pageState.currentStep)
"
v-model:product-id="value.productService.productId"
:name="value.productService.product.name"
:code="value.productService.product.code"
:product="value.productService.product"
@change-status="
(v) => {
triggerChangeStatusWork({
workStatus: v.requestWorkStatus,
step:
v.step === undefined
? pageState.currentStep
: v.step.step,
requestWorkId: value.id || '',
});
}
"
>
<template v-slot="{ product }">
<section class="column surface-1 q-pa-sm bordered-t">
<DocumentExpansion
ref="refDocumentExpansion"
:attributes="value.attributes"
@change-status="
(opt) => {
triggerChangeStatusFile({
index,
id: value.id || '',
documentType: opt.key || '',
status: opt.status,
type: opt.type || 'customer',
});
}
"
@view-doc="
(opt) => {
triggerViewFile({
id: opt.id,
fileName: opt.data.fileName,
type: opt.type,
group: opt.data.documentType,
});
}
"
@upload="
async (opt, done) => {
await triggerUpload({ ...opt });
await done(opt.type || 'customer');
}
"
@download="
(opt) => {
console.log(opt);
triggerViewFile({
id: opt.id,
fileName: opt.data.fileName,
type: opt.type,
group: opt.data.documentType,
download: true,
});
}
"
:current-id="{
customer: value.request.quotation.customerBranchId,
employee: value.request.employeeId,
}"
:listDocument="product?.document"
/>
<FormExpansion
:step="{
step: pageState.currentStep,
requestWorkId: value.id || '',
}"
:id="value.id"
:attributes-form="
value.stepStatus[pageState.currentStep - 1]?.attributes
?.form
"
/>
<PropertiesExpansion
:id="value.id"
:properties-to-show="
value.productService.work?.attributes.workflowStep[
pageState.currentStep - 1
].attributes.properties
"
:attributes="value.attributes"
/>
</section>
</template>
</ProductExpansion>
</template>
</div>
</section>
</main>
</div>
</template>
<style scoped>
.status-color {
--_color: var(--gray-0);
border-color: hsla(var(--_color));
background: hsla(var(--_color) / 0.05);
border-radius: 4px;
.icon-color {
color: hsla(var(--_color));
}
&.status-color-done {
--_color: var(--green-5-hsl);
}
&.status-color-doing {
--_color: var(--blue-5-hsl);
color: var(--foreground);
}
&.status-color-waiting {
--_color: var(--gray-4-hsl);
color: hsla(var(--_color));
}
}
.step-status-active {
opacity: 1;
font-weight: 600;
transition: 1s box-shadow ease-in-out;
animation: status 1s infinite;
}
@keyframes status {
0% {
box-shadow: 0x 0px 0px hsla(var(--_color) / 0.5);
}
50% {
box-shadow: 0px 0px 1px 4px hsla(var(--_color) / 0.2);
}
100% {
box-shadow: 0px 0px 4px 7px hsla(var(--_color) / 0);
}
}
</style>

View file

@ -0,0 +1,270 @@
<script setup lang="ts">
import { QTable, QTableProps, QTableSlots } from 'quasar';
import KebabAction from 'components/shared/KebabAction.vue';
import QuotationCard from 'src/components/05_quotation/QuotationCard.vue';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import AvatarGroup from 'src/components/shared/AvatarGroup.vue';
import { RequestData } from 'src/stores/request-list/types';
import { RequestDataStatus } from 'src/stores/request-list/types';
import useOptionStore from 'src/stores/options';
const props = withDefaults(
defineProps<{
rows: QTableProps['rows'];
columns: QTableProps['columns'];
grid?: boolean;
visibleColumns?: string[];
}>(),
{
row: () => [],
column: () => [],
grid: false,
visibleColumns: () => [],
},
);
defineEmits<{
(e: 'view', data: RequestData): void;
(e: 'edit', id: string): void;
(e: 'delete', id: string): void;
}>();
function getCustomerName(
record: RequestData,
opts?: {
locale?: string;
noCode?: boolean;
},
) {
const customer = record.quotation.customerBranch;
return (
{
['CORP']: {
['eng']: customer.registerNameEN,
['tha']: customer.registerName,
}[opts?.locale || 'eng'],
['PERS']:
{
['eng']: `${useOptionStore().mapOption(customer.namePrefix)} ${customer.firstNameEN} ${customer.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(customer.namePrefix)} ${customer.firstName} ${customer.lastName}`,
}[opts?.locale || 'eng'] || '-',
}[customer.customer.customerType] +
(opts?.noCode ? '' : ' ' + `(${customer.code})`)
);
}
function getEmployeeName(
record: RequestData,
opts?: {
locale?: string;
},
) {
const employee = record.employee;
return (
{
['eng']: `${useOptionStore().mapOption(employee.namePrefix)} ${employee.firstNameEN} ${employee.lastNameEN}`,
['tha']: `${useOptionStore().mapOption(employee.namePrefix)} ${employee.firstName} ${employee.lastName}`,
}[opts?.locale || 'eng'] || '-'
);
}
</script>
<template>
<q-table
v-bind="props"
bordered
flat
hide-pagination
card-container-class="q-col-gutter-sm"
:rows-per-page-options="[0]"
class="full-width"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }}
</q-th>
<q-th></q-th>
</q-tr>
</template>
<template
v-slot:body="props: {
row: RequestData;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr
:class="{ urgent: props.row.quotation.urgent, dark: $q.dark.isActive }"
class="text-center"
>
<q-td v-if="visibleColumns.includes('order')">
{{ props.rowIndex + 1 }}
</q-td>
<q-td v-if="visibleColumns.includes('requestList')" class="text-left">
{{ props.row.quotation.workName || '-' }}
<div class="text-caption app-text-muted">
{{ props.row.code || '-' }}
</div>
</q-td>
<q-td v-if="visibleColumns.includes('employer')" class="text-left">
{{
getCustomerName(props.row, {
noCode: true,
locale: $i18n.locale,
}) || '-'
}}
</q-td>
<q-td v-if="visibleColumns.includes('employee')" class="text-left">
{{ getEmployeeName(props.row, { locale: $i18n.locale }) || '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('quotationCode')">
{{ props.row.quotation.code || '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('responsiblePerson')">
{{ '-' }}
</q-td>
<q-td v-if="visibleColumns.includes('status')">
<BadgeComponent
:hsla-color="
props.row.requestDataStatus === RequestDataStatus.Pending
? '--orange-5-hsl'
: props.row.requestDataStatus === RequestDataStatus.InProgress
? '--blue-6-hsl'
: props.row.requestDataStatus === RequestDataStatus.Completed
? '--green-8-hsl'
: '--orange-5-hsl'
"
:title="
$t(`requestList.status.${props.row.requestDataStatus}`) || '-'
"
/>
</q-td>
<q-td class="text-right">
<q-btn
:id="`btn-eye-${props.row.quotation.workName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row)"
/>
<KebabAction
:idName="`btn-kebab-${props.row.quotation.workName}`"
status="'ACTIVE'"
hide-toggle
@view="$emit('view', props.row)"
@edit="$emit('edit', props.row.id)"
@delete="$emit('delete', props.row.id)"
/>
</q-td>
</q-tr>
</template>
<template
v-slot:item="props: {
row: RequestData;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<div class="col-md-4 col-sm-6 col-12">
<QuotationCard
:badge-color="
props.row.requestDataStatus === RequestDataStatus.Pending
? '--orange-5-hsl'
: props.row.requestDataStatus === RequestDataStatus.InProgress
? '--blue-6-hsl'
: props.row.requestDataStatus === RequestDataStatus.Completed
? '--green-8-hsl'
: '--orange-5-hsl'
"
hidePreview
:urgent="props.row.quotation.urgent"
:code="props.row.code"
:title="props.row.quotation.workName"
:status="$t(`requestList.status.${props.row.requestDataStatus}`)"
:custom-data="[
{
label: $t('customer.employer'),
value:
getCustomerName(props.row, {
noCode: true,
locale: $i18n.locale,
}) || '-',
},
{
label: $t('customer.employee'),
value:
getEmployeeName(props.row, { locale: $i18n.locale }) || '-',
},
{
label: $t('requestList.quotationCode'),
value: props.row.quotation.code || '-',
},
{
label: $t('flow.responsiblePerson'),
value: '',
slotName: 'responsiblePerson',
},
]"
@view="$emit('view', props.row)"
>
<template v-slot:responsiblePerson="{ props }">
<div class="col-4 app-text-muted q-pr-sm self-center">
{{ props.label }}
</div>
<div class="col-8">
<AvatarGroup />
</div>
</template>
</QuotationCard>
</div>
</template>
</q-table>
</template>
<style scoped>
:deep(tr:nth-child(2n)) {
background: #f9fafc;
&.dark {
background: hsl(var(--gray-11-hsl) / 0.2);
}
}
.q-table tr.urgent {
background: hsla(var(--red-6-hsl) / 0.03);
}
.q-table tr.urgent td:first-child {
&::after {
content: ' ';
display: block;
position: absolute;
left: 0;
top: 15%;
bottom: 15%;
background: var(--red-8);
width: 4px;
border-radius: 99rem;
animation: blink 1s infinite;
}
}
@keyframes blink {
0% {
background: var(--red-8);
}
50% {
background: var(--red-3);
}
100% {
background: var(--red-8);
}
}
</style>

View file

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

View file

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