The ideal candidate will have strong expertise in Vue.js/Nuxt, Svelte/SvelteKit, with a passion for creating exceptional user experiences.
Note on Language
This job description is written in English to reflect the importance of English reading comprehension. While the working language of our team is Vietnamese, we believe that the ability to read and understand English technical documentation is crucial for any frontend developer to stay current with the latest technologies and best practices.
Our Tech Stack
- Frontend: Vue 3 as our primary framework, with SvelteKit for up-coming projects.
- Backend: A powerful combination of Rust and Golang for high-performance, scalable services
- UI Libraries: PrimeVue, Flowbite, HeadlessUI, etc
- CSS: Tailwind CSS, UnoCSS for utility-first and atomic CSS approaches
- TypeScript: Used throughout our frontend codebase for enhanced developer experience and code quality
Key Responsibilities
- Develop and maintain responsive web applications using Vue.js/Nuxt (or Svelte/SvelteKit)
- Write clean, efficient, and reusable code using TypeScript
- Apply UX/UI principles to create visually appealing and user-friendly interfaces
- Optimize frontend performance for maximum speed and scalability
- Implement state management solutions (e.g., Pinia, or Svelte stores)
- Integrate RESTful APIs and handle asynchronous operations
- Stay up-to-date with emerging frontend technologies and best practices
Requirements
- Minimum 2 years of experience as a Frontend Developer
- Strong proficiency in Vue.js/Nuxt (or Svelte/SvelteKit)
- Expert knowledge of TypeScript
- Experience with modern frontend build tools (e.g., Vite, Webpack)
- Solid understanding of responsive design principles
- Familiarity with version control systems, preferably Git
- Good understanding of cross-browser compatibility issues and solutions
- Excellent problem-solving skills and attention to detail
- Strong UX/UI design thinking and ability to implement user-friendly interfaces without dedicated UX/UI team support
Preferred Skills (not required)
- Experience with server-side rendering (SSR) and static site generation (SSG)
- Knowledge of SEO best practices
- Familiarity with PostCSS and CSS preprocessors (e.g., SASS, LESS)
- Understanding of CI/CD pipelines
- Basic knowledge of backend technologies (e.g., Node.js, Express)
- Experience with PrimeVue component library
- Proficiency in Flowbite UI components
- Expert-level knowledge of Tailwind CSS
- Familiarity with UnoCSS and atomic CSS approach
- Experience integrating and customizing UI component libraries
Work Arrangement
- Remote work with 2 days per week onsite presence required
- Flexible working hours
What We Offer
- Opportunity to work on innovative CRM and CMS projects
- Competitive salary and benefits package: 700$ - 1200$ / month or 8$/hour for remote working (remote work only available for senior developers).
- Professional development opportunities
- Regular team building activities
- If you're passionate about frontend development, have a keen eye for design, and want to push the boundaries of web technologies, we'd love to hear from you. Apply now and help us create exceptional user experiences!
How to Apply
Please send your CV and a brief cover letter explaining why you're a great fit for this role to:
Email: i@vhlam.com
We look forward to reviewing your application and potentially welcoming you to our team!
Brief bằng tiếng Việt
Dự án CRM của tôi build bằng Vue.js, ban đầu do một thành viên trong team đảm nhận. Tuy nhiên, do phải tập trung vào BE nên không thể theo sát FE. Khi nhận bàn giao mới phát hiện ra rằng giao diện bên ngoài trông rất bắt mắt và chuyên nghiệp (thực ra mua template và custom dựa trên đó), nhưng source code lại là một mớ hỗn độn. Chỉ có vài component tự tay tôi viết thì useable.
Vấn đề chính
- Cấu trúc code kém: Source code hiện tại rất lộn xộn và khó bảo trì.
- Sử dụng Vue.js chưa hiệu quả: Code chủ yếu sử dụng reactive và ref một cách đơn giản, không tận dụng được sức mạnh và các API mới của Vue, hầu như sử dụng Pinia Store một cách máy móc.
- Component quá lớn: Nhiều file có độ dài lên tới 1000 dòng, chủ yếu là HTML và TS lặp đi lặp lại.
- Hard code: hard code every where, ko có config, setting hay gì cả, typesafe cũng không nốt.
Yêu cầu công việc
Tôi đang tìm kiếm một FE có khả năng:
- Refactor lại toàn bộ codebase, áp dụng các best practices và design patterns phù hợp với Vue.js.
- Tối ưu hóa việc sử dụng Vue.js, áp dụng các kỹ thuật và API của Vue 3 để cải thiện hiệu suất và khả năng mở rộng.
- Cải thiện UX của ứng dụng, đảm bảo không chỉ code "sạch" mà còn nâng cao trải nghiệm người dùng.
Sau đây là 1 Component mà bạn sẽ phải đối mặt nếu chấp nhận thử thách:
<script setup lang="ts">
import _ from "lodash";
import { ref, computed, onMounted, reactive } from "vue";
import Button from "@/base-components/Button";
import { FormInput, FormLabel } from "@/base-components/Form";
import Alert from "@/base-components/Alert";
import Lucide from "@/base-components/Lucide";
import draggable from "vuedraggable";
import { useRouter } from "vue-router";
import { Menu, Dialog } from "@/base-components/Headless";
import PersonModel from "@/pages/customer/components/PersonModal/PersonModel.vue";
import CoordinateOverview from "@/pages/clinic/CoordinateOverview.vue";
import usePipeline from "@/pages/pipeline/config/use-pipeline";
import { DealResponse, Issue, PersonResponse, Stage, UserResponse } from "@/api/bcare-types";
import useDeal from "@/pages/pipeline/config/use-deal";
import usePerson from "@/pages/customer/config/use-customer";
import DealModal from "@/pages/customer/components/DealModal/index.vue";
import { PIPELINE_IDS, STAGE_IDS } from "@/constants";
import {
dealUpdatePayload,
fakeDealData,
pipelineData,
pipelineParams,
STAGE,
} from "@/pages/dashboard/constants";
import useToggle from "@/hooks/useToggle";
import { getFirstLetterOfLastWord, getLastTwoWords, getTimePart } from "@/utils/helper";
import SelectDealModal from "@/pages/dashboard/components/SelectDealModal.vue";
import { dealPayload } from "@/pages/consulting/Contants";
import { personsPayload } from "@/pages/customer/constants";
import { useWsStore } from "@/stores/ws-store";
import useAddEventWS from "@/hooks/useAddEventWS";
import { getExpectedTask, getExpectedTaskOther, hasComplainIssue } from "@/pages/dashboard/utils";
import ModalCheckPerson from "@/pages/dashboard/components/ModalCheckPerson.vue";
import { useUserConfigsStore } from "@/stores/user-store";
import { useStageStore } from "@/stores/stage-store";
import { useModalCustomerStore } from "@/stores/modal-customer-store";
import Tippy from "@/base-components/Tippy";
import PopSetting from "@/pages/customer/components/AppointmentTab/components/PopSetting.vue";
import TrackInfo from "@/components/Deal/TrackInfo.vue";
import DealCard from "@/components/Deal/DealCard.vue";
import PipelineLayout from "@/components/PipelineLayout.vue";
const { fetchPipeline, fetchPipelineList, pipelineItem, pipelines } = usePipeline();
const userConfigsStore = useUserConfigsStore();
const stageStore = useStageStore();
const { fetchDealList, dealList, onUpdateDeal } = useDeal();
const { fetchPersonList, personList, checkPersonIn } = usePerson();
const modalStore = useModalCustomerStore();
const wsStore = useWsStore();
const router = useRouter();
const search = ref<string>("");
const updateChildStages = useToggle(false);
const isShowPersonDialog = useToggle(false);
const isShowDealDialog = useToggle(false);
const isShowSelectDealModal = useToggle(false);
const isShowCheckPerson = useToggle(false);
const toggleComponent = ref(true);
const dealsByStageId = ref<{ [key: number]: DealResponse[] }>({});
const childStages = ref<Stage[]>([]);
const dealId = ref<number>(0);
const dragStageId = ref<number>(0);
const stageDragId = ref<number>(0);
const showDoctor = ref(false);
const personId = ref();
const flagGetDetail = ref(0);
const flagGetDeal = ref(0);
const drag = ref(false);
const indexDeal = ref<number>();
const isDashboard = ref(0);
const stageMap = new Map();
const handleShowDeal = (id: number, idPerson: number) => {
isShowDealDialog.show();
flagGetDeal.value++;
dealId.value = id;
personId.value = idPerson;
};
const personPayload = reactive({ ...personsPayload });
const pipeline = reactive({ ...pipelineData });
const fakeDeal = ref<DealResponse>({ ...fakeDealData });
const toggleComponents = () => {
toggleComponent.value = !toggleComponent.value;
};
const handleAddPerson = async () => {
personId.value = 0;
flagGetDetail.value++;
isDashboard.value++;
isShowPersonDialog.show();
};
const handleSearchPerson = (val: string) => {
personPayload.search = val;
personPayload.page = 1;
debouncedSearch();
};
const debouncedSearch = _.debounce(async () => {
await fetchPersonList({ ...personPayload });
}, 500);
const dragOptions = computed(() => {
return {
animation: 200,
group: "pipeline",
ghostClass: "ghost",
};
});
const dataRender = computed(() =>
(personList.value as PersonResponse[]).map((item) => {
return {
...fakeDeal.value,
person: {
...fakeDeal.value.person,
id: item.id,
phone: item.phone,
full_name: item.full_name,
issues: item.issues,
},
};
}),
);
const fetchData = async () => {
await fetchPipeline({ id: PIPELINE_IDS.OFFLINE, name: "" });
Object.assign(pipeline, pipelineItem.value);
await fetchDeals();
};
const fetchDeals = async () => {
await fetchDealList({
...dealPayload,
pipeline_id: PIPELINE_IDS.OFFLINE,
filter: { ...dealPayload.filter },
});
for (const stage of stageStore.state.stageMapPipelineId[PIPELINE_IDS.OFFLINE]) {
dealsByStageId.value[stage.id] = [];
}
for (const stage of stageStore.state.stageMapPipelineId[PIPELINE_IDS.OFFLINE]) {
for (const childStage of stage.child_stages) {
stageMap.set(childStage.id, stage.id);
}
}
dealList.value.forEach((deal: DealResponse) => {
const stageId = stageMap.get(deal.stage_id) || deal.stage_id;
(dealsByStageId.value[stageId] ??= []).push(deal);
});
};
const handleAddDeal = async (id: number, isDragDrop: boolean, newIndex: number) => {
const checkPerson = await checkPersonIn({ id: id, full_name: "", phone: "", email: "" });
if (checkPerson) {
if (isDragDrop) {
dealsByStageId.value[1].splice(newIndex, 1);
}
isShowCheckPerson.show();
return;
}
personId.value = id;
isShowSelectDealModal.show();
if (isDragDrop) indexDeal.value = newIndex;
};
const handleDealFromModal = (deal: DealResponse) => {
if (deal) {
emitEventWS(deal.stage_id, deal);
const findFakeDeal = dealsByStageId.value[1].findIndex((item: DealResponse) => item.id === 0);
if (findFakeDeal === -1) dealsByStageId.value[1].splice(0, 0, deal);
else dealsByStageId.value[1].splice(findFakeDeal, 1, deal);
}
};
const handleCloseSelectDeal = () => {
const idx = dealsByStageId.value[1]?.findIndex((item: DealResponse) => item.id === 0);
if (idx !== -1) dealsByStageId.value[1]?.splice(idx, 1);
isShowSelectDealModal.hide();
};
const handleViewStage = (stageId: number) => {
if (stageId === STAGE_IDS.ASSISTANT) router.push({ name: "top-menu-assistant" });
else router.push({ name: "top-menu-consulting" });
};
const onStart = async () => {
drag.value = true;
};
const onEnd = async (evt: any) => {
const { clone, to, newIndex } = evt;
const draggedId = +clone.dataset.dealid;
const dropStageId = +to.dataset.stageid;
const fromStageId = +clone.dataset.stageid;
const dragPersonId = +clone.dataset.personid;
stageDragId.value = fromStageId || 0;
if (!draggedId) {
if (dropStageId) {
await handleAddDeal(dragPersonId, true, newIndex);
}
drag.value = false;
return false;
}
if (fromStageId === dropStageId) {
drag.value = false;
return false;
}
const dropStage = stageStore.state.stageMapPipelineId[PIPELINE_IDS.OFFLINE].find(
(stage: Stage) => stage.id === dropStageId,
);
if (dropStage) {
dealId.value = draggedId;
if (dropStage.child_stages.length > 0) {
updateChildStages.show();
childStages.value = dropStage.child_stages;
} else await handleUpdateDeal(dropStageId);
}
drag.value = false;
return true;
};
const emitEventWS = (stageId: number, deal: DealResponse | null) => {
emitEventWhenChangeStage({
deal: deal ?? undefined,
newStageId: stageId,
oldStageId: stageDragId.value,
});
wsStore.conn?.broadcast.emit("dashboard-updental", {
event: "dashboard-updental",
data: {
oldStageId: stageDragId.value,
newStageId: stageId,
deal: deal,
},
});
};
const handleUpdateDeal = async (stageId: number, parentStageId: number | null = null) => {
let res: DealResponse | null;
if (parentStageId !== null && parentStageId === STAGE_IDS.ASSISTANT) {
res = await onUpdateDeal({ ...dealUpdatePayload, id: dealId.value, stage_id: stageId });
} else res = await onUpdateDeal({ ...dealUpdatePayload, id: dealId.value, stage_id: stageId });
if (res) {
emitEventWS(stageId, res);
updateDealInList(res, parentStageId ?? stageId);
}
updateChildStages.hide();
};
const updateDealInList = (updatedDeal: DealResponse, newStageId: number) => {
const oldStageId = stageMap.get(updatedDeal.stage_id) || updatedDeal.stage_id;
dealsByStageId.value[oldStageId] = dealsByStageId.value[oldStageId].map((deal) =>
deal.id === updatedDeal.id ? updatedDeal : deal,
);
const findDeal = dealsByStageId.value[newStageId].findIndex((deal) => deal.id === updatedDeal.id);
if (findDeal !== -1) {
dealsByStageId.value[newStageId].splice(findDeal, 1, updatedDeal);
}
};
const handleChangeStage = async (deal: DealResponse, stageId: number, childStageId?: number) => {
const oldStageId = stageMap.get(deal.stage_id) || deal.stage_id;
stageDragId.value = deal.stage_id;
const findDeal = dealsByStageId.value[oldStageId].findIndex((i) => i.id === deal.id);
if (findDeal !== -1) {
dealsByStageId.value[oldStageId].splice(findDeal, 1);
}
const res = await onUpdateDeal({
...dealUpdatePayload,
id: deal.id,
stage_id: childStageId ?? stageId,
});
if (res) {
dealsByStageId.value[stageId].unshift(res);
emitEventWS(res.stage_id, res);
}
};
const onMove2 = (evt: any) => {
const { to, from } = evt;
const toStage = +to.dataset.stageid;
return !(toStage !== 1 && to !== from);
};
const onMove = (evt: any) => {
dealId.value = evt.draggedContext.element.id;
dragStageId.value = evt.from.dataset.stageid;
return true;
};
const emitEventWhenChangeStage = ({
deal,
newStageId,
oldStageId,
}: {
deal?: DealResponse;
newStageId?: number;
oldStageId?: number;
}) => {
const mapNewStageId = stageMap.get(newStageId) || newStageId;
const mapOldStageId = stageMap.get(oldStageId) || oldStageId;
if (oldStageId === STAGE_IDS.X_RAY || newStageId === STAGE_IDS.X_RAY) {
wsStore.conn?.broadcast.emit("x-quang", {
event: "x-quang",
});
}
if (mapOldStageId === STAGE_IDS.CONSULTING || mapNewStageId === STAGE_IDS.CONSULTING) {
wsStore.conn?.broadcast.emit("consulting", {
event: "consulting",
data: { deal, newStageId, oldStageId },
});
}
if (mapOldStageId === STAGE_IDS.ASSISTANT) {
wsStore.conn?.broadcast.to(`assistant_stage_${oldStageId}`).emit("assistant", {
event: "assistant",
data: { dealId: deal?.id, isChangeStage: true },
});
}
if (mapNewStageId === STAGE_IDS.ASSISTANT) {
wsStore.conn?.broadcast.to(`assistant_stage_${newStageId}`).emit("assistant", {
event: "assistant",
data: { dealId: deal?.id, isChangeStage: true, stageId: newStageId },
});
}
};
useAddEventWS({
event: "dashboard-updental",
callback: (msg) => {
const { deal, newStageId, oldStageId } = msg.data.data || {};
const mapNewStageId = stageMap.get(newStageId) || newStageId;
const mapOldStageId = stageMap.get(oldStageId) || oldStageId;
let isDuplicate = false;
if (!deal || !mapNewStageId) return;
if (mapOldStageId)
dealsByStageId.value[mapOldStageId] = dealsByStageId.value[mapOldStageId].filter(
(item) => item.id !== deal.id,
);
dealsByStageId.value[mapNewStageId] = dealsByStageId.value[mapNewStageId].map((item) => {
if (item.id === deal.id) {
isDuplicate = true;
return deal;
}
return item;
});
if (!isDuplicate) dealsByStageId.value[mapNewStageId].push(deal);
},
});
const getUserNameById = (userId: number): string | null => {
const user = userConfigsStore.state.users.find((user: UserResponse) => user.id === +userId);
return user ? user.name : null;
};
onMounted(async () => {
await fetchPipelineList({ ...pipelineParams });
await fetchData();
});
</script>
<template>
<div v-if="toggleComponent" class="intro-y">
<div class="col-span-12 mt-7 flex flex-col lg:flex-row xl:col-span-12">
<div class="flex justify-between">
<div class="pr-2 lg:border-r">
<Button variant="primary" class="rounded-none rounded-l-md border-r-transparent">
<Lucide icon="Columns" class="h-5 w-5" />
</Button>
<Button variant="outline-primary" class="mr-1 rounded-none rounded-r-md">
<Lucide icon="Menu" class="h-5 w-5" />
</Button>
</div>
<Button variant="primary" class="ml-3 w-32" @click="handleAddPerson">
<Lucide icon="Plus" class="mr-2 h-5 w-5" />
Thêm mới
</Button>
</div>
<div class="mt-5 flex flex-col sm:ml-auto md:flex-row lg:mt-0">
<div class="flex w-full items-center">
<Menu class="relative z-[51] ml-auto">
<Menu.Button as="Button" variant="soft-secondary" class="btn-pipeline bg-white">
<Lucide icon="Trello" class="mr-2 h-4 w-4" />
{{ pipelineItem.name }}
<Lucide icon="ChevronDown" class="ml-3 hidden h-4 w-4 font-semibold sm:block" />
</Menu.Button>
<Menu.Items placement="bottom-start" class="w-64">
<Menu.Item v-for="(item, key) in pipelines" :key="key">
<Lucide icon="Trello" class="mr-2 h-4 w-4" />
<span class="truncate">
{{ item.name }}
</span>
<Lucide icon="Check" class="ml-auto h-4 w-4 text-success" />
</Menu.Item>
<Menu.Item @click="toggleComponents">
<Lucide icon="Eye" class="mr-2 h-4 w-4" />
<span class="truncate"> Vận hành </span>
</Menu.Item>
<Menu.Item @click="router.push({ name: 'top-menu-pipeline-add' })">
<Lucide icon="Plus" class="mr-2 h-4 w-4" />
<span class="truncate"> Thêm Pipeline </span>
</Menu.Item>
</Menu.Items>
</Menu>
<Button class="h-full rounded-none rounded-r-md bg-white dark:bg-slate-700 md:mr-1">
<Lucide icon="Edit3" class="h-4 w-4" />
</Button>
</div>
<div class="flex flex-col-reverse items-center sm:flex-row md:ml-3">
<div class="relative mr-3 mt-3 w-full sm:mt-0 sm:w-auto">
<Lucide
icon="Search"
class="absolute inset-y-0 left-0 z-10 my-auto ml-3 h-4 w-4 text-slate-500"
/>
<FormInput
v-model="search"
type="text"
class="!box w-full px-10 sm:w-64"
placeholder="Tìm kiếm khách hàng"
/>
<Menu class="absolute inset-y-0 right-0 mr-3 flex items-center">
<Menu.Button as="a" role="button" class="block h-4 w-4" href="#">
<Lucide icon="ChevronDown" class="h-4 w-4 cursor-pointer text-slate-500" />
</Menu.Button>
<Menu.Items placement="bottom-end">
<Menu.Item>
<Lucide icon="Home" class="mr-2 h-4 w-4" />
<span class="truncate"> Updental Bình Thạnh </span>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
<PopSetting v-model:showDoctor="showDoctor" />
</div>
</div>
</div>
<div class="relative z-30 mt-6 flex">
<div class="mr-2">
<div class="relative mt-0">
<FormInput
id="input-search"
v-model="personPayload.search"
type="text"
:class="[
'box h-12 w-12 rounded-lg border pr-9 shadow-none transition-[width] duration-300 ease-in-out focus:w-64 focus:invalid:w-64 dark:bg-darkmode-400',
{ 'w-64': personPayload.search },
]"
placeholder="Tìm kiếm khách hàng"
@update:model-value="handleSearchPerson"
/>
<FormLabel
html-for="input-search"
class="absolute inset-y-0 right-0 my-auto mr-4 h-5 w-5 cursor-pointer text-xs"
>
<Lucide icon="Search" class="text-slate-600 dark:text-slate-500" />
</FormLabel>
</div>
<div v-if="personPayload.search" class="mt-3 w-64 max-w-64">
<draggable
v-bind="dragOptions"
v-model="dataRender"
class="list-group scrollbar-hidden h-[75vh] overflow-y-scroll"
:component-data="{
tag: 'ul',
type: 'transition-group',
name: !drag ? 'flip-list' : null,
}"
:group="{ name: 'pipeline', pull: 'clone' }"
:move="onMove2"
item-key="id"
@start="onStart"
@end="onEnd($event)"
>
<template #item="{ element }">
<div :data-personid="element.person.id" class="list-group-item cursor-pointer">
<div
class="box group mb-1 overflow-visible px-3 py-3 hover:shadow-md"
@click="() => modalStore.openModal(element.person.id)"
>
<div class="flex items-center">
<div class="relative">
<OverlayBadge
v-if="hasComplainIssue(element.person.issues)"
severity="danger"
class="inline-flex"
>
<Avatar
:label="getFirstLetterOfLastWord(element.person.full_name ?? '')"
shape="circle"
/>
</OverlayBadge>
<Avatar
v-else
:label="getFirstLetterOfLastWord(element.person.full_name ?? '')"
shape="circle"
/>
</div>
<div class="ml-2 truncate font-medium leading-none">
<a class="cursor-pointer">
{{ element.person.full_name }}
</a>
<div class="mt-1 text-xs text-slate-500">
{{ element.person.phone }}
</div>
</div>
</div>
<Menu
class="absolute right-3 top-[50%] z-[100] hidden translate-y-[-50%] group-hover:block"
>
<Menu.Button
:as="Button"
class="cursor-pointer rounded-full bg-primary/60 px-1 py-1 text-xs font-medium text-white"
@click.stop
>
<Lucide icon="ChevronRight" class="h-3 w-3" />
</Menu.Button>
<Menu.Items class="font-medium">
<Menu.Item @click.stop="handleAddDeal(element.person.id, false, 0)">
<Lucide icon="ArrowRight" class="mr-2 h-4 w-4" />
<span class="truncate"> Chờ phục vụ </span>
</Menu.Item>
<Menu.Item>
<Lucide icon="Edit" class="mr-2 h-4 w-4" />
<span class="truncate"> Cập nhật </span>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
</template>
</draggable>
</div>
</div>
<PipelineLayout :pipeline-id="PIPELINE_IDS.OFFLINE">
<template #default="{ stage }">
<Alert variant="soft-secondary" class="box flex h-12 items-center p-3 font-medium">
<Lucide icon="Settings" class="mr-2 h-4 w-4" />
{{ stage.name }}
<div
class="ml-auto flex items-center rounded-full border border-slate-500 px-2 py-0.5 text-xs font-medium text-slate-500 dark:border-slate-500 dark:text-slate-400"
>
{{ dealsByStageId[stage.id]?.length }}
<Lucide icon="User" class="ml-1 h-3 w-3" />
</div>
<Button
v-if="stage.child_stages.length > 0"
class="ml-2 bg-slate-500 p-1 hover:bg-slate-800"
@click="handleViewStage(stage.id)"
>
<Lucide icon="ListStart" class="h-3 w-3 font-extrabold text-white" />
</Button>
</Alert>
<div class="mt-3">
<draggable
v-bind="dragOptions"
v-model="dealsByStageId[stage.id]"
class="list-group scrollbar-hidden surface-overlay h-[75vh] overflow-y-auto"
:data-stageid="stage.id"
:component-data="{
tag: 'ul',
type: 'transition-group',
name: !drag ? 'flip-list' : null,
}"
group="pipeline"
:move="onMove"
item-key="index"
@start="drag = true"
@end="onEnd($event)"
>
<template #item="{ element }">
<div
v-if="
element.person_id.toString().includes(search) ||
element.person.full_name.toLowerCase().includes(search.toLowerCase())
"
:data-dealid="element.id"
:data-stageid="element.stage_id"
>
<div class="list-group-item relative">
<div
class="box group relative mb-1 cursor-pointer px-3 py-3 hover:shadow-md"
@click="handleShowDeal(element.id, element.person_id)"
>
<div class="flex items-start">
<div class="relative">
<OverlayBadge
v-if="hasComplainIssue(element.person.issues)"
severity="danger"
class="inline-flex"
>
<Avatar
:label="getFirstLetterOfLastWord(element.person.full_name ?? '')"
shape="circle"
/>
</OverlayBadge>
<Avatar
v-else
:label="getFirstLetterOfLastWord(element.person.full_name ?? '')"
shape="circle"
/>
</div>
<div class="ml-2 overflow-hidden font-medium leading-[1.2]">
<div class="flex w-full max-w-full items-end whitespace-nowrap">
<Tippy class="flex-1 truncate" :content="element.person.full_name">
{{ element.person.full_name }}
</Tippy>
<span class="text-slate-500">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-dot inline"
>
<circle cx="12.1" cy="12.1" r="1" />
</svg>
</span>
<span class="text-[11px] font-normal text-slate-500">
{{ getTimePart(element.updated_at || element.created_at) }}</span
>
</div>
<div
v-if="
!showDoctor &&
element.tracks &&
(stage.id === STAGE.WAITING ||
stage.id === STAGE.TREATMENT ||
stage.id === STAGE.FOURTH_FLOOR ||
stage.id === STAGE.FIFTH_FLOOR ||
stage.id === STAGE.SIXTH_FLOOR)
"
>
<span
v-for="track in element.tracks"
:key="track.id"
class="flex flex-col"
>
<template v-if="track.appointments">
<div
v-for="appointment in track.appointments"
:key="appointment.id"
class="mt-0.5 flex text-xs text-slate-500"
>
<div class="mr-1 rounded-full bg-slate-200 p-0.5 text-slate-700">
<Lucide icon="User" class="h-3 w-3" />
</div>
{{
getLastTwoWords(getUserNameById(appointment.doctor_id) || "")
}}
</div>
</template>
</span>
</div>
<div
v-if="
element.stage_id === STAGE.ADVISE ||
element.stage_id === STAGE.X_RAY ||
element.stage_id === STAGE.PLANNING_CONSULTATION ||
element.stage_id === STAGE.ROADMAP_CONSULTATION
"
>
<span class="mt-0.5 flex items-center">
<span
:class="[
'ring-opacity/30 mr-2 inline-block h-[6px] w-[6px] rounded-full ring-2',
{ 'bg-danger ring-danger/20': element.stage_id === STAGE.X_RAY },
{
'bg-success ring-success/20': element.stage_id !== STAGE.X_RAY,
},
]"
></span>
<span class="text-xs text-slate-500">{{
element.stage_id === STAGE.X_RAY ? "Phòng X-Quang" : "Tư vấn"
}}</span>
</span>
</div>
</div>
</div>
<template
v-if="
element.tracks &&
(element.stage_id === STAGE.WAITING ||
element.stage_id === STAGE.TREATMENT ||
element.stage_id === STAGE.EXAM ||
element.stage_id === STAGE.FOURTH_FLOOR ||
element.stage_id === STAGE.FIFTH_FLOOR ||
element.stage_id === STAGE.SIXTH_FLOOR)
"
>
<div v-for="track in element.tracks" :key="track.id" class="mt-1">
<template v-if="track.appointments">
<span v-for="appointment in track.appointments" :key="appointment.id">
<Tippy
v-for="task in getExpectedTask(appointment.extra_notes)"
as="a"
class="mr-1 mt-0.5 inline-block rounded-md bg-slate-300 px-2 py-0.5 text-xs leading-none text-slate-500 dark:bg-darkmode-300 dark:text-slate-400"
:content="appointment.notes"
>
{{ task }}
</Tippy>
<Tippy
v-for="task in getExpectedTaskOther(appointment.extra_notes)"
as="a"
class="mr-1 mt-0.5 inline-block rounded-md bg-slate-300 px-2 py-0.5 text-xs leading-none text-slate-500 dark:bg-darkmode-300 dark:text-slate-400"
:content="appointment.notes"
>
{{ task }}
</Tippy>
</span>
</template>
</div>
</template>
<Menu
class="absolute right-3 top-[50%] z-[100] hidden translate-y-[-50%] group-hover:block"
>
<Menu.Button
:as="Button"
class="rounded-full bg-primary/60 px-1 py-1 text-xs font-medium text-white"
@click.stop
>
<Lucide icon="ChevronRight" class="h-3 w-3" />
</Menu.Button>
<Menu.Items class="w-52 font-medium">
<Menu.Item
v-for="stageItem in stageStore.state.stageMapPipelineId[
PIPELINE_IDS.OFFLINE
]"
:key="stageItem.id"
class="flex flex-col items-start p-0 hover:bg-transparent"
>
<div
class="flex w-full items-start rounded-md p-2 hover:bg-slate-200"
@click.stop="handleChangeStage(element, stageItem.id)"
>
<Lucide icon="ListStart" class="mr-2 h-4 w-4" />
<span class="truncate"> {{ stageItem.name }} </span>
</div>
<div
v-for="childStage in stageItem.child_stages"
:key="childStage.id"
class="flex w-full max-w-[100%] items-start rounded-md p-2 pl-4 hover:bg-slate-200"
@click.stop="handleChangeStage(element, stageItem.id, childStage.id)"
>
<Lucide icon="CornerDownRight" class="mr-2 h-4 w-4" />
<span class="truncate"> {{ childStage.name }} </span>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
</div>
</template>
</draggable>
<!-- <a-->
<!-- href=""-->
<!-- class="block mt-1 w-full py-4 text-center border border-dotted rounded-md intro-x border-slate-400 dark:border-darkmode-300 text-slate-500"-->
<!-- >-->
<!-- Xem thêm-->
<!-- </a>-->
</div>
</template>
</PipelineLayout>
</div>
</div>
<div v-else>
<div class="mt-6 flex flex-col sm:flex-row sm:items-center">
<div class="flex items-center">
<Button
class="mr-2 border-slate-300 px-2 text-slate-600 dark:text-slate-300"
@click="toggleComponents"
>
<Lucide icon="ChevronLeft" class="h-4 w-4" />
</Button>
<h2 class="mr-auto text-lg font-medium">Vận hành phòng khám</h2>
</div>
<div class="relative mt-5 text-slate-500 sm:ml-auto sm:mt-0">
<FormInput type="text" class="!box w-full pr-10" placeholder="Tìm kiếm khách hàng" />
<Lucide icon="Search" class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" />
</div>
</div>
<CoordinateOverview />
</div>
<PersonModel
:person-id="personId"
:is-dashboard="isDashboard"
:is-open="isShowPersonDialog.isVisible.value"
:flag-get-detail="flagGetDetail"
@on-close="isShowPersonDialog.hide"
/>
<DealModal />
<SelectDealModal
:is-open="isShowSelectDealModal.isVisible.value"
:person-id="personId"
@on-close="handleCloseSelectDeal"
@selected-deal="handleDealFromModal"
@add-deal="handleDealFromModal"
/>
<ModalCheckPerson
:is-open="isShowCheckPerson.isVisible.value"
@on-close="isShowCheckPerson.hide"
/>
<Dialog :open="updateChildStages.isVisible.value">
<Dialog.Panel>
<a
class="absolute right-0 top-0 mr-3 mt-3"
href="#"
@click="
(event: MouseEvent) => {
event.preventDefault();
updateChildStages.hide();
const deal = dealList.find((item: DealResponse) => dealId === item.id);
if (deal) {
const mapNewStageId = stageMap.get(stageDragId) || stageDragId;
const mapOldStageId = stageMap.get(childStages[0]?.id) || childStages[0]?.id;
if (mapOldStageId)
dealsByStageId[mapOldStageId] = dealsByStageId[mapOldStageId]?.filter(
(item) => item.id !== deal.id,
);
if (mapNewStageId) dealsByStageId[mapNewStageId].push(deal);
}
}
"
>
<Lucide icon="X" class="h-7 w-7 text-slate-400" />
</a>
<Dialog.Title>
<h2 class="mr-auto text-base font-medium">Chọn 1 giai đoạn</h2>
</Dialog.Title>
<Dialog.Description class="">
<div class="">
<Button
v-for="(stage, key) in childStages"
:key="key"
class="mb-2 w-full font-medium last:mb-0 hover:bg-primary hover:text-white"
@click="handleUpdateDeal(stage.id, stage.parent_stage_id)"
>
{{ stage.name }}
</Button>
</div>
</Dialog.Description>
</Dialog.Panel>
</Dialog>
</template>
<style>
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
.drop-zone-highlight {
@apply border-none bg-slate-200 text-white;
}
.btn-pipeline {
@apply inline-flex cursor-pointer items-center justify-center rounded-none rounded-l-md border border-r-transparent bg-opacity-20 px-3 py-2 font-medium text-slate-600 shadow-sm transition duration-200 focus:ring-4 focus:ring-primary focus:ring-opacity-20 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-70 dark:border-darkmode-100/30 dark:bg-darkmode-100/20 dark:text-slate-300 dark:focus:ring-slate-700 dark:focus:ring-opacity-50 [&:hover:not(:disabled)]:border-opacity-90 [&:not(button)]:text-center;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
</style>