395 lines
14 KiB
Vue
395 lines
14 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 GlobalSearch from "./Partials/GlobalSearch.vue";
|
|
import NotificationsBell from "./Partials/NotificationsBell.vue";
|
|
import Breadcrumbs from "@/Components/Breadcrumbs.vue";
|
|
import ToastContainer from "@/Components/Toast/ToastContainer.vue";
|
|
import { Button } from "@/Components/ui/button";
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
import {
|
|
faDesktop,
|
|
faClipboardList,
|
|
faCircleCheck,
|
|
} from "@fortawesome/free-solid-svg-icons";
|
|
|
|
const props = defineProps({
|
|
title: String,
|
|
});
|
|
|
|
// Sidebar + responsive behavior (same feel as AppLayout)
|
|
const sidebarCollapsed = ref(false);
|
|
const hasSavedSidebarPref = ref(false);
|
|
const isMobile = ref(false);
|
|
const mobileSidebarOpen = ref(false);
|
|
function applyAutoCollapse() {
|
|
if (typeof window === "undefined") return;
|
|
isMobile.value = window.innerWidth < 1024;
|
|
sidebarCollapsed.value = isMobile.value;
|
|
}
|
|
function handleResize() {
|
|
if (typeof window !== "undefined") {
|
|
isMobile.value = window.innerWidth < 1024;
|
|
if (!isMobile.value) mobileSidebarOpen.value = false;
|
|
}
|
|
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;
|
|
try {
|
|
localStorage.setItem("sidebarCollapsed", v ? "1" : "0");
|
|
} catch {}
|
|
});
|
|
|
|
function toggleSidebar() {
|
|
hasSavedSidebarPref.value = true;
|
|
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 (same as AppLayout for consistency)
|
|
const page = usePage();
|
|
const flash = computed(() => page.props.flash || {});
|
|
const isCompletedMode = computed(() => !!page.props.completed_mode);
|
|
|
|
// On mobile, always show labels in the overlay menu, regardless of collapsed state
|
|
const showLabels = computed(() => !sidebarCollapsed.value || isMobile.value);
|
|
// On mobile, force full width for the slide-out menu
|
|
const widthClass = computed(() =>
|
|
isMobile.value ? "w-64" : sidebarCollapsed.value ? "w-16" : "w-64"
|
|
);
|
|
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;
|
|
setTimeout(() => (showToast.value = false), 3000);
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Global search modal state
|
|
const searchOpen = ref(false);
|
|
const openSearch = () => (searchOpen.value = true);
|
|
const closeSearch = () => (searchOpen.value = false);
|
|
|
|
// No automatic daily notifications
|
|
</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="[
|
|
widthClass,
|
|
'bg-white border-r border-gray-200 transition-all duration-300 ease-in-out z-50',
|
|
isMobile
|
|
? 'fixed inset-y-0 left-0 transform shadow-strong ' +
|
|
(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 border-gray-200 bg-white">
|
|
<Link
|
|
:href="route('phone.index')"
|
|
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
|
>
|
|
<ApplicationMark />
|
|
<span
|
|
v-if="showLabels"
|
|
class="text-sm font-semibold text-gray-900 transition-opacity"
|
|
>
|
|
Teren
|
|
</span>
|
|
</Link>
|
|
</div>
|
|
<nav class="py-4 overflow-y-auto">
|
|
<ul class="space-y-0.5 px-2">
|
|
<!-- Assigned jobs link -->
|
|
<li>
|
|
<Link
|
|
:href="route('phone.index')"
|
|
:class="[
|
|
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
|
|
route().current('phone.index') ||
|
|
(route().current('phone.case') && !isCompletedMode)
|
|
? 'bg-primary-50 text-primary-700 font-medium shadow-sm'
|
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
|
|
]"
|
|
title="Opravila"
|
|
>
|
|
<FontAwesomeIcon
|
|
:icon="faClipboardList"
|
|
:class="[
|
|
'w-5 h-5 flex-shrink-0 transition-colors',
|
|
route().current('phone.index') ||
|
|
(route().current('phone.case') && !isCompletedMode)
|
|
? 'text-primary-600'
|
|
: 'text-gray-500',
|
|
]"
|
|
/>
|
|
<span
|
|
v-if="showLabels"
|
|
class="truncate transition-opacity"
|
|
:class="{
|
|
'font-medium':
|
|
route().current('phone.index') ||
|
|
(route().current('phone.case') && !isCompletedMode),
|
|
}"
|
|
>
|
|
Opravila
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
<!-- Completed today link -->
|
|
<li>
|
|
<Link
|
|
:href="route('phone.completed')"
|
|
:class="[
|
|
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
|
|
route().current('phone.completed') ||
|
|
(route().current('phone.case') && isCompletedMode)
|
|
? 'bg-primary-50 text-primary-700 font-medium shadow-sm'
|
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
|
|
]"
|
|
title="Zaključeno danes"
|
|
>
|
|
<FontAwesomeIcon
|
|
:icon="faCircleCheck"
|
|
:class="[
|
|
'w-5 h-5 flex-shrink-0 transition-colors',
|
|
route().current('phone.completed') ||
|
|
(route().current('phone.case') && isCompletedMode)
|
|
? 'text-primary-600'
|
|
: 'text-gray-500',
|
|
]"
|
|
/>
|
|
<span
|
|
v-if="showLabels"
|
|
class="truncate transition-opacity"
|
|
:class="{
|
|
'font-medium':
|
|
route().current('phone.completed') ||
|
|
(route().current('phone.case') && isCompletedMode),
|
|
}"
|
|
>
|
|
Zaključeno danes
|
|
</span>
|
|
</Link>
|
|
</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-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<!-- Sidebar toggle -->
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
@click="handleSidebarToggleClick()"
|
|
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
|
|
aria-label="Toggle sidebar"
|
|
>
|
|
<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
|
|
variant="outline"
|
|
size="default"
|
|
@click="openSearch"
|
|
class="gap-2"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
stroke="currentColor"
|
|
class="w-4 h-4"
|
|
>
|
|
<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 text-sm font-medium">Globalni iskalnik</span>
|
|
<kbd
|
|
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border border-gray-300 bg-gray-100 text-gray-600 font-medium"
|
|
>Ctrl K</kbd
|
|
>
|
|
</Button>
|
|
</div>
|
|
<!-- Notifications + User drop menu + Desktop switch button -->
|
|
<div class="flex items-center">
|
|
<NotificationsBell class="mr-2" />
|
|
<!-- Desktop page quick access button -->
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
:as-child="true"
|
|
class="mr-2"
|
|
title="Desktop"
|
|
>
|
|
<Link :href="route('clientCase')">
|
|
<FontAwesomeIcon :icon="faDesktop" class="h-5 w-5" />
|
|
</Link>
|
|
</Button>
|
|
<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-primary-500 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all hover:ring-2 hover:ring-gray-200"
|
|
>
|
|
<img
|
|
class="h-8 w-8 rounded-full object-cover ring-2 ring-gray-100"
|
|
:src="$page.props.auth.user.profile_photo_url"
|
|
:alt="$page.props.auth.user.name"
|
|
/>
|
|
</button>
|
|
|
|
<span v-else class="inline-flex">
|
|
<Button
|
|
variant="outline"
|
|
size="default"
|
|
type="button"
|
|
class="gap-2"
|
|
>
|
|
{{ $page.props.auth.user.name }}
|
|
<svg
|
|
class="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 border-gray-200 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="flex-1 p-4 sm:p-6">
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Global Search Modal -->
|
|
<GlobalSearch :open="searchOpen" @update:open="(v) => (searchOpen = v)" />
|
|
|
|
<!-- Toast Notification Container -->
|
|
<ToastContainer />
|
|
</div>
|
|
</template>
|