437 lines
15 KiB
Vue
437 lines
15 KiB
Vue
<script setup>
|
|
import { reactive, ref, computed, onMounted } from "vue";
|
|
import { Link, router, usePage } from "@inertiajs/vue3";
|
|
import axios from "axios";
|
|
import AppLayout from "@/Layouts/AppLayout.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 },
|
|
name: { type: String, required: true },
|
|
description: { type: String, default: null },
|
|
inputs: { type: Array, default: () => [] },
|
|
columns: { type: Array, default: () => [] },
|
|
rows: { type: Array, default: () => [] },
|
|
meta: {
|
|
type: Object,
|
|
default: () => ({ total: 0, current_page: 1, per_page: 25, last_page: 1 }),
|
|
},
|
|
query: { type: Object, default: () => ({}) },
|
|
});
|
|
|
|
// filters: start with server-provided query or defaults
|
|
const filters = reactive(
|
|
Object.assign(
|
|
Object.fromEntries((props.inputs || []).map((i) => [i.key, i.default ?? null])),
|
|
props.query || {}
|
|
)
|
|
);
|
|
|
|
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,
|
|
...Object.fromEntries(
|
|
Object.entries(filters).filter(([_, v]) => v !== null && v !== "")
|
|
),
|
|
});
|
|
window.location.href = route("reports.export", props.slug) + "?" + params.toString();
|
|
}
|
|
|
|
// Async user options for select:user inputs
|
|
const userOptions = ref([]);
|
|
const userLoading = ref(false);
|
|
async function fetchUsers(initial = false) {
|
|
userLoading.value = true;
|
|
try {
|
|
const res = await axios.get(route("reports.users"), {
|
|
params: { limit: initial ? 50 : 50 },
|
|
});
|
|
userOptions.value = Array.isArray(res.data) ? res.data : [];
|
|
} finally {
|
|
userLoading.value = false;
|
|
}
|
|
}
|
|
const hasUserFilter = computed(() =>
|
|
(props.inputs || []).some((i) => i.type === "select:user")
|
|
);
|
|
|
|
// Async client options for select:client inputs
|
|
const clientOptions = ref([]);
|
|
const clientLoading = ref(false);
|
|
async function fetchClients(initial = false) {
|
|
clientLoading.value = true;
|
|
try {
|
|
const res = await axios.get(route("reports.clients"));
|
|
console.log("Clients response:", res.data);
|
|
clientOptions.value = Array.isArray(res.data) ? res.data : [];
|
|
console.log("clientOptions set to:", clientOptions.value);
|
|
} finally {
|
|
clientLoading.value = false;
|
|
}
|
|
}
|
|
const hasClientFilter = computed(() =>
|
|
(props.inputs || []).some((i) => i.type === "select:client")
|
|
);
|
|
|
|
onMounted(() => {
|
|
if (hasUserFilter.value) fetchUsers(true);
|
|
if (hasClientFilter.value) fetchClients(true);
|
|
});
|
|
|
|
// Formatting helpers (EU style)
|
|
function formatNumberEU(val) {
|
|
if (typeof val !== "number") return String(val ?? "");
|
|
// Use 0 decimals for integers, 2 for decimals
|
|
const hasFraction = Math.abs(val % 1) > 0;
|
|
const opts = hasFraction
|
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
|
: { maximumFractionDigits: 0 };
|
|
return new Intl.NumberFormat("sl-SI", opts).format(val);
|
|
}
|
|
|
|
function pad2(n) {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
function formatDateEU(input) {
|
|
if (!input) return "";
|
|
if (typeof input === "string") {
|
|
const s = input.trim();
|
|
// ISO date YYYY-MM-DD
|
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
if (m) {
|
|
const [, y, mo, d] = m;
|
|
return `${d}.${mo}.${y}`;
|
|
}
|
|
}
|
|
// Fallback via Date object
|
|
const d = new Date(input);
|
|
if (isNaN(d)) return String(input);
|
|
return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}.${d.getFullYear()}`;
|
|
}
|
|
|
|
function formatDateTimeEU(input) {
|
|
if (!input) return "";
|
|
if (typeof input === "string") {
|
|
// Handle "YYYY-MM-DD HH:MM:SS" or ISO
|
|
const s = input.replace("T", " ").split(".")[0];
|
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
if (m) {
|
|
const [, y, mo, d, hh, mm, ss] = m;
|
|
return `${d}.${mo}.${y} ${hh}:${mm}:${ss ?? "00"}`;
|
|
}
|
|
}
|
|
const d = new Date(input);
|
|
if (isNaN(d)) return String(input);
|
|
return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}.${d.getFullYear()} ${pad2(
|
|
d.getHours()
|
|
)}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
}
|
|
|
|
function isDateLikeKey(key) {
|
|
if (typeof key !== "string") return false;
|
|
return /(^|_)(date|datum)$/.test(key);
|
|
}
|
|
function isDateTimeKey(key) {
|
|
if (typeof key !== "string") return false;
|
|
return key.endsWith("_at");
|
|
}
|
|
|
|
function isDecimalKey(key) {
|
|
if (typeof key !== "string") return false;
|
|
return /(amount|price|total|sum|avg|mean|saldo|znesek|cost)/i.test(key);
|
|
}
|
|
|
|
function isIdentifierKey(key) {
|
|
if (typeof key !== "string") return false;
|
|
return /(reference|ref|uuid|guid|identifier|oznaka|sklic)$/i.test(key);
|
|
}
|
|
|
|
function formatCell(value, key) {
|
|
if (value === null || value === undefined) return "";
|
|
// Timestamps first
|
|
if (isDateTimeKey(key)) return formatDateTimeEU(value);
|
|
// Pure dates
|
|
if (isDateLikeKey(key)) return formatDateEU(value);
|
|
// ISO-like date string in value
|
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(value)) {
|
|
return value.length > 10 ? formatDateTimeEU(value) : formatDateEU(value);
|
|
}
|
|
// Do not format identifier-like columns (e.g., contract reference)
|
|
if (isIdentifierKey(key)) return value;
|
|
// Numeric formatting: currency/decimal keys -> two decimals
|
|
if (isDecimalKey(key)) {
|
|
if (typeof value !== "number") return value;
|
|
let num = value;
|
|
|
|
return new Intl.NumberFormat("sl-SI", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(num);
|
|
}
|
|
// Integers and generic numbers: only format actual numbers to avoid touching numeric-looking strings
|
|
if (typeof value === "number") return formatNumberEU(value);
|
|
return value;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :title="name">
|
|
<template #header />
|
|
<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>
|
|
|
|
<!-- 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 #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>
|
|
<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>
|
|
</template>
|