Dev branch
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted } from "vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
|
||||
|
||||
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 || {}
|
||||
)
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
for (const i of props.inputs || []) {
|
||||
filters[i.key] = i.default ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
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(num % 1) > 0;
|
||||
const opts = hasFraction
|
||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||
: { maximumFractionDigits: 0 };
|
||||
return new Intl.NumberFormat("sl-SI", opts).format(num);
|
||||
}
|
||||
|
||||
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-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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<template #cell="{ value, column }">
|
||||
{{ formatCell(value, column.key) }}
|
||||
</template>
|
||||
</DataTableServer>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
Reference in New Issue
Block a user