649 lines
23 KiB
Vue
649 lines
23 KiB
Vue
<script setup>
|
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
|
import { Link, router } from "@inertiajs/vue3";
|
|
import { ref, computed } from "vue";
|
|
import axios from "axios";
|
|
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
|
import DialogModal from "@/Components/DialogModal.vue";
|
|
import { Button } from "@/Components/ui/button";
|
|
import { Input } from "@/Components/ui/input";
|
|
import { Label } from "@/Components/ui/label";
|
|
import { Checkbox } from "@/Components/ui/checkbox";
|
|
import { Switch } from "@/Components/ui/switch";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/Components/ui/command";
|
|
import {
|
|
Filter,
|
|
ChevronsUpDown,
|
|
Check,
|
|
FileDown,
|
|
LayoutIcon,
|
|
UsersIcon,
|
|
} from "lucide-vue-next";
|
|
import { cn } from "@/lib/utils";
|
|
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
|
import { CardTitle } from "@/Components/ui/card";
|
|
|
|
const props = defineProps({
|
|
segment: Object,
|
|
contracts: Object, // LengthAwarePaginator payload from Laravel
|
|
clients: Array, // Full list of clients with contracts in this segment
|
|
});
|
|
|
|
// Initialize search and client filter from current URL so inputs reflect server filters
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const search = ref(urlParams.get("search") || "");
|
|
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
|
|
const selectedClient = ref(initialClient);
|
|
const filterPopoverOpen = ref(false);
|
|
const clientComboboxOpen = ref(false);
|
|
|
|
// Column definitions for the server-driven table
|
|
const columns = [
|
|
{ key: "reference", label: "Pogodba", sortable: false },
|
|
{ key: "client_case", label: "Primer", sortable: false },
|
|
{ key: "address", label: "Naslov", sortable: false },
|
|
{ key: "client", label: "Stranka", sortable: false },
|
|
{ key: "type", label: "Vrsta", sortable: false },
|
|
{ key: "start_date", label: "Začetek", sortable: false },
|
|
{ key: "end_date", label: "Konec", sortable: false },
|
|
{ key: "account", label: "Stanje", align: "right" },
|
|
];
|
|
|
|
const exportDialogOpen = ref(false);
|
|
const exportScope = ref("current");
|
|
const exportColumns = ref(columns.map((col) => col.key));
|
|
const exportError = ref("");
|
|
const isExporting = ref(false);
|
|
|
|
const hasActiveFilters = computed(() => {
|
|
return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
|
|
});
|
|
|
|
const appliedFilterCount = computed(() => {
|
|
let count = 0;
|
|
if (search.value?.trim()) {
|
|
count += 1;
|
|
}
|
|
if (selectedClient.value) {
|
|
count += 1;
|
|
}
|
|
return count;
|
|
});
|
|
|
|
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
|
|
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
|
|
const totalContracts = computed(
|
|
() => props.contracts?.total ?? props.contracts?.data?.length ?? 0
|
|
);
|
|
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
|
|
|
|
const allColumnsSelected = computed(() => exportColumns.value.length === columns.length);
|
|
const exportDisabled = computed(
|
|
() => exportColumns.value.length === 0 || isExporting.value
|
|
);
|
|
|
|
function toggleAllColumns(checked) {
|
|
exportColumns.value = checked ? columns.map((col) => col.key) : [];
|
|
}
|
|
|
|
function handleColumnToggle(key, checked) {
|
|
if (checked) {
|
|
if (!exportColumns.value.includes(key)) {
|
|
exportColumns.value = [...exportColumns.value, key];
|
|
}
|
|
} else {
|
|
exportColumns.value = exportColumns.value.filter((col) => col !== key);
|
|
}
|
|
}
|
|
|
|
function setExportScopeFromSwitch(checked) {
|
|
exportScope.value = checked ? "all" : "current";
|
|
}
|
|
|
|
function openExportDialog() {
|
|
exportDialogOpen.value = true;
|
|
exportError.value = "";
|
|
}
|
|
|
|
function closeExportDialog() {
|
|
exportDialogOpen.value = false;
|
|
}
|
|
|
|
async function submitExport() {
|
|
if (exportColumns.value.length === 0) {
|
|
exportError.value = "Izberi vsaj en stolpec.";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
exportError.value = "";
|
|
isExporting.value = true;
|
|
|
|
const payload = {
|
|
scope: exportScope.value,
|
|
columns: [...exportColumns.value],
|
|
search: search.value || "",
|
|
client: selectedClient.value || "",
|
|
page: contractsCurrentPage.value,
|
|
per_page: contractsPerPage.value,
|
|
};
|
|
|
|
const response = await axios.post(
|
|
route("segments.export", { segment: props.segment?.id ?? props.segment }),
|
|
payload,
|
|
{ responseType: "blob" }
|
|
);
|
|
|
|
const blob = new Blob([response.data], {
|
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
});
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
const filename =
|
|
extractFilenameFromHeaders(response.headers) || buildDefaultFilename();
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
exportDialogOpen.value = false;
|
|
} catch (error) {
|
|
exportError.value = "Izvoz je spodletel. Poskusi znova.";
|
|
} finally {
|
|
isExporting.value = false;
|
|
}
|
|
}
|
|
|
|
// Build client options from the full list provided by the server, so the dropdown isn't limited by current filters
|
|
const clientOptions = computed(() => {
|
|
const list = Array.isArray(props.clients) ? props.clients : [];
|
|
const opts = list.map((c) => ({
|
|
value: c.uuid || "",
|
|
label: c.name || "(neznana stranka)",
|
|
}));
|
|
return opts.sort((a, b) => (a.label || "").localeCompare(b.label || ""));
|
|
});
|
|
|
|
const selectedClientName = computed(() => {
|
|
if (!selectedClient.value) {
|
|
return "";
|
|
}
|
|
|
|
const options = clientOptions.value || [];
|
|
const match = options.find((opt) => opt.value === selectedClient.value);
|
|
|
|
return match?.label || "";
|
|
});
|
|
|
|
function isClientFilterEmpty() {
|
|
return !selectedClient.value;
|
|
}
|
|
|
|
function selectClient(value) {
|
|
selectedClient.value = value ?? "";
|
|
}
|
|
|
|
function closeClientCombobox() {
|
|
clientComboboxOpen.value = false;
|
|
}
|
|
|
|
function isClientValueSelected(value) {
|
|
return selectedClient.value === value;
|
|
}
|
|
|
|
function handleClientSelect(value) {
|
|
selectClient(value);
|
|
closeClientCombobox();
|
|
}
|
|
|
|
function buildQueryParams() {
|
|
const params = {};
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
|
|
for (const [key, value] of searchParams.entries()) {
|
|
params[key] = value;
|
|
}
|
|
|
|
const trimmedSearch = search.value.trim();
|
|
if (trimmedSearch) {
|
|
params.search = trimmedSearch;
|
|
} else {
|
|
delete params.search;
|
|
}
|
|
|
|
if (selectedClient.value) {
|
|
params.client = selectedClient.value;
|
|
} else {
|
|
delete params.client;
|
|
}
|
|
|
|
params.page = 1;
|
|
|
|
return params;
|
|
}
|
|
|
|
function applyFilters() {
|
|
const query = buildQueryParams();
|
|
router.get(
|
|
route("segments.show", { segment: props.segment?.id ?? props.segment }),
|
|
query,
|
|
{ preserveState: true, preserveScroll: true, only: ["contracts"], replace: true }
|
|
);
|
|
filterPopoverOpen.value = false;
|
|
}
|
|
|
|
function clearFilters() {
|
|
search.value = "";
|
|
selectedClient.value = "";
|
|
applyFilters();
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
const d = new Date(value);
|
|
if (isNaN(d)) return value;
|
|
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(
|
|
2,
|
|
"0"
|
|
)}.${d.getFullYear()}`;
|
|
}
|
|
|
|
function formatCurrency(value) {
|
|
if (value === null || value === undefined) return "-";
|
|
const n = Number(value);
|
|
if (isNaN(n)) return String(value);
|
|
return (
|
|
n.toLocaleString("sl-SI", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
|
|
" €"
|
|
);
|
|
}
|
|
|
|
function slugify(value) {
|
|
if (!value) {
|
|
return "data";
|
|
}
|
|
const slug = value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
|
|
return slug || "data";
|
|
}
|
|
|
|
function buildDefaultFilename() {
|
|
const now = new Date();
|
|
const dd = String(now.getDate()).padStart(2, "0");
|
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
const yy = String(now.getFullYear()).slice(-2);
|
|
let base = `${dd}${mm}${yy}_${slugify(props.segment?.name || "segment")}-Pogodbe`;
|
|
const clientName = selectedClientName.value;
|
|
if (clientName) {
|
|
base += `_${slugify(clientName)}`;
|
|
}
|
|
return `${base}.xlsx`;
|
|
}
|
|
|
|
function extractFilenameFromHeaders(headers) {
|
|
if (!headers) {
|
|
return null;
|
|
}
|
|
const disposition =
|
|
headers["content-disposition"] || headers["Content-Disposition"] || "";
|
|
if (!disposition) {
|
|
return null;
|
|
}
|
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
|
if (utf8Match?.[1]) {
|
|
try {
|
|
return decodeURIComponent(utf8Match[1]);
|
|
} catch (error) {
|
|
return utf8Match[1];
|
|
}
|
|
}
|
|
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
|
|
return asciiMatch?.[1] || null;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :title="`Segment: ${segment?.name || ''}`">
|
|
<template #header> </template>
|
|
<div class="py-6">
|
|
<div class="max-w-7xl mx-auto space-y-6 px-4 sm:px-6 lg:px-8">
|
|
<AppCard
|
|
title=""
|
|
padding="none"
|
|
class="p-5! gap-0"
|
|
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
|
body-class=""
|
|
>
|
|
<div>
|
|
<p class="text-sm uppercase text-muted-foreground">Segment</p>
|
|
<h2 class="text-2xl font-semibold text-foreground">{{ segment.name }}</h2>
|
|
</div>
|
|
<p class="text-sm text-muted-foreground">
|
|
{{ segment?.description || "Ni opisa za izbran segment." }}
|
|
</p>
|
|
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
|
<div class="rounded-lg border bg-muted/40 p-4">
|
|
<p class="text-xs uppercase text-muted-foreground">Pogodbe (stran)</p>
|
|
<p class="text-2xl font-semibold">{{ currentPageCount }}</p>
|
|
</div>
|
|
<div class="rounded-lg border bg-muted/40 p-4">
|
|
<p class="text-xs uppercase text-muted-foreground">Skupaj pogodb</p>
|
|
<p class="text-2xl font-semibold">{{ totalContracts }}</p>
|
|
</div>
|
|
<div class="rounded-lg border bg-muted/40 p-4">
|
|
<p class="text-xs uppercase text-muted-foreground">Izbrana stranka</p>
|
|
<p class="text-base font-medium">
|
|
{{ selectedClientName || "Vse" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</AppCard>
|
|
|
|
<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 gap-2">
|
|
<UsersIcon size="18" />
|
|
<CardTitle class="uppercase">Pogodbe</CardTitle>
|
|
</div>
|
|
</template>
|
|
<DataTable
|
|
:columns="columns"
|
|
:data="contracts?.data || []"
|
|
:meta="contracts || {}"
|
|
route-name="segments.show"
|
|
:route-params="{ segment: segment?.id ?? segment }"
|
|
:only-props="['contracts']"
|
|
:page-size="contracts?.per_page ?? 15"
|
|
:page-size-options="[10, 15, 25, 50]"
|
|
row-key="uuid"
|
|
empty-text="Ni pogodb v tem segmentu."
|
|
per-page-param-name="per_page"
|
|
>
|
|
<template #toolbar-filters>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Popover v-model:open="filterPopoverOpen">
|
|
<PopoverTrigger as-child>
|
|
<Button variant="outline" size="sm" class="gap-2">
|
|
<Filter class="h-4 w-4" />
|
|
Filtri
|
|
<span
|
|
v-if="hasActiveFilters"
|
|
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
|
>
|
|
{{ appliedFilterCount }}
|
|
</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-105" align="start">
|
|
<div class="space-y-4">
|
|
<div class="space-y-1">
|
|
<h4 class="text-sm font-medium">Filtri pogodb</h4>
|
|
<p class="text-sm text-muted-foreground">
|
|
Zoži prikaz pogodb po iskalnem nizu in stranki.
|
|
</p>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div class="space-y-1.5">
|
|
<label class="text-sm font-medium">Iskanje</label>
|
|
<Input
|
|
v-model="search"
|
|
type="text"
|
|
placeholder="Išči po referenci, vrsti ..."
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<label class="text-sm font-medium">Stranka</label>
|
|
<Popover v-model:open="clientComboboxOpen">
|
|
<PopoverTrigger as-child>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
:aria-expanded="clientComboboxOpen"
|
|
class="w-full justify-between"
|
|
>
|
|
{{ selectedClientName || "Vse stranke" }}
|
|
<ChevronsUpDown
|
|
class="ml-2 h-4 w-4 shrink-0 opacity-50"
|
|
/>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-full p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="Išči stranko..." />
|
|
<CommandList>
|
|
<CommandEmpty>Ni zadetkov.</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
value="vse"
|
|
@select="handleClientSelect('')"
|
|
>
|
|
<Check
|
|
:class="
|
|
cn(
|
|
'mr-2 h-4 w-4',
|
|
isClientFilterEmpty
|
|
? 'opacity-100'
|
|
: 'opacity-0'
|
|
)
|
|
"
|
|
/>
|
|
Vse stranke
|
|
</CommandItem>
|
|
<CommandItem
|
|
v-for="client in clientOptions"
|
|
:key="client.value || client.label"
|
|
:value="client.label"
|
|
@select="handleClientSelect(client.value)"
|
|
>
|
|
<Check
|
|
:class="
|
|
cn(
|
|
'mr-2 h-4 w-4',
|
|
isClientValueSelected(client.value)
|
|
? 'opacity-100'
|
|
: 'opacity-0'
|
|
)
|
|
"
|
|
/>
|
|
{{ client.label }}
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between border-t pt-2">
|
|
<Button
|
|
v-if="hasActiveFilters"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="gap-2"
|
|
@click="clearFilters"
|
|
>
|
|
Počisti
|
|
</Button>
|
|
<div v-else></div>
|
|
<Button size="sm" class="gap-2" @click="applyFilters">
|
|
Uporabi
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="gap-2"
|
|
@click="openExportDialog"
|
|
>
|
|
<FileDown class="h-4 w-4" />
|
|
Izvozi v Excel
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-client_case="{ row }">
|
|
<Link
|
|
v-if="row.client_case?.uuid"
|
|
:href="
|
|
route('clientCase.show', {
|
|
client_case: row.client_case.uuid,
|
|
segment: segment?.id ?? segment,
|
|
})
|
|
"
|
|
class="text-indigo-600 hover:underline"
|
|
>
|
|
{{ row.client_case?.person?.full_name || "Primer stranke" }}
|
|
</Link>
|
|
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
|
</template>
|
|
<template #cell-client="{ row }">
|
|
{{ row.client?.person?.full_name || "-" }}
|
|
</template>
|
|
<template #cell-type="{ row }">
|
|
{{ row.type?.name || "-" }}
|
|
</template>
|
|
<template #cell-start_date="{ row }">
|
|
{{ formatDate(row.start_date) }}
|
|
</template>
|
|
<template #cell-end_date="{ row }">
|
|
{{ formatDate(row.end_date) }}
|
|
</template>
|
|
<template #cell-account="{ row }">
|
|
<div class="text-right">
|
|
{{ formatCurrency(row.account?.balance_amount) }}
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</AppCard>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
|
|
<template #title>
|
|
<div class="space-y-1">
|
|
<h3 class="text-lg font-semibold leading-6 text-foreground">Izvoz v Excel</h3>
|
|
<p class="text-sm text-muted-foreground">
|
|
Izberi stolpce in obseg podatkov za izvoz.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<form id="segment-export-form" class="space-y-5" @submit.prevent="submitExport">
|
|
<div class="space-y-3 rounded-lg border bg-muted/40 p-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-medium text-foreground">Obseg podatkov</p>
|
|
<p class="text-sm text-muted-foreground">
|
|
Preklopi, ali izvoziš samo trenutni pogled ali celoten segment.
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="flex items-center gap-3 rounded-md bg-background px-3 py-2 shadow-sm"
|
|
>
|
|
<span class="text-xs font-medium text-muted-foreground">Stran</span>
|
|
<Switch
|
|
:model-value="exportScope === 'all'"
|
|
@update:modelValue="setExportScopeFromSwitch"
|
|
aria-label="Preklopi obseg izvoza"
|
|
/>
|
|
<span class="text-xs font-medium text-muted-foreground">Vse</span>
|
|
</div>
|
|
</div>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<div class="rounded-lg border bg-background p-3 shadow-sm">
|
|
<p class="text-sm font-semibold text-foreground">Trenutna stran</p>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ currentPageCount }} zapisov
|
|
</p>
|
|
</div>
|
|
<div class="rounded-lg border bg-background p-3 shadow-sm">
|
|
<p class="text-sm font-semibold text-foreground">Celoten segment</p>
|
|
<p class="text-xs text-muted-foreground">{{ totalContracts }} zapisov</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4 rounded-lg border bg-muted/40 p-4">
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-medium text-foreground">Stolpci</p>
|
|
<p class="text-sm text-muted-foreground">
|
|
Izberi, katere stolpce želiš vključiti v izvoz.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<Checkbox
|
|
id="export-columns-all"
|
|
:model-value="allColumnsSelected"
|
|
@update:modelValue="toggleAllColumns"
|
|
aria-label="Označi vse stolpce"
|
|
/>
|
|
<Label for="export-columns-all" class="text-sm text-muted-foreground">
|
|
Označi vse
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<label
|
|
v-for="col in columns"
|
|
:key="col.key"
|
|
class="flex items-start gap-3 rounded-lg border bg-background px-3 py-3 text-sm shadow-sm transition hover:border-primary/40"
|
|
:for="`export-col-${col.key}`"
|
|
>
|
|
<Checkbox
|
|
:id="`export-col-${col.key}`"
|
|
:model-value="exportColumns.includes(col.key)"
|
|
:value="col.key"
|
|
@update:modelValue="(checked) => handleColumnToggle(col.key, checked)"
|
|
class="mt-0.5"
|
|
/>
|
|
<div class="space-y-0.5">
|
|
<p class="font-medium text-foreground">{{ col.label }}</p>
|
|
<p class="text-xs text-muted-foreground">Vključi stolpec v datoteko.</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
<template #footer>
|
|
<div class="flex flex-row gap-2">
|
|
<Button type="button" variant="ghost" @click="closeExportDialog">
|
|
Prekliči
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
form="segment-export-form"
|
|
:disabled="exportDisabled"
|
|
class="gap-2"
|
|
>
|
|
<span v-if="!isExporting">Prenesi Excel</span>
|
|
<span v-else>Pripravljam ...</span>
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</DialogModal>
|
|
</AppLayout>
|
|
</template>
|