Teren-app/resources/js/Pages/Dashboard.vue
2025-10-07 21:57:10 +02:00

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>