complete Complete User Request Submission section RAD-1
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user