Dev branch

This commit is contained in:
Simon Pocrnjič
2025-11-02 12:31:01 +01:00
parent 5f879c9436
commit 63e0958b66
241 changed files with 17686 additions and 7327 deletions
@@ -0,0 +1,54 @@
<script setup>
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { cn } from '@/lib/utils';
const props = defineProps({
icon: {
type: [String, Object, Array],
default: null,
},
label: {
type: String,
required: true,
},
danger: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
function handleClick(event) {
if (!props.disabled) {
emit('click', event);
}
}
</script>
<template>
<DropdownMenuItem
:disabled="disabled"
:class="
cn(
'flex items-center gap-2 cursor-pointer',
danger && 'text-red-700 focus:text-red-700 focus:bg-red-50',
)
"
@select="handleClick"
>
<FontAwesomeIcon
v-if="icon"
:icon="icon"
class="w-4 h-4 flex-shrink-0"
/>
<span>{{ label }}</span>
</DropdownMenuItem>
</template>
@@ -0,0 +1,134 @@
<script setup>
import { ref, watch } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faFilter, faTimes } from '@fortawesome/free-solid-svg-icons';
import DatePicker from '@/Components/DatePicker.vue';
const props = defineProps({
column: {
type: Object,
required: true,
},
value: {
type: [String, Number, Array, null],
default: null,
},
type: {
type: String,
default: 'text', // text, select, date, date-range, number
validator: (v) => ['text', 'select', 'date', 'date-range', 'number'].includes(v),
},
options: {
type: Array,
default: () => [], // For select type: [{ value, label }]
},
});
const emit = defineEmits(['update:value', 'clear']);
const internalValue = ref(props.value);
watch(
() => props.value,
(newVal) => {
internalValue.value = newVal;
}
);
watch(internalValue, (newVal) => {
emit('update:value', newVal);
});
function clear() {
internalValue.value = props.type === 'select' || props.type === 'date-range' ? null : '';
emit('clear');
}
</script>
<template>
<div class="relative">
<!-- Filter Button -->
<button
type="button"
:class="[
'inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors',
value && value !== '' && (Array.isArray(value) ? value.length > 0 : true)
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
]"
@click.stop
>
<FontAwesomeIcon :icon="faFilter" class="h-3 w-3 mr-1" />
{{ column.label }}
<button
v-if="value && value !== '' && (Array.isArray(value) ? value.length > 0 : true)"
@click.stop="clear"
class="ml-1.5 text-gray-500 hover:text-gray-700"
>
<FontAwesomeIcon :icon="faTimes" class="h-3 w-3" />
</button>
</button>
<!-- Filter Dropdown -->
<div
v-if="showDropdown"
class="absolute z-50 mt-1 w-64 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5"
@click.stop
>
<div class="p-3">
<!-- Text Filter -->
<input
v-if="type === 'text'"
v-model="internalValue"
type="text"
:placeholder="`Filtriraj ${column.label.toLowerCase()}...`"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
<!-- Select Filter -->
<select
v-else-if="type === 'select'"
v-model="internalValue"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option :value="null">Vse</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- Number Filter -->
<div v-else-if="type === 'number'" class="space-y-2">
<input
v-model.number="internalValue"
type="number"
:placeholder="`Filtriraj ${column.label.toLowerCase()}...`"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<!-- Date Filter -->
<DatePicker
v-else-if="type === 'date'"
v-model="internalValue"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue';
const showDropdown = ref(false);
// Close dropdown when clicking outside
if (typeof window !== 'undefined') {
document.addEventListener('click', () => {
showDropdown.value = false;
});
}
</script>
@@ -0,0 +1,884 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import DataTableToolbar from './DataTableToolbar.vue';
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
import EmptyState from '../EmptyState.vue';
import Pagination from '../Pagination.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import {
faChevronLeft,
faChevronRight,
faSort,
faSortUp,
faSortDown,
} from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === 'number' &&
typeof meta.per_page === 'number' &&
typeof meta.total === 'number' &&
typeof meta.last_page === 'number'),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: '' },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: 'page' },
onlyProps: { type: Array, default: () => [] },
// Features
showToolbar: { type: Boolean, default: true },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
showPagination: { type: Boolean, default: true },
showFilters: { type: Boolean, default: false },
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
showOptions: { type: Boolean, default: false }, // Show custom options slot
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
rowKey: { type: [String, Function], default: 'uuid' },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: 'Ni podatkov' },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 }, // Tailwind md breakpoint
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:search',
'update:sort',
'update:page',
'update:pageSize',
'row:click',
'row:select',
'selection:change',
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
function keyOf(row) {
if (typeof props.rowKey === 'function') return props.rowKey(row);
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
// Client-side sorting
const sortedRows = computed(() => {
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
return props.rows;
}
const key = props.sort.key;
const direction = props.sort.direction;
const sorted = [...props.rows];
sorted.sort((a, b) => {
let aVal = a?.[key];
let bVal = b?.[key];
// Handle nulls/undefined
if (aVal == null) return 1;
if (bVal == null) return -1;
// Handle dates
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
aVal = new Date(aVal);
bVal = new Date(bVal);
}
// Handle numbers
if (typeof aVal === 'number' && typeof bVal === 'number') {
return direction === 'asc' ? aVal - bVal : bVal - aVal;
}
// Handle strings
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (direction === 'asc') {
return aStr.localeCompare(bStr);
}
return bStr.localeCompare(aStr);
});
return sorted;
});
// Client-side pagination
const currentPage = computed(() => {
if (isServerSide.value) return props.meta?.current_page ?? 1;
return internalPage.value;
});
const internalPage = ref(1);
// Use computed for pageSize to always reflect the correct value
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
const internalPageSize = computed({
get: () => {
if (isServerSide.value && props.meta?.per_page) {
return props.meta.per_page;
}
return internalPageSizeState.value ?? props.pageSize;
},
set: (value) => {
internalPageSizeState.value = value;
}
});
// Internal state for client-side or when user changes page size before server responds
const internalPageSizeState = ref(null);
const totalPages = computed(() => {
if (isServerSide.value) return props.meta?.last_page ?? 1;
return Math.ceil(sortedRows.value.length / internalPageSize.value);
});
const paginatedRows = computed(() => {
if (isServerSide.value) return props.rows;
const start = (currentPage.value - 1) * internalPageSize.value;
const end = start + internalPageSize.value;
return sortedRows.value.slice(start, end);
});
// Client-side search
const filteredRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
const searchTerm = internalSearch.value.toLowerCase();
return paginatedRows.value.filter((row) => {
return props.columns.some((col) => {
const value = row?.[col.key];
if (value == null) return false;
return String(value).toLowerCase().includes(searchTerm);
});
});
});
// Search handling
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Selection
const selectedRows = ref(new Set());
const isAllSelected = computed(() => {
if (filteredRows.value.length === 0) return false;
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
});
const isSomeSelected = computed(() => {
return (
selectedRows.value.size > 0 &&
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
);
});
function toggleSelectAll() {
if (isAllSelected.value) {
filteredRows.value.forEach((row) => {
selectedRows.value.delete(keyOf(row));
});
} else {
filteredRows.value.forEach((row) => {
selectedRows.value.add(keyOf(row));
});
}
emit('selection:change', Array.from(selectedRows.value));
}
function toggleSelectRow(row) {
const key = keyOf(row);
if (selectedRows.value.has(key)) {
selectedRows.value.delete(key);
} else {
selectedRows.value.add(key);
}
emit('row:select', row, selectedRows.value.has(key));
emit('selection:change', Array.from(selectedRows.value));
}
// Sorting
function toggleSort(col) {
if (!col.sortable) return;
if (isServerSide.value) {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
} else {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
}
}
function getSortIcon(col) {
if (props.sort?.key !== col.key) return faSort;
if (props.sort?.direction === 'asc') return faSortUp;
if (props.sort?.direction === 'desc') return faSortDown;
return faSort;
}
// Server-side request
function doServerRequest(overrides = {}) {
// Preserve existing query parameters from URL
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams, // Preserve all existing query parameters
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
};
const pageParam = props.pageParamName || 'page';
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== 'page') {
delete q.page;
}
// Clean nulls and empty strings
Object.keys(q).forEach((k) => {
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
});
const url = route(props.routeName, props.routeParams || {});
router.get(
url,
q,
{
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
onSuccess: () => {
// Scroll to top of table after server request completes
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
},
}
);
}
function handleSearchChange(value) {
internalSearch.value = value;
emit('update:search', value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSizeState.value = newSize;
emit('update:pageSize', newSize);
if (isServerSide.value) {
// Reset to page 1 when changing page size to avoid being on a non-existent page
doServerRequest({ perPage: newSize, page: 1 });
} else {
// Calculate total pages with new size
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
// If current page exceeds new total, reset to last page or page 1
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
internalPage.value = targetPage;
emit('update:page', targetPage);
}
}
function handlePageChange(page) {
if (isServerSide.value) {
doServerRequest({ page });
} else {
internalPage.value = page;
emit('update:page', page);
}
// Scroll to top of table after page change
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, isServerSide.value ? 100 : 50);
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== 'undefined') {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener('resize', checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
return filteredRows.value;
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
return Math.min(currentPage.value * internalPageSize.value, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? '';
});
return exported;
});
if (format === 'csv') {
exportToCSV(data);
} else if (format === 'xlsx') {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return '';
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(',')
),
].join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
// Simple implementation: use CSV format with .xlsx extension
exportToCSV(data);
// In production, you might want to use a library like 'xlsx' or 'exceljs'
}
// Generate visible page numbers with ellipsis
function getVisiblePages() {
const pages = [];
const total = totalPages.value;
const current = currentPage.value;
const maxVisible = 7;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) {
pages.push(i);
}
return pages;
}
// Always show first page
pages.push(1);
// Calculate start and end
let start = Math.max(2, current - 1);
let end = Math.min(total - 1, current + 1);
// Adjust if near start
if (current <= 3) {
end = Math.min(4, total - 1);
}
// Adjust if near end
if (current >= total - 2) {
start = Math.max(2, total - 3);
}
// Add ellipsis after first page if needed
if (start > 2) {
pages.push('...');
}
// Add middle pages
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Add ellipsis before last page if needed
if (end < total - 1) {
pages.push('...');
}
// Always show last page
if (total > 1) {
pages.push(total);
}
return pages;
}
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:search="internalSearch"
:show-search="showSearch"
:show-page-size="showPageSize"
:page-size="internalPageSize"
:page-size-options="pageSizeOptions"
:selected-count="selectedRows.size"
:show-selected-count="showSelectedCount"
:show-export="showExport"
:show-add="showAdd"
:show-options="showOptions"
:show-filters="showFilters"
:show-options-menu="showOptionsMenu"
:has-active-filters="hasActiveFilters"
:compact="compactToolbar"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<template #add>
<slot name="toolbar-add" />
</template>
<template #options>
<slot name="toolbar-options" />
</template>
<template #filters>
<slot name="toolbar-filters" />
</template>
<template #actions>
<slot name="toolbar-actions" />
</template>
</DataTableToolbar>
<!-- Table Container -->
<div
data-table-container
class="relative overflow-hidden"
>
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<!-- Header -->
<thead>
<tr>
<!-- Select All Checkbox -->
<th
v-if="selectable"
class="w-12 px-6 py-3 text-left"
scope="col"
>
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isSomeSelected && !isAllSelected"
@change="toggleSelectAll"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</th>
<!-- Column Headers -->
<th
v-for="col in columns"
:key="col.key"
scope="col"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
:class="[
col.class,
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
]"
>
<button
v-if="col.sortable"
type="button"
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span>{{ col.label }}</span>
<FontAwesomeIcon
:icon="getSortIcon(col)"
class="w-3 h-3 transition-colors"
:class="{
'text-gray-700': sort?.key === col.key,
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
}"
/>
</button>
<span v-else>{{ col.label }}</span>
</th>
<!-- Actions Column -->
<th
v-if="showActions || $slots.actions"
scope="col"
class="relative w-px px-6 py-3"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<!-- Body -->
<tbody class="divide-y divide-gray-200 bg-white">
<!-- Loading State -->
<template v-if="loading">
<tr>
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
<SkeletonTable :rows="5" :cols="columns.length" />
</td>
</tr>
</template>
<!-- Empty State -->
<template v-else-if="!loading && displayRows.length === 0">
<tr>
<td
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="px-6 py-12 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</td>
</tr>
</template>
<!-- Rows -->
<template v-else>
<tr
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
class="transition-colors"
:class="{
'cursor-pointer': !!$attrs.onRowClick,
'bg-gray-50': striped && idx % 2 === 1,
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
'bg-primary-50': selectedRows.has(keyOf(row)),
}"
>
<!-- Select Checkbox -->
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
:checked="selectedRows.has(keyOf(row))"
@click.stop="toggleSelectRow(row)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</td>
<!-- Data Cells -->
<td
v-for="col in columns"
:key="col.key"
class="px-6 py-4 whitespace-nowrap text-sm"
:class="[
col.class,
col.align === 'right'
? 'text-right text-gray-900'
: col.align === 'center'
? 'text-center text-gray-900'
: 'text-left text-gray-900',
]"
>
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
<span>{{ row?.[col.key] ?? '—' }}</span>
</slot>
</slot>
</td>
<!-- Actions Cell -->
<td
v-if="showActions || $slots.actions"
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
>
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && displayRows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="$emit('row:click', row, idx)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row" :index="idx">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
{{ row?.[col.key] ?? '—' }}
</slot>
</span>
</div>
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination && totalPages > 1">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="from"
:to="to"
:total="total"
/>
</template>
<!-- Custom pagination for client-side -->
<template v-else>
<div class="flex flex-1 justify-between sm:hidden">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Prejšnja
</button>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Naslednja
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Prikazano
<span class="font-medium">{{ from }}</span>
do
<span class="font-medium">{{ to }}</span>
od
<span class="font-medium">{{ total }}</span>
rezultatov
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Prejšnja</span>
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
</button>
<template v-for="page in getVisiblePages()" :key="page">
<button
v-if="page !== '...'"
@click="handlePageChange(page)"
:aria-current="page === currentPage ? 'page' : undefined"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
:class="
page === currentPage
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
"
>
{{ page }}
</button>
<span
v-else
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
>
...
</span>
</template>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Naslednja</span>
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
</button>
</nav>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -1,13 +1,15 @@
<script setup>
import { computed, ref, watch } from "vue";
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
import EmptyState from "@/Components/EmptyState.vue";
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/Components/ui/table";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
@@ -197,42 +199,43 @@ function setPageSize(ps) {
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
<Table class="text-sm">
<TableHeader
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
</button>
<span v-else>{{ col.label }}</span>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
>
</button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
</TableRow>
</TableHeader>
<FwbTableBody>
<TableBody>
<template v-if="!loading && pageRows.length">
<FwbTableRow
<TableRow
v-for="(row, idx) in pageRows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default"
class="cursor-default hover:bg-gray-50/50"
>
<FwbTableCell
<TableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<template v-if="$slots['cell-' + col.key]">
<slot
@@ -255,33 +258,37 @@ function setPageSize(ps) {
<template v-else>
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
</template>
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
</TableCell>
<TableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
<template v-else-if="loading">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<template v-else>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
<EmptyState
:title="emptyText"
size="sm"
/>
</slot>
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
</FwbTableBody>
</FwbTable>
</TableBody>
</Table>
</div>
<nav
v-if="showPagination"
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
aria-label="Pagination"
>
@@ -1,14 +1,16 @@
<script setup>
import { ref, watch, computed } from "vue";
import { router } from "@inertiajs/vue3";
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
import EmptyState from "@/Components/EmptyState.vue";
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/Components/ui/table";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
@@ -94,7 +96,8 @@ function setPageSize(ps) {
function doRequest(overrides = {}) {
const q = {
...props.query,
perPage: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
// Laravel expects snake_case per_page
per_page: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
sort: overrides.sort ?? props.sort?.key ?? null,
direction: overrides.direction ?? props.sort?.direction ?? null,
search: overrides.search ?? props.search ?? "",
@@ -197,42 +200,43 @@ function goToPageInput() {
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
<Table class="text-sm">
<TableHeader
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
</button>
<span v-else>{{ col.label }}</span>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
>
</button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
</TableRow>
</TableHeader>
<FwbTableBody>
<TableBody>
<template v-if="!loading && rows.length">
<FwbTableRow
<TableRow
v-for="(row, idx) in rows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default"
class="cursor-default hover:bg-gray-50/50"
>
<FwbTableCell
<TableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<template v-if="$slots['cell-' + col.key]">
<slot
@@ -255,30 +259,30 @@ function goToPageInput() {
<template v-else>
{{ row?.[col.key] ?? "" }}
</template>
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
</TableCell>
<TableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
<template v-else-if="loading">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<template v-else>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
</slot>
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
</FwbTableBody>
</FwbTable>
</TableBody>
</Table>
</div>
<nav
@@ -0,0 +1,318 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faSearch, faTimes, faDownload, faEllipsisVertical, faGear, faPlus, faFilter } from '@fortawesome/free-solid-svg-icons';
import Dropdown from '../Dropdown.vue';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Label } from '@/Components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const props = defineProps({
search: { type: String, default: '' },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
selectedCount: { type: Number, default: 0 },
showSelectedCount: { type: Boolean, default: false }, // Control visibility of selected count badge
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Control visibility of add buttons dropdown
showOptions: { type: Boolean, default: false }, // Control visibility of custom options slot
showFilters: { type: Boolean, default: false }, // Control visibility of filters button
showOptionsMenu: { type: Boolean, default: false }, // Control visibility of options menu (three dots)
compact: { type: Boolean, default: false }, // New prop to toggle compact menu mode
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
});
const emit = defineEmits(['update:search', 'update:page-size', 'export']);
const internalSearch = ref(props.search);
const menuOpen = ref(false);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
const hasActiveFilters = computed(() => {
return !!internalSearch.value || props.selectedCount > 0;
});
function clearSearch() {
internalSearch.value = '';
emit('update:search', '');
}
function handleSearchInput() {
emit('update:search', internalSearch.value);
}
function handlePageSizeChange(value) {
emit('update:page-size', Number(value));
}
function handleExport(format) {
emit('export', format);
menuOpen.value = false;
}
</script>
<template>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<!-- Left side: Search and Add buttons dropdown -->
<div class="flex items-center gap-3 flex-1">
<!-- Search (always visible if showSearch is true) -->
<div v-if="showSearch && !compact" class="flex-1 max-w-sm">
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 z-10">
<FontAwesomeIcon :icon="faSearch" class="h-4 w-4 text-gray-400" />
</div>
<Input
v-model="internalSearch"
@input="handleSearchInput"
type="text"
placeholder="Iskanje..."
class="pl-10"
:class="internalSearch ? 'pr-10' : ''"
/>
<Button
v-if="internalSearch"
@click="clearSearch"
type="button"
variant="ghost"
size="icon"
class="absolute inset-y-0 right-0 h-full w-auto px-3 text-gray-400 hover:text-gray-600"
>
<FontAwesomeIcon :icon="faTimes" class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Add buttons dropdown (after search input) -->
<Dropdown v-if="$slots.add && showAdd && !compact" align="left">
<template #trigger>
<Button
type="button"
variant="outline"
size="icon"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
<span class="sr-only">Dodaj</span>
</Button>
</template>
<template #content>
<slot name="add" />
</template>
</Dropdown>
<!-- Custom options dropdown (after search input and add buttons) -->
<div v-if="$slots.options && showOptions && !compact" class="flex items-center">
<slot name="options" />
</div>
<!-- Filters button (after options, before right side) -->
<Popover v-if="showFilters && $slots.filters && !compact">
<PopoverTrigger as-child>
<Button variant="outline" size="icon" class="relative">
<FontAwesomeIcon :icon="faFilter" class="h-4 w-4" />
<span
v-if="hasActiveFilters"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
></span>
<span class="sr-only">Filtri</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-4" align="start">
<slot name="filters" />
</PopoverContent>
</Popover>
</div>
<!-- Right side: Selected count, Page size, Menu & Actions -->
<div class="flex items-center gap-3">
<!-- Selected count badge -->
<div
v-if="selectedCount > 0 && showSelectedCount"
class="inline-flex items-center rounded-md bg-primary-50 px-3 py-1.5 text-sm font-medium text-primary-700"
>
{{ selectedCount }} izbran{{ selectedCount === 1 ? 'o' : 'ih' }}
</div>
<!-- Page size selector (visible when not in compact mode) -->
<div v-if="showPageSize && !compact" class="flex items-center gap-2">
<Label for="page-size" class="text-sm text-gray-600 whitespace-nowrap">Na stran:</Label>
<Select
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger id="page-size" class="w-[100px]">
<SelectValue :placeholder="String(pageSize)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in pageSizeOptions"
:key="opt"
:value="String(opt)"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Table Options Menu (compact mode or always as dropdown) -->
<DropdownMenu v-if="showOptionsMenu" v-model:open="menuOpen">
<DropdownMenuTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
:class="hasActiveFilters && !compact ? 'relative' : ''"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
<span
v-if="hasActiveFilters && !compact"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
></span>
<span class="sr-only">Možnosti tabele</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<!-- Search in menu (only in compact mode) -->
<div v-if="compact && showSearch" class="p-2 border-b">
<Label for="menu-search" class="text-xs font-medium mb-1.5 block">Iskanje</Label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 z-10">
<FontAwesomeIcon :icon="faSearch" class="h-3.5 w-3.5 text-gray-400" />
</div>
<Input
id="menu-search"
v-model="internalSearch"
@input="handleSearchInput"
type="text"
placeholder="Iskanje..."
class="pl-8 h-8 text-sm"
:class="internalSearch ? 'pr-8' : ''"
/>
<Button
v-if="internalSearch"
@click="clearSearch"
type="button"
variant="ghost"
size="icon"
class="absolute inset-y-0 right-0 h-full w-auto px-2 text-gray-400 hover:text-gray-600"
>
<FontAwesomeIcon :icon="faTimes" class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Page size in menu (only in compact mode) -->
<div v-if="compact && showPageSize" class="p-2 border-b">
<Label for="menu-page-size" class="text-xs font-medium mb-1.5 block">Elementov na stran</Label>
<Select
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger id="menu-page-size" class="w-full h-8 text-sm">
<SelectValue :placeholder="String(pageSize)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in pageSizeOptions"
:key="opt"
:value="String(opt)"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Export options -->
<template v-if="showExport">
<DropdownMenuLabel>Izvozi</DropdownMenuLabel>
<DropdownMenuItem @select="handleExport('csv')">
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
CSV
</DropdownMenuItem>
<DropdownMenuItem @select="handleExport('xlsx')">
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
Excel
</DropdownMenuItem>
</template>
<!-- Custom actions slot in menu -->
<template v-if="$slots.actions">
<DropdownMenuSeparator />
<slot name="actions" />
</template>
</DropdownMenuContent>
</DropdownMenu>
<template v-else>
<!-- If options menu is hidden but we have content to show, render it inline -->
<div v-if="showExport && !compact" class="flex items-center gap-2">
<Dropdown v-if="showExport" align="right">
<template #trigger>
<Button
type="button"
variant="outline"
class="gap-2"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4" />
Izvozi
</Button>
</template>
<template #content>
<div class="py-1">
<button
type="button"
@click="handleExport('csv')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
CSV
</button>
<button
type="button"
@click="handleExport('xlsx')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
Excel
</button>
</div>
</template>
</Dropdown>
</div>
</template>
<!-- Custom actions slot (visible when not in compact mode) -->
<div v-if="$slots.actions && !compact" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,170 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: {
type: String,
required: true,
},
variant: {
type: String,
default: 'default', // default, dot, pill
validator: (v) => ['default', 'dot', 'pill'].includes(v),
},
size: {
type: String,
default: 'md', // sm, md, lg
validator: (v) => ['sm', 'md', 'lg'].includes(v),
},
color: {
type: String,
default: null, // If null, uses status-based colors
validator: (v) =>
!v ||
[
'gray',
'red',
'yellow',
'green',
'blue',
'indigo',
'purple',
'pink',
'amber',
'emerald',
].includes(v),
},
});
// Status-based color mapping
const statusColors = {
active: 'green',
inactive: 'gray',
archived: 'gray',
pending: 'yellow',
completed: 'green',
failed: 'red',
success: 'green',
error: 'red',
warning: 'yellow',
info: 'blue',
draft: 'gray',
published: 'green',
};
const badgeColor = computed(() => {
if (props.color) return props.color;
const lowerStatus = props.status.toLowerCase();
return statusColors[lowerStatus] || 'gray';
});
const sizeClasses = {
sm: {
text: 'text-xs',
padding: 'px-2 py-0.5',
dot: 'w-1.5 h-1.5',
},
md: {
text: 'text-sm',
padding: 'px-2.5 py-1',
dot: 'w-2 h-2',
},
lg: {
text: 'text-base',
padding: 'px-3 py-1.5',
dot: 'w-2.5 h-2.5',
},
};
const colorClasses = {
gray: {
bg: 'bg-gray-100',
text: 'text-gray-800',
dot: 'bg-gray-500',
border: 'border-gray-300',
},
red: {
bg: 'bg-red-100',
text: 'text-red-800',
dot: 'bg-red-500',
border: 'border-red-300',
},
yellow: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
dot: 'bg-yellow-500',
border: 'border-yellow-300',
},
green: {
bg: 'bg-green-100',
text: 'text-green-800',
dot: 'bg-green-500',
border: 'border-green-300',
},
blue: {
bg: 'bg-blue-100',
text: 'text-blue-800',
dot: 'bg-blue-500',
border: 'border-blue-300',
},
indigo: {
bg: 'bg-indigo-100',
text: 'text-indigo-800',
dot: 'bg-indigo-500',
border: 'border-indigo-300',
},
purple: {
bg: 'bg-purple-100',
text: 'text-purple-800',
dot: 'bg-purple-500',
border: 'border-purple-300',
},
pink: {
bg: 'bg-pink-100',
text: 'text-pink-800',
dot: 'bg-pink-500',
border: 'border-pink-300',
},
amber: {
bg: 'bg-amber-100',
text: 'text-amber-800',
dot: 'bg-amber-500',
border: 'border-amber-300',
},
emerald: {
bg: 'bg-emerald-100',
text: 'text-emerald-800',
dot: 'bg-emerald-500',
border: 'border-emerald-300',
},
};
const colors = computed(() => colorClasses[badgeColor.value] || colorClasses.gray);
const sizes = computed(() => sizeClasses[props.size] || sizeClasses.md);
</script>
<template>
<span
:class="[
'inline-flex items-center font-medium',
sizes.text,
sizes.padding,
colors.bg,
colors.text,
variant === 'pill' ? 'rounded-full' : 'rounded-md',
variant === 'default' ? `border ${colors.border}` : '',
]"
>
<span
v-if="variant === 'dot'"
:class="[
'rounded-full mr-1.5',
sizes.dot,
colors.dot,
]"
></span>
<slot>{{ status }}</slot>
</span>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue';
import Dropdown from '../Dropdown.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
align: {
type: String,
default: 'right', // left, right
validator: (v) => ['left', 'right'].includes(v),
},
size: {
type: String,
default: 'md', // sm, md
validator: (v) => ['sm', 'md'].includes(v),
},
});
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
};
const emit = defineEmits(['action']);
function handleAction(action) {
emit('action', action);
if (action.onClick) {
action.onClick();
}
}
</script>
<template>
<Dropdown :align="align" :content-classes="['py-1']">
<template #trigger>
<button
type="button"
:class="[
'inline-flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 transition-colors',
sizeClasses[size],
]"
aria-label="Actions"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
</button>
</template>
<template #content>
<slot :handle-action="handleAction" />
</template>
</Dropdown>
</template>