Teren-app/resources/js/Pages/Dashboard.vue

429 lines
18 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) }))
);
</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">Staro: {{ c.days_stale }} dni</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>