Teren-app/resources/js/Pages/Dashboard.vue
Simon Pocrnjič 8f2e5e282c Changes to UI
2025-10-18 22:56:51 +02:00

654 lines
23 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,
});
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 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
v-if="c?.uuid"
:href="safeCaseHref(c.uuid)"
class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
>{{ c.client_ref || c.uuid.slice(0, 8) }}</Link
>
<span v-else class="text-gray-700 dark:text-gray-300 font-medium">{{
c.client_ref || "Primer"
}}</span>
<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-start justify-between gap-3"
>
<div class="min-w-0 flex-1">
<p class="text-gray-700 dark:text-gray-300 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 dark:text-indigo-400 hover:underline"
>
{{ f.contract.reference || f.contract.uuid?.slice(0, 8) }}
</Link>
<span v-else class="text-gray-700 dark:text-gray-300">{{
f.contract.reference || f.contract.uuid?.slice(0, 8)
}}</span>
<span
v-if="f.contract.person_full_name"
class="text-gray-500 dark:text-gray-400"
>
{{ f.contract.person_full_name }}
</span>
</template>
</p>
<p class="text-[11px] text-gray-400 dark:text-gray-500">
{{ 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 dark:bg-rose-900/30 text-rose-600 dark:text-rose-300"
>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 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>