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:
Methapon Metanipat 2024-11-13 11:53:25 +07:00 committed by GitHub
parent 481d9d4e9e
commit 202e8c2e6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 359 additions and 25 deletions

View file

@ -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>

View file

@ -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 />

View file

@ -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.',
},

View file

@ -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: 'ยืนยันการตอบรับใบเสนอราคา',
},

View 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>

View file

@ -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}`,

View file

@ -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`, {