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

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>