Teren-app/resources/js/Pages/Dashboard.vue
Simon Pocrnjič 63e0958b66 Dev branch
2025-11-02 12:31:01 +01:00

712 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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,
smsStats: 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;
try {
// Prefer Ziggy when available and force stringification here
const href = String(route("clientCase.show", { client_case: caseParam }));
links.push({
type: "client_case",
label: "Primer",
href,
});
} catch (e) {
// Safe fallback to a best-effort URL to avoid breaking render
links.push({
type: "client_case",
label: "Primer",
href: `/client-cases/${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";
}
// Robust time formatter to avoid fixed 02:00:00 (timezone / fallback issues)
function formatJobTime(ts) {
if (!ts) return "";
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return "";
// Show HH:MM (24h) and seconds only if non-zero seconds
const pad = (n) => n.toString().padStart(2, "0");
const h = pad(d.getHours());
const m = pad(d.getMinutes());
const s = d.getSeconds();
return s ? `${h}:${m}:${pad(s)}` : `${h}:${m}`;
} catch (e) {
return "";
}
}
// Safely build a client case href using Ziggy when available, with a plain fallback.
function safeCaseHref(uuid, segment = null) {
if (!uuid) {
return "#";
}
try {
const params = { client_case: uuid };
if (segment != null) {
params.segment = segment;
}
return String(route("clientCase.show", params));
} catch (e) {
return segment != null
? `/client-cases/${uuid}?segment=${segment}`
: `/client-cases/${uuid}`;
}
}
</script>
<template>
<AppLayout title="Nadzorna plošča">
<template #header> </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 border 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 text-indigo-600 group-hover:bg-indigo-100"
>
<FontAwesomeIcon :icon="k.icon" class="w-5 h-5" />
</span>
<span
class="text-[11px] text-gray-400 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"
>{{ 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 border 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 uppercase"
>
Aktivnost
</h3>
</div>
<ul
class="divide-y divide-gray-100 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 line-clamp-2">
{{ a.note || "Dogodek" }}
</p>
<div class="flex flex-wrap items-center gap-2">
<span class="text-[11px] text-gray-400">{{
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 text-indigo-600 hover:bg-indigo-100 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"
>
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 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 hover:underline"
>Več kmalu
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="w-3 h-3"
/></Link>
<span v-if="systemHealth" class="text-gray-400"
>Posodobljeno
{{ new Date(systemHealth.generated_at).toLocaleTimeString() }}</span
>
</div>
</div>
</div>
<!-- Right side panels -->
<div class="lg:col-span-2 space-y-8">
<!-- SMS Overview -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
>
SMS stanje
</h3>
<div v-if="props.smsStats?.length" class="overflow-x-auto">
<table class="w-full text-sm">
<thead
class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider"
>
<tr>
<th class="px-3 py-2 text-left">Profil</th>
<th class="px-3 py-2 text-left">Bilanca</th>
<th class="px-3 py-2 text-left">Danes (skupaj)</th>
<th class="px-3 py-2 text-left">Sent</th>
<th class="px-3 py-2 text-left">Delivered</th>
<th class="px-3 py-2 text-left">Failed</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in props.smsStats"
:key="p.id"
class="border-t last:border-b"
>
<td class="px-3 py-2">
<span class="font-medium text-gray-900">{{
p.name
}}</span>
<span
class="ml-2 text-[11px]"
:class="p.active ? 'text-emerald-600' : 'text-gray-400'"
>{{ p.active ? "Aktiven" : "Neaktiven" }}</span
>
</td>
<td class="px-3 py-2 text-gray-700">
{{ p.balance ?? "—" }}
</td>
<td class="px-3 py-2">{{ p.today?.total ?? 0 }}</td>
<td class="px-3 py-2 text-sky-700">{{ p.today?.sent ?? 0 }}</td>
<td class="px-3 py-2 text-emerald-700">
{{ p.today?.delivered ?? 0 }}
</td>
<td class="px-3 py-2 text-rose-700">{{ p.today?.failed ?? 0 }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-sm text-gray-500">
Ni podatkov o SMS.
</div>
</div>
<!-- System Health -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 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"
>Queue backlog</span
>
<span class="font-semibold text-gray-800">{{
systemHealth.queue_backlog ?? "—"
}}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] uppercase text-gray-400"
>Failed jobs</span
>
<span class="font-semibold text-gray-800">{{
systemHealth.failed_jobs ?? "—"
}}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] uppercase text-gray-400"
>Last activity (min)</span
>
<span
class="font-semibold text-gray-800"
: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"
>Generated</span
>
<span class="font-semibold text-gray-800">{{
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 rounded"
/>
</div>
</div>
<!-- Completed Field Jobs Trend (7 dni) -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 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">
<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 rounded" />
</div>
<!-- Stale Cases -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
>
Stari primeri brez aktivnosti
</h3>
<ul
v-if="staleCases"
class="divide-y divide-gray-100 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
v-if="c?.uuid"
:href="safeCaseHref(c.uuid)"
class="text-indigo-600 hover:underline font-medium"
>{{ c.client_ref || c.uuid.slice(0, 8) }}</Link
>
<span v-else class="text-gray-700 font-medium">{{
c.client_ref || "Primer"
}}</span>
<p class="text-[11px] text-gray-400">
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 text-amber-600"
>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 rounded"
/>
</div>
</div>
<!-- Field Jobs Assigned Today -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
>
Današnje dodelitve terenskih
</h3>
<ul
v-if="fieldJobsAssignedToday"
class="divide-y divide-gray-100 text-sm"
>
<li
v-for="f in fieldJobsAssignedToday"
:key="f.id"
class="py-2 flex items-start justify-between gap-3"
>
<div class="min-w-0 flex-1">
<p class="text-gray-700 text-sm font-medium">
#{{ f.id }}
<template v-if="f.contract">
·
<Link
v-if="f.contract.client_case_uuid"
:href="
safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)
"
class="text-indigo-600 hover:underline"
>
{{ f.contract.reference || f.contract.uuid?.slice(0, 8) }}
</Link>
<span v-else class="text-gray-700">{{
f.contract.reference || f.contract.uuid?.slice(0, 8)
}}</span>
<span
v-if="f.contract.person_full_name"
class="text-gray-500"
>
{{ f.contract.person_full_name }}
</span>
</template>
</p>
<p class="text-[11px] text-gray-400">
{{ formatJobTime(f.created_at) }}
</p>
</div>
<div class="flex items-center gap-2">
<span
v-if="f.priority"
class="text-[10px] px-2 py-0.5 rounded bg-rose-50 text-rose-600"
>Prioriteta</span
>
</div>
</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 rounded"
/>
</div>
</div>
<!-- Imports In Progress -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
>
Uvozi v teku
</h3>
<ul
v-if="importsInProgress"
class="divide-y divide-gray-100 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 truncate">
{{ im.file_name }}
</p>
<span
class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600"
>{{ im.status }}</span
>
</div>
<div
class="w-full h-2 bg-gray-100 rounded overflow-hidden"
>
<div
class="h-full bg-indigo-500"
:style="{ width: (im.progress_pct || 0) + '%' }"
></div>
</div>
<p class="text-[10px] text-gray-400">
{{ 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 rounded"
/>
</div>
</div>
<!-- Active Document Templates -->
<div
class="bg-white border rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
>
Aktivne predloge dokumentov
</h3>
<ul
v-if="activeTemplates"
class="divide-y divide-gray-100 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 font-medium truncate">
{{ t.name }}
</p>
<p class="text-[11px] text-gray-400">
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 text-indigo-600 hover:bg-indigo-100"
>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 rounded"
/>
</div>
</div>
<!-- ...end of right side panels -->
</div>
</div>
</div>
</AppLayout>
</template>