ADD: Search and Index system

FIX: Minor Bugs
FIX: Bugs in CreateBelonging Page
This commit is contained in:
2025-04-29 20:49:16 +03:30
parent 2ef8f81f79
commit b1ad3ef174
10 changed files with 285 additions and 148 deletions
+20
View File
@@ -0,0 +1,20 @@
name: deploy-chabokan
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '18'
- name: update-chabokan
env:
CHABOKAN_TOKEN: ${{ secrets.CHABOKAN_TOKEN }}
run: |
npm install -g @chabokan.net/cli
chabok login -t $CHABOKAN_TOKEN
chabok deploy -s bm-vue
-1
View File
@@ -3,7 +3,6 @@
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
justify-content: space-between; justify-content: space-between;
padding: 6rem 2rem 0 2rem;
transition: margin-right var(--layout-section-transition-duration); transition: margin-right var(--layout-section-transition-duration);
} }
+1 -1
View File
@@ -5,7 +5,7 @@
position: fixed; position: fixed;
width: 20rem; width: 20rem;
height: calc(100vh - 8rem); height: calc(100vh - 8rem);
z-index: 999; z-index: 1001;
overflow-y: auto; overflow-y: auto;
user-select: none; user-select: none;
top: 6rem; top: 6rem;
+2 -5
View File
@@ -12,7 +12,6 @@
&.layout-overlay { &.layout-overlay {
.layout-main-container { .layout-main-container {
margin-left: 0; margin-left: 0;
padding-left: 2rem;
} }
.layout-sidebar { .layout-sidebar {
@@ -52,7 +51,6 @@
.layout-main-container { .layout-main-container {
margin-right: 0; margin-right: 0;
padding-left: 2rem;
} }
} }
} }
@@ -71,11 +69,10 @@
.layout-wrapper { .layout-wrapper {
.layout-main-container { .layout-main-container {
margin-left: 0; margin-left: 0;
padding-left: 2rem;
} }
.layout-sidebar { .layout-sidebar {
transform: translateX(-100%); transform: translateX(350px);
left: 0; left: 0;
top: 0; top: 0;
height: 100vh; height: 100vh;
@@ -99,7 +96,7 @@
&.layout-mobile-active { &.layout-mobile-active {
.layout-sidebar { .layout-sidebar {
transform: translateX(0); transform: translateX(30px);
} }
.layout-mask { .layout-mask {
+93
View File
@@ -0,0 +1,93 @@
<!-- LoadingCheck.vue -->
<template>
<div class="inline-flex items-center justify-center">
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
class="transform transition-transform duration-500"
>
<circle
cx="12"
cy="12"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-blue-500"
:style="circleStyle"
/>
<path
d="M8 12l3 3l5-5"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-green-500"
:style="checkStyle"
/>
</svg>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
isComplete: {
type: Boolean,
default: false
},
size: {
type: Number,
default: 24
}
})
const dashOffset = ref(0)
let animationFrame = null
const circleStyle = computed(() => ({
strokeDasharray: '60 60',
strokeDashoffset: props.isComplete ? '60' : String(dashOffset.value),
transition: props.isComplete ? 'all 0.2s ease-in-out' : 'none',
transformOrigin: 'center',
transform: `rotate(${props.isComplete ? -90 : 0}deg)`
}))
const checkStyle = computed(() => ({
opacity: props.isComplete ? 1 : 0,
strokeDasharray: 20,
strokeDashoffset: props.isComplete ? 0 : 20,
transition: 'all 0.3s ease-in-out',
transitionDelay: '0.2s'
}))
let startTime = null
const animate = (timestamp) => {
if (!startTime) startTime = timestamp
dashOffset.value = (timestamp - startTime) / 10
animationFrame = requestAnimationFrame(animate)
}
watch(
() => props.isComplete,
(newValue) => {
if (newValue && animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
}
}
)
onMounted(() => {
if (!props.isComplete) {
animationFrame = requestAnimationFrame(animate)
}
})
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
})
</script>
@@ -2,6 +2,7 @@
import { ProductService } from '@/service/ProductService'; import { ProductService } from '@/service/ProductService';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { BelongingService } from '@/service/BelongingService'; import { BelongingService } from '@/service/BelongingService';
import { useRouter } from 'vue-router';
const products = ref(null); const products = ref(null);
@@ -13,6 +14,7 @@ onMounted(() => {
BelongingService.getAllBelongings().then((data) => (products.value = data)); BelongingService.getAllBelongings().then((data) => (products.value = data));
}); });
const router = useRouter();
function editBelonging(belongingId) { function editBelonging(belongingId) {
router.push('/belonging/edit-belonging/' + belongingId); router.push('/belonging/edit-belonging/' + belongingId);
} }
@@ -34,7 +36,7 @@ function editBelonging(belongingId) {
</template> </template>
</Column> </Column>
<Column style="min-width: 15%" header="نمایش"> <Column style="min-width: 15%" header="نمایش">
<template #body> <template #body="slotProps">
<Button icon="pi pi-search" type="button" class="p-button-text" @click="editBelonging(slotProps.data._id)"></Button> <Button icon="pi pi-search" type="button" class="p-button-text" @click="editBelonging(slotProps.data._id)"></Button>
</template> </template>
</Column> </Column>
+1 -2
View File
@@ -53,14 +53,13 @@ function isOutsideClicked(event) {
return !(sidebarEl.isSameNode(event.target) || sidebarEl.contains(event.target) || topbarEl.isSameNode(event.target) || topbarEl.contains(event.target)); return !(sidebarEl.isSameNode(event.target) || sidebarEl.contains(event.target) || topbarEl.isSameNode(event.target) || topbarEl.contains(event.target));
} }
</script> </script>
<template> <template>
<div class="layout-wrapper" :class="containerClass"> <div class="layout-wrapper" :class="containerClass">
<app-topbar></app-topbar> <app-topbar></app-topbar>
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>
<div class="layout-main-container"> <div class="layout-main-container pt-24 lg:px-10 px-3">
<div class="layout-main"> <div class="layout-main">
<router-view></router-view> <router-view></router-view>
</div> </div>
+27
View File
@@ -27,6 +27,33 @@ export const BelongingService = {
console.log(err); console.log(err);
} }
}, },
searchBelongings: async (query) => {
const config = {
method: 'get',
url: '/api/belongings/admin/search',
withCredentials: true,
params: query
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
}
},
indexBelongings: async () => {
const config = {
method: 'post',
url: '/api/belongings/admin/index-all',
withCredentials: true
};
try {
const response = await axios(config);
return response.data;
} catch (err) {
console.log(err);
}
},
createBelonging: async (data) => { createBelonging: async (data) => {
const config = { const config = {
method: 'post', method: 'post',
@@ -288,7 +288,7 @@ const formatDate = (dateString) => {
</script> </script>
<template> <template>
<TransactionDialog v-model:visible="showTransactionDialog" :belongingId="belonging._id" @saved="onTransactionSaved" /> <TransactionDialog v-model:visible="showTransactionDialog" :belongingId="belonging._id" @saved="onTransactionSaved" />
<div className="card"> <div className="card lg:p-5 px-3">
<div class="mb-4" v-if="editMode === true"> <div class="mb-4" v-if="editMode === true">
<Button @click="openTransactionDialog">افزایش یا کاهش موجودی</Button> <Button @click="openTransactionDialog">افزایش یا کاهش موجودی</Button>
</div> </div>
@@ -299,8 +299,8 @@ const formatDate = (dateString) => {
<span class="text-primary">{{ belonging.name }}</span> <span class="text-primary">{{ belonging.name }}</span>
</div> </div>
<div class="flex my-6"> <div class="lg:flex my-6">
<div class="w-3/4 ml-3"> <div class="lg:w-3/4 w-full ml-3">
<div class=""> <div class="">
<label class="text-lg"> نام دارایی </label> <label class="text-lg"> نام دارایی </label>
<InputText v-model="belonging.name" class="w-full my-2" placeholder="مثلا: 100 عدد کولر گازی" /> <InputText v-model="belonging.name" class="w-full my-2" placeholder="مثلا: 100 عدد کولر گازی" />
@@ -349,7 +349,7 @@ const formatDate = (dateString) => {
</p> </p>
<div class="mx-6 py-4"> <div class="mx-6 py-4">
<label> تراکنش ها </label> <label> تراکنش ها </label>
<div class="card"> <div class="card lg:p-5 px-3">
<DataTable <DataTable
:value="transactions" :value="transactions"
paginator paginator
@@ -362,11 +362,10 @@ const formatDate = (dateString) => {
:globalFilterFields="['type', 'status', 'value', 'date', 'description']" :globalFilterFields="['type', 'status', 'value', 'date', 'description']"
> >
<template #header> <template #header>
<div class="flex justify-content-between"> <div class="lg:flex lg:justify-content-between">
<h3 class="m-0">تراکنش‌ها</h3>
<span class="p-input-icon-left"> <span class="p-input-icon-left">
<i class="pi pi-search" /> <!-- <i class="pi pi-search" />-->
<InputText v-model="filters['global'].value" placeholder="جستجو..." /> <InputText v-model="filters['global'].value" placeholder="جستجو..." class="w-full" />
</span> </span>
</div> </div>
</template> </template>
@@ -416,7 +415,7 @@ const formatDate = (dateString) => {
</div> </div>
</div> </div>
</div> </div>
<div class="w-1/4"> <div class="lg:w-1/4 w-full">
<div> <div>
<label class="text-lg"> دسته بندی </label> <label class="text-lg"> دسته بندی </label>
<Listbox @change="handleSelectCategory($event)" v-model="belonging.category" :options="categories" optionLabel="nativeName" optionValue="_id" checkmark :highlightOnSelect="false" class="w-full my-2" /> <Listbox @change="handleSelectCategory($event)" v-model="belonging.category" :options="categories" optionLabel="nativeName" optionValue="_id" checkmark :highlightOnSelect="false" class="w-full my-2" />
+121 -120
View File
@@ -2,12 +2,16 @@
import { ProductService } from '@/service/ProductService'; import { ProductService } from '@/service/ProductService';
import { FilterMatchMode } from '@primevue/core/api'; import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { BelongingService } from '@/service/BelongingService'; import { BelongingService } from '@/service/BelongingService';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield';
import LoadingCheck from '@/components/LoadingCheck.vue';
onMounted(() => { onMounted(() => {
BelongingService.getAllBelongings().then((data) => (belongings.value = data)); loadData();
}); });
const toast = useToast(); const toast = useToast();
@@ -23,12 +27,6 @@ const filters = ref({
}); });
const submitted = ref(false); const submitted = ref(false);
function openNew() {
product.value = {};
submitted.value = false;
productDialog.value = true;
}
function hideDialog() { function hideDialog() {
productDialog.value = false; productDialog.value = false;
submitted.value = false; submitted.value = false;
@@ -40,69 +38,115 @@ function editBelonging(belongingId) {
router.push('/belonging/edit-belonging/' + belongingId); router.push('/belonging/edit-belonging/' + belongingId);
} }
function confirmDeleteProduct(prod) { const route = useRoute();
product.value = prod; const searchPhrase = ref(route.query.query);
deleteProductDialog.value = true; const page = ref(Number(route.query.page) || 1);
} const per_page = ref(Number(route.query.per_page) || 20);
const totalCount = ref(0);
const pagesCount = ref(0);
function deleteProduct() { let searchTimeout;
products.value = products.value.filter((val) => val.id !== product.value.id);
deleteProductDialog.value = false;
product.value = {};
toast.add({ severity: 'success', summary: 'Successful', detail: 'Product Deleted', life: 3000 });
}
function findIndexById(id) { // Update your search watcher
let index = -1; watch(searchPhrase, (newPhrase, oldPhrase) => {
for (let i = 0; i < products.value.length; i++) { if (newPhrase !== oldPhrase) {
if (products.value[i].id === id) { // Debounce search to avoid too many requests
index = i; if (searchTimeout) clearTimeout(searchTimeout);
break; searchTimeout = setTimeout(() => {
router.push({
query: {
...route.query,
query: newPhrase || undefined,
page: 1 // Reset to first page on new search
} }
});
}, 300);
} }
});
return index; watch(
} () => route.query,
() => {
page.value = Number(route.query.page) || 1;
per_page.value = Number(route.query.per_page) || 20;
searchPhrase.value = route.query.query || '';
loadData();
},
{ deep: true }
);
const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key
searchPhrase.value = '';
};
function createId() { const removeFromQuery = (queryToRemove) => {
let id = ''; const newQuery = { ...route.query };
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (var i = 0; i < 5; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length));
}
return id;
}
function exportCSV() { delete newQuery[queryToRemove];
dt.value.exportCSV();
router.replace({ query: newQuery }).catch((err) => {
if (err.name !== 'NavigationDuplicated') {
console.error(err);
} }
});
};
function confirmDeleteSelected() { const onPageChange = (event) => {
deleteProductsDialog.value = true; const newPage = event.page + 1;
const newPerPage = event.rows;
if (page.value !== newPage || per_page.value !== newPerPage) {
router.push({
query: {
...route.query,
page: newPage,
per_page: newPerPage
} }
});
function deleteSelectedBelongings() {
belonging.value = belonging.value.filter((val) => !selectedBelongings.value.includes(val));
deleteProductsDialog.value = false;
selectedBelongings.value = null;
toast.add({ severity: 'success', summary: 'Successful', detail: 'Products Deleted', life: 3000 });
} }
};
function getStatusLabel(status) { const loadData = () => {
switch (status) { BelongingService.searchBelongings(route.query)
case 'INSTOCK': .then((response) => {
return 'success'; console.log('response', response);
belongings.value = response.belongings || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load data', life: 3000 });
console.error('Error loading data:', error);
});
};
case 'LOWSTOCK': const loading = ref(false);
return 'warn'; const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
const animationKey = ref(0);
case 'OUTOFSTOCK': // Function to handle indexing
return 'danger'; const indexBelongings = async () => {
loading.value = true;
indexingFinished.value = false;
default: try {
return null; // Your indexing logic here
} // await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate API call
await BelongingService.indexBelongings();
// When indexing completes
indexingFinished.value = true;
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
} catch (error) {
indexingMessage.value = 'خطا در ایندکس';
} finally {
// Reset after some time
setTimeout(() => {
loading.value = false;
animationKey.value++; // Force re-render of animation
}, 3000);
} }
};
</script> </script>
<template> <template>
@@ -110,8 +154,18 @@ function getStatusLabel(status) {
<div class="card"> <div class="card">
<Toolbar class="mb-6"> <Toolbar class="mb-6">
<template #start> <template #start>
<Button label="ساخت دارایی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="openNew" /> <Button label="ساخت دارایی جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/belonging/create-belonging')" />
<Button label="حذف دارایی" icon="pi pi-trash" severity="secondary" @click="confirmDeleteSelected" :disabled="!selectedBelongings || !selectedBelongings.length" /> <Button @click="indexBelongings" :disabled="loading" severity="secondary mx-3" class="indexing-button">
<div class="button-content">
<div v-if="!loading">ایندکس کردن محصولات</div>
<div v-else class="loading-content flex">
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
<div :key="indexingMessage">{{ indexingMessage }}</div>
</Transition>
</div>
</div>
</Button>
</template> </template>
<template #end> </template> <template #end> </template>
@@ -123,35 +177,39 @@ function getStatusLabel(status) {
:value="belongings" :value="belongings"
dataKey="id" dataKey="id"
:paginator="true" :paginator="true"
:rows="20" :rows="per_page"
:filters="filters" :filters="filters"
:total-records="totalCount"
:current-page="page - 1"
@page="onPageChange($event)"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown" paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[20, 40, 80]" :rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی" currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دارایی"
> >
<template #header> <template #header>
<div class="flex flex-wrap gap-2 items-center justify-between"> <div class="flex flex-wrap gap-2 items-center justify-start">
<h4 class="m-0">مدیریت اموال</h4> <h4 class="m-0">مدیریت اموال</h4>
<IconField> <IconField>
<InputIcon> <InputIcon>
<i class="pi pi-search" /> <i class="pi pi-search" />
</InputIcon> </InputIcon>
<InputText v-model="filters['global'].value" placeholder="جستجو..." /> <InputText v-model="searchPhrase" placeholder="جستجو..." />
</IconField> </IconField>
</div> </div>
<Chip v-if="searchPhrase" class="mx-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
</template> </template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column> <Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر"> <Column header="تصویر">
<template #body="slotProps"> <template #body="slotProps">
<img :src="`${slotProps.data.mainImageUrls[0]}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" /> <img :src="`${slotProps.data.imageUrl}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" />
</template> </template>
</Column> </Column>
<Column field="belongingCode" header="کد" style="min-width: 4rem"></Column> <Column field="belongingCode" header="کد" style="min-width: 4rem"></Column>
<Column field="name" header="نام" sortable style="min-width: 16rem"></Column> <Column field="name" header="نام" sortable style="min-width: 16rem"></Column>
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem"> <Column field="category" header="دسته بندی" sortable style="min-width: 6rem">
<template #body="slotProps"> <template #body="slotProps">
{{ slotProps.data.category?.nativeName }} {{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }}
</template> </template>
</Column> </Column>
<Column :exportable="false" style="min-width: 12rem"> <Column :exportable="false" style="min-width: 12rem">
@@ -163,63 +221,6 @@ function getStatusLabel(status) {
</DataTable> </DataTable>
</div> </div>
<Dialog v-model:visible="productDialog" :style="{ width: '450px' }" header="Product Details" :modal="true">
<div class="flex flex-col gap-6">
<img v-if="belonging.image" :src="`https://primefaces.org/cdn/primevue/images/product/${product.image}`" :alt="product.image" class="block m-auto pb-4" />
<div>
<label for="name" class="block font-bold mb-3">Name</label>
<InputText id="name" v-model.trim="product.name" required="true" autofocus :invalid="submitted && !product.name" fluid />
<small v-if="submitted && !product.name" class="text-red-500">Name is required.</small>
</div>
<div>
<label for="description" class="block font-bold mb-3">Description</label>
<Textarea id="description" v-model="product.description" required="true" rows="3" cols="20" fluid />
</div>
<div>
<label for="inventoryStatus" class="block font-bold mb-3">Inventory Status</label>
<Select id="inventoryStatus" v-model="product.inventoryStatus" :options="statuses" optionLabel="label" placeholder="Select a Status" fluid></Select>
</div>
<div>
<span class="block font-bold mb-4">Category</span>
<div class="grid grid-cols-12 gap-4">
<div class="flex items-center gap-2 col-span-6">
<RadioButton id="category1" v-model="product.category" name="category" value="Accessories" />
<label for="category1">Accessories</label>
</div>
<div class="flex items-center gap-2 col-span-6">
<RadioButton id="category2" v-model="product.category" name="category" value="Clothing" />
<label for="category2">Clothing</label>
</div>
<div class="flex items-center gap-2 col-span-6">
<RadioButton id="category3" v-model="product.category" name="category" value="Electronics" />
<label for="category3">Electronics</label>
</div>
<div class="flex items-center gap-2 col-span-6">
<RadioButton id="category4" v-model="product.category" name="category" value="Fitness" />
<label for="category4">Fitness</label>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-6">
<label for="price" class="block font-bold mb-3">Price</label>
<InputNumber id="price" v-model="product.price" mode="currency" currency="USD" locale="en-US" fluid />
</div>
<div class="col-span-6">
<label for="quantity" class="block font-bold mb-3">Quantity</label>
<InputNumber id="quantity" v-model="product.quantity" integeronly fluid />
</div>
</div>
</div>
<template #footer>
<Button label="Cancel" icon="pi pi-times" text @click="hideDialog" />
<Button label="Save" icon="pi pi-check" @click="saveProduct" />
</template>
</Dialog>
<Dialog v-model:visible="deleteProductDialog" :style="{ width: '450px' }" header="Confirm" :modal="true"> <Dialog v-model:visible="deleteProductDialog" :style="{ width: '450px' }" header="Confirm" :modal="true">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<i class="pi pi-exclamation-triangle !text-3xl" /> <i class="pi pi-exclamation-triangle !text-3xl" />