TanStack Query (dawniej znany jako React Query) to potężna biblioteka do zarządzania asynchronicznym stanem aplikacji (tzw. server state). Pomaga w pobieraniu (fetching), cache'owaniu, synchronizowaniu oraz aktualizowaniu danych pochodzących z serwera, eliminując potrzebę pisania skomplikowanego kodu opartego na useEffect i useState.
npm i @tanstack/vue-query
npm i @tanstack/vue-tableimport { VueQueryPlugin } from '@tanstack/vue-query'
// Add in withApp
app.use(VueQueryPlugin)import { router } from '@inertiajs/vue'
const newTodo = { todo: 'Kupić komputer', completed: 'false' }
// Dla lokalnych zapytań (fetch dla zewnętrznego api)
router.post('/todos', newTodo, {
onBefore: () => {}, // Loader
onSuccess: () => {},
onError: (errors) => {},
})Reaktywne zapytania (zależność od ref)
<script setup>
import { useQuery } from '@tanstack/vue-query'
import { ref } from 'vue'
const todoId = ref(1)
const { isPending, isError, data, error } = useQuery({
queryKey: ['todos', todoId],
// Z fetch
queryFn: () =>
fetch(`https://dummyjson.com/todos/${todoId.value}`).then((res) =>
res.json(),
),
// Z async/await
queryFn: async () => {
const res = await fetch(`/todos/${todoId.value}`)
if (!res.ok) throw new Error('Błąd pobierania danych')
return res.json()
},
})
</script>
<template>
<h1>ToDo List</h1>
<button @click="todoId++">Następne zadanie (ID: {{ todoId }})</button>
<div v-if="isPending">Ładowanie zadań...</div>
<div v-else-if="isError">Wystąpił błąd: {{ error.message }}</div>
<ul v-else>
<span>{{ data }}</span>
<li v-for="i in data" :key="i.id">{{ i.todo }}</li>
</ul>
</template>Modyfikacja danych (useMutation)
<script setup>
import { router } from '@inertiajs/vue'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const createTodo = (newTodo) => {
router.post('https://dummyjson.com/todos/add', newTodo, {
onSuccess: () => {
console.log('Zadanie dodane pomyślnie!')
},
onError: (errors) => {
console.error('Błędy walidacji z serwera:', errors)
},
})
}
const queryClient = useQueryClient()
const { mutate, isPending } = useMutation({
mutationFn: async (newTodo) => {
const res = await fetch('https://dummyjson.com/todos/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!res.ok) {
throw new Error('Błąd serwera')
}
return res.json()
},
// Po udanym zapisie informujemy system, że lista 'todos' jest nieaktualna
onSuccess: () => {
// Odświeży wszystkie zadania z kluczami ['todos'], ['todos', { type: 'done' }], ['todos', 1]
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Wyłącznie zadania z kluczem 'toodo'
// queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
},
})
const addTodo = () => {
mutate({ title: 'Nowe zadanie do zrobienia', completed: false })
}
</script>
<template>
<button :disabled="isPending" @click="addTodo">
{{ isPending ? 'Dodawanie...' : 'Dodaj zadanie' }}
</button>
</template><script setup>
import { useQueryClient, useQuery, useMutation } from '@tanstack/vue-query'
// Access QueryClient instance
const queryClient = useQueryClient()
// Query
const { isPending, isError, data, error } = useQuery({
queryKey: ['todos'],
queryFn: getTodos,
})
// Mutation
const mutation = useMutation({
mutationFn: postTodo,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
function onButtonClick() {
mutation.mutate({
id: Date.now(),
title: 'Do Laundry',
})
}
</script>
<template>
<span v-if="isPending">Loading...</span>
<span v-else-if="isError">Error: {{ error.message }}</span>
<!-- We can assume by this point that `isSuccess === true` -->
<ul v-else>
<li v-for="todo in data" :key="todo.id">{{ todo.title }}</li>
</ul>
<button @click="onButtonClick">Add Todo</button>
</template><script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const queryKey = ['todos']
const { mutate } = useMutation({
// 1. Definiujemy funkcję wysyłającą dane na serwer
mutationFn: (newTodo) => {
return fetch('https://dummyjson.com/todos/add', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: { 'Content-Type': 'application/json' },
}).then((res) => {
if (!res.ok) throw new Error('Błąd serwera')
return res.json()
})
},
// 2. Wywoływane po kliknięciu (przed zapytaniem sieciowym)
onMutate: async (newTodo) => {
// Anulujemy wychodzące zapytania dla tego klucza, aby nie nadpisały naszej optymistycznej zmiany
await queryClient.cancelQueries({ queryKey })
// Pobieramy i zapamiętujemy aktualny stan cache (jako kopię bezpieczeństwa)
const previousTodos = queryClient.getQueryData(queryKey)
// Optymistycznie aktualizujemy cache o nowy element (nadajemy mu tymczasowe ID)
queryClient.setQueryData(queryKey, (old) => {
const optimisticTodo = {
id: Date.now(),
title: newTodo.title,
completed: false,
}
return old ? [...old, optimisticTodo] : [optimisticTodo]
})
// Zwracamy kontekst z kopią bezpieczeństwa – trafi on do onError w razie problemów
return { previousTodos }
},
// 3. Wywoływane TYLKO, gdy serwer zwróci błąd
onError: (err, newTodo, context) => {
// Przywracamy poprzedni stan danych sprzed kliknięcia
if (context?.previousTodos) {
queryClient.setQueryData(queryKey, context.previousTodos)
}
alert('Nie udało się dodać zadania. Przywrócono poprzedni stan.')
},
// 4. Wywoływane ZAWSZE (zarówno przy sukcesie, jak i przy błędzie)
onSettled: () => {
// Synchronizujemy stan lokalny z serwerem, pobierając oficjalną listę w tle
queryClient.invalidateQueries({ queryKey })
},
})
const handleAddTodo = () => {
mutate({ title: 'Moje optymistyczne zadanie' })
}
</script>
<template>
<button @click="handleAddTodo">Dodaj ekspresowo zadanie</button>
</template><script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const queryKey = ['todos']
const { mutate: deleteTodo } = useMutation({
// 1. Funkcja usuwająca element na serwerze
mutationFn: (todoId) => {
return fetch(`https://example.com{todoId}`, {
method: 'DELETE',
}).then((res) => {
if (!res.ok) throw new Error('Nie udało się usunąć elementu')
return res.json()
})
},
// 2. Wywoływane natychmiast po kliknięciu "Usuń"
onMutate: async (todoId) => {
// Anulujemy trwające zapytania pobierania, aby nie nadpisały naszej optymistycznej zmiany
await queryClient.cancelQueries({ queryKey })
// Zapisujemy aktualny stan cache (kopia bezpieczeństwa)
const previousTodos = queryClient.getQueryData(queryKey)
// Optymistycznie usuwamy element z cache – natychmiast znika z ekranu
queryClient.setQueryData(queryKey, (old) => {
return old ? old.filter((todo) => todo.id !== todoId) : []
})
// Zwracamy kopię bezpieczeństwa w kontekście
return { previousTodos }
},
// 3. Wywoływane tylko w przypadku błędu serwera
onError: (err, todoId, context) => {
// Przywracamy element na listę, jeśli serwer odrzucił operację
if (context?.previousTodos) {
queryClient.setQueryData(queryKey, context.previousTodos)
}
alert('Błąd usuwania! Przywrócono element na listę.')
},
// 4. Wywoływane zawsze (sukces lub błąd) – synchronizacja z serwerem
onSettled: () => {
queryClient.invalidateQueries({ queryKey })
},
})
</script>
<template>
<!-- Przykład użycia w liście -->
<ul>
<li v-for="todo in data" :key="todo.id">
{{ todo.title }}
<!-- Przekazujemy ID usuwanego zadania bezpośrednio do deleteTodo -->
<button @click="deleteTodo(todo.id)">Usuń</button>
</li>
</ul>
</template><script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const queryKey = ['todos']
const { mutate: toggleTodoStatus } = useMutation({
// 1. Funkcja aktualizująca stan elementu na serwerze (np. metodą PATCH)
mutationFn: (updatedTodo) => {
return fetch(`https://example.com{updatedTodo.id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: updatedTodo.completed }),
headers: { 'Content-Type': 'application/json' },
}).then((res) => {
if (!res.ok) throw new Error('Nie udało się zaktualizować zadania')
return res.json()
})
},
// 2. Wywoływane natychmiast po kliknięciu checkboxa
onMutate: async (updatedTodo) => {
// Anulujemy trwające zapytania pobierania, aby nie nadpisały naszej zmiany
await queryClient.cancelQueries({ queryKey })
// Zapisujemy aktualny stan cache (kopia bezpieczeństwa)
const previousTodos = queryClient.getQueryData(queryKey)
// Optymistycznie modyfikujemy konkretny element w cache
queryClient.setQueryData(queryKey, (old) => {
if (!old) return []
return old.map((todo) =>
todo.id === updatedTodo.id
? { ...todo, completed: updatedTodo.completed } // Podmieniamy tylko zmieniony status
: todo,
)
})
// Zwracamy kopię bezpieczeństwa w kontekście
return { previousTodos }
},
// 3. Wywoływane tylko w przypadku błędu serwera
onError: (err, updatedTodo, context) => {
// Przywracamy poprzedni stan (checkbox wróci do starej pozycji)
if (context?.previousTodos) {
queryClient.setQueryData(queryKey, context.previousTodos)
}
alert('Błąd aktualizacji! Przywrócono poprzedni stan.')
},
// 4. Wywoływane zawsze (sukces lub błąd) – synchronizacja stanu z bazą danych
onSettled: () => {
queryClient.invalidateQueries({ queryKey })
},
})
</script>
<template>
<ul>
<li v-for="todo in data" :key="todo.id">
<!-- Przy zmianie przekazujemy obiekt z nowym stanem completed -->
<input
type="checkbox"
:checked="todo.completed"
@change="toggleTodoStatus({ id: todo.id, completed: !todo.completed })"
/>
<span :class="{ 'line-through': todo.completed }">{{ todo.title }}</span>
</li>
</ul>
</template>
<style scoped>
.line-through {
text-decoration: line-through;
}
</style><script setup>
import { ref, computed } from 'vue'
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
FlexRender,
} from '@tanstack/vue-table'
// 1. Zwykłe, surowe dane w czystym JS (tablica obiektów)
const data = ref([
{ id: 1, name: 'Jan Kowalski', email: 'jan@wp.pl', role: 'Admin' },
{ id: 2, name: 'Anna Nowak', email: 'anna@o2.pl', role: 'User' },
{ id: 3, name: 'Piotr Zieliński', email: 'piotr@gmail.com', role: 'Editor' },
{ id: 4, name: 'Maria Dąbrowska', email: 'maria@wp.pl', role: 'User' },
{ id: 5, name: 'Tomasz Lewandowski', email: 'tomasz@o2.pl', role: 'User' },
{
id: 6,
name: 'Katarzyna Wiśniewska',
email: 'kasia@gmail.com',
role: 'Admin',
},
])
// 2. Definicja kolumn jako zwykła tablica obiektów konfiguracyjnych
const columns = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: 'Imię i Nazwisko',
},
{
accessorKey: 'email',
header: 'Adres Email',
},
{
accessorKey: 'role',
header: 'Rola',
},
]
// 3. Stan tabeli (potrzebny do sortowania)
const sorting = ref([])
// 4. Inicjalizacja TanStack Table za pomocą zwykłego obiektu konfiguracyjnego
const table = useVueTable({
// Przekazujemy dane (musi być funkcja zwracająca wartość lub getter)
get data() {
return data.value
},
columns,
state: {
get sorting() {
return sorting.value
},
},
onSortingChange: (updater) => {
sorting.value =
typeof updater === 'function' ? updater(sorting.value) : updater
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 3, // pokazujemy po 3 wiersze na stronę, żeby przetestować paginację
},
},
})
</script>
<template>
<div class="table-container">
<table>
<thead>
<!-- Iteracja po nagłówkach grup (TanStack wymaga tego poziomu) -->
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<!-- Iteracja po właściwych nagłówkach kolumn -->
<th
v-for="header in header.headers"
:key="header.id"
@click="header.column.getToggleSortingHandler()?.($event)"
style="cursor: pointer; user-select: none;"
>
<!-- FlexRender to specjalny komponent TanStacka do renderowania tekstów/komponentów w komórce -->
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<!-- Wskaźnik sortowania -->
<span v-if="header.column.getIsSorted() === 'asc'"> 🔼</span>
<span v-else-if="header.column.getIsSorted() === 'desc'"> 🔽</span>
</th>
</tr>
</thead>
<tbody>
<!-- Iteracja po przefiltrowanych/posortowanych wierszach -->
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- Przyciski do obsługi stron (Paginacja) -->
<div class="pagination-controls">
<button
@click="table.previousPage()"
:disabled="!table.getCanPreviousPage()"
>
Poprzednia
</button>
<span>
Strona {{ table.getState().pagination.pageIndex + 1 }} z
{{ table.getPageCount() }}
</span>
<button @click="table.nextPage()" :disabled="!table.getCanNextPage()">
Następna
</button>
</div>
</div>
</template>
<style scoped>
/* Prosty CSS dla czytelności (żadnego Tailwinda!) */
.table-container {
font-family: sans-serif;
margin: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
}
th,
td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
th:hover {
background-color: #e9e9e9;
}
.pagination-controls {
display: flex;
gap: 15px;
align-items: center;
}
button {
padding: 5px 10px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style><script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// 1. Reaktywny stan dla wyszukiwarki (v-model)
const searchQuery = ref('')
// Zamiast dummyjson.com/todos użyjemy ://dummyjson.com...,
// ponieważ API DummyJSON pozwala filtrować zadania po stronie serwera!
const apiUrl = computed(() => {
return `https://://dummyjson.com${searchQuery.value}`
})
// 2. Pobieranie danych za pomocą TanStack Query (useQuery)
// Przekazujemy apiUrl w kluczu (queryKey), aby useQuery automatycznie
// odpalało się na nowo, gdy zmieni się tekst w wyszukiwarce.
const {
data: apiResponse,
isLoading,
isError,
error,
} = useQuery({
queryKey: ['todos', apiUrl],
queryFn: async () => {
const res = await fetch(apiUrl.value)
if (!res.ok) throw new Error('Błąd pobierania danych')
return res.json()
},
})
// 3. Wyciągamy samą tablicę 'todos' z odpowiedzi API (jeśli dane już doleciały)
const defaultData = []
const tableData = computed(() => apiResponse.value?.todos ?? defaultData)
// 4. Definicja kolumn dla danych z DummyJSON
const columns = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'todo',
header: 'Zadanie do wykonania',
},
{
accessorKey: 'completed',
header: 'Status',
// Możemy dostosować wygląd komórki za pomocą funkcji cell
cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
},
{
accessorKey: 'userId',
header: 'ID Użytkownika',
},
]
// 5. Konfiguracja TanStack Table w czystym JS
// Ponieważ filtrowanie robi serwer (poprzez useQuery), tabela potrzebuje tylko podstawowego CoreRowModel.
const table = useVueTable({
get data() {
return tableData.value
},
columns,
getCoreRowModel: getCoreRowModel(),
})
</script>
<template>
<div class="table-container">
<h2>Lista zadań (TanStack Table + Query)</h2>
<!-- Wyszukiwarka -->
<div class="search-box">
<input
v-model="searchQuery"
type="text"
placeholder="Wyszukaj zadanie (np. watch, bake, learn)..."
/>
</div>
<!-- Obsługa stanów ładowania i błędów z useQuery -->
<div v-if="isLoading" class="status-msg">Pobieranie danych z API...</div>
<div v-else-if="isError" class="status-msg error">
Błąd: {{ error.message }}
</div>
<!-- Właściwa tabela -->
<table v-else>
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th v-for="header in headerGroup.headers" :key="header.id">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</th>
</tr>
</thead>
<tbody>
<!-- Jeśli serwer nic nie znalazł -->
<tr v-if="table.getRowModel().rows.length === 0">
<td colspan="4" style="text-align: center; color: #888;">
Nie znaleziono zadań pasujących do frazy "{{ searchQuery }}"
</td>
</tr>
<!-- Renderowanie wierszy -->
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.table-container {
font-family: sans-serif;
margin: 20px;
max-width: 800px;
}
.search-box {
margin-bottom: 20px;
}
.search-box input {
width: 100%;
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.status-msg {
padding: 20px;
background: #f0f0f0;
border-radius: 4px;
text-align: center;
}
.status-msg.error {
background: #ffebee;
color: #c62828;
}
</style>Gdyby API DummyJSON pozwalało tylko na pobranie wszystkich 300 zadań na raz (/todos), a Ty musiałbyś filtrować je lokalnie w przeglądarce, wtedy i tylko wtedy musiałbyś dodać ustawienia filtrowania do konfiguracji tabeli:
// TAK wyglądałaby konfiguracja, gdyby to TABELA miała filtrować pobrane dane:
import { getFilteredRowModel } from '@tanstack/vue-table'
const table = useVueTable({
get data() {
return tableData.value
}, // tu byłoby całe 300 pozycji
columns,
state: {
// Musisz przekazać tekst z inputa do stanu tabeli
get globalFilter() {
return searchQuery.value
},
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), // Funkcja wykonująca filtrowanie w JS
})<script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// 1. JEDEN OBIEKT na cały stan tabeli (Filtry, Paginacja, Sortowanie)
const tableState = ref({
search: '',
status: 'all', // 'all' | 'completed' | 'pending'
page: 1,
limit: 10,
sortBy: 'id',
order: 'asc', // 'asc' | 'desc'
})
// 2. Automatycznie generowany URL na podstawie naszego obiektu stanu
// DummyJSON obsługuje parametry: ?limit=X&skip=Y&sortBy=Z&order=asc/desc
const apiUrl = computed(() => {
const { search, page, limit, sortBy, order } = tableState.value
const skip = (page - 1) * limit
// Jeśli użytkownik szuka, używamy endpointu /search, jeśli nie - zwykłego /todos
const endpoint = search ? `/search?q=${search}&` : '?'
return `https://dummyjson.com{endpoint}limit=${limit}&skip=${skip}&sortBy=${sortBy}&order=${order}`
})
// 3. Pobieranie danych przez TanStack Query
// Przekazujemy CAŁY obiekt tableState do klucza (jako kopię), aby każda zmiana wywołała fetch
const {
data: apiResponse,
isLoading,
isError,
error,
} = useQuery({
queryKey: ['todos', computed(() => ({ ...tableState.value }))],
queryFn: async () => {
const res = await fetch(apiUrl.value)
if (!res.ok) throw new Error('Błąd sieci')
return res.json()
},
})
// 4. PRZETWARZANIE DANYCH PO STRONIE KLIENTA (Status todo)
// DummyJSON NIE ma w API filtra po statusie (ukończone/nieukończone).
// Dlatego ten konkretny filtr musimy obsłużyć lokalnie za pomocą computed!
const finalTableData = computed(() => {
const rawTodos = apiResponse.value?.todos ?? []
const statusFilter = tableState.value.status
if (statusFilter === 'all') return rawTodos
return rawTodos.filter((todo) => {
return statusFilter === 'completed' ? todo.completed : !todo.completed
})
})
// Wyliczanie łącznej liczby stron (z API)
const totalItems = computed(() => apiResponse.value?.total ?? 0)
const totalPages = computed(() =>
Math.ceil(totalItems.value / tableState.value.limit),
)
// 5. Definicja kolumn (Natywny JS)
const columns = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
{
accessorKey: 'completed',
header: 'Status',
cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
},
]
// 6. Konfiguracja TanStack Table (Służy nam tylko jako szkielet HTML)
const table = useVueTable({
get data() {
return finalTableData.value
},
columns,
getCoreRowModel: getCoreRowModel(),
})
// 7. Funkcja pomocnicza do zmiany sortowania
function handleSort(columnKey) {
if (tableState.value.sortBy === columnKey) {
// Odwracamy kierunek, jeśli kliknięto tę samą kolumnę
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
// Zmieniamy kolumnę i ustawiamy domyślnie 'asc'
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
tableState.value.page = 1 // Resetujemy na pierwszą stronę po zmianie sortowania
}
// Funkcja resetująca filtry przy zmianie wyszukiwania
function onSearchInput() {
tableState.value.page = 1
}
</script>
<template>
<div class="table-container">
<h2>Zaawansowana Tabela zadań (Czysty JS)</h2>
<!-- PANEL FILTRÓW -->
<div class="filters-panel">
<!-- Szukanie -->
<input
v-model="tableState.search"
@input="onSearchInput"
type="text"
placeholder="Szukaj zadania..."
class="search-input"
/>
<!-- Filtr Statusu -->
<select v-model="tableState.status" class="status-select">
<option value="all">Wszystkie statusy</option>
<option value="completed">Tylko ukończone ✅</option>
<option value="pending">W trakcie ⏳</option>
</select>
</div>
<!-- Obsługa stanów ładowania -->
<div v-if="isLoading" class="status-msg">Ładowanie...</div>
<div v-else-if="isError" class="status-msg error">{{ error.message }}</div>
<!-- TABELA HTML -->
<table v-else>
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
class="sortable-th"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<!-- Aktywna strzałka sortowania -->
<span v-if="tableState.sortBy === header.column.id">
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="table.getRowModel().rows.length === 0">
<td
colspan="3"
style="text-align: center; color: #888; padding: 20px;"
>
Brak wyników spełniających kryteria.
</td>
</tr>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- PAGINACJA -->
<div v-if="!isLoading && !isError" class="pagination-panel">
<button :disabled="tableState.page <= 1" @click="tableState.page--">
Poprzednia
</button>
<span>Strona {{ tableState.page }} z {{ totalPages || 1 }}</span>
<button
:disabled="tableState.page >= totalPages"
@click="tableState.page++"
>
Następna
</button>
<!-- Wybór limitu wierszy -->
<select v-model="tableState.limit" @change="tableState.page = 1">
<option :value="5">Pokazuj: 5</option>
<option :value="10">Pokazuj: 10</option>
<option :value="20">Pokazuj: 20</option>
</select>
</div>
</div>
</template>
<style scoped>
/* Czytelny, czysty CSS bez Tailwinda */
.table-container {
font-family: sans-serif;
margin: 20px;
max-width: 900px;
}
.filters-panel {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.search-input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.status-select {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
.sortable-th {
cursor: pointer;
user-select: none;
}
.sortable-th:hover {
background-color: #e9e9e9;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.pagination-panel {
display: flex;
gap: 15px;
align-items: center;
justify-content: flex-start;
}
.pagination-panel button,
.pagination-panel select {
padding: 8px 12px;
cursor: pointer;
}
.pagination-panel button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.status-msg {
padding: 30px;
text-align: center;
background: #f5f5f5;
border-radius: 4px;
}
.status-msg.error {
background: #ffebee;
color: #c62828;
}
</style><script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// 1. Jeden czysty obiekt stanu aplikacji
const tableState = ref({
search: '',
status: 'all', // 'all' | 'completed' | 'pending'
page: 1,
limit: 10,
sortBy: 'id',
order: 'asc',
})
// 2. Pełna logika przeniesiona do adresu URL – serwer robi całą robotę
const apiUrl = computed(() => {
const { search, status, page, limit, sortBy, order } = tableState.value
const skip = (page - 1) * limit
// Budujemy parametry bazowe, które DummyJSON przyjmuje zawsze
const baseParams = `limit=${limit}&skip=${skip}&sortBy=${sortBy}&order=${order}`
// SCENARIUSZ A: Użytkownik wpisuje tekst w wyszukiwarkę
if (search) {
return `https://dummyjson.com{search}&${baseParams}`
}
// SCENARIUSZ B: Użytkownik filtruje po statusie (bez wpisywania tekstu)
if (status === 'completed') {
return `https://dummyjson.com{baseParams}`
} else if (status === 'pending') {
return `https://dummyjson.com{baseParams}`
}
// SCENARIUSZ C: Brak filtrów (Wszystkie zadania)
return `https://dummyjson.com{baseParams}`
})
// 3. Pobieranie danych przez TanStack Query
const {
data: apiResponse,
isLoading,
isError,
error,
} = useQuery({
queryKey: ['todos', computed(() => ({ ...tableState.value }))],
queryFn: async () => {
const res = await fetch(apiUrl.value)
if (!res.ok) throw new Error('Błąd pobierania danych z serwera')
return res.json()
},
})
// 4. Zero lokalnego filtrowania! Bierzemy to, co dał serwer 1:1
const tableData = computed(() => apiResponse.value?.todos ?? [])
// Statystyki stron prosto z odpowiedzi serwera
const totalItems = computed(() => apiResponse.value?.total ?? 0)
const totalPages = computed(() =>
Math.ceil(totalItems.value / tableState.value.limit),
)
// 5. Definicja kolumn (Natywny JS)
const columns = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
{
accessorKey: 'completed',
header: 'Status',
cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
},
]
// 6. Tabela jako zwykła makieta HTML
const table = useVueTable({
get data() {
return tableData.value
},
columns,
getCoreRowModel: getCoreRowModel(),
})
// Obsługa kliknięcia nagłówka (Sortowanie)
function handleSort(columnKey) {
if (tableState.value.sortBy === columnKey) {
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
tableState.value.page = 1
}
// Resetowanie do 1. strony, gdy użytkownik zmienia filtry
function resetPage() {
tableState.value.page = 1
}
</script>
<template>
<div class="table-container">
<h2>Tabela z pełną obsługą po stronie serwera</h2>
<!-- PANEL FILTRÓW -->
<div class="filters-panel">
<!-- Szukanie -->
<input
v-model="tableState.search"
@input="resetPage"
type="text"
placeholder="Szukaj (np. watch, bake)..."
class="search-input"
/>
<!-- Filtr Statusu (Teraz steruje bezpośrednio API!) -->
<select
v-model="tableState.status"
@change="resetPage"
class="status-select"
>
<option value="all">Wszystkie statusy</option>
<option value="completed">Tylko ukończone ✅</option>
<option value="pending">W trakcie ⏳</option>
</select>
</div>
<div v-if="isLoading" class="status-msg">
Serwer przetwarza zapytanie...
</div>
<div v-else-if="isError" class="status-msg error">{{ error.message }}</div>
<!-- TABELA -->
<table v-else>
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
class="sortable-th"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span v-if="tableState.sortBy === header.column.id">
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="table.getRowModel().rows.length === 0">
<td
colspan="3"
style="text-align: center; color: #888; padding: 20px;"
>
Serwer nie znalazł żadnych pasujących rekordów.
</td>
</tr>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- PAGINACJA -->
<div v-if="!isLoading && !isError" class="pagination-panel">
<button :disabled="tableState.page <= 1" @click="tableState.page--">
Poprzednia
</button>
<span>Strona {{ tableState.page }} z {{ totalPages || 1 }}</span>
<button
:disabled="tableState.page >= totalPages"
@click="tableState.page++"
>
Następna
</button>
<select v-model="tableState.limit" @change="resetPage">
<option :value="5">Limit: 5</option>
<option :value="10">Limit: 10</option>
</select>
</div>
</div>
</template>
<style scoped>
.table-container {
font-family: sans-serif;
margin: 20px;
max-width: 900px;
}
.filters-panel {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.search-input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.status-select {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
.sortable-th {
cursor: pointer;
user-select: none;
}
.sortable-th:hover {
background-color: #e9e9e9;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.pagination-panel {
display: flex;
gap: 15px;
align-items: center;
}
.pagination-panel button,
.pagination-panel select {
padding: 8px 12px;
cursor: pointer;
}
.pagination-panel button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.status-msg {
padding: 30px;
text-align: center;
background: #f5f5f5;
border-radius: 4px;
}
</style>import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery } from '@tanstack/vue-query'
const route = useRoute()
const router = useRouter()
// 1. ODCZYT: Tworzymy reaktywny tableState powiązany z paskiem adresu URL
const tableState = computed({
get() {
return {
search: route.query.search || '',
status: route.query.status || 'all',
page: Number(route.query.page) || 1,
limit: Number(route.query.limit) || 10,
sortBy: route.query.sortBy || 'id',
order: route.query.order || 'asc',
}
},
set(newValue) {
// 2. ZAPIS: Kiedy zmieniamy jakąś wartość, wrzucamy ją do paska adresu URL
router.push({
query: {
// Czyścimy puste wartości, żeby nie śmiecić w URL (np. ?search= gdy jest puste)
search: newValue.search || undefined,
status: newValue.status !== 'all' ? newValue.status : undefined,
page: newValue.page > 1 ? newValue.page : undefined,
limit: newValue.limit !== 10 ? newValue.limit : undefined,
sortBy: newValue.sortBy !== 'id' ? newValue.sortBy : undefined,
order: newValue.order !== 'asc' ? newValue.order : undefined,
},
})
},
})
// Funkcja pomocnicza do aktualizacji pojedynczego filtra
function updateState(key, value) {
const currentState = { ...tableState.value }
currentState[key] = value
// Jeśli zmieniamy filtr lub szukanie, resetujemy stronę do 1
if (key === 'search' || key === 'status') {
currentState.page = 1
}
tableState.value = currentState // Wywoła funkcję set() i zmieni URL w przeglądarce
}<input
:value="tableState.search"
@input="updateState('search', $event.target.value)"
type="text"
placeholder="Szukaj..."
/>
<button
:disabled="tableState.page <= 1"
@click="updateState('page', tableState.page - 1)"
>
Poprzednia
</button>https://tanstack.com/query/latest/docs/framework/vue/quick-start
https://tanstack.com/query/latest/docs/framework/vue/examples/basic
https://tanstack.com/table/latest/docs/framework/vue/examples/with-tanstack-query<?php
namespace App\Http\Controllers;
use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;
class TodoController extends Controller
{
public function index(Request $request)
{
$query = Todo::query();
if ($request->filled('search')) {
$query->where('todo', 'like', '%' . $request->search . '%');
}
if ($request->filled('status') && $request->status !== 'all') {
$query->where('completed', $request->status === 'completed');
}
$sortBy = $request->input('sortBy', 'id');
$order = $request->input('order', 'asc');
$query->orderBy($sortBy, $order);
$limit = $request->input('limit', 10);
$todos = $query->paginate($limit)->withQueryString();
return Inertia::render('Todos/Index', [
'todos' => $todos,
'filters' => $request->only(['search', 'status', 'sortBy', 'order', 'limit'])
]);
}
}<script setup>
import { ref, computed, h } from 'vue' // Importujemy funkcję h
import { router } from '@inertiajs/vue3'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
const props = defineProps({
todos: Object,
filters: Object
})
const tableState = ref({
search: props.filters.search || '',
status: props.filters.status || 'all',
sortBy: props.filters.sortBy || 'id',
order: props.filters.order || 'asc',
limit: props.filters.limit || 10
})
const rowSelection = ref({})
// 1. DEFINICJA KOLUMN Z UŻYCIEM FUNKCJI h()
const columns = [
{
id: 'select',
// Nagłówek: funkcja h() tworzy zwykły input typu checkbox i podpina zdarzenie onChange
header: ({ table }) => {
const hasRows = table.getRowModel().rows.length > 0
return h('input', {
type: 'checkbox',
class: 'table-checkbox',
checked: hasRows && table.getIsAllPageRowsSelected(),
// W czystym h() atrybut indeterminate przekazujemy w sekcji 'prop' (właściwości DOM)
domProps: {
indeterminate: hasRows && table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
},
onChange: (event) => table.getToggleAllPageRowsSelectedHandler()(event)
})
},
// Komórka wiersza: funkcja h() tworzy pojedynczy checkbox dla każdego todo
cell: ({ row }) => {
return h('input', {
type: 'checkbox',
class: 'table-checkbox',
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
onChange: (event) => row.getToggleSelectedHandler()(event)
})
}
},
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
{
accessorKey: 'completed',
header: 'Status',
cell: (info) => info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'
}
]
// Konfiguracja TanStack Table
const table = useVueTable({
get data() { return props.todos.data },
columns,
state: {
get rowSelection() { return rowSelection.value }
},
enableRowSelection: true,
onRowSelectionChange: (updater) => {
rowSelection.value = typeof updater === 'function' ? updater(rowSelection.value) : updater
},
getCoreRowModel: getCoreRowModel()
})
const selectedTodoIds = computed(() => {
return table.getSelectedRowModel().rows.map(row => row.original.id)
})
function updateTable(additionalParams = {}) {
router.get('/todos', { ...tableState.value, ...additionalParams }, { preserveState: true, preserveScroll: true, replace: true })
}
function onFilterChange() {
rowSelection.value = {}
updateTable({ page: 1 })
}
function handleSort(columnKey) {
if (columnKey === 'select') return
if (tableState.value.sortBy === columnKey) {
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
updateTable({ page: 1 })
}
</script>
<template>
<div class="table-container">
<h2>Laravel + Inertia + TanStack (Zaznaczanie z funkcją h())</h2>
<div class="filters-panel">
<input v-model="tableState.search" @input="onFilterChange" type="text" placeholder="Szukaj..." class="search-input" />
<select v-model="tableState.status" @change="onFilterChange" class="status-select">
<option value="all">Wszystkie</option>
<option value="completed">Ukończone</option>
<option value="pending">W trakcie</option>
</select>
<button v-if="selectedTodoIds.length > 0" class="danger-btn">
Usuń zaznaczone ({{ selectedTodoIds.length }})
</button>
</div>
<!-- TABELA HTML (Teraz czysta, bez nasłuchiwania @change na poziomie tabeli!) -->
<table>
<thead>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
:class="{ 'sortable': header.column.id !== 'select' }"
>
<!-- FlexRender natywnie obsługuje obiekty wygenerowane przez funkcję h() -->
<FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
<span v-if="tableState.sortBy === header.column.id && header.column.id !== 'select'">
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="props.todos.data.length === 0">
<td colspan="4" style="text-align: center; color: #888; padding: 20px;">Brak wyników.</td>
</tr>
<tr
v-for="row in table.getRowModel().rows"
:key="row.id"
:class="{ 'selected-row': row.getIsSelected() }"
>
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
</tbody>
</table>
<!-- PAGINACJA -->
<div class="pagination-panel">
<button :disabled="!props.todos.prev_page_url" @click="router.get(props.todos.prev_page_url, {}, { preserveScroll: true, preserveState: true })">Poprzednia</button>
<span>Strona {{ props.todos.current_page }} z {{ props.todos.last_page }}</span>
<button :disabled="!props.todos.next_page_url" @click="router.get(props.todos.next_page_url, {}, { preserveScroll: true, preserveState: true })">Następna</button>
</div>
</div>
</script>
<style scoped>
/* Stylizacja bez zmian... */
.table-container { font-family: sans-serif; margin: 20px; max-width: 900px; }
.filters-panel { display: flex; gap: 15px; margin-bottom: 20px; align-items: center; }
.search-input { flex-grow: 1; padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
.status-select { padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #f4f4f4; }
.sortable { cursor: pointer; user-select: none; }
.sortable:hover { background-color: #e9e9e9; }
tr:nth-child(even) { background-color: #f9f9f9; }
.selected-row { background-color: #e3f2fd !important; }
.table-checkbox { transform: scale(1.2); cursor: pointer; }
.pagination-panel { display: flex; gap: 15px; align-items: center; }
.pagination-panel button { padding: 8px 12px; cursor: pointer; }
.danger-btn { padding: 10px 15px; background-color: #d32f2f; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
</style><script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// 1. Odbieramy gotowe dane i filtry bezpośrednio z Laravela
const props = defineProps({
todos: Object, // Zawiera przefiltrowane wiersze z bazy (todos.data) oraz paginację
filters: Object, // Zawiera aktualny stan filtrów z URL
})
// 2. Jeden obiekt stanu zsynchronizowany z propsami
const tableState = ref({
search: props.filters.search || '',
status: props.filters.status || 'all',
sortBy: props.filters.sortBy || 'id',
order: props.filters.order || 'asc',
limit: props.filters.limit || 10,
})
// 3. TANSTACK TABLE: Karmimy tabelę danymi bezpośrednio z Laravel Props!
// ZerouseQuery, zero asynchronicznych fetchów w komponencie.
const columns = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
{
accessorKey: 'completed',
header: 'Status',
cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
},
]
const table = useVueTable({
// Tabela reaguje bezpośrednio na to, co przysłał serwer do propsów
get data() {
return props.todos.data
},
columns,
getCoreRowModel: getCoreRowModel(),
})
// 4. JEDNA funkcja do aktualizacji paska URL i pobierania świeżych danych
function updateTable(additionalParams = {}) {
router.get(
'/todos',
{ ...tableState.value, ...additionalParams },
{ preserveState: true, preserveScroll: true, replace: true },
)
}
// Obsługa interakcji użytkownika
function onFilterChange() {
updateTable({ page: 1 }) // reset do pierwszej strony przy zmianie filtrów
}
function handleSort(columnKey) {
if (tableState.value.sortBy === columnKey) {
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
updateTable({ page: 1 })
}
</script>
<template>
<div class="table-container">
<h2>Prawidłowy wzorzec: Laravel + Inertia + TanStack (Czysty JS)</h2>
<!-- FILTRY -->
<div class="filters-panel">
<input
v-model="tableState.search"
@input="onFilterChange"
type="text"
placeholder="Szukaj zadania..."
class="search-input"
/>
<select
v-model="tableState.status"
@change="onFilterChange"
class="status-select"
>
<option value="all">Wszystkie statusy</option>
<option value="completed">Ukończone</option>
<option value="pending">W trakcie</option>
</select>
</div>
<!-- TABELA (Rysowana przez TanStack na bazie propsów) -->
<table>
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
class="sortable"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span v-if="tableState.sortBy === header.column.id">
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="props.todos.data.length === 0">
<td
colspan="3"
style="text-align: center; color: #888; padding: 20px;"
>
Brak wyników w bazie danych.
</td>
</tr>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- PAGINACJA (Korzysta z natywnych linków i stanu paginatora Laravela) -->
<div class="pagination-panel">
<button
:disabled="!props.todos.prev_page_url"
@click="router.get(props.todos.prev_page_url, {}, { preserveScroll: true, preserveState: true })"
>
Poprzednia
</button>
<span
>Strona {{ props.todos.current_page }} z {{ props.todos.last_page
}}</span
>
<button
:disabled="!props.todos.next_page_url"
@click="router.get(props.todos.next_page_url, {}, { preserveScroll: true, preserveState: true })"
>
Następna
</button>
<select
v-model="tableState.limit"
@change="onFilterChange"
class="limit-select"
>
<option :value="5">Limit: 5</option>
<option :value="10">Limit: 10</option>
<option :value="20">Limit: 20</option>
</select>
</div>
</div>
</template>
<style scoped>
.table-container {
font-family: sans-serif;
margin: 20px;
max-width: 900px;
}
.filters-panel {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.search-input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.status-select,
.limit-select {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #e9e9e9;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.pagination-panel {
display: flex;
gap: 15px;
align-items: center;
}
.pagination-panel button {
padding: 8px 12px;
cursor: pointer;
}
</style><script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
const props = defineProps({
todos: Object,
filters: Object,
})
const tableState = ref({
search: props.filters.search || '',
status: props.filters.status || 'all',
sortBy: props.filters.sortBy || 'id',
order: props.filters.order || 'asc',
limit: props.filters.limit || 10,
})
// 1. REAKTYWNY STAN DLA ZAZNACZONYCH WIERSZY
// TanStack przechowuje to w formacie obiektu: { "id_wiersza": true }
const rowSelection = ref({})
// 2. NOWA KOLUMNA Z CHECKBOXEM NA POCZĄTKU LISTY
const columns = [
{
id: 'select',
// Nagłówek tabeli (Przycisk "Zaznacz wszystkie" na danej stronie)
header: ({ table }) => {
// Bezpiecznie sprawdzamy, czy wiersze z propsów w ogóle już są załadowane
const hasRows = table.getRowModel().rows.length > 0
return `<input
type="checkbox"
${hasRows && table.getIsAllPageRowsSelected() ? 'checked' : ''}
${hasRows && table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected() ? 'indeterminate="true"' : ''}
class="table-checkbox header-checkbox"
/>`
},
// Komórka wiersza (Checkbox dla konkretnego wiersza)
cell: ({ row }) => {
return `<input
type="checkbox"
${row.getIsSelected() ? 'checked' : ''}
${!row.getCanSelect() ? 'disabled' : ''}
class="table-checkbox row-checkbox"
/>`
},
},
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
{
accessorKey: 'completed',
header: 'Status',
cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
},
]
// 3. ROZBUDOWANA KONFIGURACJA TANSTACK TABLE
const table = useVueTable({
get data() {
return props.todos.data
},
columns,
state: {
// Przekazujemy stan zaznaczenia do TanStacka
get rowSelection() {
return rowSelection.value
},
},
// Włączamy obsługę zaznaczania wierszy
enableRowSelection: true,
// Funkcja, która aktualizuje nasz ref, gdy użytkownik coś kliknie
onRowSelectionChange: (updater) => {
rowSelection.value =
typeof updater === 'function' ? updater(rowSelection.value) : updater
},
getCoreRowModel: getCoreRowModel(),
})
// 4. OBSŁUGA KLIKNIĘĆ W CHECKBOXY (Przechwytujemy zdarzenia z surowego HTML)
function handleCheckboxClick(event) {
const target = event.target
// Jeśli kliknięto checkbox w nagłówku (Zaznacz wszystkie)
if (target.classList.contains('header-checkbox')) {
table.getToggleAllPageRowsSelectedHandler()(event)
}
// Jeśli kliknięto checkbox w konkretnym wierszu
if (target.classList.contains('row-checkbox')) {
// Szukamy najbliższego wiersza TR, aby dowiedzieć się, który to indeks TanStacka
const tr = target.closest('tr')
if (tr && tr.dataset.rowIndex !== undefined) {
const rowIndex = Number(tr.dataset.rowIndex)
const row = table.getRowModel().rows[rowIndex]
if (row) {
row.getToggleSelectedHandler()(event)
}
}
}
}
// 5. WYCIĄGANIE IDENTYFIKATORÓW ZAZNACZONYCH ELEMENTÓW
// Przydatne, gdy chcesz wysłać zaznaczone ID do Laravela (np. do masowego usuwania)
const selectedTodoIds = computed(() => {
return table.getSelectedRowModel().rows.map((row) => row.original.id)
})
// Przykładowa akcja masowa (np. usuwanie)
function deleteSelected() {
if (
confirm(
`Czy na pewno chcesz usunąć zaznaczone zadania (ID: ${selectedTodoIds.value.join(', ')})?`,
)
) {
router.delete('/todos/mass-destroy', {
data: { ids: selectedTodoIds.value },
onSuccess: () => {
rowSelection.value = {} // resetujemy zaznaczenie po udanym usunięciu
},
})
}
}
// Reszta logiki Inertia bez zmian...
function updateTable(additionalParams = {}) {
router.get(
'/todos',
{ ...tableState.value, ...additionalParams },
{ preserveState: true, preserveScroll: true, replace: true },
)
}
function onFilterChange() {
rowSelection.value = {} // Dobra praktyka: czyścimy zaznaczenie przy zmianie filtrów/wyszukiwania
updateTable({ page: 1 })
}
function handleSort(columnKey) {
if (columnKey === 'select') return // ignorujemy sortowanie po checkboxie
if (tableState.value.sortBy === columnKey) {
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
updateTable({ page: 1 })
}
// Przykład
// h(DropdownMenu, {}, {
// default: () => [
// h(DropdownMenuTrigger, ...),
// h(DropdownMenuContent, ...)
// ]
// })
</script>
<template>
<div class="table-container">
<h2>Laravel + Inertia + TanStack Row Selection (Czysty JS)</h2>
<!-- PANEL FILTRÓW I AKCJI MASOWYCH -->
<div class="filters-panel">
<input
v-model="tableState.search"
@input="onFilterChange"
type="text"
placeholder="Szukaj..."
class="search-input"
/>
<select
v-model="tableState.status"
@change="onFilterChange"
class="status-select"
>
<option value="all">Wszystkie</option>
<option value="completed">Ukończone</option>
<option value="pending">W trakcie</option>
</select>
<!-- Przycisk akcji masowej pojawia się tylko, gdy coś zaznaczono -->
<button
v-if="selectedTodoIds.length > 0"
@click="deleteSelected"
class="danger-btn"
>
Usuń zaznaczone ({{ selectedTodoIds.length }})
</button>
</div>
<!-- TABELA HTML -->
<!-- Podpinamy @change pod całą tabelę (Event Delegation), aby obsłużyć checkboxy wyrenderowane przez FlexRender -->
<table @change="handleCheckboxClick">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
:class="{ 'sortable': header.column.id !== 'select' }"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span
v-if="tableState.sortBy === header.column.id && header.column.id !== 'select'"
>
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="props.todos.data.length === 0">
<td
colspan="4"
style="text-align: center; color: #888; padding: 20px;"
>
Brak wyników.
</td>
</tr>
<!-- Dodajemy :data-row-index, aby funkcja kliknięcia wiedziała, który wiersz zaznaczono -->
<tr
v-for="(row, index) in table.getRowModel().rows"
:key="row.id"
:data-row-index="index"
:class="{ 'selected-row': row.getIsSelected() }"
>
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- PAGINACJA -->
<div class="pagination-panel">
<button
:disabled="!props.todos.prev_page_url"
@click="router.get(props.todos.prev_page_url, {}, { preserveScroll: true, preserveState: true })"
>
Poprzednia
</button>
<span
>Strona {{ props.todos.current_page }} z {{ props.todos.last_page
}}</span
>
<button
:disabled="!props.todos.next_page_url"
@click="router.get(props.todos.next_page_url, {}, { preserveScroll: true, preserveState: true })"
>
Następna
</button>
<select v-model="tableState.limit" @change="onFilterChange">
<option :value="5">Limit: 5</option>
<option :value="10">Limit: 10</option>
</select>
</div>
</div>
</template>
<style scoped>
.table-container {
font-family: sans-serif;
margin: 20px;
max-width: 900px;
}
.filters-panel {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
}
.search-input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.status-select {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #e9e9e9;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.selected-row {
background-color: #e3f2fd !important;
} /* podświetlenie zaznaczonego wiersza */
.table-checkbox {
transform: scale(1.2);
cursor: pointer;
}
.pagination-panel {
display: flex;
gap: 15px;
align-items: center;
}
.pagination-panel button {
padding: 8px 12px;
cursor: pointer;
}
.danger-btn {
padding: 10px 15px;
background-color: #d32f2f;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.danger-btn:hover {
background-color: #b71c1c;
}
</style><script setup>
import { ref, computed, h } from 'vue'
import { router } from '@inertiajs/vue3'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// 1. IMPORTUJEMY KOMPONENTY (np. z Twojego folderu shadcn/ui)
// Jeśli nie masz shadcn, mogą to być jakiekolwiek inne komponenty Vue
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
// Ikona trzech kropek (możesz użyć lucide-vue-next lub zwykłego tekstu)
// Tutaj użyjemy zwykłego wielokropka "⋯" dla uproszczenia przykładu bez dodatkowych paczek
const MoreHorizontalIcon = () => h('span', { class: 'text-lg' }, '⋯')
const props = defineProps({
todos: Object,
filters: Object,
})
const tableState = ref({
search: props.filters.search || '',
status: props.filters.status || 'all',
sortBy: props.filters.sortBy || 'id',
order: props.filters.order || 'asc',
limit: props.filters.limit || 10,
})
const rowSelection = ref({})
// Metody dla akcji wiersza
function editTodo(id) {
// Przekierowanie Inertii na stronę edycji
router.get(`/todos/${id}/edit`)
}
function deleteTodo(id) {
if (confirm(`Czy na pewno usunąć zadanie o ID ${id}?`)) {
router.delete(`/todos/${id}`)
}
}
// 2. ROZBUDOWANA DEFINICJA KOLUMN Z KOMPONENTAMI SHADCN
const columns = [
// Kolumna z checkboxem (zostaje z poprzedniego kroku, też używa h())
{
id: 'select',
header: ({ table }) => {
const hasRows = table.getRowModel().rows.length > 0
return h('input', {
type: 'checkbox',
checked: hasRows && table.getIsAllPageRowsSelected(),
domProps: {
indeterminate:
hasRows &&
table.getIsSomePageRowsSelected() &&
!table.getIsAllPageRowsSelected(),
},
onChange: (event) =>
table.getToggleAllPageRowsSelectedHandler()(event),
})
},
cell: ({ row }) =>
h('input', {
type: 'checkbox',
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
onChange: (event) => row.getToggleSelectedHandler()(event),
}),
},
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
// 3. NOWA KOLUMNA: AKCJE (Dropdown z shadcn)
{
id: 'actions',
header: '', // brak tekstu w nagłówku kolumny akcji
cell: ({ row }) => {
// Wyciągamy surowy obiekt danych z bazy dla tego wiersza
const todo = row.original
// W czystym JS funkcja h() tworzy strukturę komponentów przez zagnieżdżanie:
return h(
DropdownMenu,
{},
{
// Slot domyślny (lub nazwany) w shadcn przekazujemy jako funkcje zwracające h()
default: () => [
// Trigger: przycisk wywołujący menu
h(
DropdownMenuTrigger,
{ asChild: true },
{
default: () =>
h(
Button,
{ variant: 'ghost', class: 'h-8 w-8 p-0' },
{
default: () => [
h('span', { class: 'sr-only' }, 'Otwórz menu'),
h(MoreHorizontalIcon),
],
},
),
},
),
// Content: zawartość menu (lista opcji)
h(
DropdownMenuContent,
{ align: 'end' },
{
default: () => [
// Opcja 1: Edytuj
h(
DropdownMenuItem,
{
onSelect: () => editTodo(todo.id),
},
{ default: () => 'Edytuj zadanie' },
),
// Opcja 2: Usuń (z czerwoną klasą destruktywną)
h(
DropdownMenuItem,
{
class: 'text-red-600 focus:text-red-600',
onSelect: () => deleteTodo(todo.id),
},
{ default: () => 'Usuń' },
),
],
},
),
],
},
)
},
},
]
// Konfiguracja tabeli
const table = useVueTable({
get data() {
return props.todos.data
},
columns,
state: {
get rowSelection() {
return rowSelection.value
},
},
enableRowSelection: true,
onRowSelectionChange: (updater) => {
rowSelection.value =
typeof updater === 'function' ? updater(rowSelection.value) : updater
},
getCoreRowModel: getCoreRowModel(),
})
// Reszta metod Inertii bez zmian...
function updateTable(additionalParams = {}) {
router.get(
'/todos',
{ ...tableState.value, ...additionalParams },
{ preserveState: true, preserveScroll: true, replace: true },
)
}
function handleSort(columnKey) {
if (['select', 'actions'].includes(columnKey)) return
if (tableState.value.sortBy === columnKey) {
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
updateTable({ page: 1 })
}
</script>
<template>
<div class="table-container">
<table>
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
:class="{ 'sortable': !['select', 'actions'].includes(header.column.id) }"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span
v-if="tableState.sortBy === header.column.id && !['select', 'actions'].includes(header.column.id)"
>
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in table.getRowModel().rows"
:key="row.id"
:class="{ 'selected-row': row.getIsSelected() }"
>
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
/* Te same style CSS co poprzednio... */
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #e9e9e9;
}
.selected-row {
background-color: #e3f2fd !important;
}
</style>use App\Http\Controllers\TodoController;
use Illuminate\Support\Facades\Route;
Route::delete('/todos/mass-destroy', [TodoController::class, 'massDestroy'])->name('todos.mass-destroy');<?php
namespace App\Http\Controllers;
use App\Models\Todo;
use Illuminate\Http\Request;
class TodoController extends Controller
{
// ... poprzednie metody (index, etc.)
public function massDestroy(Request $request)
{
// 1. Walidacja: sprawdzamy czy 'ids' to tablica i czy te ID istnieją w tabeli 'todos'
$validated = $request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:todos,id',
]);
// 2. Bezpieczne masowe usunięcie jednym zapytaniem SQL
Todo::whereIn('id', $validated['ids'])->delete();
// 3. Przekierowanie powrotne (Inertia automatycznie odświeży dane w tabeli)
return redirect()->back()->with('message', 'Pomyślnie usunięto zaznaczone zadania.');
}
}<script setup>
import { ref, computed, h } from 'vue'
import { router } from '@inertiajs/vue3'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// ... konfiguracja stanów, kolumn i TanStack Table z poprzednich kroków
// Wyciągamy tablicę z ID zaznaczonych elementów z modelu TanStacka
const selectedTodoIds = computed(() => {
return table.getSelectedRowModel().rows.map((row) => row.original.id)
})
// FUNKCJA MASOWEGO USUWANIA
function deleteSelected() {
if (selectedTodoIds.value.length === 0) return
if (
confirm(
`Czy na pewno chcesz usunąć te zadania? (ID: ${selectedTodoIds.value.join(', ')})`,
)
) {
router.delete('/todos/mass-destroy', {
// Przekazujemy tablicę z ID w sekcji 'data'
data: {
ids: selectedTodoIds.value,
},
preserveScroll: true, // Zapobiega skakaniu strony w górę po usunięciu
onSuccess: () => {
// Czyścimy zaznaczenie w tabeli po udanym usunięciu z bazy
rowSelection.value = {}
},
})
}
}
</script>
<template>
<div class="table-container">
<!-- Dynamiczny przycisk masowego usuwania w panelu filtrów -->
<div class="filters-panel">
<!-- ... wyszukiwarka i statusy ... -->
<!-- Przycisk pojawia się w DOM tylko, gdy użytkownik zaznaczy przynajmniej 1 wiersz -->
<button
v-if="selectedTodoIds.length > 0"
@click="deleteSelected"
class="danger-btn"
>
Usuń zaznaczone ({{ selectedTodoIds.length }})
</button>
</div>
<!-- ... kod tabeli HTML i paginacji ... -->
</div>
</template>
<style scoped>
.danger-btn {
padding: 10px 15px;
background-color: #ef4444; /* Czerwony kolor destruktywny */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
.danger-btn:hover {
background-color: #dc2626;
}
</style><?php
namespace App\Http\Controllers;
use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;
class TodoController extends Controller
{
public function index(Request $request)
{
// 1. Pobieramy aktualne filtry z paska URL
$filters = $request->only(['search', 'status', 'page', 'limit', 'sortBy', 'order']);
// 2. Jeśli zapytanie chce czysty JSON (od TanStack Query)
if ($request->wantsJson()) {
$query = Todo::query();
if ($request->filled('search')) {
$query->where('todo', 'like', '%' . $request->search . '%');
}
if ($request->filled('status') && $request->status !== 'all') {
$query->where('completed', $request->status === 'completed');
}
$sortBy = $request->input('sortBy', 'id');
$order = $request->input('order', 'asc');
$query->orderBy($sortBy, $order);
// Zwracamy paginację jako czysty JSON dla useQuery
return response()->json($query->paginate($request->input('limit', 10)));
}
// 3. Pierwsze wejście na stronę: renderujemy widok Inertii i przekazujemy filtry z URL do props
return Inertia::render('Todos/IndexWithTanstack', [
'filters' => $filters
]);
}
}<script setup>
import { ref, computed, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'
// Pobieramy filtry z Laravela na starcie strony
const props = defineProps({
filters: Object,
})
// 1. Inicjalizacja stanu na bazie props.filters z paska adresu URL
const tableState = ref({
search: props.filters.search || '',
status: props.filters.status || 'all',
page: Number(props.filters.page) || 1,
limit: Number(props.filters.limit) || 10,
sortBy: props.filters.sortBy || 'id',
order: props.filters.order || 'asc',
})
// 2. Budujemy adres URL dla TanStack Query
const apiFetchUrl = computed(() => {
const { search, status, page, limit, sortBy, order } = tableState.value
return `/todos?search=${search}&status=${status}&page=${page}&limit=${limit}&sortBy=${sortBy}&order=${order}`
})
// 3. TanStack Query pobiera czyste dane JSON z Laravela
const { data: apiResponse, isLoading } = useQuery({
queryKey: ['todos', computed(() => ({ ...tableState.value }))],
queryFn: async () => {
// Nagłówek 'X-Requested-With' mówi Laravelowi, że chcemy czysty JSON (wantsJson())
const res = await fetch(apiFetchUrl.value, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
})
return res.json()
},
})
// Dane dla TanStack Table
const tableData = computed(() => apiResponse.value?.data || [])
const totalPages = computed(() => apiResponse.value?.last_page || 1)
// 4. Definicja kolumn TanStack Table (Czysty JS)
const columns = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'todo', header: 'Zadanie' },
{
accessorKey: 'completed',
header: 'Status',
cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
},
]
const table = useVueTable({
get data() {
return tableData.value
},
columns,
getCoreRowModel: getCoreRowModel(),
})
// 5. SYNCHRONIZACJA Z PASKIEM ADRESU URL PRZEZ INERTIA
// Zamiast wysyłać zapytanie fetch, aktualizujemy pasek adresu.
function syncWithUrl() {
router.get(
'/todos',
{ ...tableState.value },
{ preserveState: true, preserveScroll: true, replace: true },
)
}
// Obsługa zmian w filtrach
function onFilterChange() {
tableState.value.page = 1 // reset do 1 strony
syncWithUrl()
}
function handleSort(columnKey) {
if (tableState.value.sortBy === columnKey) {
tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
} else {
tableState.value.sortBy = columnKey
tableState.value.order = 'asc'
}
tableState.value.page = 1
syncWithUrl()
}
function changePage(newPage) {
tableState.value.page = newPage
syncWithUrl()
}
</script>
<template>
<div class="table-container">
<h2>TanStack Table + Query wewnątrz Inertia.js (Czysty JS)</h2>
<!-- FILTRY -->
<div class="filters-panel">
<input
v-model="tableState.search"
@input="onFilterChange"
type="text"
placeholder="Szukaj..."
class="search-input"
/>
<select
v-model="tableState.status"
@change="onFilterChange"
class="status-select"
>
<option value="all">Wszystkie</option>
<option value="completed">Ukończone</option>
<option value="pending">W trakcie</option>
</select>
</div>
<div v-if="isLoading" class="status-msg">
TanStack Query pobiera dane...
</div>
<!-- TABELA TANSTACK -->
<table v-else>
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
@click="handleSort(header.column.id)"
class="sortable"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span v-if="tableState.sortBy === header.column.id">
{{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
<!-- PAGINACJA -->
<div v-if="!isLoading" class="pagination-panel">
<button
:disabled="tableState.page <= 1"
@click="changePage(tableState.page - 1)"
>
Poprzednia
</button>
<span>Strona {{ tableState.page }} z {{ totalPages }}</span>
<button
:disabled="tableState.page >= totalPages"
@click="changePage(tableState.page + 1)"
>
Następna
</button>
<select v-model="tableState.limit" @change="onFilterChange">
<option :value="5">Pokaż: 5</option>
<option :value="10">Pokaż: 10</option>
</select>
</div>
</div>
</template>
<style scoped>
.table-container {
font-family: sans-serif;
margin: 20px;
max-width: 900px;
}
.filters-panel {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.search-input {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.status-select {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #e9e9e9;
}
.pagination-panel {
display: flex;
gap: 15px;
align-items: center;
}
.pagination-panel button {
padding: 8px 12px;
}
.status-msg {
padding: 30px;
text-align: center;
background: #f5f5f5;
}
</style>