596 lines
21 KiB
Vue
596 lines
21 KiB
Vue
<script setup>
|
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
|
import { computed, ref, onMounted } from "vue";
|
|
import { usePage, Link } from "@inertiajs/vue3";
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
import {
|
|
faUsers,
|
|
faUserPlus,
|
|
faClipboardList,
|
|
faFileLines,
|
|
faCloudArrowUp,
|
|
faArrowUpRightFromSquare,
|
|
} from "@fortawesome/free-solid-svg-icons";
|
|
import { faFileContract } from "@fortawesome/free-solid-svg-icons";
|
|
|
|
const props = defineProps({
|
|
kpis: Object,
|
|
activities: Array,
|
|
trends: Object,
|
|
systemHealth: Object,
|
|
staleCases: Array,
|
|
fieldJobsAssignedToday: Array,
|
|
importsInProgress: Array,
|
|
activeTemplates: Array,
|
|
});
|
|
|
|
const kpiDefs = [
|
|
{ key: "clients_total", label: "Vse stranke", icon: faUsers, route: "client" },
|
|
{ key: "clients_new_7d", label: "Nove (7d)", icon: faUserPlus, route: "client" },
|
|
{
|
|
key: "field_jobs_today",
|
|
label: "Terenske danes",
|
|
icon: faClipboardList,
|
|
route: "fieldjobs.index",
|
|
},
|
|
{
|
|
key: "documents_today",
|
|
label: "Dokumenti danes",
|
|
icon: faFileLines,
|
|
route: "clientCase",
|
|
},
|
|
{
|
|
key: "active_imports",
|
|
label: "Aktivni uvozi",
|
|
icon: faCloudArrowUp,
|
|
route: "imports.index",
|
|
},
|
|
{
|
|
key: "active_contracts",
|
|
label: "Aktivne pogodbe",
|
|
icon: faFileContract,
|
|
route: "clientCase",
|
|
},
|
|
];
|
|
|
|
const page = usePage();
|
|
|
|
// Simple sparkline path generator
|
|
function sparkline(values) {
|
|
if (!values || !values.length) {
|
|
return "";
|
|
}
|
|
const max = Math.max(...values) || 1;
|
|
const h = 24;
|
|
const w = 60;
|
|
const step = w / (values.length - 1 || 1);
|
|
return values
|
|
.map(
|
|
(v, i) =>
|
|
`${i === 0 ? "M" : "L"}${(i * step).toFixed(2)},${(h - (v / max) * h).toFixed(2)}`
|
|
)
|
|
.join(" ");
|
|
}
|
|
|
|
// Remove single relatedTarget helper and replace with multi-link builder
|
|
function buildRelated(a) {
|
|
const links = [];
|
|
// Only client case link (other routes not defined yet)
|
|
if (a.client_case_uuid || a.client_case_id) {
|
|
const caseParam = a.client_case_uuid || a.client_case_id;
|
|
if (typeof route === "function" && route().hasOwnProperty) {
|
|
try {
|
|
links.push({
|
|
type: "client_case",
|
|
label: "Primer",
|
|
href: route("clientCase.show", caseParam),
|
|
});
|
|
} catch (e) {
|
|
/* silently ignore */
|
|
}
|
|
} else {
|
|
links.push({
|
|
type: "client_case",
|
|
label: "Primer",
|
|
href: route("clientCase.show", caseParam),
|
|
});
|
|
}
|
|
}
|
|
return links;
|
|
}
|
|
|
|
const activityItems = computed(() =>
|
|
(props.activities || []).map((a) => ({ ...a, links: buildRelated(a) }))
|
|
);
|
|
|
|
// Format stale days label: never negative; '<1 dan' if 0<=value<1; else integer with proper suffix.
|
|
function formatStaleDaysLabel(value) {
|
|
const num = Number.parseFloat(value);
|
|
if (Number.isNaN(num)) {
|
|
return "—";
|
|
}
|
|
if (num < 1) {
|
|
return "<1 dan";
|
|
}
|
|
const whole = Math.floor(num);
|
|
return whole === 1 ? "1 dan" : whole + " dni";
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout title="Nadzorna plošča">
|
|
<template #header>
|
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nadzorna plošča</h2>
|
|
</template>
|
|
|
|
<div class="max-w-7xl mx-auto space-y-10 py-6">
|
|
<!-- KPI Cards with trends -->
|
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
|
<Link
|
|
v-for="k in kpiDefs"
|
|
:key="k.key"
|
|
:href="route(k.route)"
|
|
class="group relative bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm px-4 py-5 flex flex-col gap-3 hover:border-indigo-300 hover:shadow transition focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<span
|
|
class="inline-flex items-center justify-center h-10 w-10 rounded-md bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 group-hover:bg-indigo-100 dark:group-hover:bg-indigo-800/40"
|
|
>
|
|
<FontAwesomeIcon :icon="k.icon" class="w-5 h-5" />
|
|
</span>
|
|
<span
|
|
class="text-[11px] text-gray-400 dark:text-gray-500 uppercase tracking-wide"
|
|
>{{ k.label }}</span
|
|
>
|
|
</div>
|
|
<div class="flex items-end gap-2">
|
|
<span
|
|
class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
|
|
>{{ props.kpis?.[k.key] ?? "—" }}</span
|
|
>
|
|
<span
|
|
class="text-[10px] text-indigo-500 opacity-0 group-hover:opacity-100 transition"
|
|
>Odpri →</span
|
|
>
|
|
</div>
|
|
<div v-if="trends" class="mt-1 h-6">
|
|
<svg
|
|
v-if="k.key === 'clients_new_7d'"
|
|
:viewBox="'0 0 60 24'"
|
|
class="w-full h-6 overflow-visible"
|
|
>
|
|
<path
|
|
:d="sparkline(trends.clients_new)"
|
|
fill="none"
|
|
class="stroke-indigo-400"
|
|
stroke-width="2"
|
|
stroke-linejoin="round"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
<svg
|
|
v-else-if="k.key === 'documents_today'"
|
|
:viewBox="'0 0 60 24'"
|
|
class="w-full h-6"
|
|
>
|
|
<path
|
|
:d="sparkline(trends.documents_new)"
|
|
fill="none"
|
|
class="stroke-emerald-400"
|
|
stroke-width="2"
|
|
stroke-linejoin="round"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
<svg
|
|
v-else-if="k.key === 'field_jobs_today'"
|
|
:viewBox="'0 0 60 24'"
|
|
class="w-full h-6"
|
|
>
|
|
<path
|
|
:d="sparkline(trends.field_jobs)"
|
|
fill="none"
|
|
class="stroke-amber-400"
|
|
stroke-width="2"
|
|
stroke-linejoin="round"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
<svg
|
|
v-else-if="k.key === 'active_imports'"
|
|
:viewBox="'0 0 60 24'"
|
|
class="w-full h-6"
|
|
>
|
|
<path
|
|
:d="sparkline(trends.imports_new)"
|
|
fill="none"
|
|
class="stroke-fuchsia-400"
|
|
stroke-width="2"
|
|
stroke-linejoin="round"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
|
|
<div class="grid lg:grid-cols-3 gap-8">
|
|
<!-- Activity Feed -->
|
|
<div class="lg:col-span-1 space-y-4">
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-5 flex flex-col gap-4"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase"
|
|
>
|
|
Aktivnost
|
|
</h3>
|
|
</div>
|
|
<ul
|
|
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
|
|
v-if="activities"
|
|
>
|
|
<li
|
|
v-for="a in activityItems"
|
|
:key="a.id"
|
|
class="py-2 flex items-start gap-3"
|
|
>
|
|
<span class="w-2 h-2 mt-2 rounded-full bg-indigo-400" />
|
|
<div class="flex-1 min-w-0 space-y-1">
|
|
<p class="text-gray-700 dark:text-gray-300 line-clamp-2">
|
|
{{ a.note || "Dogodek" }}
|
|
</p>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{
|
|
new Date(a.created_at).toLocaleString()
|
|
}}</span>
|
|
<Link
|
|
v-for="l in a.links"
|
|
:key="l.type + l.href"
|
|
:href="l.href"
|
|
class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-800/60 font-medium tracking-wide"
|
|
>{{ l.label }}</Link
|
|
>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<li
|
|
v-if="!activities?.length"
|
|
class="py-4 text-xs text-gray-500 text-center dark:text-gray-500"
|
|
>
|
|
Ni zabeleženih aktivnosti.
|
|
</li>
|
|
</ul>
|
|
<ul v-else class="animate-pulse space-y-2">
|
|
<li
|
|
v-for="n in 5"
|
|
:key="n"
|
|
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
|
|
/>
|
|
</ul>
|
|
<div class="pt-1 flex justify-between items-center text-[11px]">
|
|
<Link
|
|
:href="route('dashboard')"
|
|
class="inline-flex items-center gap-1 font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
|
|
>Več kmalu
|
|
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="w-3 h-3"
|
|
/></Link>
|
|
<span v-if="systemHealth" class="text-gray-400 dark:text-gray-500"
|
|
>Posodobljeno
|
|
{{ new Date(systemHealth.generated_at).toLocaleTimeString() }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right side panels -->
|
|
<div class="lg:col-span-2 space-y-8">
|
|
<!-- System Health -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
|
>
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
|
>
|
|
System Health
|
|
</h3>
|
|
<div
|
|
v-if="systemHealth"
|
|
class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"
|
|
>
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
|
|
>Queue backlog</span
|
|
>
|
|
<span class="font-semibold text-gray-800 dark:text-gray-100">{{
|
|
systemHealth.queue_backlog ?? "—"
|
|
}}</span>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
|
|
>Failed jobs</span
|
|
>
|
|
<span class="font-semibold text-gray-800 dark:text-gray-100">{{
|
|
systemHealth.failed_jobs ?? "—"
|
|
}}</span>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
|
|
>Last activity (min)</span
|
|
>
|
|
<span
|
|
class="font-semibold text-gray-800 dark:text-gray-100"
|
|
:title="
|
|
systemHealth.last_activity_iso
|
|
? new Date(systemHealth.last_activity_iso).toLocaleString()
|
|
: ''
|
|
"
|
|
>{{
|
|
Math.max(0, parseInt(systemHealth.last_activity_minutes ?? 0))
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
|
|
>Generated</span
|
|
>
|
|
<span class="font-semibold text-gray-800 dark:text-gray-100">{{
|
|
new Date(systemHealth.generated_at).toLocaleTimeString()
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="grid sm:grid-cols-4 gap-4 animate-pulse">
|
|
<div
|
|
v-for="n in 4"
|
|
:key="n"
|
|
class="h-10 bg-gray-100 dark:bg-gray-700 rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Completed Field Jobs Trend (7 dni) -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
|
>
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
|
>
|
|
Zaključena terenska dela (7 dni)
|
|
</h3>
|
|
<div v-if="trends" class="h-24">
|
|
<svg viewBox="0 0 140 60" class="w-full h-full">
|
|
<defs>
|
|
<linearGradient id="fjc" x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.35" />
|
|
<stop offset="100%" stop-color="#6366f1" stop-opacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path
|
|
v-if="trends.field_jobs_completed"
|
|
:d="sparkline(trends.field_jobs_completed)"
|
|
stroke="#6366f1"
|
|
stroke-width="2"
|
|
fill="none"
|
|
stroke-linejoin="round"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
<div class="mt-2 flex gap-2 text-[10px] text-gray-400 dark:text-gray-500">
|
|
<span
|
|
v-for="(l, i) in trends.labels"
|
|
:key="i"
|
|
class="flex-1 truncate text-center"
|
|
>{{ l.slice(5) }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
<div v-else class="h-24 animate-pulse bg-gray-100 dark:bg-gray-700 rounded" />
|
|
</div>
|
|
|
|
<!-- Stale Cases -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
|
>
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
|
>
|
|
Stari primeri brez aktivnosti
|
|
</h3>
|
|
<ul
|
|
v-if="staleCases"
|
|
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
|
|
>
|
|
<li
|
|
v-for="c in staleCases"
|
|
:key="c.id"
|
|
class="py-2 flex items-center justify-between"
|
|
>
|
|
<div class="min-w-0">
|
|
<Link
|
|
:href="route('clientCase.show', c.uuid)"
|
|
class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
|
>{{ c.client_ref || c.uuid.slice(0, 8) }}</Link
|
|
>
|
|
<p class="text-[11px] text-gray-400 dark:text-gray-500">
|
|
Brez aktivnosti: {{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
|
|
</p>
|
|
</div>
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-300"
|
|
>Stale</span
|
|
>
|
|
</li>
|
|
<li
|
|
v-if="!staleCases.length"
|
|
class="py-4 text-xs text-gray-500 text-center"
|
|
>
|
|
Ni starih primerov.
|
|
</li>
|
|
</ul>
|
|
<div v-else class="space-y-2 animate-pulse">
|
|
<div
|
|
v-for="n in 5"
|
|
:key="n"
|
|
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Field Jobs Assigned Today -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
|
>
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
|
>
|
|
Današnje dodelitve terenskih
|
|
</h3>
|
|
<ul
|
|
v-if="fieldJobsAssignedToday"
|
|
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
|
|
>
|
|
<li
|
|
v-for="f in fieldJobsAssignedToday"
|
|
:key="f.id"
|
|
class="py-2 flex items-center justify-between"
|
|
>
|
|
<div class="min-w-0">
|
|
<p class="text-gray-700 dark:text-gray-300 text-sm">#{{ f.id }}</p>
|
|
<p class="text-[11px] text-gray-400 dark:text-gray-500">
|
|
{{
|
|
f.assigned_at || f.created_at
|
|
? new Date(f.assigned_at || f.created_at).toLocaleTimeString()
|
|
: ""
|
|
}}
|
|
</p>
|
|
</div>
|
|
<span
|
|
v-if="f.priority"
|
|
class="text-[10px] px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-900/30 text-rose-600 dark:text-rose-300"
|
|
>Prioriteta</span
|
|
>
|
|
</li>
|
|
<li
|
|
v-if="!fieldJobsAssignedToday.length"
|
|
class="py-4 text-xs text-gray-500 text-center"
|
|
>
|
|
Ni dodelitev.
|
|
</li>
|
|
</ul>
|
|
<div v-else class="space-y-2 animate-pulse">
|
|
<div
|
|
v-for="n in 5"
|
|
:key="n"
|
|
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Imports In Progress -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
|
>
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
|
>
|
|
Uvozi v teku
|
|
</h3>
|
|
<ul
|
|
v-if="importsInProgress"
|
|
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
|
|
>
|
|
<li v-for="im in importsInProgress" :key="im.id" class="py-2 space-y-1">
|
|
<div class="flex items-center justify-between">
|
|
<p class="font-medium text-gray-700 dark:text-gray-300 truncate">
|
|
{{ im.file_name }}
|
|
</p>
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300"
|
|
>{{ im.status }}</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded overflow-hidden"
|
|
>
|
|
<div
|
|
class="h-full bg-indigo-500 dark:bg-indigo-400"
|
|
:style="{ width: (im.progress_pct || 0) + '%' }"
|
|
></div>
|
|
</div>
|
|
<p class="text-[10px] text-gray-400 dark:text-gray-500">
|
|
{{ im.imported_rows }}/{{ im.total_rows }} (veljavnih:
|
|
{{ im.valid_rows }}, neveljavnih: {{ im.invalid_rows }})
|
|
</p>
|
|
</li>
|
|
<li
|
|
v-if="!importsInProgress.length"
|
|
class="py-4 text-xs text-gray-500 text-center"
|
|
>
|
|
Ni aktivnih uvozov.
|
|
</li>
|
|
</ul>
|
|
<div v-else class="space-y-2 animate-pulse">
|
|
<div
|
|
v-for="n in 4"
|
|
:key="n"
|
|
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Document Templates -->
|
|
<div
|
|
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
|
>
|
|
<h3
|
|
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
|
>
|
|
Aktivne predloge dokumentov
|
|
</h3>
|
|
<ul
|
|
v-if="activeTemplates"
|
|
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
|
|
>
|
|
<li
|
|
v-for="t in activeTemplates"
|
|
:key="t.id"
|
|
class="py-2 flex items-center justify-between"
|
|
>
|
|
<div class="min-w-0">
|
|
<p class="text-gray-700 dark:text-gray-300 font-medium truncate">
|
|
{{ t.name }}
|
|
</p>
|
|
<p class="text-[11px] text-gray-400 dark:text-gray-500">
|
|
v{{ t.version }} · {{ new Date(t.updated_at).toLocaleDateString() }}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
:href="route('admin.document-templates.edit', t.id)"
|
|
class="text-[10px] px-2 py-0.5 rounded bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-800/60"
|
|
>Uredi</Link
|
|
>
|
|
</li>
|
|
<li
|
|
v-if="!activeTemplates.length"
|
|
class="py-4 text-xs text-gray-500 text-center"
|
|
>
|
|
Ni aktivnih predlog.
|
|
</li>
|
|
</ul>
|
|
<div v-else class="space-y-2 animate-pulse">
|
|
<div
|
|
v-for="n in 5"
|
|
:key="n"
|
|
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ...end of right side panels -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|