complete Complete User Request Submission section RAD-1

This commit is contained in:
2026-01-03 19:14:30 +03:30
commit 7137ec5792
393 changed files with 152602 additions and 0 deletions
@@ -0,0 +1,32 @@
<script setup>
import { ref } from 'vue';
import { useGlobalStore } from '../store/Global';
const globalStore = useGlobalStore();
const toggleSidebar = () => {
globalStore.sidebarOpen = !globalStore.sidebarOpen;
};
</script>
<template>
<header class="h-16 !bg-white grid items-center shadow-md
shadow-client-gray/10 px-4 grid-cols-2a justify-between relative z-20">
<div class="grid grid-cols-2a justify-start items-center gap-4">
<Button
icon="fi fi-rr-menu-burger"
class="lg:hidden !w-8 !h-8 visible sm:invisible flex items-center"
pt:icon="mt-[.6rem]"
variant="outlined"
@click="toggleSidebar"
/>
<figure class="max-sm:w-auto w-auto !h-12 flex justify-center p-1">
<img class="h-full w-full object-contain m-auto" src="@/images/radicall_logo.png" alt="">
</figure>
</div>
<div class="grid grid-cols-2a justify-start items-center gap-4">
<Avatar icon="fi fi-rr-user" severity="secondary" class="mr-2 !w-10 !h-10 flex" shape="circle" />
</div>
</header>
</template>
@@ -0,0 +1,56 @@
<script setup>
import {Link} from "@inertiajs/inertia-vue3";
const props = defineProps([
'currentPage',
'lastPage',
'total',
'perPage',
'links',
])
</script>
<template>
<section class="pagination" v-if="lastPage > 1">
<template v-for="(link , index) in links" :key="index">
<div class="paginate" v-if="link.label == 'قبلی'">
<Link v-if="link.url" :href="link.url">
<i class="fi fi-rr-angle-small-right flex text-xl"></i>
</Link>
</div>
<div class="paginate" v-else-if="link.label == 'بعدی'">
<Link v-if="link.url" :href="link.url">
<i class="fi fi-rr-angle-small-left flex text-xl"></i>
</Link>
</div>
<div class="paginate flex items-center" :class="{'!text-white !bg-client-primary' : currentPage == link.label }" v-else>
<Link v-if="link.url" :href="link.url" class="flex pt-1">{{link.label}}</Link>
<Link v-else href="" class="flex">{{link.label}}</Link>
</div>
</template>
</section>
</template>
<style lang="postcss">
section.pagination{
padding: 1rem;
display: flex;
align-items: center;
}
section.pagination div.paginate:first-child a{
border-top-right-radius: .4rem;
border-bottom-right-radius: .4rem;
}
section.pagination div.paginate:last-child a{
border-top-left-radius: .4rem;
border-bottom-left-radius: .4rem;
}
section.pagination div.paginate a{
display: flex;
height: 2.2rem;
width: 2.2rem;
align-items: center;
justify-content: center;
border: 1px solid #e6e6e6;
@apply text-client-Fontcolor
}
</style>
@@ -0,0 +1,77 @@
<script setup>
const props = defineProps({
currentPage: Number,
lastPage: Number,
total: Number,
perPage: Number,
links: Array,
topScroll: String,
onPaginate: { type: Function, required: true }
});
const paginateHandler = (page) => {
console.log(props.topScroll)
document.getElementById(props.topScroll).scrollIntoView({behavior: "smooth"});
props.onPaginate(page);
}
const perv = () => {
if (props.currentPage > 1) {
// document.getElementById(props.topScroll).scrollIntoView({behavior: "smooth"});
props.onPaginate(props.currentPage - 1);
}
}
const next = () => {
if (props.currentPage < props.lastPage) {
// document.getElementById(props.topScroll).scrollIntoView({behavior: "smooth"});
props.onPaginate(props.currentPage + 1);
}
}
</script>
<template>
<section class="pagination" v-if="lastPage > 1">
<template v-for="(link , index) in links" :key="index">
<div class="paginate cursor-pointer" v-if="link.label == 'قبلی'">
<div @click="perv" v-if="link.url">
<i class="fi fi-rr-angle-small-right flex text-xl"></i>
</div>
</div>
<div class="paginate cursor-pointer" v-else-if="link.label == 'بعدی'">
<div @click="next" v-if="link.url">
<i class="fi fi-rr-angle-small-left flex text-xl"></i>
</div>
</div>
<div class="paginate cursor-pointer flex items-center" :class="{'!text-white !bg-client-primary' : currentPage == link.label }" v-else>
<div @click="paginateHandler(link.label)" v-if="link.url" :href="link.url" class="flex pt-1">{{link.label}}</div>
<div v-else href="" class="flex">{{link.label}}</div>
</div>
</template>
</section>
</template>
<style lang="postcss">
section.pagination{
padding: 1rem;
display: flex;
align-items: center;
}
section.pagination div.paginate:first-child div{
border-top-right-radius: .4rem;
border-bottom-right-radius: .4rem;
}
section.pagination div.paginate:last-child div{
border-top-left-radius: .4rem;
border-bottom-left-radius: .4rem;
}
section.pagination div.paginate div{
display: flex;
height: 2.2rem;
width: 2.2rem;
align-items: center;
justify-content: center;
border: 1px solid #e6e6e6;
@apply text-client-Fontcolor
}
</style>
@@ -0,0 +1,249 @@
<script setup lang="ts">
import {ref, computed, onMounted, watch} from 'vue';
import {Link, usePage} from '@inertiajs/inertia-vue3';
import type { Ref } from 'vue';
import type { MenuItem } from 'primevue/menuitem';
import {useGlobalStore} from '../store/Global';
interface SidebarItem extends MenuItem {
label: string;
icon?: string;
to?: string;
expanded?: boolean;
items?: SidebarItem[];
permission?: string | null;
}
const globalStore = useGlobalStore();
const {url ,props} = usePage();
const currentUser = ref(props.value.auth.User);
const userPermissions = computed(() => props.value.auth.permissions ?? []);
function hasPermission(permission) {
if (!permission) return true;
return userPermissions.value.includes(permission);
}
function filterSidebarItems(items) {
return items
.filter(item => hasPermission(item.permission))
.map(item => {
if (item.items) {
const filteredChildren = filterSidebarItems(item.items);
if (filteredChildren.length > 0) {
return { ...item, items: filteredChildren };
} else if (!item.permission) {
return null;
}
}
return item;
})
.filter(Boolean);
}
const filteredItems = computed(() => filterSidebarItems(items.value));
// @ts-ignore
const items: Ref<SidebarItem[]> = ref([
{
label: "داشبورد",
icon: "fi fi-rr-dashboard",
to: "/admin/dashboard",
key: 'panel',
expanded: false,
permission: null,
},
{
label: "همه کیس ها",
icon: "fi fi-rr-document",
to: "",
key: 'pets',
permission: null,
expanded: false,
items: [
{ label: "همه کیس ها", icon: "fi fi-rr-wifi-1", to: "/admin/pets" , permission: "pets.index" },
{ label: "افزودن کیس جدید", icon: "fi fi-rr-wifi-1", to: "/admin/pets/create" , permission: "pets.create" },
],
},
{
label: "همه پرونده ها",
icon: "fi fi-rr-document",
to: "",
key: 'documents',
permission: null,
expanded: false,
items: [
{ label: "همه پرونده ها", icon: "fi fi-rr-wifi-1", to: "/admin/documents" , permission: "documents.index" },
{ label: "افزودن پرونده جدید", icon: "fi fi-rr-wifi-1", to: "/admin/documents/create" , permission: "documents.create" },
],
},
{
label: "همه گزارش ها",
icon: "fi fi-rr-document",
to: "/admin/reports",
key: 'reports',
permission: "reports.index",
expanded: false,
},
{
label: "مدیریت رسانه",
icon: "fi fi-rr-gallery",
to: "/admin/medium",
key: 'medium',
permission: "medium.index",
},
{
label: "مدیریت دسترسی ها",
icon: "fi fi-rr-user-permissions",
key: 'roles',
expanded: false,
permission: null,
items: [
{ label: " مدیریت مقام ها", icon: "fi fi-rr-wifi-1", to: "/admin/roles", permission: "roles.index" },
{ label: "مدیریت سطح ها", icon: "fi fi-rr-wifi-1", to: "/admin/permissions", permission: "permissions.index" },
],
},
{
label: "کاربران",
icon: "fi fi-rr-user",
key: 'users',
expanded: false,
permission: null,
items: [
{ label: "همه کاربران", icon: "fi fi-rr-wifi-1", to: "/admin/users", permission: "users.index" },
{ label: "افزودن کاربر جدید", icon: "fi fi-rr-wifi-1", to: "/admin/users/create", permission: "users.create" },
],
},
{
label: "تنظیمات",
icon: "fi fi-rr-settings",
key: 'settings',
expanded: false,
permission: null,
items: [
{ label: "اطلاعات کاربری",icon: "fi fi-rr-wifi-1", to: "/admin/setting-profile", permission: null },
{ label: "تنظیمات امنیتی", icon: "fi fi-rr-wifi-1", to: "/admin/setting-profile/security", permission: null },
{ label: "دستگاه های فعال", icon: "fi fi-rr-wifi-1", to: "/admin/setting-profile/sessions", permission: null },
],
},
{
label: "خروج از حساب",
icon: "fi fi-rr-sign-out-alt",
to: "/admin/auth/logout",
permission: null,
},
]);
const toggleSubmenu = (item: SidebarItem) => {
// expandedKeys.value = {};
// item.expanded = !item.expanded;
};
const closeSidebarOnClick = () => {
globalStore.sidebarOpen = false;
};
const expandedKeys = ref<Record<string, boolean>>({});
onMounted(() => {
globalStore.current_user = currentUser;
const saved = localStorage.getItem('expandedKeys')
if (saved) expandedKeys.value = JSON.parse(saved)
updateExpandedKeys()
document.addEventListener('click', (e) => {
if (e.target.closest('.p-panelmenu-content a')) {
globalStore.sidebarOpen = false;
}
});
});
watch(expandedKeys, v => {
localStorage.setItem('expandedKeys', JSON.stringify(v))
})
watch(() => url.value, () => {
updateExpandedKeys()
})
const isActive = (item: SidebarItem): boolean => {
return expandedKeys.value[item.key] === true;
};
const updateExpandedKeys = () => {
const newKeys = {}
const currentPath = url.value.split('?')[0];
items.value.forEach(item => {
if (item.to === currentPath || item.items?.some(c => {
const itemPath = c.to || '';
return currentPath.startsWith(itemPath) && itemPath !== '';
})) {
newKeys[item.key] = true
}
})
expandedKeys.value = newKeys
};
</script>
<template>
<div v-if="globalStore.sidebarOpen"
class="fixed inset-0 bg-black/10 backdrop-blur-sm bg-opacity-50 z-10 sm:hidden"
@click="globalStore.sidebarOpen = false">
</div>
<aside class="!bg-white shadow-all shadow-client-gray/5 w-[16rem]
py-4 z-10 pb-8 fixed sm:relative inset-y-0 right-0 sm:inset-auto transition-transform duration-300 z-20"
:class="[globalStore.sidebarOpen ? 'translate-x-0' : 'translate-x-full sm:translate-x-0']">
<section class="sticky top-10 right-0 ">
<div class="grid grid-cols-a border-b-[1px] border-client-gray/5 pb-6 justify-center gap-2 items-center">
<figure class="grid grid-cols-a justify-center">
<Avatar v-if="globalStore.current_user?.profile_url" :image="globalStore.current_user?.profile_url" size="xlarge" shape="circle" />
<Avatar v-else image="/images/profile_default.png" size="xlarge" shape="circle" />
</figure>
<div class="text-xl grid grid-cols-a text-center text-client-Fontcolor font-semibold">
<span class="text-lg font-extrabold mt-2">{{ (globalStore.current_user.first_name ?? '' ) + ' ' + (globalStore.current_user.last_name ?? '')}} </span>
<span class="text-xs mt-1">{{ globalStore.current_user.email }}</span>
</div>
</div>
<div class="overflow-y-auto h-full">
<PanelMenu
:pt="{
header: 'custom-header',
headerAction: 'custom-header-action',
menuContent: 'custom-menu-content',
menuitem: 'custom-menuitem',
action: 'custom-action',
icon: 'custom-icon',
submenuIcon: 'custom-submenu-icon !text-xl',
}"
v-model:expanded-keys="expandedKeys"
:model="filteredItems"
class="custom-menu px-4">
<template #item="{ item, root, hasSubmenu, toggle }">
<div
:class="(url.split('?')[0]) === item.to ? 'bg-client-primary !text-white font-bold' : 'text-client-Fontcolor'"
class="flex items-center h-10 justify-between pl-2 text-client-Fontcolor rounded-lg cursor-pointer w-full"
@click="hasSubmenu ? toggleSubmenu(item) : null">
<div class="px-2 flex w-full items-center">
<Link v-if="item.to" :href="item.to" class="flex items-center h-10 w-full" @click="globalStore.sidebarOpen = false">
<i v-if="item.icon" :class="item.icon" class="flex !text-[1.2rem] -mt-0.5"></i>
<div class="flex items-center font-normal text-sm p-2 gap-2 w-full">
{{ item.label }}
</div>
</Link>
<div v-else class="flex items-center h-10 w-full">
<i v-if="item.icon" :class="item.icon" class="flex !text-[1.2rem] -mt-0.5"></i>
<span class="w-full p-2 text-sm text-client-Fontcolor">{{ item.label }}</span>
</div>
</div>
<i v-if="hasSubmenu"
class="fi fi-rr-angle-small-left !text-sm flex transition-transform duration-300"
:class="[{ 'rotate-90': item.expanded || isActive }]">
</i>
</div>
</template>
</PanelMenu>
</div>
</section>
</aside>
</template>
@@ -0,0 +1,402 @@
<script setup>
import { ref, watch, onMounted } from 'vue';
import Pagination from './Pagination.vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Toolbar from 'primevue/toolbar';
import Dropdown from 'primevue/dropdown';
import MultiSelect from 'primevue/multiselect';
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
},
columns: {
type: Array,
required: true,
default: () => []
},
selectable: {
type: Boolean,
default: false
},
modelValue: {
type: Array,
default: () => []
},
pagination: {
type: Object,
default: () => null
},
searchable: {
type: Boolean,
default: true
},
searchValue: {
type: String,
default: ''
},
filters: {
type: Object,
default: () => ({})
},
filterLoader: {
type: Boolean,
default: false
},
});
const emit = defineEmits(['update:modelValue', 'selection-change', 'page-change', 'search-change', 'filter-change', 'apply-filters', 'clear-filters']);
const localSelection = ref(props.modelValue);
watch(() => props.modelValue, (newValue) => {
localSelection.value = newValue;
}, { immediate: true });
const handleSelectionChange = (event) => {
localSelection.value = event;
emit('update:modelValue', event);
emit('selection-change', event);
};
const localSearchValue = ref(props.searchValue);
watch(() => props.searchValue, (newValue) => {
localSearchValue.value = newValue;
});
let searchDebounceTimer = null;
const handleSearchInput = () => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
emit('search-change', localSearchValue.value);
}, 1500);
};
const localFilters = ref({ ...props.filters });
const dynamicOptions = ref({});
const loadingOptions = ref({});
const searchDebounceTimers = ref({});
const searchValues = ref({});
onMounted(() => {
props.columns.forEach(column => {
if (column.filter?.dynamic && props.filters[column.key]) {
const filterValue = props.filters[column.key];
searchValues.value[column.key] = filterValue.toString();
loadDynamicOptions(column, true, filterValue.toString(), true);
}
else if (column.filter?.dynamic) {
searchValues.value[column.key] = '';
loadDynamicOptions(column, true, '', false);
}
});
});
watch(() => props.filters, (newValue) => {
Object.keys(newValue).forEach(key => {
if (newValue[key] !== undefined && newValue[key] !== null) {
localFilters.value[key] = newValue[key];
}
});
}, { immediate: true, deep: true });
const handleFilterChange = (columnKey, value) => {
localFilters.value[columnKey] = value;
emit('filter-change', { ...localFilters.value });
};
const clearAllFilters = () => {
localFilters.value = {};
dynamicOptions.value = {};
searchValues.value = {};
emit('clear-filters');
};
const loadDynamicOptions = async (column, forceReload = false, searchTerm = '', shouldSelectValue = false) => {
if (!column.filter?.dynamic || !column.filter?.url) return;
const columnKey = column.key;
loadingOptions.value[columnKey] = true;
try {
let url = column.filter.url;
if (searchTerm) {
url += `?search=${encodeURIComponent(searchTerm)}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.success && data.data) {
dynamicOptions.value[columnKey] = data.data;
if (shouldSelectValue && props.filters[columnKey]) {
const filterValue = props.filters[columnKey];
const foundOption = data.data.find(option => option.value == filterValue);
if (foundOption) {
localFilters.value[columnKey] = foundOption.value;
}
}
} else {
dynamicOptions.value[columnKey] = [];
}
} catch (error) {
console.error('Error:', error);
dynamicOptions.value[columnKey] = [];
} finally {
loadingOptions.value[columnKey] = false;
}
};
const handleFilterSearch = (column, searchTerm) => {
if (!column.filter?.dynamic || !column.filter?.search) return;
const columnKey = column.key;
searchValues.value[columnKey] = searchTerm;
if (searchDebounceTimers.value[columnKey]) {
clearTimeout(searchDebounceTimers.value[columnKey]);
}
searchDebounceTimers.value[columnKey] = setTimeout(() => {
loadDynamicOptions(column, true, searchTerm, false);
}, 1500);
};
const renderFilter = (column) => {
if (!column.filter) return null;
const filterConfig = column.filter;
const currentValue = localFilters.value[column.key] || null;
const columnKey = column.key;
let options = [];
if (filterConfig.dynamic) {
options = dynamicOptions.value[columnKey] || [];
} else if (filterConfig.options) {
options = filterConfig.options;
}
if (filterConfig.type === 'dropdown') {
return {
type: 'dropdown',
options: loadingOptions.value[columnKey] ? [{ label: 'در حال پردازش...', value: null, disabled: true }] : options,
value: currentValue,
placeholder: filterConfig.placeholder || 'انتخاب کنید...',
loading: loadingOptions.value[columnKey] || false,
dynamic: filterConfig.dynamic || false,
search: filterConfig.search || false,
searchValue: searchValues.value[columnKey] || ''
};
} else if (filterConfig.type === 'multiselect') {
return {
type: 'multiselect',
options: loadingOptions.value[columnKey] ? [{ label: 'در حال پردازش...', value: null, disabled: true }] : options,
value: currentValue || [],
placeholder: filterConfig.placeholder || 'انتخاب کنید...',
loading: loadingOptions.value[columnKey] || false,
dynamic: filterConfig.dynamic || false,
search: filterConfig.search || false,
searchValue: searchValues.value[columnKey] || ''
};
}
return null;
};
const renderColumnContent = (column, data) => {
let content = '';
if (column.render && typeof column.render === 'function') {
content = column.render(data);
} else {
content = data[column.key];
}
const defaultStyle = '!font-medium !text-sm';
const customStyle = column.style || defaultStyle;
return `<span class="${customStyle} text-right wrap-break-word text-sm">${content}</span>`;
};
const getColumnStyle = (column) => {
const baseStyle = { minWidth: '120px' };
if (column.width) {
baseStyle.width = column.width + 'px';
baseStyle.minWidth = column.width + 'px';
baseStyle.maxWidth = column.width + 'px';
}
return baseStyle;
};
</script>
<template>
<div class="table-generic">
<div v-if="searchable" class="p-4 pb-2">
<Toolbar class="">
<template #start>
<div class="grid grid-cols-2a max-sm:grid-cols-1 justify-start gap-2">
<div class="grid grid-cols-1 items-center justify-start">
<InputText
size="small"
class="placeholder:!font-light placeholder:!text-xs !h-10"
placeholder="جستجو..."
type="text"
v-model="localSearchValue"
@input="handleSearchInput"
/>
</div>
<div v-if="columns.some(col => col.filter)" class="">
<div class="flex flex-wrap max-sm:!flex-col gap-2 py-2">
<div
v-for="column in columns.filter(col => col.filter)"
:key="`filter-${column.key}`"
class="flex items-center gap-2"
>
<Dropdown
v-if="renderFilter(column)?.type === 'dropdown'"
:modelValue="renderFilter(column).value"
:options="renderFilter(column).options"
:placeholder="renderFilter(column).loading ? 'در حال بارگذاری...' : renderFilter(column).placeholder"
optionLabel="label"
optionValue="value"
size="small"
:pt="{label : '!text-[12px] !pt-2.5' , option : '!text-[12px] !font-normal'}"
class="min-w-[130px] max-sm:!w-full max-w-[200px] !text-[12px] !h-10"
:loading="renderFilter(column).loading"
:disabled="renderFilter(column).loading"
:filter="renderFilter(column).search"
:filterValue="renderFilter(column).searchValue"
:filterPlaceholder="renderFilter(column).search ? 'جستجو...' : ''"
emptyMessage="داده ای یافت نشد"
:filterMatchModeOptions="[]"
:filterFunction="() => true"
@update:modelValue="(value) => handleFilterChange(column.key, value)"
@filter="(event) => handleFilterSearch(column, event.value)"
showClear
/>
<MultiSelect
v-else-if="renderFilter(column)?.type === 'multiselect'"
:modelValue="renderFilter(column).value"
:options="renderFilter(column).options"
:placeholder="renderFilter(column).loading ? 'در حال بارگذاری...' : renderFilter(column).placeholder"
optionLabel="label"
optionValue="value"
size="small"
:pt="{label : '!text-[12px] !pt-2.5' , option : '!text-[12px] !font-normal'}"
class="min-w-[150px] max-sm:!w-full max-w-[200px] !text-[12px] !h-10"
:loading="renderFilter(column).loading"
:disabled="renderFilter(column).loading"
:filter="renderFilter(column).search"
:filterValue="renderFilter(column).searchValue"
:filterPlaceholder="renderFilter(column).search ? 'جستجو...' : ''"
emptyMessage="داده ای یافت نشد"
:filterMatchModeOptions="[]"
:filterFunction="() => true"
@update:modelValue="(value) => handleFilterChange(column.key, value)"
@filter="(event) => handleFilterSearch(column, event.value)"
showClear
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="filterLoader ? 'در حال پردازش...' : 'اعمال فیلتر'"
size="small"
severity="primary"
class="!text-xs h-10"
:loading="filterLoader"
:disabled="filterLoader"
@click="$emit('apply-filters')"
/>
<Button
label="پاک کردن"
size="small"
severity="secondary"
class="!text-xs h-10"
outlined
@click="clearAllFilters"
/>
</div>
</div>
</div>
</div>
<div v-if="localSelection && localSelection.length > 0" class="mr-2 !h-9 grid grid-cols-1">
<slot name="bulk-action" :selection="localSelection"></slot>
</div>
</template>
<template #end>
<slot name="toolbar-actions"></slot>
</template>
</Toolbar>
</div>
<DataTable
:value="data"
dataKey="id"
v-model:selection="localSelection"
:showGridlines="false"
:stripedRows="false"
@selection-change="handleSelectionChange"
>
<template #empty>
<div class="flex justify-center py-4 items-center h-full">
<span class="text-center text-sm">هیچ داده‌ای پیدا نشده!</span>
</div>
</template>
<Column
v-if="selectable"
selectionMode="multiple"
headerStyle="width: 4rem"
/>
<Column
v-for="column in columns"
:key="column.key"
:field="column.key"
:header="column.title"
:sortable="column.hasSort === true"
:style="getColumnStyle(column)"
>
<template #body="{ data }">
<slot v-if="column.slot" :name="`column-${column.key}`" :data="data" :column="column">
<div v-html="renderColumnContent(column, data)"></div>
</slot>
<div v-else v-html="renderColumnContent(column, data)"></div>
</template>
</Column>
</DataTable>
<Pagination
v-if="pagination && pagination.total"
:total="pagination.total"
:current-page="pagination.current_page"
:perPage="pagination.per_page"
:lastPage="pagination.last_page"
:links="pagination.links"
@page-change="(page) => emit('page-change', page)"
/>
</div>
</template>
<style scoped>
.table-generic {
width: 100%;
}
</style>
@@ -0,0 +1,44 @@
<script setup>
import vueDropzone from 'dropzone-vue3'
import {useMediumStore} from "../../store/Medium.js";
const props = defineProps({
uploadEndpoint: {
type: String,
default: '/admin/medium'
}
});
const dropzoneOptions = {
url: props.uploadEndpoint,
thumbnailWidth: 150,
maxFilesize: 20,
dictDefaultMessage: `
<div class="!font-dana">
<span class="font-black text-lg">آپلود رسانه</span>
<p class="font-normal text-xs">حداکثر آپلود فایل 20 مگابایت میباشد</p>
</div>
`,
headers : { "X-CSRF-TOKEN": document.head.querySelector("[name=csrf-token]").content}
}
const MediumStore = useMediumStore();
const MediumSuccess = (response) => {
if (response.xhr.response == 'noFormat'){
alert('فرمت فایل نا معتبر است')
}
MediumStore.mediums.unshift(JSON.parse(response.xhr.response));
console.log(JSON.parse(response.xhr.response))
};
</script>
<template>
<vue-dropzone
ref="myVueDropzone"
id="dropzone"
class="!border-dashed !bg-transparent overflow-y-auto max-h-[12rem]"
:options="dropzoneOptions"
v-on:vdropzone-success="MediumSuccess"
/>
</template>
@@ -0,0 +1,174 @@
<script setup>
import {onMounted, reactive, ref, watch} from "vue";
import {useMediumStore} from "../../store/Medium.js";
import Media from "./Media.vue";
import PaginationAjax from "../PaginationAjax.vue";
import AddMedium from "./AddMedia.vue";
const MediumStore = useMediumStore();
const emit = defineEmits(['update:modelValue', 'remove-item']);
const props = defineProps({
modelValue: { type: [Boolean, Array], required: true },
multiple: { type: Boolean, default: false },
uploadEndpoint: { type: String, default: '/admin/upload-medium' }
});
const selectImage = ref({});
const selectedImages = ref([]);
const isChooseMedium = ref(false);
const visible_modal_mediums = ref(false);
const hasLoaded = ref(false);
watch(() => props.modelValue, (newVal) => {
if (props.multiple) {
selectedImages.value = Array.isArray(newVal) ? newVal : [];
isChooseMedium.value = selectedImages.value.length > 0;
} else {
selectImage.value = newVal;
isChooseMedium.value = newVal && newVal.url ? true : false;
}
}, { immediate: true });
const diselect_medium = () => {
if (props.multiple) {
selectedImages.value = [];
isChooseMedium.value = false;
emit('update:modelValue', []);
} else {
selectImage.value = {};
isChooseMedium.value = false;
emit('update:modelValue', selectImage.value);
}
}
const getMediums = (page = 1) => {
axios.get(`/admin/get-medium?page=${page}`).then(res => {
MediumStore.mediums = res.data.data ?? [];
MediumStore.paginate.total = res.data.total ?? null;
MediumStore.paginate.last_page = res.data.last_page ?? null;
MediumStore.paginate.current_page = res.data.current_page ?? null;
MediumStore.paginate.links = res.data.links ?? null;
MediumStore.paginate.per_page = res.data.per_page ?? null;
hasLoaded.value = true;
})
}
const toggle_select_image = (medium, event) => {
if (props.multiple) {
const isCtrlPressed = event.ctrlKey || event.metaKey;
const existingIndex = selectedImages.value.findIndex(c => c.id === medium.id);
if (existingIndex > -1) {
selectedImages.value.splice(existingIndex, 1);
} else {
if (isCtrlPressed) {
selectedImages.value.push(medium);
} else {
selectedImages.value = [medium];
}
}
} else {
if (selectImage.value != medium) {
selectImage.value = medium;
} else {
selectImage.value = {};
}
}
}
const chooseMedium = () => {
isChooseMedium.value = true;
if (props.multiple) {
emit('update:modelValue', selectedImages.value);
} else {
emit('update:modelValue', selectImage.value);
}
toggle_show_modal();
}
const toggle_show_modal = () => {
visible_modal_mediums.value = !visible_modal_mediums.value;
if (!hasLoaded.value){
getMediums();
}
}
const removeSelectedImage = (index) => {
emit('remove-item', index);
}
</script>
<template>
<div class="card flex justify-center">
<div @click="toggle_show_modal" class="m-auto mt-6 grid grid-cols-a shadow-lg items-center border-3 border-client-gray/5 relative w-[14rem] content-center justify-center
rounded-2xl h-[14rem] cursor-pointer text-center text-client-Fontcolor/90 shadow-client-gray/10">
<div v-if="!isChooseMedium && (!selectImage?.url && selectedImages.length === 0)" class="grid grid-cols-a">
<div class="flex justify-center m-auto bg-client-gray/10 p-2 rounded-2xl">
<i class="fi fi-rr-cloud-upload-alt flex text-client-Fontcolor/80 m-auto text-4xl"></i>
</div>
<div class="absolute -top-3 -right-3 shadow-all p-3 rounded-3xl">
<i class="fi fi-rr-add-image flex text-xl"></i>
</div>
<span class="block text-sm text-client-Fontcolor/80 font-black mt-3">
{{ multiple ? 'انتخاب یا بارگذاری فایل‌ها' : 'انتخاب یا بارگذاری تصویر کاربر' }}
</span>
<span class="block text-xs mt-1">حداکثر آپلود ۲ مگابایت میباشد</span>
</div>
<div v-else-if="multiple && selectedImages.length > 0" class="p-2">
<h1 class="font-bold text-sm">
انتخاب فایل های بیشتر
</h1>
<div class="text-center mt-2">
<span class="text-xs text-gray-500">{{ selectedImages.length }} فایل انتخاب شده</span>
</div>
</div>
<div v-else-if="!multiple && selectImage?.url">
<figure class="h-[13rem] px-4 py-0">
<img class="w-full h-full object-contain" :src="selectImage.url" :alt="selectImage.filename">
</figure>
</div>
</div>
<Dialog v-model:visible="visible_modal_mediums" maximizable modal header="انتخاب رسانه"
:style="{ width: '75vw' , minHeight: '45rem' }"
:pt="{header : '!bg-client-primaryBlue'}"
:breakpoints="{ '1199px': '75vw', '575px': '90vw' }">
<template #header>
<div class="flex items-center justify-between w-full">
<h2 class="font-black text-lg">نمایش همه رسانه</h2>
<div class="flex gap-2 px-6" v-if="(selectImage && selectImage?.id) || (multiple && selectedImages.length > 0)">
<Button @click="chooseMedium" label="انتخاب میکنم" variant="outlined" size="small" severity="" class="" />
</div>
</div>
</template>
<div class="grid grid-cols-1 overflow-y-auto max-h-[12rem]">
<add-medium class="h-full w-full" :upload-endpoint="uploadEndpoint"></add-medium>
</div>
<div id="startModal" class="grid grid-cols-[repeat(auto-fill,_minmax(8rem,11rem))] mt-3 gap-4 justify-center">
<Media :action="false" @click="toggle_select_image(medium, $event)" v-for="(medium , index) in MediumStore.mediums"
:medium="medium" :class="['border-[2px] border-transparent transition-all',
{'!border-[2px] !border-client-primary' : multiple ? selectedImages.some(c => c.id === medium.id) : medium.id == selectImage.id }]" :key="index"></Media>
</div>
<PaginationAjax
:onPaginate="(page) => getMediums(page)"
top-scroll="startModal"
:total="MediumStore.paginate.total"
:current-page="MediumStore.paginate.current_page"
:perPage="MediumStore.paginate.per_page"
:lastPage="MediumStore.paginate.last_page"
:links="MediumStore.paginate.links">
</PaginationAjax>
</Dialog>
</div>
<div v-if="((selectImage && selectImage?.id) || (multiple && selectedImages.length > 0)) && isChooseMedium" class="grid mt-4 grid-cols-a justify-center gap-2">
<Button @click="diselect_medium" variant="outlined" size="small" severity="secondary" class="flex m-auto" label="لغو انتخاب"/>
<div v-if="multiple && selectedImages.length > 0" class="space-y-1">
<div v-for="(image, index) in selectedImages" :key="index" class="flex items-center justify-between bg-gray-100 p-2 rounded text-sm">
<span class="truncate text-xs px-4">{{ image.filename }}</span>
<Button icon="fi fi-rr-trash" size="small" severity="danger" text @click="removeSelectedImage(index)" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,30 @@
<script setup>
import {useMediumStore} from "../../store/Medium.js";
import Pagination from "../Pagination.vue";
import Media from "./Media.vue";
const MediumStore = useMediumStore();
</script>
<template>
<section class="grid grid-cols-1 mt-4">
<div class="grid grid-cols-5 max-xl:grid-cols-4 max-lg:grid-cols-3 max-md:grid-cols-2 gap-4 justify-center max-sm:grid-cols-1">
<Media :action="true" v-for="medium in MediumStore.mediums" :medium="medium" :key="medium.id"
:class="{'active' : medium.id === MediumStore.selected }"></Media>
</div>
<div class="bottom">
<Pagination
:total="MediumStore.paginate.total"
:current-page="MediumStore.paginate.current_page"
:perPage="MediumStore.paginate.per_page"
:lastPage="MediumStore.paginate.last_page"
:links="MediumStore.paginate.links"></Pagination>
</div>
</section>
</template>
<style scoped lang="css">
</style>
@@ -0,0 +1,94 @@
<script setup>
import {ref} from "vue";
import {useMediumStore} from "../../store/Medium.js";
import {Inertia} from "@inertiajs/inertia";
const MediumStore = useMediumStore();
const hasCopyUrl = ref(false);
const props = defineProps({
medium : {
type:Object
},
action : {
type: Boolean,
default: () => false
}
});
const copyImageUrl = (id) => {
let testingCodeToCopy = document.getElementById(`mediumUrlCopy_${id}`);
testingCodeToCopy.setAttribute('type', 'value');
testingCodeToCopy.select();
document.execCommand('copy')
testingCodeToCopy.setAttribute('type', 'hidden');
hasCopyUrl.value = id;
window.getSelection().removeAllRanges();
}
const singleDelete = (id) => {
Inertia.delete(`/admin/medium/${id}`, {
onSuccess: () => {
MediumStore.mediums = MediumStore.mediums.filter(medium => medium.id !== id);
}
}, { replace: false });
}
const getImageUrl = (medium) => {
if (medium.disk === 'public') {
return medium.url;
}
if (medium.disk === 'private') {
return `/admin/medium/${medium.id}/serve`;
}
return medium.url;
}
</script>
<template>
<div class="grid grid-cols-1 bg-white border-[1px] border-gray-100 shadow-all cursor-pointer rounded-2xl">
<input :id="`mediumUrlCopy_${medium.id}`" type="hidden" :value="getImageUrl(medium)"/>
<figure class="p-3 h-[10rem] content-center">
<img class="w-full h-full rounded-2xl object-contain" v-if="medium.mime_type == 'mp4' || medium.mime_type == 'mkv'" src="@/images/musicFolder.png">
<img class="w-full h-full rounded-2xl object-contain" v-else-if="medium.mime_type == 'mp3'" src="@/images/musicFolder.png">
<img class="w-full h-full rounded-2xl object-contain" v-else-if="medium.mime_type == 'application/zip' || medium.mime_type == 'application/rar'" src="@/images/zip.ico">
<img class="w-full h-full rounded-2xl object-contain" v-else-if="medium.mime_type == 'application/pdf'" src="@/images/pdf.webp">
<img class="w-full h-full rounded-2xl object-contain" v-else-if="medium.mime_type == 'application/txt'" src="@/images/txtFormat.png">
<img class="w-full h-full rounded-2xl object-contain" v-else-if="medium.mime_type == 'sql'" src="@/images/1769_-_SQL-512.png">
<img class="w-full h-full object-contain rounded-2xl" v-else :src="getImageUrl(medium)">
</figure>
<div class="px-4 text-xs grid grid-cols-1 gap-2 mb-4">
<div class="grid grid-cols-2a justify-between gap-2 items-center">
<span class="text-[.67rem]">نام فایل:</span>
<span class="text-xs font-light w-full truncate">{{medium.filename}}</span>
</div>
<div class="grid grid-cols-2a justify-between gap-2 items-center">
<span class="text-[.67rem]">سایز فایل:</span>
<span class="text-xs font-light w-full truncate"> {{medium.size}}kb </span>
</div>
<div class="grid grid-cols-2a justify-between gap-2 items-center">
<span class="text-[.67rem]">فرمت فایل:</span>
<span class="text-xs font-light w-full truncate">{{medium.mime_type}}</span>
</div>
<div v-if="action" class="grid grid-cols-1 justify-between gap-2 items-center" :class="{ '!grid-cols-2' : action}">
<Button type="button"
@click="singleDelete(medium.id)" severity="danger"
label="حذف"
outlined
class="!text-xs"
size="small"
:pt="{icon : '!mt-2 !text-xs'}"/>
<Button @click="copyImageUrl(medium.id)" type="button" class="!text-xs"
severity="secondary"
size="small"
:label="hasCopyUrl == medium.id ? '✓ کپی شد ' : 'کپی آدرس'"
:pt="{icon : '!mt-2 !text-xs'}"/>
</div>
</div>
</div>
</template>
<style scoped lang="css">
</style>
@@ -0,0 +1,248 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import axios from 'axios'
import UISelect from '../ui/UISelect.vue'
import { useReportStore } from '../../store/Report.js'
import { useToast } from 'primevue/usetoast'
const reportStore = useReportStore()
const props = defineProps({
reportData: {
type: Object,
default: null
},
isEdit: {
type: Boolean,
default: false
},
isFullscreen: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'cancel'])
const toast = useToast()
const form = ref({
document_id: null,
doctor_id: null,
medium_id: null,
status: 'Draft',
body: '',
submitted_at: null
})
const isLoading = ref(false)
const quillToolbar = computed(() => {
return props.isFullscreen ? 'full' : 'essential'
})
const statusOptions = [
{ label: 'پیش‌نویس', value: 'Draft' },
{ label: 'در حال انجام', value: 'InProgress' },
{ label: 'تکمیل شده', value: 'Completed' },
{ label: 'بررسی شده', value: 'Reviewed' },
{ label: 'تایید شده', value: 'Approved' },
{ label: 'رد شده', value: 'Rejected' }
]
const documentOptions = ref([])
const doctorOptions = ref([])
const mediaOptions = ref([])
const isLoadingOptions = ref(true)
onMounted(() => {
loadInitialOptions()
if (props.isEdit && props.reportData) {
populateFormWithData()
addCurrentToOptions()
}
})
const loadInitialOptions = async () => {
try {
isLoadingOptions.value = true
const documentsResponse = await axios.get('/admin/documents-options')
if (documentsResponse.data.success) {
documentOptions.value = documentsResponse.data.data
}
const doctorsResponse = await axios.get('/admin/doctors-options')
if (doctorsResponse.data.success) {
doctorOptions.value = doctorsResponse.data.data
}
mediaOptions.value = []
} catch (error) {
console.error('Error loading initial options:', error)
} finally {
isLoadingOptions.value = false
}
}
const populateFormWithData = () => {
if (props.reportData) {
const statusOption = statusOptions.find(option => option.label === props.reportData.status)
const statusValue = statusOption ? statusOption.value : 'Draft'
form.value = {
document_id: props.reportData.document?.id || null,
doctor_id: props.reportData.doctor?.id || null,
medium_id: props.reportData.medium_id || null,
status: statusValue,
body: props.reportData.body || '',
submitted_at: props.reportData.submitted_at_form
}
}
}
const addCurrentToOptions = () => {
if (props.reportData) {
if (props.reportData.document && !documentOptions.value.find(opt => opt.value === props.reportData.document.id)) {
documentOptions.value.unshift({
label: `پرونده ${props.reportData.document.code}`,
value: props.reportData.document.id
})
}
if (props.reportData.doctor && !doctorOptions.value.find(opt => opt.value === props.reportData.doctor.id)) {
doctorOptions.value.unshift({
label: props.reportData.doctor.fullname,
value: props.reportData.doctor.id
})
}
}
}
const handleSubmit = async () => {
isLoading.value = true
try {
reportStore.createForm = form.value
await emit('submit', props.isEdit ? props.reportData.id : null)
} catch (error) {
console.error('Error submitting form:', error)
} finally {
isLoading.value = false
}
}
const handleCancel = () => {
resetForm()
emit('cancel')
}
const resetForm = () => {
form.value = {
document_id: null,
doctor_id: null,
medium_id: null,
status: 'Draft',
body: '',
submitted_at: null
}
loadInitialOptions()
}
</script>
<template>
<div class="space-y-6 overflow-hidden">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1">
<label class="block text-sm font-medium text-gray-700 mb-2">
انتخاب پرونده
</label>
<UISelect
v-model="form.document_id"
:options="documentOptions"
optionValue="value"
optionLabel="label"
searchUrl="/admin/documents-options"
searchParam="search"
:placeholder="isLoadingOptions ? 'در حال بارگذاری...' : 'انتخاب پرونده'"
:filter="true"
@update:options="documentOptions = $event"
/>
</div>
<div class="grid grid-cols-1">
<label class="block text-sm font-medium text-gray-700 mb-2">
انتخاب پزشک
</label>
<UISelect
v-model="form.doctor_id"
:options="doctorOptions"
optionValue="value"
optionLabel="label"
searchUrl="/admin/doctors-options"
searchParam="search"
:placeholder="isLoadingOptions ? 'در حال بارگذاری...' : ' انتخاب پزشک'"
:filter="true"
@update:options="doctorOptions = $event"
/>
</div>
<div class="grid grid-cols-1">
<label class="block text-sm font-medium text-gray-700 mb-2">
وضعیت
</label>
<UISelect
v-model="form.status"
:options="statusOptions"
optionValue="value"
optionLabel="label"
placeholder="انتخاب وضعیت"
/>
</div>
<div class="">
<label class="block text-sm font-medium text-gray-700 mb-2">
تاریخ ارسال
</label>
<CustomDatePicker
v-model="form.submitted_at"
type="datetime"
compact-time
class="w-full"
/>
</div>
</div>
<div class="grid grid-cols-1">
<label class="block text-sm font-medium text-gray-700 mb-2">
محتوای گزارش
</label>
<div class="text-right w-full max-w-full">
<quill-editor
:key="`quill-${props.isFullscreen ? 'full' : 'essential'}`"
theme="snow"
:toolbar="quillToolbar"
:content="form.body"
@update:content="form.body = $event"
content-type="html"
placeholder="محتوای گزارش را اینجا بنویسید..."
/>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<Button
class="!text-sm"
label="انصراف"
severity="secondary"
@click="handleCancel"
:disabled="isLoading"
/>
<Button
class="!text-sm"
:label="isEdit ? 'ویرایش گزارش' : 'ایجاد گزارش'"
@click="handleSubmit"
:loading="isLoading"
/>
</div>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup>
defineProps({
class: {
type: String,
default: ''
}
});
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-4" :class="class">
<slot />
</div>
</template>
@@ -0,0 +1,154 @@
<script setup>
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
maximizable: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large', 'xlarge'].includes(value)
},
closable: {
type: Boolean,
default: true
},
dismissableMask: {
type: Boolean,
default: true
},
modal: {
type: Boolean,
default: true
},
class: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'hide', 'show', 'fullscreen-change'])
const isVisible = ref(props.visible)
const isFullscreen = ref(false)
watch(() => props.visible, (newValue) => {
isVisible.value = newValue
})
watch(isVisible, (newValue) => {
emit('update:visible', newValue)
if (newValue) {
emit('show')
nextTick(() => {
checkFullscreenState()
})
} else {
emit('hide')
}
})
const checkFullscreenState = () => {
const dialog = document.querySelector('.p-dialog:not(.p-dialog-hidden)')
if (dialog) {
const rect = dialog.getBoundingClientRect()
const isMaximized = rect.width >= window.innerWidth * 0.95 && rect.height >= window.innerHeight * 0.95
if (isMaximized !== isFullscreen.value) {
isFullscreen.value = isMaximized
emit('fullscreen-change', isMaximized)
}
}
}
const handleResize = () => {
if (isVisible.value) {
checkFullscreenState()
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
const hide = () => {
isVisible.value = false
}
const show = () => {
isVisible.value = true
}
const handleMaximize = () => {
isFullscreen.value = true
emit('fullscreen-change', true)
}
const handleUnmaximize = () => {
isFullscreen.value = false
emit('fullscreen-change', false)
}
const sizeClasses = {
small: 'max-w-md',
medium: 'max-w-lg',
large: 'max-w-2xl',
xlarge: 'max-w-3xl'
}
defineExpose({
show,
hide
})
</script>
<template>
<Dialog
v-model:visible="isVisible"
:modal="modal"
:closable="closable"
:dismissableMask="dismissableMask"
:maximizable="maximizable"
@hide="hide"
@show="show"
@maximize="handleMaximize"
@unmaximize="handleUnmaximize"
:class="[!maximizable ? sizeClasses[size] : '', props.class]"
:pt="{
root: 'backdrop-blur-sm',
header: 'border-b border-gray-200',
content: 'p-0',
footer: 'border-t border-gray-200',
}"
>
<template #header v-if="title">
<div class="flex items-center justify-between w-full">
<h3 class="text-lg font-semibold text-gray-900">{{ title }}</h3>
</div>
</template>
<div class="p-6">
<slot :isFullscreen="isFullscreen" />
</div>
<template #footer v-if="$slots.footer">
<div class="flex justify-end gap-3">
<slot name="footer" />
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,165 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: [String, Number, Array, Object],
default: null
},
options: {
type: Array,
default: () => []
},
optionValue: {
type: String,
default: 'value'
},
optionLabel: {
type: String,
default: 'label'
},
placeholder: {
type: String,
default: 'انتخاب کنید'
},
multiple: {
type: Boolean,
default: false
},
filter: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
searchUrl: {
type: String,
default: null
},
searchParam: {
type: String,
default: 'search'
},
class: {
type: String,
default: '!text-xs !h-full'
},
pt: {
type: Object,
default: () => ({
option: '!text-sm',
placeholder: '!text-sm',
label: '!pt-3'
})
}
})
const emit = defineEmits(['update:modelValue', 'filter', 'change', 'update:options'])
const isLoading = ref(false)
const searchTimeout = ref(null)
const handleFilter = (event) => {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value)
}
const searchValue = event.value ? event.value.trim() : ''
// If search is empty, make request immediately (no debounce)
if (!searchValue) {
isLoading.value = true
if (props.searchUrl) {
axios.get(props.searchUrl, {
params: {}
}).then(response => {
if (response.data.success) {
emit('update:options', response.data.data)
}
isLoading.value = false
}).catch(error => {
console.error('Search error:', error)
isLoading.value = false
})
} else {
emit('filter', event)
isLoading.value = false
}
return
}
// If search has value, use debounce
isLoading.value = true
searchTimeout.value = setTimeout(() => {
if (props.searchUrl) {
const params = {}
params[props.searchParam] = searchValue
axios.get(props.searchUrl, {
params: params
}).then(response => {
if (response.data.success) {
const newOptions = response.data.data
const currentValue = props.modelValue
const currentOption = props.options.find(opt => {
const value = typeof opt === 'object' ? opt[props.optionValue] : opt
return value == currentValue
})
let finalOptions = newOptions
if (currentOption && !newOptions.find(opt => {
const value = typeof opt === 'object' ? opt[props.optionValue] : opt
return value == currentValue
})) {
finalOptions = [currentOption, ...newOptions]
}
emit('update:options', finalOptions)
}
isLoading.value = false
}).catch(error => {
console.error('Search error:', error)
isLoading.value = false
})
} else {
emit('filter', event)
isLoading.value = false
}
}, 1000)
}
const handleChange = (value) => {
emit('update:modelValue', value)
emit('change', value)
}
onMounted(() => {
return () => {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value)
}
}
})
</script>
<template>
<Select
:class="class"
:placeholder="placeholder"
:modelValue="modelValue"
:options="options"
:optionValue="optionValue"
:optionLabel="optionLabel"
:multiple="multiple"
:filter="filter"
:loading="loading || isLoading"
:pt="pt"
@update:modelValue="handleChange"
@filter="handleFilter"
/>
</template>