Skip to content

Commit 0e87862

Browse files
committed
feat: add reusable filtering system with search, category, and stock status filters
1 parent a17d956 commit 0e87862

11 files changed

Lines changed: 327 additions & 19 deletions

File tree

app/Http/Controllers/Admin/InventoryController.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Http\Requests\Inventory\StoreInventoryRequest;
77
use App\Models\Supplier;
88
use App\Services\InventoryService;
9+
use Illuminate\Http\Request;
910
use Inertia\Inertia;
1011

1112
class InventoryController extends Controller
@@ -22,12 +23,19 @@ public function __construct(
2223
/**
2324
* Display a listing of the resource.
2425
*/
25-
public function index()
26+
public function index(Request $request)
2627
{
27-
$items = $this->inventoryService->getPaginated();
28+
$filters = $request->only([
29+
'search',
30+
'category',
31+
'stock_status',
32+
]);
33+
34+
$items = $this->inventoryService->getPaginated(10, $filters);
2835

2936
return Inertia::render('admin/Inventory/Index', [
30-
'items' => $items
37+
'items' => $items,
38+
'filters' => $filters
3139
]);
3240
}
3341

app/Repositories/BaseRepository.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
77
use Illuminate\Database\Eloquent\Collection;
88
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Builder;
910

1011
abstract class BaseRepository implements BaseRepositoryInterface
1112
{
@@ -95,4 +96,14 @@ public function delete(int $id): bool
9596

9697
return $record->delete();
9798
}
99+
100+
/**
101+
* Get query builder instance
102+
*
103+
* @return Builder
104+
*/
105+
public function query(): Builder
106+
{
107+
return $this->model->query();
108+
}
98109
}

app/Repositories/Interfaces/BaseRepositoryInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
66
use Illuminate\Database\Eloquent\Collection;
77
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Builder;
89

910
interface BaseRepositoryInterface
1011
{
@@ -14,4 +15,5 @@ public function paginate(int $perPage = 10): LengthAwarePaginator;
1415
public function create(array $data): Model;
1516
public function update(int $id, array $data): bool;
1617
public function delete(int $id): bool;
18+
public function query(): Builder;
1719
}

app/Services/InventoryService.php

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,48 @@ public function __construct(
1919
) {}
2020

2121
/**
22-
* Get paginated inventory items
22+
* Get paginated inventory items with filters
2323
*
2424
* @param int $perPage
25+
* @param array $filters
2526
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
2627
*/
27-
public function getPaginated(int $perPage = 10)
28+
public function getPaginated(int $perPage = 10, array $filters = [])
2829
{
29-
return $this->inventoryRepo->paginate($perPage);
30+
$query = $this->inventoryRepo->query();
31+
32+
// Search filter
33+
if (!empty($filters['search'])) {
34+
$search = $filters['search'];
35+
$query->where(function ($q) use ($search) {
36+
$q->where('item_name', 'like', "%{$search}%")
37+
->orWhere('item_code', 'like', "%{$search}%")
38+
->orWhere('brand_name', 'like', "%{$search}%");
39+
});
40+
}
41+
42+
// Category filter
43+
if (!empty($filters['category'])) {
44+
$query->where('category', $filters['category']);
45+
}
46+
47+
// Stock status filter
48+
if (!empty($filters['stock_status'])) {
49+
switch ($filters['stock_status']) {
50+
case 'out_of_stock':
51+
$query->where('quantity', '<=', 0);
52+
break;
53+
case 'low_stock':
54+
$query->whereColumn('quantity', '<=', 'restock_threshold')
55+
->where('quantity', '>', 0);
56+
break;
57+
case 'in_stock':
58+
$query->whereColumn('quantity', '>', 'restock_threshold');
59+
break;
60+
}
61+
}
62+
63+
return $query->paginate($perPage);
3064
}
3165

3266
/**

package-lock.json

Lines changed: 13 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@eslint/js": "^9.19.0",
1515
"@laravel/vite-plugin-wayfinder": "^0.1.3",
1616
"@tailwindcss/vite": "^4.1.11",
17+
"@types/lodash-es": "^4.17.12",
1718
"@types/node": "^22.13.5",
1819
"@vitejs/plugin-vue": "^6.0.0",
1920
"@vue/eslint-config-typescript": "^14.3.0",
@@ -37,6 +38,7 @@
3738
"class-variance-authority": "^0.7.1",
3839
"clsx": "^2.1.1",
3940
"laravel-vite-plugin": "^2.0.0",
41+
"lodash-es": "^4.17.21",
4042
"lucide-vue-next": "^0.468.0",
4143
"reka-ui": "^2.6.1",
4244
"tailwind-merge": "^3.2.0",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
import { Input } from '@/components/ui/input';
3+
import Select from '@/components/Select.vue';
4+
import { Button } from '@/components/ui/button';
5+
import { Search, X } from 'lucide-vue-next';
6+
import { ref, watch, computed } from 'vue';
7+
import type { FilterConfig } from '@/types';
8+
9+
interface Props {
10+
searchPlaceholder?: string;
11+
searchValue?: string;
12+
filters?: FilterConfig[];
13+
showReset?: boolean;
14+
}
15+
16+
const props = withDefaults(defineProps<Props>(), {
17+
searchPlaceholder: 'Search...',
18+
searchValue: '',
19+
filters: () => [],
20+
showReset: true,
21+
});
22+
23+
const emit = defineEmits<{
24+
'update:search': [value: string];
25+
'update:filter': [key: string, value: string];
26+
'reset': [];
27+
}>();
28+
29+
// Local search state for immediate UI updates
30+
const localSearch = ref(props.searchValue);
31+
32+
// Local filter values to track selections
33+
const localFilterValues = ref<Record<string, string>>({});
34+
35+
// Initialize local filter values from props
36+
props.filters.forEach(filter => {
37+
if (filter.value) {
38+
localFilterValues.value[filter.key] = filter.value;
39+
}
40+
});
41+
42+
// Watch for external changes (like reset)
43+
watch(() => props.searchValue, (newValue) => {
44+
localSearch.value = newValue;
45+
});
46+
47+
// Watch for filter value changes from props
48+
watch(() => props.filters, (newFilters) => {
49+
newFilters.forEach(filter => {
50+
if (filter.value) {
51+
localFilterValues.value[filter.key] = filter.value;
52+
} else {
53+
delete localFilterValues.value[filter.key];
54+
}
55+
});
56+
}, { deep: true });
57+
58+
// Check if any filters are active
59+
const hasActiveFilters = computed(() => {
60+
// Check if search has value
61+
if (localSearch.value && localSearch.value.trim() !== '') {
62+
return true;
63+
}
64+
65+
// Check if any filter dropdown has a value
66+
return Object.keys(localFilterValues.value).length > 0;
67+
});
68+
69+
const handleSearchInput = (event: Event) => {
70+
const target = event.target as HTMLInputElement;
71+
localSearch.value = target.value;
72+
emit('update:search', target.value);
73+
}
74+
75+
const handleFilterChange = (key: string, value: string) => {
76+
if (value) {
77+
localFilterValues.value[key] = value;
78+
} else {
79+
delete localFilterValues.value[key];
80+
}
81+
emit('update:filter', key, value);
82+
}
83+
84+
const handleReset = () => {
85+
localSearch.value = '';
86+
localFilterValues.value = {};
87+
emit('reset');
88+
}
89+
</script>
90+
91+
<template>
92+
<div class="flex flex-col gap-4 mb-4">
93+
<div class="flex flex-col md:flex-row gap-4">
94+
<!-- Search Input -->
95+
<div class="flex-1">
96+
<div class="relative">
97+
<Search :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
98+
<Input v-model="localSearch" @input="handleSearchInput" :placeholder="searchPlaceholder"
99+
class="pl-10" />
100+
</div>
101+
</div>
102+
103+
<!-- Filter Dropdowns -->
104+
<template v-for="filter in filters" :key="filter.key">
105+
<div class="w-full md:w-48">
106+
<Select v-model="localFilterValues[filter.key]" :options="filter.options"
107+
:placeholder="filter.placeholder || `Filter by ${filter.label}`"
108+
@update:modelValue="(value: string) => handleFilterChange(filter.key, value)" />
109+
</div>
110+
</template>
111+
112+
<!-- Reset Button (only show if filters are active) -->
113+
<Button v-if="showReset && hasActiveFilters" variant="outline" @click="handleReset"
114+
class="w-full md:w-auto">
115+
<X :size="16" class="mr-2" />
116+
Reset
117+
</Button>
118+
</div>
119+
</div>
120+
</template>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ref } from "vue";
2+
import { router } from "@inertiajs/vue3";
3+
import { debounce } from "lodash-es";
4+
5+
export interface FilterConfig {
6+
[key: string]: string | number | null;
7+
}
8+
9+
export function useFilters(baseUrl: string, initialFilters: FilterConfig = {}) {
10+
const filters = ref<FilterConfig>({ ...initialFilters });
11+
12+
// Apply filters to the URL
13+
const applyFilters = (): void => {
14+
const params: Record<string, string> = {};
15+
16+
Object.entries(filters.value).forEach(([key, value]) => {
17+
if (value !== null && value !== '' && value !== undefined) {
18+
params[key] = String(value);
19+
}
20+
});
21+
22+
router.get(baseUrl, params, {
23+
preserveState: true,
24+
preserveScroll: true,
25+
})
26+
};
27+
28+
// Debounced version of applyFilters for search inputs
29+
const debouncedApplyFilters = debounce(applyFilters, 150);
30+
31+
// Reset all filters to initial state
32+
const resetFilters = (): void => {
33+
filters.value = { ...initialFilters };
34+
router.get(baseUrl, {}, {
35+
preserveState: true,
36+
preserveScroll: true,
37+
});
38+
};
39+
40+
// Update a specific filter value
41+
const updateFilter = (key: string, value: string | number | null, immediate: boolean = false): void => {
42+
filters.value[key] = value;
43+
44+
if (immediate) {
45+
applyFilters();
46+
} else {
47+
debouncedApplyFilters();
48+
}
49+
};
50+
51+
return {
52+
filters,
53+
applyFilters,
54+
resetFilters,
55+
updateFilter,
56+
};
57+
}

0 commit comments

Comments
 (0)