store/resources/js/components/data-table/DataTableToolbar.vue
Yoga Pangestu c2c5f12b5c
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add date filters and summary for order management
2026-08-05 00:30:41 +07:00

155 lines
5.9 KiB
Vue

<script setup lang="ts">
import { ListFilter, Search } from '@lucide/vue';
import { useDebounceFn, useMediaQuery } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { DataTableFilterDef } from '@/types/data-table';
const props = defineProps<{
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
searchPlaceholder?: string;
debounceMs?: number;
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
'filter-change': [key: string, value: string];
'filters-reset': [];
}>();
const localSearch = ref(search.value || '');
const emitSearchDebounced = useDebounceFn((value: string) => {
search.value = value;
}, props.debounceMs ?? 500);
watch(localSearch, (value) => {
emitSearchDebounced(value || '');
});
watch(search, (val) => {
const safeVal = val || '';
if (safeVal !== localSearch.value) {
localSearch.value = safeVal;
}
});
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeFilterCount = computed(() => {
if (!props.filterDefs?.length || !props.filterValues) {
return 0;
}
return props.filterDefs.filter((filter) => {
const value = props.filterValues?.[filter.key];
return value !== undefined && value !== '';
}).length;
});
function filterValue(key: string): string {
return props.filterValues?.[key] ?? '';
}
function onFilterChange(key: string, value: string): void {
emit('filter-change', key, value === 'all' ? '' : value);
}
function resetFilters(): void {
emit('filters-reset');
}
const isMobile = useMediaQuery('(max-width: 640px)');
const selectSide = computed(() => (isMobile.value ? 'bottom' : 'left'));
const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
</script>
<template>
<div class="flex items-center justify-between gap-4">
<div class="relative w-full max-w-sm">
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2 pointer-events-none" />
<Input v-model="localSearch" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
</div>
<Popover v-if="filterDefs?.length">
<PopoverTrigger as-child>
<Button variant="outline" class="shrink-0">
<ListFilter class="size-4" />
Filter
<Badge v-if="activeFilterCount > 0" variant="secondary"
class="ml-1 size-5 rounded-full px-1 text-xs">
{{ activeFilterCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-80 space-y-4 p-4" @pointer-down-outside="(event) => {
const target = event.target as HTMLElement;
if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]') || target.closest('[data-slot=popover-content]')) {
event.preventDefault();
}
}">
<div class="space-y-1">
<h4 class="text-sm font-medium">
Filter
</h4>
<p class="text-muted-foreground text-xs">
Sesuaikan filter untuk mempersempit data.
</p>
</div>
<div v-for="filter in filterDefs" :key="filter.key" class="space-y-2">
<Label :for="`filter-${filter.key}`">{{ filter.label }}</Label>
<Input v-if="filter.type === 'text'" :id="`filter-${filter.key}`"
:model-value="filterValue(filter.key)"
:placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
<DatePicker v-else-if="filter.type === 'date'"
:model-value="filterValue(filter.key)"
:placeholder="filter.placeholder ?? 'Pilih tanggal'"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
<Select v-else :model-value="filterValue(filter.key) || 'all'"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))">
<SelectTrigger :id="`filter-${filter.key}`" class="w-full">
<SelectValue :placeholder="filter.placeholder ?? 'Semua'" />
</SelectTrigger>
<SelectContent class="z-[300]" :side="selectSide" :side-offset="selectSideOffset">
<SelectItem value="all">
Semua
</SelectItem>
<SelectItem v-for="option in filter.options" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button v-if="activeFilterCount > 0" variant="ghost" size="sm" class="w-full" @click="resetFilters">
Reset Filter
</Button>
</PopoverContent>
</Popover>
</div>
</template>