Remodel for Mobile

This commit is contained in:
2025-11-20 12:38:38 +01:00
parent 5f6df7c69d
commit 24f576aeaa
13 changed files with 1226 additions and 595 deletions

View File

@@ -1,70 +1,134 @@
<script setup>
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
type: {
type: String,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
item: { type: Object, required: true },
type: { type: String, required: true },
topLevelType: { type: String, required: true },
platform: { type: String, required: true }
})
const emit = defineEmits(["updateNeeded"])
const files = useFiles()
const availableFiles = ref([])
const activeFile = ref(null)
const showViewer = ref(false)
const setup = async () => {
if(props.item.files) {
availableFiles.value = (await files.selectSomeDocuments(props.item.files.map(i => i.id))) || []
if (props.item.files?.length > 0) {
availableFiles.value =
(await files.selectSomeDocuments(props.item.files.map((f) => f.id))) || []
}
}
setup()
// Datei öffnen (Mobile/Tablet)
function openFile(file) {
activeFile.value = file
showViewer.value = true
}
function closeViewer() {
showViewer.value = false
activeFile.value = null
}
// PDF oder Bild?
function isPdf(file) {
return file.path.includes("pdf")
}
function isImage(file) {
return file.mimetype?.startsWith("image/")
}
</script>
<template>
<UCard class="mt-5" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<template #header v-if="props.platform === 'mobile'">
<template #header>
<span>Dateien</span>
</template>
<!-- Upload -->
<Toolbar>
<DocumentUpload
:type="props.topLevelType.substring(0,props.topLevelType.length-1)"
:type="props.topLevelType.substring(0, props.topLevelType.length - 1)"
:element-id="props.item.id"
@uploadFinished="emit('updateNeeded')"
/>
</Toolbar>
<DocumentList
:key="props.item.files.length"
:documents="availableFiles"
v-if="availableFiles.length > 0"
/>
<UAlert
v-else
icon="i-heroicons-x-mark"
title="Keine Dateien verfügbar"
/>
<!-- 📱 MOBILE: File Cards -->
<div v-if="props.platform === 'mobile'" class="space-y-3 mt-3">
<div
v-for="file in availableFiles"
:key="file.id"
class="p-4 border rounded-xl bg-gray-50 dark:bg-gray-900 flex items-center justify-between active:scale-95 transition cursor-pointer"
@click="openFile(file)"
>
<div>
<p class="font-semibold truncate max-w-[200px]">{{ file?.path?.split("/").pop() }}</p>
</div>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 text-gray-400"
/>
</div>
<UAlert
v-if="!availableFiles.length"
icon="i-heroicons-x-mark"
title="Keine Dateien verfügbar"
/>
</div>
<!-- 🖥 DESKTOP: Classic List -->
<template v-else>
<DocumentList
:key="props.item.files.length"
:documents="availableFiles"
v-if="availableFiles.length > 0"
/>
<UAlert v-else icon="i-heroicons-x-mark" title="Keine Dateien verfügbar" />
</template>
</UCard>
<!-- 📱 PDF / IMG Viewer Slideover -->
<UModal v-model="showViewer" side="bottom" class="h-[100dvh]" fullscreen>
<!-- Header -->
<div class="p-4 border-b flex justify-between items-center flex-shrink-0">
<h3 class="font-bold truncate max-w-[70vw]">{{ activeFile?.path?.split("/").pop() }}</h3>
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="closeViewer" />
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto m-2">
<!-- PDF -->
<div v-if="activeFile && isPdf(activeFile)" class="h-full">
<PDFViewer
:no-controls="true"
:file-id="activeFile.id"
location="fileviewer-mobile"
class="h-full"
/>
</div>
<!-- IMAGE -->
<div
v-else-if="activeFile && isImage(activeFile)"
class="p-4 flex justify-center"
>
<img
:src="activeFile.url"
class="max-w-full max-h-[80vh] rounded-lg shadow"
/>
</div>
<UAlert
v-else
title="Nicht unterstützter Dateityp"
icon="i-heroicons-exclamation-triangle"
/>
</div>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,67 @@
<script setup>
import { Browser } from "@capacitor/browser"
const props = defineProps({
links: {
type: Array,
required: true,
},
})
/**
* Öffnet externen Link in iOS/Android über InApp Browser.
* Öffnet externen Link im Web über window.open.
* Interne Links → navigateTo
*/
async function openLink(link) {
if (link.external) {
if (useCapacitor().getIsNative()) {
await Browser.open({
url: link.to,
presentationStyle: "popover",
})
} else {
window.open(link.to, "_blank")
}
} else {
return navigateTo(link.to)
}
}
</script>
<template>
<UDashboardCard
v-if="links.length > 0"
title="Schnellzugriffe"
>
<div class="space-y-2">
<div
v-for="(link, index) in links"
:key="index"
class="
p-3 bg-gray-50 dark:bg-gray-900
rounded-xl border flex items-center justify-between
active:scale-95 transition cursor-pointer
"
@click="openLink(link)"
>
<div class="flex items-center gap-3">
<UIcon
:name="link.icon || 'i-heroicons-link'"
class="w-6 h-6 text-primary-500"
/>
<span class="font-medium truncate max-w-[60vw]">
{{ link.label }}
</span>
</div>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 text-gray-400"
/>
</div>
</div>
</UDashboardCard>
</template>

View File

@@ -6,9 +6,9 @@ const auth = useAuthStore()
</script>
<template>
<div>
<div v-if="auth.activeTenant">
<h1 class="font-bold text-xl">Willkommen zurück {{auth.profile.full_name}}</h1>
<span v-if="auth.activeTenant">bei {{auth.activeTenantData.name}}</span>
<span>bei {{auth.activeTenantData.name}}</span>
</div>
</template>

View File

@@ -2,46 +2,65 @@
const props = defineProps({
label: {
type: String,
required: true,
required: false,
default: null
},
icon: {
type: String,
required: false
},
variant: {
type: String,
default: 'solid'
default: "solid"
},
color: {
type: String,
default: 'primary'
default: "primary"
},
pos: {
type: Number,
default: 0
default: 6 // Abstand von unten in Rem (6 = 1.5rem * 6 = 9rem)
}
})
const emit = defineEmits(['click'])
const emit = defineEmits(["click"])
</script>
<template>
<UButton
id="fab"
:icon="props.icon"
:label="props.label"
:variant="props.variant"
:color="props.color"
@click="emit('click')"
:style="`bottom: ${15 + props.pos * 5}vh;`"
class="bg-white dark:bg-gray-950"
/>
<!-- Wrapper für Position + Animation -->
<div
class="fixed right-5 z-40 transition-all"
:style="{ bottom: `calc(${props.pos}rem + env(safe-area-inset-bottom))` }"
>
<UButton
id="fab"
:icon="props.icon"
:label="props.label"
:variant="props.variant"
:color="props.color"
@click="emit('click')"
class="
fab-base
shadow-xl
hover:shadow-2xl
active:scale-95
transition
"
/>
</div>
</template>
<style scoped>
#fab {
position: fixed;
right: 15px;
z-index: 5;
/* FAB Basis */
.fab-base {
@apply rounded-full px-5 py-4 text-lg font-semibold;
/* Wenn nur ein Icon vorhanden ist → runder Kreis */
/* Wenn Label + Icon → Extended FAB */
}
</style>
/* Optional: Auto-Kreisen wenn kein Label */
#fab:not([label]) {
@apply w-14 h-14 p-0 flex items-center justify-center text-2xl;
}
</style>

View File

@@ -1,79 +1,58 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue"
import MainNav from "~/components/MainNav.vue";
import dayjs from "dayjs";
import {useProfileStore} from "~/stores/profile.js";
import {useCapacitor} from "../composables/useCapacitor.js";
const dataStore = useDataStore()
const profileStore = useProfileStore()
const colorMode = useColorMode()
const { isHelpSlideoverOpen } = useDashboard()
const supabase = useSupabaseClient()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
//profileStore.initializeData((await supabase.auth.getUser()).data.user.id)
const month = dayjs().format("MM")
const actions = [
{
id: 'new-customer',
label: 'Kunde hinzufügen',
icon: 'i-heroicons-user-group',
to: "/customers/create" ,
},
{
id: 'new-vendor',
label: 'Lieferant hinzufügen',
icon: 'i-heroicons-truck',
to: "/vendors/create" ,
},
{
id: 'new-contact',
label: 'Ansprechpartner hinzufügen',
icon: 'i-heroicons-user-group',
to: "/contacts/create" ,
},
{
id: 'new-task',
label: 'Aufgabe hinzufügen',
icon: 'i-heroicons-rectangle-stack',
to: "/tasks/create" ,
},
{
id: 'new-plant',
label: 'Objekt hinzufügen',
icon: 'i-heroicons-clipboard-document',
to: "/plants/create" ,
},
{
id: 'new-product',
label: 'Artikel hinzufügen',
icon: 'i-heroicons-puzzle-piece',
to: "/products/create" ,
},
{
id: 'new-project',
label: 'Projekt hinzufügen',
icon: 'i-heroicons-clipboard-document-check',
to: "/projects/create" ,
const hideNav = ref(false)
let lastScrollY = 0
let scrollElement = null
let returnTimer = null
const SHOW_DELAY = 1000 // 1 Sekunden
function showNavAfterDelay() {
clearTimeout(returnTimer)
returnTimer = setTimeout(() => {
hideNav.value = false
}, SHOW_DELAY)
}
const handleScroll = () => {
const current = scrollElement.scrollTop
// Runter scrollen -> verstecken
if (current > lastScrollY + 10) {
hideNav.value = true
showNavAfterDelay()
}
]
// Hoch scrollen -> sofort zeigen
if (current < lastScrollY - 10) {
hideNav.value = false
clearTimeout(returnTimer)
}
lastScrollY = current
}
onMounted(() => {
scrollElement = document.querySelector('.mobile-scroll-area')
if (scrollElement) {
scrollElement.addEventListener('scroll', handleScroll)
}
})
onBeforeUnmount(() => {
if (scrollElement) scrollElement.removeEventListener('scroll', handleScroll)
clearTimeout(returnTimer)
})
const footerLinks = [/*{
label: 'Invite people',
icon: 'i-heroicons-plus',
to: '/settings/members'
}, */{
label: 'Hilfe & Info',
icon: 'i-heroicons-question-mark-circle',
click: () => isHelpSlideoverOpen.value = true
}]
</script>
@@ -191,45 +170,49 @@ const footerLinks = [/*{
</UDashboardPanel>
</UDashboardPage>
<div class="mobileFooter bg-white dark:bg-gray-950">
<!-- Modernisierte Mobile Navigation -->
<nav
:class="[
'fixed bottom-0 left-0 right-0 z-50', // ← bottom-0 hinzugefügt!
'h-[70px] bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl',
'border-t border-gray-200 dark:border-gray-800',
'flex justify-around items-center pt-2 pb-[max(env(safe-area-inset-bottom),0.5rem)]',
'transition-transform duration-300 ease-in-out',
hideNav ? 'translate-y-full' : 'translate-y-0'
]"
>
<UButton
icon="i-heroicons-home"
to="/mobile/"
variant="ghost"
:color="route.fullPath === '/mobile' ? 'primary' : 'gray'"
class="nav-btn"
/>
<UButton
icon="i-heroicons-clipboard-document-check"
to="/standardEntity/tasks"
variant="ghost"
:color="route.fullPath === '/standardEntity/tasks' ? 'primary' : 'gray'"
class="nav-btn"
/>
<UButton
icon="i-heroicons-rectangle-stack"
to="/standardEntity/projects"
variant="ghost"
:color="route.fullPath === '/standardEntity/projects' ? 'primary' : 'gray'"
class="nav-btn"
/>
<!-- <UButton
icon="i-heroicons-clock"
to="/workingtimes"
variant="ghost"
:color="route.fullPath === '/workingtimes' ? 'primary' : 'gray'"
/>-->
<UButton
icon="i-heroicons-bars-4"
to="/mobile/menu"
variant="ghost"
:color="route.fullPath === '/mobile/menu' ? 'primary' : 'gray'"
class="nav-btn"
/>
</div>
<!-- ~/components/HelpSlideover.vue -->
<HelpSlideover/>
<!-- ~/components/NotificationsSlideover.vue -->
<NotificationsSlideover />
</nav>
</UDashboardLayout>
@@ -276,20 +259,9 @@ const footerLinks = [/*{
</template>
<style scoped>
.mobileFooter {
position: absolute;
bottom: 0;
left: 0;
height: 8vh;
width: 100%;
border-top: 1px solid grey;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 1em;
.nav-btn {
@apply w-12 h-12 flex justify-center items-center rounded-xl active:scale-95 transition;
}
.mobileFooter > a {
}
</style>

View File

@@ -1,9 +1,9 @@
export default defineNuxtRouteMiddleware(async (to, _from) => {
const router = useRouter()
console.log(await useCapacitor().getIsPhone())
console.log(useCapacitor().getIsNative())
if(await useCapacitor().getIsPhone() && _from.path !== '/mobile') {
if(useCapacitor().getIsNative() && _from.path !== '/mobile') {
return router.push('/mobile')
}
})

View File

@@ -6,7 +6,7 @@ definePageMeta({
const auth = useAuthStore()
const toast = useToast()
const platformIsNative = useCapacitor().getIsNative()
const doLogin = async (data:any) => {
@@ -14,7 +14,7 @@ const doLogin = async (data:any) => {
await auth.login(data.email, data.password)
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Einloggen erfolgreich"})
if(useCapacitor().getIsNative()) {
if(platformIsNative) {
return navigateTo("/mobile")
} else {
return navigateTo("/")
@@ -26,7 +26,7 @@ const doLogin = async (data:any) => {
</script>
<template>
<UCard class="max-w-sm w-full mx-auto mt-5">
<UCard class="max-w-sm w-full mx-auto mt-5" v-if="!platformIsNative">
<UColorModeImage
light="/Logo.png"
@@ -67,4 +67,35 @@ const doLogin = async (data:any) => {
</template>
</UAuthForm>
</UCard>
<div v-else class="mt-20 m-2 p-2">
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Login"
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}, {
name: 'password',
label: 'Passwort',
type: 'password',
placeholder: 'Dein Passwort'
}]"
:loading="false"
@submit="doLogin"
:submit-button="{label: 'Weiter'}"
divider="oder"
>
<template #password-hint>
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
</template>
</UAuthForm>
</div>
</template>

View File

@@ -1,13 +1,33 @@
<script setup>
import DisplayPresentProfiles from "~/components/noAutoLoad/displayPresentProfiles.vue";
definePageMeta({
layout: 'mobile'
})
//const profileStore = useProfileStore()
const auth = useAuthStore()
const pinnedLinks = computed(() => {
return (auth.profile?.pinned_on_navigation || [])
.map((pin) => {
if (pin.type === "external") {
return {
label: pin.label,
to: pin.link,
icon: pin.icon,
external: true,
}
} else if (pin.type === "standardEntity") {
return {
label: pin.label,
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
icon: pin.icon,
external: false,
}
}
})
.filter(Boolean)
})
</script>
<template>
@@ -42,6 +62,7 @@ definePageMeta({
>
<display-projects-in-phases/>
</UDashboardCard>
<display-pinnend-links :links="pinnedLinks"/>
</UPageGrid>
</UDashboardPanelContent>

View File

@@ -13,30 +13,19 @@ const auth = useAuthStore()
<UDivider class="mb-3">Weiteres</UDivider>
<UButton
class="w-full my-1"
to="/times"
to="/staff/time"
icon="i-heroicons-clock"
>
Zeiten
</UButton>
<UButton
<!-- <UButton
class="w-full my-1"
to="/standardEntity/absencerequests"
icon="i-heroicons-document-text"
>
Abwesenheiten
</UButton>
<UButton
class="w-full my-1"
to="/workingtimes"
icon="i-heroicons-clock"
>
Anwesenheiten
</UButton>
<!-- <UButton
class="w-full my-1">
Kalender
</UButton>-->
<UButton
class="w-full my-1"
to="/standardEntity/customers"

View File

@@ -9,6 +9,8 @@ const workingtimes = ref([])
const absencerequests = ref([])
const workingTimeInfo = ref(null)
const platformIsNative = ref(useCapacitor().getIsNative())
const selectedPresetRange = ref("Dieser Monat bis heute")
const selectedStartDay = ref("")
const selectedEndDay = ref("")
@@ -63,6 +65,7 @@ async function setupPage() {
profile.value = (await useNuxtApp().$api("/api/tenant/profiles")).data.find(i => i.user_id === route.params.id)
console.log(profile.value)
setPageLayout(platformIsNative.value ? 'mobile' : 'default')
}
@@ -119,29 +122,30 @@ changeRange()
</script>
<template>
<UDashboardNavbar :ui="{ center: 'flex items-stretch gap-1.5 min-w-0' }">
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push('/staff/time')"
>
Anwesenheiten
</UButton>
</template>
<template v-if="!platformIsNative">
<UDashboardNavbar :ui="{ center: 'flex items-stretch gap-1.5 min-w-0' }">
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push('/staff/time')"
>
Anwesenheiten
</UButton>
</template>
<template #center>
<h1 class="text-xl font-medium truncate">
Auswertung Anwesenheiten: {{ profile?.full_name || '' }}
</h1>
</template>
</UDashboardNavbar>
<template #center>
<h1 class="text-xl font-medium truncate">
Auswertung Anwesenheiten: {{ profile?.full_name || '' }}
</h1>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UFormGroup label="Zeitraum:">
<USelectMenu
:options="[
<UDashboardToolbar>
<template #left>
<UFormGroup label="Zeitraum:">
<USelectMenu
:options="[
'Dieser Monat bis heute',
'Diese Woche',
'Dieser Monat',
@@ -150,114 +154,265 @@ changeRange()
'Letzter Monat',
'Letztes Jahr'
]"
v-model="selectedPresetRange"
@change="changeRange"
/>
</UFormGroup>
<UFormGroup label="Start:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="selectedStartDay ? $dayjs(selectedStartDay).format('DD.MM.YYYY') : 'Datum wählen'"
v-model="selectedPresetRange"
@change="changeRange"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</UFormGroup>
</UFormGroup>
<UFormGroup label="Ende:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="selectedEndDay ? $dayjs(selectedEndDay).format('DD.MM.YYYY') : 'Datum wählen'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</UFormGroup>
</template>
<template #right>
<UTooltip
:text="fileSaved ? 'Bericht bereits gespeichert' : 'Bericht speichern'"
v-if="openTab === 1 && uri"
>
<UButton
icon="i-mdi-content-save"
:disabled="fileSaved"
@click="saveFile"
>Bericht</UButton>
</UTooltip>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<UTabs
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
v-model="openTab"
@change="onTabChange"
>
<template #item="{ item }">
<div v-if="item.label === 'Information'">
<UCard v-if="workingTimeInfo" class="my-5">
<template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3>
<UFormGroup label="Start:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="selectedStartDay ? $dayjs(selectedStartDay).format('DD.MM.YYYY') : 'Datum wählen'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
</template>
<div class="grid grid-cols-2 gap-3 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesEingereicht) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesApproved) }}</b></p>
<p>Feiertagsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesRecreationDays) }}</b> / {{ workingTimeInfo.sumRecreationDays }} Tage</p>
<p>Urlaubs-/Berufsschulausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesVacationDays) }}</b> / {{ workingTimeInfo.sumVacationDays }} Tage</p>
<p>Krankheitsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesSickDays) }}</b> / {{ workingTimeInfo.sumSickDays }} Tage</p>
<p>Soll-Stunden: <b>{{ formatMinutesToHHMM(workingTimeInfo.timeSpanWorkingMinutes) }}</b></p>
<p class="col-span-2">
Inoffizielles Saldo: <b>{{ (workingTimeInfo.saldoInOfficial >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldoInOfficial)) }}</b>
</p>
<p class="col-span-2">
Saldo: <b>{{ (workingTimeInfo.saldo >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldo)) }}</b>
</p>
</div>
</UCard>
</UPopover>
</UFormGroup>
<UDashboardPanel>
<UTable
v-if="workingTimeInfo"
:rows="workingTimeInfo.times"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }"
:columns="[
<UFormGroup label="Ende:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="selectedEndDay ? $dayjs(selectedEndDay).format('DD.MM.YYYY') : 'Datum wählen'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</UFormGroup>
</template>
<template #right>
<UTooltip
:text="fileSaved ? 'Bericht bereits gespeichert' : 'Bericht speichern'"
v-if="openTab === 1 && uri"
>
<UButton
icon="i-mdi-content-save"
:disabled="fileSaved"
@click="saveFile"
>Bericht</UButton>
</UTooltip>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<UTabs
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
v-model="openTab"
@change="onTabChange"
>
<template #item="{ item }">
<div v-if="item.label === 'Information'">
<UCard v-if="workingTimeInfo" class="my-5">
<template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3>
</template>
<div class="grid grid-cols-2 gap-3 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesEingereicht) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesApproved) }}</b></p>
<p>Feiertagsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesRecreationDays) }}</b> / {{ workingTimeInfo.sumRecreationDays }} Tage</p>
<p>Urlaubs-/Berufsschulausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesVacationDays) }}</b> / {{ workingTimeInfo.sumVacationDays }} Tage</p>
<p>Krankheitsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesSickDays) }}</b> / {{ workingTimeInfo.sumSickDays }} Tage</p>
<p>Soll-Stunden: <b>{{ formatMinutesToHHMM(workingTimeInfo.timeSpanWorkingMinutes) }}</b></p>
<p class="col-span-2">
Inoffizielles Saldo: <b>{{ (workingTimeInfo.saldoInOfficial >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldoInOfficial)) }}</b>
</p>
<p class="col-span-2">
Saldo: <b>{{ (workingTimeInfo.saldo >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldo)) }}</b>
</p>
</div>
</UCard>
<UDashboardPanel>
<UTable
v-if="workingTimeInfo"
:rows="workingTimeInfo.times"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }"
:columns="[
{ key: 'state', label: 'Status' },
{ key: 'start', label: 'Start' },
{ key: 'end', label: 'Ende' },
{ key: 'duration', label: 'Dauer' },
{ key: 'description', label: 'Beschreibung' }
]"
@select="(row) => router.push(`/workingtimes/edit/${row.id}`)"
>
<template #state-data="{row}">
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span>
<span v-else-if="row.state === 'submitted'" class="text-cyan-500">Eingereicht</span>
<span v-else-if="row.state === 'draft'" class="text-red-500">Entwurf</span>
</template>
@select="(row) => router.push(`/workingtimes/edit/${row.id}`)"
>
<template #state-data="{row}">
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span>
<span v-else-if="row.state === 'submitted'" class="text-cyan-500">Eingereicht</span>
<span v-else-if="row.state === 'draft'" class="text-red-500">Entwurf</span>
</template>
<template #start-data="{ row }">
{{ $dayjs(row.started_at).format('HH:mm DD.MM.YY') }} Uhr
</template>
<template #start-data="{ row }">
{{ $dayjs(row.started_at).format('HH:mm DD.MM.YY') }} Uhr
</template>
<template #end-data="{ row }">
{{ $dayjs(row.stopped_at).format('HH:mm DD.MM.YY') }} Uhr
</template>
<template #end-data="{ row }">
{{ $dayjs(row.stopped_at).format('HH:mm DD.MM.YY') }} Uhr
</template>
<template #duration-data="{ row }">
{{ useFormatDuration(row.duration_minutes) }}
</template>
</UTable>
</UDashboardPanel>
<template #duration-data="{ row }">
{{ useFormatDuration(row.duration_minutes) }}
</template>
</UTable>
</UDashboardPanel>
</div>
<div v-else-if="item.label === 'Bericht'">
<PDFViewer
v-if="showDocument"
:uri="uri"
location="show_time_evaluation"
/>
</div>
</template>
</UTabs>
</UDashboardPanelContent>
</template>
<!-- ====================== -->
<!-- 📱 MOBILE ANSICHT -->
<!-- ====================== -->
<template v-else>
<!-- 🔙 Navigation -->
<UDashboardNavbar title="Auswertung">
<template #toggle><div></div></template>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="ghost"
@click="router.push('/staff/time')"
/>
</template>
</UDashboardNavbar>
<!-- 📌 Mobile Zeitraumwahl -->
<div class="p-4 space-y-4 border-b bg-white dark:bg-gray-900">
<!-- Predefined Ranges -->
<USelectMenu
v-model="selectedPresetRange"
:options="[
'Dieser Monat bis heute',
'Diese Woche',
'Dieser Monat',
'Dieses Jahr',
'Letzte Woche',
'Letzter Monat',
'Letztes Jahr'
]"
@change="changeRange"
placeholder="Zeitraum wählen"
class="w-full"
/>
<!-- Start/End Datum -->
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-xs text-gray-500 mb-1">Start</p>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar"
class="w-full"
:label="$dayjs(selectedStartDay).format('DD.MM.YYYY')"
/>
<template #panel>
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</div>
<div>
<p class="text-xs text-gray-500 mb-1">Ende</p>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar"
class="w-full"
:label="$dayjs(selectedEndDay).format('DD.MM.YYYY')"
/>
<template #panel>
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</div>
</div>
</div>
<!-- 📑 Mobile Tabs -->
<UTabs
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
v-model="openTab"
@change="onTabChange"
class="mt-3 mx-3"
>
<template #item="{ item }">
<!-- ====================== -->
<!-- TAB 1 INFORMATION -->
<!-- ====================== -->
<div v-if="item.label === 'Information'" class="space-y-4">
<!-- Summary Card -->
<UCard v-if="workingTimeInfo" class="mt-3">
<template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3>
</template>
<div class="space-y-2 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesEingereicht) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesApproved) }}</b></p>
<p>
Feiertagsausgleich:
<b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesRecreationDays) }}</b>
/ {{ workingTimeInfo.sumRecreationDays }} Tage
</p>
<p>
Urlaubs-/Berufsschule:
<b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesVacationDays) }}</b>
/ {{ workingTimeInfo.sumVacationDays }} Tage
</p>
<p>
Krankheitsausgleich:
<b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesSickDays) }}</b>
/ {{ workingTimeInfo.sumSickDays }} Tage
</p>
<p>Soll: <b>{{ formatMinutesToHHMM(workingTimeInfo.timeSpanWorkingMinutes) }}</b></p>
<p>
Inoffizielles Saldo:
<b>{{ (workingTimeInfo.saldoInOfficial >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldoInOfficial)) }}</b>
</p>
<p>
Saldo:
<b>{{ (workingTimeInfo.saldo >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldo)) }}</b>
</p>
</div>
</UCard>
</div>
<!-- ====================== -->
<!-- TAB 2 BERICHT -->
<!-- ====================== -->
<div v-else-if="item.label === 'Bericht'">
<UButton
v-if="uri && !fileSaved"
icon="i-mdi-content-save"
color="primary"
class="w-full mb-3"
@click="saveFile"
>
Bericht speichern
</UButton>
<PDFViewer
v-if="showDocument"
:uri="uri"
@@ -266,5 +421,7 @@ changeRange()
</div>
</template>
</UTabs>
</UDashboardPanelContent>
</template>
</template>

View File

@@ -1,20 +1,28 @@
<script setup lang="ts">
import { useStaffTime } from '~/composables/useStaffTime'
import { useAuthStore } from '~/stores/auth'
import FloatingActionButton from "~/components/mobile/FloatingActionButton.vue";
const { list, start, stop, submit,approve } = useStaffTime()
definePageMeta({
layout: "default",
})
const { list, start, stop, submit, approve } = useStaffTime()
const auth = useAuthStore()
const router = useRouter()
// MOBILE DETECTION
const platformIsNative = useCapacitor().getIsNative()
// LIST + ACTIVE
const entries = ref([])
const active = computed(() => entries.value.find(e => !e.stopped_at && e.user_id === auth.user.id))
const loading = ref(false)
const showModal = ref(false)
const editEntry = ref(null)
// 👥 Nutzer-Filter (nur für Berechtigte)
const users = ref([])
const selectedUser = ref<string | null>(null)
const selectedUser = ref(platformIsNative ? auth.user.id : null)
const canViewAll = computed(() => auth.permissions.includes('staff.time.read_all'))
@@ -25,15 +33,18 @@ async function loadUsers() {
users.value = res
}
// LOAD ENTRIES (only own entries on mobile)
async function load() {
entries.value = await list(
canViewAll.value && selectedUser.value ? { user_id: selectedUser.value } : undefined
)
}
async function handleStart() {
loading.value = true
await start('Arbeitszeit gestartet')
await start("Arbeitszeit gestartet")
await load()
loading.value = false
}
@@ -61,160 +72,291 @@ async function handleApprove(entry: any) {
await load()
}
onMounted(async () => {
await loadUsers()
await load()
await loadUsers()
setPageLayout(platformIsNative ? 'mobile' : 'default')
})
</script>
<template>
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
<!-- ============================= -->
<!-- DESKTOP VERSION -->
<!-- ============================= -->
<template v-if="!platformIsNative">
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
<!-- TOOLBAR -->
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-clock" class="text-primary-500" />
<span v-if="active" class="text-primary-600 font-medium">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
</span>
<span v-else class="text-gray-500">Keine aktive Zeit</span>
</div>
</template>
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-clock" class="text-primary-500" />
<span v-if="active" class="text-primary-600 font-medium">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
</span>
<span v-else class="text-gray-500">Keine aktive Zeit</span>
</div>
</template>
<template #right>
<UButton
v-if="active"
color="red"
icon="i-heroicons-stop"
:loading="loading"
label="Stoppen"
@click="handleStop"
/>
<UButton
v-else
color="green"
icon="i-heroicons-play"
:loading="loading"
label="Starten"
@click="handleStart"
/>
<UButton
color="primary"
icon="i-heroicons-plus"
label="Zeit"
@click="() => { editEntry = null; showModal = true }"
/>
</template>
</UDashboardToolbar>
<UDashboardToolbar>
<template #left>
<!-- 👥 User-Filter (nur bei Berechtigung) -->
<div v-if="canViewAll" class="flex items-center gap-2">
<USelectMenu
v-model="selectedUser"
:options="[
<template #right>
<UButton
v-if="active"
color="red"
icon="i-heroicons-stop"
:loading="loading"
label="Stoppen"
@click="handleStop"
/>
<UButton
v-else
color="green"
icon="i-heroicons-play"
:loading="loading"
label="Starten"
@click="handleStart"
/>
<UButton
color="primary"
icon="i-heroicons-plus"
label="Zeit"
@click="() => { editEntry = null; showModal = true }"
/>
</template>
</UDashboardToolbar>
<UDashboardToolbar>
<template #left>
<!-- 👥 User-Filter (nur bei Berechtigung) -->
<div v-if="canViewAll" class="flex items-center gap-2">
<USelectMenu
v-model="selectedUser"
:options="[
{ label: 'Alle Benutzer', value: null },
...users.map(u => ({ label: u.full_name || u.email, value: u.user_id }))
]"
placeholder="Benutzer auswählen"
value-attribute="value"
option-attribute="label"
class="min-w-[220px]"
@change="load"
/>
<!-- 🔹 Button zur Auswertung -->
<UTooltip
:text="selectedUser ? 'Anwesenheiten des Mitarbeiters auswerten' : 'Mitarbeiter für die Auswertung auswählen'"
>
<UButton
:disabled="!selectedUser"
color="gray"
icon="i-heroicons-chart-bar"
label="Auswertung"
variant="soft"
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
placeholder="Benutzer auswählen"
value-attribute="value"
option-attribute="label"
class="min-w-[220px]"
@change="load"
/>
</UTooltip>
<!-- 🔹 Button zur Auswertung -->
<UTooltip
:text="selectedUser ? 'Anwesenheiten des Mitarbeiters auswerten' : 'Mitarbeiter für die Auswertung auswählen'"
>
<UButton
:disabled="!selectedUser"
color="gray"
icon="i-heroicons-chart-bar"
label="Auswertung"
variant="soft"
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
/>
</UTooltip>
</div>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<UTable
:rows="entries"
:columns="[
{ key: 'actions', label: '' },
{ key: 'state', label: 'Status' },
{ key: 'started_at', label: 'Start' },
{ key: 'stopped_at', label: 'Ende' },
{ key: 'duration_minutes', label: 'Dauer' },
{ key: 'description', label: 'Beschreibung' },
]"
>
<template #state-data="{ row }">
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span>
<span v-else-if="row.state === 'submitted'" class="text-cyan-500">Eingereicht</span>
<span v-else-if="row.state === 'draft'" class="text-red-500">Entwurf</span>
</template>
<template #started_at-data="{ row }">
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}
</template>
<template #stopped_at-data="{ row }">
<span v-if="row.stopped_at">
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}
</span>
<span v-else class="text-primary-500 font-medium">läuft...</span>
</template>
<template #duration_minutes-data="{ row }">
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
</template>
<template #actions-data="{ row }">
<UTooltip text="Zeit genehmigen" v-if="row.state === 'submitted'">
<UButton
variant="ghost"
icon="i-heroicons-check-circle"
@click="handleApprove(row)"
/>
</UTooltip>
<UTooltip text="Zeit einreichen" v-if="row.state === 'draft'">
<UButton
variant="ghost"
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="handleSubmit(row)"
/>
</UTooltip>
<UTooltip text="Zeit bearbeiten" v-if="row.state === 'draft'">
<UButton
variant="ghost"
icon="i-heroicons-pencil-square"
@click="handleEdit(row)"
/>
</UTooltip>
</template>
</UTable>
</UDashboardPanelContent>
</template>
<!-- ============================= -->
<!-- MOBILE VERSION -->
<!-- ============================= -->
<template v-else>
<UDashboardNavbar title="Zeiterfassung" />
<div class="relative flex flex-col h-[100dvh] overflow-hidden">
<!-- 🔥 FIXED ACTIVE TIMER -->
<div class="p-4 bg-white dark:bg-gray-900 border-b sticky top-0 z-20 shadow-sm">
<UCard class="p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Aktive Zeit</p>
<p v-if="active" class="text-primary-600 font-semibold">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
</p>
<p v-else class="text-gray-600">Keine aktive Zeit</p>
</div>
<UButton
v-if="active"
color="red"
icon="i-heroicons-stop"
:loading="loading"
@click="handleStop"
/>
<UButton
v-else
color="green"
icon="i-heroicons-play"
:loading="loading"
@click="handleStart"
/>
</div>
</UCard>
</div>
</template>
</UDashboardToolbar>
<!-- TABELLE -->
<UDashboardPanelContent>
<UTable
:rows="entries"
:columns="[
{ key: 'actions', label: '' },
{ key: 'state', label: 'Status' },
{ key: 'started_at', label: 'Start' },
{ key: 'stopped_at', label: 'Ende' },
{ key: 'duration_minutes', label: 'Dauer' },
{ key: 'description', label: 'Beschreibung' },
...(canViewAll ? [{ key: 'user_name', label: 'Benutzer' }] : []),
<div class="px-3 mt-3">
<UButton
color="gray"
icon="i-heroicons-chart-bar"
label="Eigene Auswertung"
class="w-full"
variant="soft"
@click="router.push(`/staff/time/${auth.user.id}/evaluate`)"
/>
</div>
]"
>
<template #state-data="{row}">
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span>
<span v-else-if="row.state === 'submitted'" class="text-cyan-500">Eingereicht</span>
<span v-else-if="row.state === 'draft'" class="text-red-500">Entwurf</span>
</template>
<template #started_at-data="{ row }">
{{ useNuxtApp().$dayjs(row.started_at).format('DD.MM.YY HH:mm') }}
</template>
<template #stopped_at-data="{ row }">
<span v-if="row.stopped_at">
{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}
</span>
<span v-else class="text-primary-500 font-medium">läuft...</span>
</template>
<template #duration_minutes-data="{ row }">
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : '-' }}
</template>
<template #user_name-data="{ row }">
{{ row.user_id ? users.find(i => i.user_id === row.user_id).full_name : '-' }}
</template>
<template #actions-data="{ row }">
<UTooltip
text="Zeit genehmigen"
v-if="row.state === 'submitted'"
<!-- 📜 SCROLLABLE CONTENT -->
<UDashboardPanelContent class="flex-1 overflow-y-auto p-3 space-y-4 pb-24">
<!-- ZEIT-CARDS -->
<UCard
v-for="row in entries"
:key="row.id"
class="p-4 border rounded-xl active:scale-[0.98] transition cursor-pointer"
@click="handleEdit(row)"
>
<UButton
variant="ghost"
icon="i-heroicons-check-circle"
@click="handleApprove(row)"
<div class="flex justify-between items-center">
<p class="font-semibold">
{{ row.description || 'Keine Beschreibung' }}
</p>
/>
</UTooltip>
<UTooltip
text="Zeit einreichen"
v-if="row.state === 'draft'"
>
<UButton
variant="ghost"
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="handleSubmit(row)"
<UBadge
:color="{
approved: 'primary',
submitted: 'cyan',
draft: 'red'
}[row.state]"
>
{{
{
approved: 'Genehmigt',
submitted: 'Eingereicht',
draft: 'Entwurf'
}[row.state] || row.state
}}
</UBadge>
/>
</UTooltip>
<UTooltip
text="Zeit bearbeiten"
v-if="row.state === 'draft'"
>
<UButton
variant="ghost"
icon="i-heroicons-pencil-square"
@click="handleEdit(row)"
/>
</UTooltip>
</div>
<p class="text-sm text-gray-500 mt-1">
Start: {{ useNuxtApp().$dayjs(row.started_at).format('DD.MM.YY HH:mm') }}
</p>
</template>
</UTable>
</UDashboardPanelContent>
<p class="text-sm text-gray-500">
Ende:
<span v-if="row.stopped_at">
{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}
</span>
<span v-else class="text-primary-500 font-medium">läuft...</span>
</p>
<p class="text-sm text-gray-500">
Dauer:
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : '-' }}
</p>
<!-- ACTION-BUTTONS -->
<div class="flex gap-2 mt-3">
<UButton
v-if="row.state === 'draft'"
color="gray"
icon="i-heroicons-arrow-right-end-on-rectangle"
label="Einreichen"
variant="soft"
@click.stop="handleSubmit(row)"
/>
<!-- <UButton
v-if="row.state === 'submitted'"
color="primary"
icon="i-heroicons-check"
label="Genehmigen"
variant="soft"
@click.stop="handleApprove(row)"
/>-->
</div>
</UCard>
</UDashboardPanelContent>
<!-- FLOATING ACTION BUTTON -->
<FloatingActionButton
icon="i-heroicons-plus"
class="!fixed bottom-6 right-6 z-50"
color="primary"
@click="() => { editEntry = null; showModal = true }"
/>
</div>
</template>
<!-- MODAL -->
<StaffTimeEntryModal v-model="showModal" :entry="editEntry" @saved="load" />

View File

@@ -9,7 +9,7 @@ const api = useNuxtApp().$api
const type = route.params.type
const platform = await useCapacitor().getIsPhone() ? "mobile" : "default"
const platform = await useCapacitor().getIsNative() ? "mobile" : "default"
const dataType = dataStore.dataTypes[route.params.type]

View File

@@ -3,9 +3,12 @@ import {useTempStore} from "~/stores/temp.js";
import FloatingActionButton from "~/components/mobile/FloatingActionButton.vue";
import EntityTable from "~/components/EntityTable.vue";
import EntityTableMobile from "~/components/EntityTableMobile.vue";
import {setPageLayout} from "#app";
const { has } = usePermission()
const platformIsNative = useCapacitor().getIsNative()
defineShortcuts({
'/': () => {
//console.log(searchinput)
@@ -67,8 +70,31 @@ const sort = ref({
const columnsToFilter = ref({})
const showMobileFilter = ref(false)
//Functions
function resetMobileFilters() {
if (!itemsMeta.value?.distinctValues) return
Object.keys(itemsMeta.value.distinctValues).forEach(key => {
columnsToFilter.value[key] = [...itemsMeta.value.distinctValues[key]]
})
showMobileFilter.value = false
setupPage()
}
function applyMobileFilters() {
Object.keys(columnsToFilter.value).forEach(key => {
tempStore.modifyFilter(type, key, columnsToFilter.value[key])
})
showMobileFilter.value = false
setupPage()
}
const clearSearchString = () => {
tempStore.clearSearchString(type)
searchString.value = ''
@@ -77,13 +103,14 @@ const clearSearchString = () => {
const performSearch = async () => {
tempStore.modifySearchString(type,searchString)
changePage(1,true)
setupPage()
}
const changePage = (number) => {
const changePage = (number, noSetup = false) => {
page.value = number
tempStore.modifyPages(type, number)
setupPage()
if(!noSetup) setupPage()
}
@@ -99,10 +126,23 @@ const changeSort = (column) => {
changePage(1)
}
const isFiltered = computed(() => {
if (!itemsMeta.value?.distinctValues) return false
return Object.keys(columnsToFilter.value).some(key => {
const allValues = itemsMeta.value.distinctValues[key]
const selected = columnsToFilter.value[key]
if (!allValues || !selected) return false
return selected.length !== allValues.length
})
})
//SETUP
const setupPage = async () => {
loading.value = true
setPageLayout(platformIsNative ? "mobile" : "default")
const filters = {
archived:false
@@ -160,17 +200,11 @@ const handleFilterChange = async (action,column) => {
</script>
<template>
<!-- <FloatingActionButton
:label="`+ ${dataType.labelSingle}`"
variant="outline"
v-if="platform === 'mobile'"
@click="router.push(`/standardEntity/${type}/create`)"
/>-->
<UDashboardNavbar :title="dataType.label" :badge="itemsMeta.total">
<template #toggle>
<div v-if="platform === 'mobile'"></div>
<div v-if="platformIsNative"></div>
</template>
<template #right>
<template #right v-if="!platformIsNative">
<UTooltip :text="`${dataType.label} durchsuchen`">
<UInput
id="searchinput"
@@ -211,58 +245,17 @@ const handleFilterChange = async (action,column) => {
</UDashboardNavbar>
<UDashboardToolbar>
<UDashboardToolbar v-if="!platformIsNative">
<template #left>
<UTooltip :text="`${dataType.label} pro Seite`">
<USelectMenu
:options="[10,15,25,50,100,250]"
:options="[{value:10},{value:15, disabled: itemsMeta.total < 15},{value:25, disabled: itemsMeta.total < 25},{value:50, disabled: itemsMeta.total < 50},{value:100, disabled: itemsMeta.total < 100},{value:250, disabled: itemsMeta.total < 250}]"
v-model="pageLimit"
value-attribute="value"
option-attribute="value"
@change="setupPage"
/>
</UTooltip>
<!-- <UTooltip text="Erste Seite">
<UButton
variant="outline"
@click="changePage(1)"
icon="i-heroicons-chevron-double-left"
:disabled="page <= 1"
/>
</UTooltip>
<UTooltip text="Eine Seite nach vorne">
<UButton
variant="outline"
@click="changePage(page-1)"
icon="i-heroicons-chevron-left"
:disabled="page <= 1"
/>
</UTooltip>
<UTooltip
v-for="pageNumber in itemsMeta.totalPages"
:text="`Zu Seite ${pageNumber} wechseln`"
>
<UButton
:variant="page === pageNumber ? 'solid' : 'outline'"
@click="changePage(pageNumber)"
>
{{pageNumber}}
</UButton>
</UTooltip>
<UTooltip text="Eine Seite nach hinten">
<UButton
variant="outline"
@click="changePage(page+1)"
icon="i-heroicons-chevron-right"
:disabled="page >= itemsMeta.totalPages"
/>
</UTooltip>
<UTooltip text="Letzte Seite">
<UButton
variant="outline"
@click="changePage(itemsMeta.totalPages)"
icon="i-heroicons-chevron-double-right"
:disabled="page >= itemsMeta.totalPages"
/>
</UTooltip>-->
<UPagination
v-if="initialSetupDone && items.length > 0"
:disabled="loading"
@@ -299,95 +292,96 @@ const handleFilterChange = async (action,column) => {
</template>
</UDashboardToolbar>
<UTable
:loading="loading"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
sort-mode="manual"
v-model:sort="sort"
@update:sort="setupPage"
v-if="dataType && columns && items.length > 0 && !loading"
:rows="items"
:columns="columns"
class="w-full"
style="height: 85dvh"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/standardEntity/${type}/show/${i.id}`) "
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }"
>
<template
v-for="column in dataType.templateColumns.filter(i => !i.disabledInTable)"
v-slot:[`${column.key}-header`]="{row}">
<InputGroup>
<UTooltip v-if="column.sortable">
<div v-if="!platformIsNative">
<UTable
:loading="loading"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
sort-mode="manual"
v-model:sort="sort"
@update:sort="setupPage"
v-if="dataType && columns && items.length > 0 && !loading"
:rows="items"
:columns="columns"
class="w-full"
style="height: 85dvh"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/standardEntity/${type}/show/${i.id}`) "
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }"
>
<template
v-for="column in dataType.templateColumns.filter(i => !i.disabledInTable)"
v-slot:[`${column.key}-header`]="{row}">
<InputGroup>
<UTooltip v-if="column.sortable">
<UButton
variant="outline"
@click="changeSort(column.key)"
:color="sort.column === column.key ? 'primary' : 'white'"
:icon="sort.column === column.key ? (sort.direction === 'asc' ? 'i-heroicons-arrow-up' : 'i-heroicons-arrow-down') : 'i-heroicons-arrows-up-down'"
>
</UButton>
</UTooltip>
<UTooltip
v-if="column.distinct"
:text="!columnsToFilter[column.key]?.length > 0 ? `Keine Einträge für ${column.label} verfügbar` : `${column.label} Spalte nach Einträgen filtern`"
>
<USelectMenu
:options="itemsMeta?.distinctValues?.[column.key]"
v-model="columnsToFilter[column.key]"
multiple
@change="handleFilterChange('change', column.key)"
searchable
searchable-placeholder="Suche..."
:search-attributes="[column.key]"
:ui-menu="{ width: 'min-w-max' }"
clear-search-on-close
>
<template #empty>
Keine Einträge in der Spalte {{column.label}}
</template>
<template #default="{open}">
<UButton
:disabled="!columnsToFilter[column.key]?.length > 0"
:variant="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'outline' : 'solid'"
:color="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'primary' : 'white'"
>
<span class="truncate">{{ column.label }}</span>
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-5 h-5 transition-transform text-gray-400 dark:text-gray-500" :class="[open && 'transform rotate-90']" />
</UButton>
</template>
</USelectMenu>
</UTooltip>
<UButton
variant="outline"
@click="changeSort(column.key)"
:color="sort.column === column.key ? 'primary' : 'white'"
:icon="sort.column === column.key ? (sort.direction === 'asc' ? 'i-heroicons-arrow-up' : 'i-heroicons-arrow-down') : 'i-heroicons-arrows-up-down'"
variant="solid"
color="white"
v-else
class="mr-2 truncate"
>{{column.label}}</UButton>
<UTooltip
text="Filter zurücksetzen"
v-if="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length && column.distinct"
>
</UButton>
</UTooltip>
<UTooltip
v-if="column.distinct"
:text="!columnsToFilter[column.key]?.length > 0 ? `Keine Einträge für ${column.label} verfügbar` : `${column.label} Spalte nach Einträgen filtern`"
>
<USelectMenu
:options="itemsMeta?.distinctValues?.[column.key]"
v-model="columnsToFilter[column.key]"
multiple
@change="handleFilterChange('change', column.key)"
searchable
searchable-placeholder="Suche..."
:search-attributes="[column.key]"
:ui-menu="{ width: 'min-w-max' }"
clear-search-on-close
>
<template #empty>
Keine Einträge in der Spalte {{column.label}}
</template>
<template #default="{open}">
<UButton
:disabled="!columnsToFilter[column.key]?.length > 0"
:variant="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'outline' : 'solid'"
:color="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'primary' : 'white'"
>
<span class="truncate">{{ column.label }}</span>
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-5 h-5 transition-transform text-gray-400 dark:text-gray-500" :class="[open && 'transform rotate-90']" />
</UButton>
</template>
<UButton
@click="handleFilterChange('reset',column.key)"
variant="outline"
color="rose"
>
X
</UButton>
</UTooltip>
</USelectMenu>
</UTooltip>
<UButton
variant="solid"
color="white"
v-else
class="mr-2 truncate"
>{{column.label}}</UButton>
</InputGroup>
<UTooltip
text="Filter zurücksetzen"
v-if="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length && column.distinct"
>
<UButton
@click="handleFilterChange('reset',column.key)"
variant="outline"
color="rose"
>
X
</UButton>
</UTooltip>
</InputGroup>
</template>
<template #name-data="{row}">
</template>
<template #name-data="{row}">
<span
v-if="row.id === items[selectedItem].id"
class="text-primary-500 font-bold">
@@ -396,61 +390,236 @@ const handleFilterChange = async (action,column) => {
>
{{dataType.templateColumns.find(i => i.key === "name").maxLength ? (row.name.length > dataType.templateColumns.find(i => i.key === "name").maxLength ? `${row.name.substring(0,dataType.templateColumns.find(i => i.key === "name").maxLength)}...` : row.name ) : row.name}}
</UTooltip> </span>
<span v-else>
<span v-else>
<UTooltip
:text="row.name"
>
{{dataType.templateColumns.find(i => i.key === "name").maxLength ? (row.name.length > dataType.templateColumns.find(i => i.key === "name").maxLength ? `${row.name.substring(0,dataType.templateColumns.find(i => i.key === "name").maxLength)}...` : row.name ) : row.name}}
</UTooltip>
</span>
</template>
<template #fullName-data="{row}">
</template>
<template #fullName-data="{row}">
<span
v-if="row.id === items[selectedItem].id"
class="text-primary-500 font-bold">{{row.fullName}}
</span>
<span v-else>
<span v-else>
{{row.fullName}}
</span>
</template>
<template #licensePlate-data="{row}">
</template>
<template #licensePlate-data="{row}">
<span
v-if="row.id === items[selectedItem].id"
class="text-primary-500 font-bold">{{row.licensePlate}}
</span>
<span v-else>
<span v-else>
{{row.licensePlate}}
</span>
</template>
<template
v-for="column in dataType.templateColumns.filter(i => i.key !== 'name' && i.key !== 'fullName' && i.key !== 'licensePlate' && !i.disabledInTable)"
v-slot:[`${column.key}-data`]="{row}">
<component v-if="column.component" :is="column.component" :row="row"></component>
<span v-else-if="row[column.key]">
</template>
<template
v-for="column in dataType.templateColumns.filter(i => i.key !== 'name' && i.key !== 'fullName' && i.key !== 'licensePlate' && !i.disabledInTable)"
v-slot:[`${column.key}-data`]="{row}">
<component v-if="column.component" :is="column.component" :row="row"></component>
<span v-else-if="row[column.key]">
<UTooltip :text="row[column.key]">
{{row[column.key] ? `${column.maxLength ? (row[column.key].length > column.maxLength ? `${row[column.key].substring(0,column.maxLength)}...` : row[column.key]) : row[column.key]} ${column.unit ? column.unit : ''}`: ''}}
</UTooltip>
</span>
</template>
</UTable>
<UCard
class="w-1/3 mx-auto mt-10"
v-else-if="!loading"
>
<div
class="flex flex-col text-center"
</template>
</UTable>
<UCard
class="w-1/3 mx-auto mt-10"
v-else-if="!loading"
>
<UIcon
class="mx-auto w-10 h-10 mb-5"
name="i-heroicons-circle-stack-20-solid"/>
<span class="font-bold">Keine {{dataType.label}} anzuzeigen</span>
<div
class="flex flex-col text-center"
>
<UIcon
class="mx-auto w-10 h-10 mb-5"
name="i-heroicons-circle-stack-20-solid"/>
<span class="font-bold">Keine {{dataType.label}} anzuzeigen</span>
</div>
</UCard>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto mt-5"></UProgress>
</div>
<div v-else class="relative flex flex-col h-[calc(100dvh-80px)]">
<!-- Mobile Searchbar (sticky top) -->
<div class="p-2 bg-white dark:bg-gray-900 border-b sticky top-0 z-20">
<InputGroup>
<UInput
v-model="searchString"
icon="i-heroicons-magnifying-glass"
placeholder="Suche..."
@keyup="performSearch"
@change="performSearch"
class="w-full"
/>
<UButton
v-if="searchString.length > 0"
icon="i-heroicons-x-mark"
variant="ghost"
color="rose"
@click="clearSearchString()"
/>
<UButton
icon="i-heroicons-funnel"
variant="ghost"
:color="isFiltered ? 'primary' : 'gray'"
@click="showMobileFilter = true"
/>
</InputGroup>
</div>
<!-- Scroll Area -->
<UDashboardPanelContent class="flex-1 overflow-y-auto px-2 py-3 space-y-3 pb-[calc(8vh+env(safe-area-inset-bottom))] mobile-scroll-area">
</UCard>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto mt-5"></UProgress>
<UCard
v-for="item in items"
:key="item.id"
class="p-4 rounded-xl shadow-sm border cursor-pointer active:scale-[0.98] transition"
@click="router.push(`/standardEntity/${type}/show/${item.id}`)"
>
<div class="flex items-center justify-between mb-1">
<p class="text-base font-semibold truncate text-primary-600">
{{
dataType.templateColumns.find(i => i.title)?.key
? item[dataType.templateColumns.find(i => i.title).key]
: null
}}
</p>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="text-gray-400 w-5 h-5 flex-shrink-0"
/>
</div>
<p class="text-sm text-gray-500 truncate">
{{ dataType.numberRangeHolder ? item[dataType.numberRangeHolder] : null }}
</p>
<div
v-for="secondInfo in dataType.templateColumns.filter(i => i.secondInfo)"
:key="secondInfo.key"
class="text-sm text-gray-400 truncate"
>
{{
(secondInfo.secondInfoKey && item[secondInfo.key])
? item[secondInfo.key][secondInfo.secondInfoKey]
: item[secondInfo.key]
}}
</div>
</UCard>
<div
v-if="!loading && items.length > 0"
class="p-4 bg-white dark:bg-gray-900 border-t flex items-center justify-center mt-4 rounded-xl"
>
<UPagination
v-if="initialSetupDone && items.length > 0"
:disabled="loading"
v-model="page"
:page-count="pageLimit"
:total="itemsMeta.total"
@update:modelValue="(i) => changePage(i)"
show-first
show-last
:first-button="{ icon: 'i-heroicons-chevron-double-left', color: 'gray' }"
:last-button="{ icon: 'i-heroicons-chevron-double-right', trailing: true, color: 'gray' }"
/>
</div>
<!-- Empty -->
<UCard
v-if="!loading && items.length === 0"
class="mx-auto mt-10 p-6 text-center"
>
<UIcon name="i-heroicons-circle-stack-20-solid" class="mx-auto w-10 h-10 mb-3"/>
<p class="font-bold">Keine {{ dataType.label }} gefunden</p>
</UCard>
<div v-if="loading" class="mt-5">
<UProgress animation="carousel" class="w-3/4 mx-auto"></UProgress>
</div>
</UDashboardPanelContent>
<!-- Mobile Filter Slideover -->
<USlideover
v-model="showMobileFilter"
side="bottom"
:ui="{ width: '100%', height: 'auto', maxHeight: '90vh' }"
class="pb-[env(safe-area-inset-bottom)]"
>
<!-- Header -->
<div class="p-4 border-b flex items-center justify-between flex-shrink-0">
<h2 class="text-xl font-bold">Filter</h2>
<UButton
icon="i-heroicons-x-mark"
variant="ghost"
@click="showMobileFilter = false"
/>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<div
v-for="column in dataType.templateColumns.filter(c => c.distinct)"
:key="column.key"
class="space-y-2"
>
<p class="font-semibold">{{ column.label }}</p>
<USelectMenu
v-model="columnsToFilter[column.key]"
:options="itemsMeta?.distinctValues?.[column.key]"
multiple
searchable
:search-attributes="[column.key]"
placeholder="Auswählen…"
:ui-menu="{ width: '100%' }"
/>
</div>
</div>
<!-- Footer FIXED in card -->
<div
class="
flex justify-between gap-3
px-4 py-4 border-t flex-shrink-0
bg-white dark:bg-gray-900
rounded-b-2xl
"
>
<UButton
color="rose"
variant="outline"
class="flex-1"
@click="resetMobileFilters"
>
Zurücksetzen
</UButton>
<UButton
color="primary"
class="flex-1"
@click="applyMobileFilters"
>
Anwenden
</UButton>
</div>
</USlideover>
</div>
</template>
<style scoped>