feat: import worker with multiple criteria (#72)
* feat: add i18n import from file * fix: background color * feat: add import worker component * feat: support newer passport * refactor: update validator * fix: wrong endpoint * refactor: change to display passport * feat: add component and state * fix: wrong validator * refactor: add i18n * refactor: add slont top-append * refactor: use v-model * refactor: impurt workder * fix: criteria * refactor: add customer branch id --------- Co-authored-by: Thanaphon Frappet <thanaphon@frappet.com>
This commit is contained in:
parent
481d9d4e9e
commit
202e8c2e6f
7 changed files with 359 additions and 25 deletions
|
|
@ -140,18 +140,21 @@ const currentTab = defineModel<string>('currentTab');
|
|||
{{ badgeLabel }}
|
||||
</text>
|
||||
</div>
|
||||
<CancelButton
|
||||
icon-only
|
||||
id="btn-form-close"
|
||||
@click="
|
||||
() => {
|
||||
modal = beforeClose ? beforeClose() : !modal;
|
||||
close?.();
|
||||
}
|
||||
"
|
||||
type="reset"
|
||||
resetValidation
|
||||
/>
|
||||
<slot name="top-append" />
|
||||
<div>
|
||||
<CancelButton
|
||||
icon-only
|
||||
id="btn-form-close"
|
||||
@click="
|
||||
() => {
|
||||
modal = beforeClose ? beforeClose() : !modal;
|
||||
close?.();
|
||||
}
|
||||
"
|
||||
type="reset"
|
||||
resetValidation
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const state = defineModel({ default: false });
|
|||
|
||||
<!-- NOTE: DIALOG BODY -->
|
||||
<div
|
||||
class="col full-height column full-width"
|
||||
class="col full-height column full-width surface-0"
|
||||
:class="{ dark: $q.dark.isActive }"
|
||||
>
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export default {
|
|||
visaNo: 'VISA No.',
|
||||
enterToAdd: 'Press enter to add.',
|
||||
forExample: 'eg. {example}',
|
||||
importFromFile: 'Import From File {suffix}',
|
||||
},
|
||||
|
||||
menu: {
|
||||
|
|
@ -710,6 +711,8 @@ export default {
|
|||
},
|
||||
|
||||
quotation: {
|
||||
addWorker: 'Add Worker',
|
||||
importWorker: 'Search for worker from the data.',
|
||||
templateForm: 'Select a template document form.',
|
||||
title: 'Quotation',
|
||||
caption: 'All Quotation',
|
||||
|
|
@ -843,6 +846,7 @@ export default {
|
|||
confirmChangeStatus: 'Confirm Status Change',
|
||||
confirmDelete: 'Confirm Deletion {msg}',
|
||||
youngWorker: 'Employee under 15',
|
||||
importWorker: 'Import Worker',
|
||||
confirmLogout: 'Confirm Logout',
|
||||
confirmQuotationAccept: 'Confirm acceptance of the quotation.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export default {
|
|||
visaNo: 'หมายเลขหนังสือลงตรา',
|
||||
enterToAdd: 'กดปุ่ม Enter เพื่อเพิ่ม',
|
||||
forExample: 'เช่น {example}',
|
||||
importFromFile: 'นำเข้าจากไฟล์ {suffix}',
|
||||
},
|
||||
|
||||
menu: {
|
||||
|
|
@ -702,6 +703,8 @@ export default {
|
|||
},
|
||||
|
||||
quotation: {
|
||||
addWorker: 'เพิ่มแรงงาน',
|
||||
importWorker: 'ค้นหาแรงงานจากข้อมูล ',
|
||||
templateForm: 'เลือกแบบฟอร์มเอกสารต้นแบบ',
|
||||
title: 'ใบเสนอราคา',
|
||||
caption: 'ใบเสนอราคาทั้งหมด',
|
||||
|
|
@ -836,6 +839,7 @@ export default {
|
|||
confirmChangeStatus: 'ยืนยันการเปลี่ยนสถานะ',
|
||||
confirmDelete: 'ยืนยันการลบ {msg}',
|
||||
youngWorker: 'ลูกจ้างอายุต่ำกว่า 15 ปี',
|
||||
importWorker: 'นำเข้าคนงาน',
|
||||
confirmLogout: 'ยืนยันการออกจากระบบ',
|
||||
confirmQuotationAccept: 'ยืนยันการตอบรับใบเสนอราคา',
|
||||
},
|
||||
|
|
|
|||
232
src/pages/05_quotation/ImportWorker.vue
Normal file
232
src/pages/05_quotation/ImportWorker.vue
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
import { csvToData } from 'src/utils/file';
|
||||
|
||||
import UploadFileArea from 'components/upload-file/UploadFileArea.vue';
|
||||
import DialogHeader from 'components/dialog/DialogHeader.vue';
|
||||
import { SaveButton, CancelButton } from 'components/button';
|
||||
import DialogFormContainer from 'src/components/dialog/DialogFormContainer.vue';
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
const payload = defineModel<{
|
||||
passport: string[] | null;
|
||||
visa: string[] | null;
|
||||
}>('data', { required: true });
|
||||
|
||||
defineEmits<{
|
||||
(e: 'importWorker', v: typeof payload.value): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
importWorker?: (v: typeof payload.value) => boolean | Promise<boolean>;
|
||||
}>();
|
||||
|
||||
const passportValidator = /[a-zA-Z]{1}[a-zA-Z0-9]{1}[0-9]{5,7}$/;
|
||||
|
||||
async function handleFileSelect(file: File[] | File) {
|
||||
const promise = [file]
|
||||
.flat()
|
||||
.map((v) => csvToData<{ passport: string; visa: string }>(v));
|
||||
|
||||
const data = await Promise.all(promise);
|
||||
|
||||
payload.value.passport = (payload.value.passport ?? []).concat(
|
||||
data
|
||||
.flat()
|
||||
.flatMap((v) =>
|
||||
v?.passport &&
|
||||
!payload.value.passport?.includes(v.passport) &&
|
||||
passportValidator.test(v.passport)
|
||||
? v.passport
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const SELECT_PROPS = {
|
||||
clearable: true,
|
||||
dense: true,
|
||||
multiple: true,
|
||||
outlined: true,
|
||||
useInput: true,
|
||||
useChips: true,
|
||||
hideDropdownIcon: true,
|
||||
stackLabel: true,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogFormContainer
|
||||
v-model="open"
|
||||
@submit="
|
||||
async () => {
|
||||
if (importWorker) open = !(await importWorker(payload));
|
||||
$emit('importWorker', payload);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<DialogHeader :title="$t('dialog.title.importWorker')" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<CancelButton type="button" class="q-ml-auto" outlined v-close-popup />
|
||||
<SaveButton
|
||||
:label="$t('dialog.title.importWorker')"
|
||||
icon="mdi-plus"
|
||||
class="q-ml-sm"
|
||||
type="submit"
|
||||
solid
|
||||
/>
|
||||
</template>
|
||||
<div class="q-pa-md">
|
||||
<UploadFileArea
|
||||
:label="$t('general.importFromFile', { suffix: '(.csv)' })"
|
||||
type="button"
|
||||
accept=".csv"
|
||||
multiple
|
||||
@file="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<div style="flex: 1; width: 100%; overflow-y: auto" class="q-px-md q-mb-md">
|
||||
<div class="content-container">
|
||||
<q-expansion-item
|
||||
dense
|
||||
class="overflow-hidden"
|
||||
switch-toggle-side
|
||||
default-opened
|
||||
style="border-radius: var(--radius-2)"
|
||||
expand-icon="mdi-chevron-down-circle"
|
||||
header-class="surface-1"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<section class="row items-center full-width">
|
||||
<div class="row items-center q-pr-md q-py-xs">
|
||||
<span
|
||||
class="text-weight-medium q-mr-md"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
{{ $t('general.passport') }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div class="surface-1 q-pa-md">
|
||||
<q-select
|
||||
class="fixed-height"
|
||||
v-model="payload.passport"
|
||||
v-bind="SELECT_PROPS"
|
||||
lazy-rules
|
||||
:placeholder="`${$t('general.passportNo')} ${$t('general.forExample', { example: 'G420316, P897546' })}`"
|
||||
:hint="$t('general.enterToAdd')"
|
||||
@new-value="
|
||||
(value, done) => {
|
||||
value = value.trim();
|
||||
if (passportValidator.test(value)) {
|
||||
return done(value.toUpperCase(), 'add-unique');
|
||||
}
|
||||
}
|
||||
"
|
||||
:rules="[
|
||||
(v: string[]) =>
|
||||
(!!v && v.length > 0) || $t('form.error.required'),
|
||||
]"
|
||||
new-value-mode="add-unique"
|
||||
autocomplete="off"
|
||||
>
|
||||
<template #selected-item="scope">
|
||||
<q-chip
|
||||
removable
|
||||
dense
|
||||
@remove="scope.removeAtIndex(scope.index)"
|
||||
:tabindex="scope.tabindex"
|
||||
class="q-ml-none q-pa-md"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi-passport"
|
||||
class="q-mr-xs"
|
||||
style="font-size: 150%"
|
||||
color="hsla(var(--blue-9-hsl) / 1)"
|
||||
/>
|
||||
{{ scope.opt }}
|
||||
</q-chip>
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
dense
|
||||
v-if="false"
|
||||
class="overflow-hidden"
|
||||
switch-toggle-side
|
||||
default-opened
|
||||
style="border-radius: var(--radius-2)"
|
||||
expand-icon="mdi-chevron-down-circle"
|
||||
header-class="surface-1"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<section class="row items-center full-width">
|
||||
<div class="row items-center q-pr-md q-py-sm">
|
||||
<span
|
||||
class="text-weight-medium q-mr-md"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
{{ $t('general.visa') }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div class="surface-1 q-pa-md">
|
||||
<q-select
|
||||
class="fixed-height"
|
||||
v-model="payload.visa"
|
||||
v-bind="SELECT_PROPS"
|
||||
:placeholder="`${$t('general.visaNo')} ${$t('general.forExample', { example: 'G420316, V897546' })}`"
|
||||
@new-value="
|
||||
(value, done) => {
|
||||
value = value.trim();
|
||||
if (passportValidator.test(value)) {
|
||||
return done(value.toUpperCase(), 'add-unique');
|
||||
}
|
||||
}
|
||||
"
|
||||
:hint="$t('general.enterToAdd')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFormContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4);
|
||||
}
|
||||
|
||||
.fixed-height {
|
||||
--_height: 10rem;
|
||||
}
|
||||
|
||||
.fixed-height :deep(.q-field__control) {
|
||||
min-height: var(--_height) !important;
|
||||
align-items: start;
|
||||
}
|
||||
.fixed-height :deep(.q-field__append) {
|
||||
min-height: var(--_height) !important;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -56,6 +56,7 @@ import ToggleButton from 'components/button/ToggleButton.vue';
|
|||
import FormAbout from 'components/05_quotation/FormAbout.vue';
|
||||
import SelectZone from 'components/shared/SelectZone.vue';
|
||||
import PersonCard from 'components/shared/PersonCard.vue';
|
||||
import ImportWorker from './ImportWorker.vue';
|
||||
import {
|
||||
AddButton,
|
||||
SaveButton,
|
||||
|
|
@ -233,6 +234,7 @@ const pageState = reactive({
|
|||
fieldSelected: [],
|
||||
gridView: false,
|
||||
isLoaded: false,
|
||||
importWorker: false,
|
||||
|
||||
currentTab: 'all',
|
||||
addModal: false,
|
||||
|
|
@ -1043,9 +1045,50 @@ async function getInvoiceCodeFullPay() {
|
|||
const ret = await invoiceStore.getInvoiceList();
|
||||
if (ret) code.value = ret.result.at(0)?.code || '';
|
||||
}
|
||||
|
||||
const importWorkerCriteria = ref<{
|
||||
passport: string[] | null;
|
||||
visa: string[] | null;
|
||||
}>({
|
||||
passport: [],
|
||||
visa: [],
|
||||
});
|
||||
|
||||
async function getWorkerFromCriteria(
|
||||
payload: typeof importWorkerCriteria.value,
|
||||
) {
|
||||
const ret = await employeeStore.fetchList({
|
||||
payload: {
|
||||
passport: payload.passport || undefined,
|
||||
},
|
||||
pageSize: 99999, // must include all possible worker
|
||||
page: 1,
|
||||
passport: true,
|
||||
customerBranchId: quotationFormData.value.customerBranchId,
|
||||
});
|
||||
|
||||
if (!ret) return false; // error, do not close dialog
|
||||
|
||||
// TODO: Merge employee into worker list
|
||||
const deduplicate = ret.result.filter(
|
||||
(a) => !selectedWorker.value.find((b) => a.id === b.id),
|
||||
);
|
||||
|
||||
preSelectedWorker.value = [...deduplicate, ...selectedWorker.value];
|
||||
|
||||
convertEmployeeToTable();
|
||||
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ImportWorker
|
||||
v-model:open="pageState.importWorker"
|
||||
v-model:data="importWorkerCriteria"
|
||||
:import-worker="getWorkerFromCriteria"
|
||||
/>
|
||||
|
||||
<div class="column surface-0 fullscreen">
|
||||
<div class="color-bar">
|
||||
<div class="orange-segment"></div>
|
||||
|
|
@ -1839,6 +1882,63 @@ async function getInvoiceCodeFullPay() {
|
|||
}
|
||||
"
|
||||
>
|
||||
<template #top-append>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
icon="mdi-dots-horizontal"
|
||||
:id="`btn-kebab-action`"
|
||||
@click.stop
|
||||
:title="$t('general.additional')"
|
||||
>
|
||||
<q-menu class="bordered" ref="refMenu">
|
||||
<q-list>
|
||||
<q-item
|
||||
v-close-popup
|
||||
dense
|
||||
clickable
|
||||
class="row items-center"
|
||||
style="white-space: nowrap"
|
||||
@click.stop="
|
||||
() => {
|
||||
triggerCreateEmployee();
|
||||
}
|
||||
"
|
||||
>
|
||||
<q-icon
|
||||
size="xs"
|
||||
class="q-mr-sm"
|
||||
name="mdi-plus"
|
||||
style="color: hsl(var(--info-bg))"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('quotation.addWorker') }}
|
||||
</span>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
v-close-popup
|
||||
dense
|
||||
clickable
|
||||
style="white-space: nowrap"
|
||||
class="row items-center"
|
||||
@click.stop="() => (pageState.importWorker = true)"
|
||||
>
|
||||
<q-icon
|
||||
size="xs"
|
||||
class="q-mr-sm"
|
||||
name="mdi-import"
|
||||
style="color: hsl(195 90% 50%)"
|
||||
/>
|
||||
<span>{{ $t('quotation.importWorker') }}</span>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
<section class="col row scroll">
|
||||
<SelectZone
|
||||
ref="refSelectZoneEmployee"
|
||||
|
|
@ -1851,16 +1951,6 @@ async function getInvoiceCodeFullPay() {
|
|||
:items="workerList"
|
||||
:new-items="newWorkerList"
|
||||
>
|
||||
<template #top>
|
||||
<AddButton
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
triggerCreateEmployee();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #data="{ item }">
|
||||
<PersonCard
|
||||
noAction
|
||||
|
|
@ -1871,7 +1961,7 @@ async function getInvoiceCodeFullPay() {
|
|||
$i18n.locale === 'eng'
|
||||
? `${item.firstNameEN} ${item.lastNameEN}`
|
||||
: `${item.firstName} ${item.lastName}`,
|
||||
code: item.code,
|
||||
code: item.employeePassport.at(0)?.number,
|
||||
female: item.gender === 'female',
|
||||
male: item.gender === 'male',
|
||||
img: `${baseUrl}/customer/${item.id}/image/${item.selectedImage}`,
|
||||
|
|
|
|||
|
|
@ -43,12 +43,13 @@ const useEmployeeStore = defineStore('api-employee', () => {
|
|||
visa?: boolean;
|
||||
zipCode?: string;
|
||||
customerId?: string;
|
||||
customerBranchId?: string;
|
||||
payload?: { passport?: string[] };
|
||||
}) {
|
||||
const { payload, ...params } = opts || {};
|
||||
|
||||
const res = payload
|
||||
? await api.post<Pagination<Employee[]>>(`/employee`, payload, {
|
||||
? await api.post<Pagination<Employee[]>>(`/employee/list`, payload, {
|
||||
params,
|
||||
})
|
||||
: await api.get<Pagination<Employee[]>>(`/employee`, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue