Teren-app/resources/js/Layouts/AppLayout.vue
2025-10-05 19:45:49 +02:00

476 lines
15 KiB
Vue

<script setup>
import { onMounted, onUnmounted, ref, watch, computed } from "vue";
import { Head, Link, router, usePage } from "@inertiajs/vue3";
import ApplicationMark from "@/Components/ApplicationMark.vue";
import Banner from "@/Components/Banner.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from "@/Components/DropdownLink.vue";
import Breadcrumbs from "@/Components/Breadcrumbs.vue";
import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faMobileScreenButton,
faGaugeHigh,
faLayerGroup,
faUserGroup,
faFolderOpen,
faFileImport,
faTableList,
faFileCirclePlus,
faMap,
faGear,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
title: String,
});
// Collapsible sidebar state (persisted when user explicitly toggles)
const sidebarCollapsed = ref(false);
const hasSavedSidebarPref = ref(false);
// Mobile off-canvas state
const isMobile = ref(false);
const mobileSidebarOpen = ref(false);
function applyAutoCollapse() {
if (typeof window === "undefined") return;
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
sidebarCollapsed.value = isMobile.value;
}
function handleResize() {
if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
}
if (!hasSavedSidebarPref.value) applyAutoCollapse();
}
onMounted(() => {
try {
const saved = localStorage.getItem("sidebarCollapsed");
if (saved !== null) {
hasSavedSidebarPref.value = true;
sidebarCollapsed.value = saved === "1";
} else {
applyAutoCollapse();
}
} catch {}
window.addEventListener("resize", handleResize);
});
onUnmounted(() => window.removeEventListener("resize", handleResize));
watch(sidebarCollapsed, (v) => {
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
try {
localStorage.setItem("sidebarCollapsed", v ? "1" : "0");
} catch {}
});
// Global search modal state
const searchOpen = ref(false);
const openSearch = () => (searchOpen.value = true);
const closeSearch = () => (searchOpen.value = false);
// Keyboard shortcut: Ctrl+K / Cmd+K to open search
function onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openSearch();
}
if (e.key === "Escape" && mobileSidebarOpen.value) {
mobileSidebarOpen.value = false;
}
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
function toggleSidebar() {
hasSavedSidebarPref.value = true; // user explicitly chose
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function toggleMobileSidebar() {
mobileSidebarOpen.value = !mobileSidebarOpen.value;
}
function handleSidebarToggleClick() {
if (isMobile.value) toggleMobileSidebar();
else toggleSidebar();
}
const logout = () => {
router.post(route("logout"));
};
// Flash toast notifications
const page = usePage();
const flash = computed(() => page.props.flash || {});
const showToast = ref(false);
const toastMessage = ref("");
const toastType = ref("success");
watch(
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
([s, e, w, i]) => {
const message = s || e || w || i;
const type = s ? "success" : e ? "error" : w ? "warning" : i ? "info" : null;
if (message && type) {
toastMessage.value = message;
toastType.value = type;
showToast.value = true;
// auto-hide after 3s
setTimeout(() => (showToast.value = false), 3000);
}
},
{ immediate: true }
);
// No automatic daily notifications
// Sidebar menu groups (sorted alphabetically within each group)
const rawMenuGroups = [
{
label: "Glavno",
items: [
{
key: "dashboard",
title: "Nadzorna plošča",
routeName: "dashboard",
active: ["dashboard"],
},
],
},
{
label: "Stranke",
items: [
{
key: "clients",
title: "Naročniki",
routeName: "client",
active: ["client", "client.*"],
},
{
key: "cases",
title: "Primeri",
routeName: "clientCase",
active: ["clientCase", "clientCase.*"],
},
{
key: "segments",
title: "Segmenti",
routeName: "segments.index",
active: ["segments.index"],
},
],
},
{
label: "Uvoz",
items: [
{
key: "imports",
title: "Uvozi",
routeName: "imports.index",
active: ["imports.index", "imports.*"],
},
{
key: "import-templates",
title: "Uvozne predloge",
routeName: "importTemplates.index",
active: ["importTemplates.index"],
},
{
key: "import-templates-new",
title: "Nova uvozna predloga",
routeName: "importTemplates.create",
active: ["importTemplates.create"],
},
],
},
{
label: "Terensko delo",
items: [
{
key: "fieldjobs",
title: "Terenske naloge",
routeName: "fieldjobs.index",
active: ["fieldjobs.index"],
},
],
},
{
label: "Konfiguracija",
items: [
{
key: "settings",
title: "Nastavitve",
routeName: "settings",
active: ["settings", "settings.*"],
},
],
},
];
const menuGroups = computed(() => {
return rawMenuGroups.map((g) => ({
label: g.label,
items: [...g.items].sort((a, b) =>
a.title.localeCompare(b.title, "sl", { sensitivity: "base" })
),
}));
});
// Icon map for menu keys -> FontAwesome icon definitions
const menuIconMap = {
dashboard: faGaugeHigh,
segments: faLayerGroup,
clients: faUserGroup,
cases: faFolderOpen,
imports: faFileImport,
"import-templates": faTableList,
"import-templates-new": faFileCirclePlus,
fieldjobs: faMap,
settings: faGear,
};
function isActive(patterns) {
try {
return patterns?.some((p) => route().current(p));
} catch {
return false;
}
}
</script>
<template>
<div>
<Head :title="title" />
<Banner />
<div class="min-h-screen bg-gray-100 flex">
<!-- Mobile backdrop -->
<div
v-if="isMobile && mobileSidebarOpen"
class="fixed inset-0 z-40 bg-black/30"
@click="mobileSidebarOpen = false"
></div>
<!-- Sidebar -->
<aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
// Off-canvas behavior on mobile; sticky fixed-like sidebar on desktop
isMobile
? 'fixed inset-y-0 left-0 transform ' +
(mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full')
: 'sticky top-0 h-screen overflow-y-auto',
]"
>
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('dashboard')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" />
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-3">
<li v-for="group in menuGroups" :key="group.label">
<div
v-if="!sidebarCollapsed"
class="px-4 py-1 text-[11px] uppercase tracking-wide text-gray-400"
>
{{ group.label }}
</div>
<ul class="space-y-1">
<li v-for="item in group.items" :key="item.key">
<Link
:href="route(item.routeName)"
:class="[
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
isActive(item.active)
? 'bg-gray-100 text-gray-900'
: 'text-gray-600',
]"
:title="item.title"
>
<!-- Unified FontAwesome icon rendering -->
<FontAwesomeIcon
v-if="menuIconMap[item.key]"
:icon="menuIconMap[item.key]"
class="w-5 h-5 text-gray-600"
/>
<!-- Title -->
<span v-if="!sidebarCollapsed">{{ item.title }}</span>
</Link>
</li>
</ul>
</li>
</ul>
</nav>
</aside>
<!-- Main column -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div
class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between sticky top-0 z-30"
>
<div class="flex items-center gap-2">
<!-- Sidebar toggle -->
<button
@click="handleSidebarToggleClick()"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar"
>
<!-- Hamburger (Bars) icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<!-- Search trigger -->
<button
@click="openSearch"
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
/>
</svg>
<span class="hidden sm:inline">Globalni iskalnik</span>
<kbd
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50"
>Ctrl K</kbd
>
</button>
</div>
<!-- Notifications + User drop menu --->
<div class="flex items-center">
<NotificationsBell class="mr-2" />
<!-- Phone page quick access button -->
<Link
:href="route('phone.index')"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2"
title="Phone"
>
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
</Link>
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button
v-if="$page.props.jetstream.managesProfilePhotos"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition"
>
<img
class="h-8 w-8 rounded-full object-cover"
:src="$page.props.auth.user.profile_photo_url"
:alt="$page.props.auth.user.name"
/>
</button>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150"
>
{{ $page.props.auth.user.name }}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
</span>
</template>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">
Nastavitve računa
</div>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink
v-if="$page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')"
>API Tokens</DropdownLink
>
<div class="border-t border-gray-200" />
<form @submit.prevent="logout">
<DropdownLink as="button">Izpis</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
</div>
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
:breadcrumbs="$page.props.breadcrumbs"
/>
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main class="p-4">
<slot />
</main>
</div>
</div>
<!-- Global Search Modal -->
<GlobalSearch :open="searchOpen" @update:open="(v) => (searchOpen = v)" />
<!-- Simple Toast -->
<transition name="fade">
<div
v-if="showToast"
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
:class="{
'bg-emerald-600': toastType === 'success',
'bg-red-600': toastType === 'error',
'bg-amber-500': toastType === 'warning',
'bg-blue-600': toastType === 'info',
}"
>
{{ toastMessage }}
</div>
</transition>
</div>
</template>