New report system and views
This commit is contained in:
+247
-123
@@ -1,9 +1,27 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted } from "vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import DatePicker from "@/Components/DatePicker.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import { Download, Filter, RotateCcw } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
slug: { type: String, required: true },
|
||||
@@ -27,12 +45,53 @@ const filters = reactive(
|
||||
)
|
||||
);
|
||||
|
||||
const filterPopoverOpen = ref(false);
|
||||
|
||||
const appliedFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
for (const inp of props.inputs || []) {
|
||||
const value = filters[inp.key];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
function resetFilters() {
|
||||
for (const i of props.inputs || []) {
|
||||
filters[i.key] = i.default ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
filterPopoverOpen.value = false;
|
||||
router.get(
|
||||
route("reports.show", props.slug),
|
||||
{ ...filters, per_page: props.meta?.per_page || 25, page: 1 },
|
||||
{ preserveState: false, replace: false }
|
||||
);
|
||||
}
|
||||
|
||||
// If no query params were provided and we have inputs with defaults, apply filters automatically
|
||||
// OR if there are validation errors for required filters, redirect with defaults
|
||||
onMounted(() => {
|
||||
const page = usePage();
|
||||
const errors = page.props.errors || {};
|
||||
const hasQueryParams = Object.keys(props.query || {}).length > 0;
|
||||
const hasRequiredFilters = (props.inputs || []).some(i => i.required);
|
||||
|
||||
// Check if we have validation errors for required filter fields
|
||||
const hasRequiredFilterErrors = (props.inputs || []).some(
|
||||
i => i.required && errors[i.key]
|
||||
);
|
||||
|
||||
if ((!hasQueryParams && hasRequiredFilters) || hasRequiredFilterErrors) {
|
||||
// Apply filters with defaults on first load to satisfy required fields
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
|
||||
function exportFile(fmt) {
|
||||
const params = new URLSearchParams({
|
||||
format: fmt,
|
||||
@@ -88,11 +147,11 @@ onMounted(() => {
|
||||
function formatNumberEU(val) {
|
||||
if (typeof val !== "number") return String(val ?? "");
|
||||
// Use 0 decimals for integers, 2 for decimals
|
||||
const hasFraction = Math.abs(num % 1) > 0;
|
||||
const hasFraction = Math.abs(val % 1) > 0;
|
||||
const opts = hasFraction
|
||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||
: { maximumFractionDigits: 0 };
|
||||
return new Intl.NumberFormat("sl-SI", opts).format(num);
|
||||
return new Intl.NumberFormat("sl-SI", opts).format(val);
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
@@ -184,128 +243,193 @@ function formatCell(value, key) {
|
||||
<template>
|
||||
<AppLayout :title="name">
|
||||
<template #header />
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{{ name }}</h1>
|
||||
<p v-if="description" class="text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="exportFile('csv')"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="exportFile('pdf')"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="exportFile('xlsx')"
|
||||
>
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 grid gap-3 md:grid-cols-4">
|
||||
<div v-for="inp in inputs" :key="inp.key" class="flex flex-col">
|
||||
<label class="text-sm text-gray-700 mb-1">{{ inp.label || inp.key }}</label>
|
||||
<template v-if="inp.type === 'date'">
|
||||
<DatePicker
|
||||
v-model="filters[inp.key]"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datum"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'integer'">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="filters[inp.key]"
|
||||
class="border rounded px-2 py-1"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:user'">
|
||||
<select v-model.number="filters[inp.key]" class="border rounded px-2 py-1">
|
||||
<option :value="null">— brez —</option>
|
||||
<option v-for="u in userOptions" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="userLoading" class="text-xs text-gray-500 mt-1">Nalagam…</div>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:client'">
|
||||
<select
|
||||
v-model="filters[inp.key]"
|
||||
class="border rounded px-2 py-1"
|
||||
@change="
|
||||
(e) => {
|
||||
console.log('Select changed:', e.target.value, 'filters:', filters);
|
||||
}
|
||||
"
|
||||
>
|
||||
<option :value="null">— brez —</option>
|
||||
<option v-for="c in clientOptions" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="clientLoading" class="text-xs text-gray-500 mt-1">Nalagam…</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters[inp.key]"
|
||||
class="border rounded px-2 py-1"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="
|
||||
$inertia.get(
|
||||
route('reports.show', slug),
|
||||
{ ...filters, per_page: meta?.per_page || 25, page: 1 },
|
||||
{ preserveState: false, replace: false }
|
||||
)
|
||||
"
|
||||
>
|
||||
Prikaži
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="resetFilters"
|
||||
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
|
||||
>
|
||||
Ponastavi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header Card -->
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ name }}</CardTitle>
|
||||
<CardDescription v-if="description">{{ description }}</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" @click="exportFile('csv')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="exportFile('pdf')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<!-- data table component -->
|
||||
<DataTableServer
|
||||
:columns="columns.map((c) => ({ key: c.key, label: c.label || c.key }))"
|
||||
:rows="rows"
|
||||
:meta="meta"
|
||||
route-name="reports.show"
|
||||
:route-params="{ slug: slug }"
|
||||
:query="filters"
|
||||
:show-toolbar="false"
|
||||
:only-props="['rows', 'meta', 'query']"
|
||||
:preserve-state="false"
|
||||
<!-- Results Card -->
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #cell="{ value, column }">
|
||||
{{ formatCell(value, column.key) }}
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<CardTitle>Rezultati</CardTitle>
|
||||
<CardDescription>
|
||||
Skupaj {{ meta?.total || 0 }}
|
||||
{{ meta?.total === 1 ? "rezultat" : "rezultatov" }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</template>
|
||||
</DataTableServer>
|
||||
<DataTable
|
||||
:columns="
|
||||
columns.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label || c.key,
|
||||
sortable: false,
|
||||
}))
|
||||
"
|
||||
:data="rows"
|
||||
:meta="meta"
|
||||
route-name="reports.show"
|
||||
:route-params="{ slug: slug, ...filters }"
|
||||
:page-size="meta?.per_page || 25"
|
||||
:page-size-options="[10, 25, 50, 100]"
|
||||
:show-pagination="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
empty-text="Ni rezultatov."
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<AppPopover
|
||||
v-if="inputs && inputs.length > 0"
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[400px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="appliedFilterCount > 0"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ appliedFilterCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri poročila</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite parametre za zožanje prikaza podatkov.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="inp in inputs" :key="inp.key" class="space-y-1.5">
|
||||
<InputLabel>{{ inp.label || inp.key }}</InputLabel>
|
||||
<template v-if="inp.type === 'date'">
|
||||
<DatePicker
|
||||
v-model="filters[inp.key]"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datum"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'integer'">
|
||||
<Input
|
||||
v-model.number="filters[inp.key]"
|
||||
type="number"
|
||||
placeholder="Vnesi število"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:user'">
|
||||
<AppCombobox
|
||||
v-model="filters[inp.key]"
|
||||
:items="
|
||||
userOptions.map((u) => ({ value: u.id, label: u.name }))
|
||||
"
|
||||
placeholder="Brez"
|
||||
search-placeholder="Išči uporabnika..."
|
||||
empty-text="Ni uporabnikov"
|
||||
:disabled="userLoading"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<div v-if="userLoading" class="text-xs text-muted-foreground">
|
||||
Nalagam…
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="inp.type === 'select:client'">
|
||||
<AppCombobox
|
||||
v-model="filters[inp.key]"
|
||||
:items="
|
||||
clientOptions.map((c) => ({ value: c.id, label: c.name }))
|
||||
"
|
||||
placeholder="Brez"
|
||||
search-placeholder="Išči stranko..."
|
||||
empty-text="Ni strank"
|
||||
:disabled="clientLoading"
|
||||
button-class="w-full"
|
||||
/>
|
||||
<div v-if="clientLoading" class="text-xs text-muted-foreground">
|
||||
Nalagam…
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Input
|
||||
v-model="filters[inp.key]"
|
||||
type="text"
|
||||
placeholder="Vnesi vrednost"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="appliedFilterCount === 0"
|
||||
@click="resetFilters"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyFilters">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</template>
|
||||
<template
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
#[`cell-${col.key}`]="{ row }"
|
||||
>
|
||||
{{ formatCell(row[col.key], col.key) }}
|
||||
</template>
|
||||
</DataTable>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="meta.from"
|
||||
:to="meta.to"
|
||||
:total="meta.total"
|
||||
:per-page="meta.per_page || 25"
|
||||
:last-page="meta.last_page"
|
||||
:current-page="meta.current_page"
|
||||
per-page-param="per_page"
|
||||
page-param="page"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
Reference in New Issue
Block a user