Merge branch 'beta' into 'main'

2025.21.0

See merge request fedeo/software!40
This commit is contained in:
2025-12-08 16:14:48 +00:00
34 changed files with 1696 additions and 971 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

@@ -40,7 +40,8 @@ const links = computed(() => {
id: 'historyitems',
label: "Logbuch",
to: "/historyitems",
icon: "i-heroicons-book-open"
icon: "i-heroicons-book-open",
disabled: true
},
{
label: "Organisation",
@@ -52,7 +53,7 @@ const links = computed(() => {
to: "/standardEntity/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
... true ? [{
/*... true ? [{
label: "Plantafel",
to: "/calendar/timeline",
icon: "i-heroicons-calendar-days"
@@ -66,7 +67,7 @@ const links = computed(() => {
label: "Termine",
to: "/standardEntity/events",
icon: "i-heroicons-calendar-days"
}] : [],
}] : [],*/
/*{
label: "Dateien",
to: "/files",
@@ -83,10 +84,16 @@ const links = computed(() => {
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
},{
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
},{
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box"
icon: "i-heroicons-archive-box",
disabled: true
},
]
},
@@ -98,12 +105,14 @@ const links = computed(() => {
{
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right"
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
},
{
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope"
icon: "i-heroicons-envelope",
disabled: true
}/*, {
label: "Logbücher",
to: "/communication/historyItems",
@@ -145,7 +154,7 @@ const links = computed(() => {
... true ? [{
label: "Anwesenheiten",
to: "/staff/time",
icon: "i-heroicons-clock"
icon: "i-heroicons-clock",
}] : [],
/*... has("absencerequests") ? [{
label: "Abwesenheiten",
@@ -175,7 +184,7 @@ const links = computed(() => {
},{
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text"
icon: "i-heroicons-document-text",
},{
label: "Kostenstellen",
to: "/standardEntity/costcentres",
@@ -183,7 +192,7 @@ const links = computed(() => {
},{
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text"
icon: "i-heroicons-document-text",
},{
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
@@ -192,7 +201,7 @@ const links = computed(() => {
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text"
icon: "i-heroicons-document-text",
},
]
}],
@@ -285,11 +294,11 @@ const links = computed(() => {
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
},] : [],
... has("checks") ? [{
/*... has("checks") ? [{
label: "Überprüfungen",
to: "/standardEntity/checks",
icon: "i-heroicons-magnifying-glass"
},] : [],
},] : [],*/
{
label: "Einstellungen",
defaultOpen: false,
@@ -298,7 +307,7 @@ const links = computed(() => {
{
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list"
icon: "i-heroicons-clipboard-document-list",
},/*{
label: "Rollen",
to: "/roles",
@@ -306,15 +315,15 @@ const links = computed(() => {
},*/{
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope"
icon: "i-heroicons-envelope",
},{
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro"
icon: "i-heroicons-currency-euro",
},{
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list"
icon: "i-heroicons-clipboard-document-list",
},/*{
label: "Eigene Felder",
to: "/settings/ownfields",
@@ -322,15 +331,16 @@ const links = computed(() => {
},*/{
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office"
icon: "i-heroicons-building-office",
},{
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list"
icon: "i-heroicons-clipboard-document-list",
},{
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
icon: "i-heroicons-clipboard-document-list",
disabled: true
}
]
}

View File

@@ -17,6 +17,10 @@ const props = defineProps({
location: {
type: String,
},
noControls: {
type: Boolean,
default: false,
}
})
@@ -123,7 +127,18 @@ const handleKeyPress = (event) => {
const downloadControl = computed(() => vpvRef.value?.downloadControl)
const handleDownloadFile = async () => {
await useFiles().downloadFile(props.fileId)
if(props.fileId){
await useFiles().downloadFile(props.fileId)
} else if(props.uri){
const a = document.createElement("a");
a.href = props.uri;
a.download = "entwurf.pdf";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/*const downloadCtrl = unref(downloadControl)
if (!downloadCtrl) return
@@ -145,7 +160,7 @@ watch(downloadControl, (downloadCtrl) => {
</script>
<template>
<div class="flex flex-col gap-4 justify-self-center">
<div class="flex flex-col gap-4 justify-self-center" v-if="!noControls">
<div class="flex items-center gap-4 text-[#7862FF] bg-pale-blue border-[#D7D1FB] rounded-lg p-2 justify-center">
<!-- Zoom out button -->
@@ -168,6 +183,7 @@ watch(downloadControl, (downloadCtrl) => {
variant="outline"
></UButton>
<UButton
v-if="props.fileId || props.uri"
@click="handleDownloadFile"
variant="outline"
icon="i-heroicons-arrow-down-on-square"

View File

@@ -3,55 +3,75 @@ import dayjs from "dayjs";
const props = defineProps<{
modelValue: boolean;
entry?: null;
entry?: any | null;
users: any[];
canSelectUser: boolean;
defaultUserId: string;
}>();
const emit = defineEmits(["update:modelValue", "saved"]);
const { create, update } = useStaffTime();
// v-model für das Modal
const show = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
// 🧱 Lokale reactive Kopie, die beim Öffnen aus props.entry befüllt wird
const local = reactive<{
id?: string;
description: string;
started_at: string;
stopped_at: string | null;
type: string;
}>({
// 🌈 Typen
const typeOptions = [
{ label: "Arbeitszeit", value: "work" },
{ label: "Urlaub", value: "vacation" },
{ label: "Krankheit", value: "sick" },
{ label: "Feiertag", value: "holiday" },
];
// Lokaler State
const local = reactive({
id: "",
user_id: "", // 👈 Mitarbeiter
description: "",
started_at: dayjs().startOf("hour").format("YYYY-MM-DDTHH:mm"),
stopped_at: dayjs().add(1, "hour").format("YYYY-MM-DDTHH:mm"),
started_at: "",
stopped_at: "",
type: "work",
vacation_reason: "",
sick_reason: "",
});
// 📡 Wenn das Modal geöffnet wird, Entry-Daten übernehmen
// 📡 ENTRY —> LOCAL
watch(
() => props.entry,
(val) => {
if (val) {
Object.assign(local, {
id: val.id,
user_id: val.user_id, // 👈 Mitarbeiter vorbelegen
description: val.description || "",
started_at: dayjs(val.started_at).format("YYYY-MM-DDTHH:mm"),
stopped_at: val.stopped_at
? dayjs(val.stopped_at).format("YYYY-MM-DDTHH:mm")
: dayjs(val.started_at).add(1, "hour").format("YYYY-MM-DDTHH:mm"),
type: val.type || "work",
started_at:
val.type === "vacation"
? dayjs(val.started_at).format("YYYY-MM-DD")
: dayjs(val.started_at).format("YYYY-MM-DDTHH:mm"),
stopped_at:
val.type === "vacation"
? dayjs(val.stopped_at).format("YYYY-MM-DD")
: dayjs(val.stopped_at).format("YYYY-MM-DDTHH:mm"),
vacation_reason: val.vacation_reason || "",
sick_reason: val.sick_reason || "",
});
} else {
Object.assign(local, {
id: "",
user_id: props.defaultUserId, // 👈 Neuer Eintrag → aktueller Nutzer
description: "",
started_at: dayjs().startOf("hour").format("YYYY-MM-DDTHH:mm"),
stopped_at: dayjs().add(1, "hour").format("YYYY-MM-DDTHH:mm"),
type: "work",
started_at: dayjs().format("YYYY-MM-DDTHH:mm"),
stopped_at: dayjs().add(1, "hour").format("YYYY-MM-DDTHH:mm"),
vacation_reason: "",
sick_reason: "",
});
}
},
@@ -63,13 +83,27 @@ const loading = ref(false);
async function handleSubmit() {
loading.value = true;
try {
const payload = {
description: local.description,
started_at: dayjs(local.started_at).toISOString(),
stopped_at: local.stopped_at ? dayjs(local.stopped_at).toISOString() : null,
const payload: any = {
user_id: local.user_id, // 👈 immer senden
type: local.type,
};
if (local.type === "vacation") {
payload.started_at = dayjs(local.started_at).startOf("day").toISOString();
payload.stopped_at = dayjs(local.stopped_at).endOf("day").toISOString();
payload.vacation_reason = local.vacation_reason;
} else {
payload.started_at = dayjs(local.started_at).toISOString();
payload.stopped_at = local.stopped_at
? dayjs(local.stopped_at).toISOString()
: null;
payload.description = local.description;
if (local.type === "sick") {
payload.sick_reason = local.sick_reason;
}
}
if (local.id) {
await update(local.id, payload);
} else {
@@ -84,6 +118,7 @@ async function handleSubmit() {
}
</script>
<template>
<UModal v-model="show" :ui="{ width: 'w-full sm:max-w-md' }" :key="local.id || 'new'">
<UCard>
@@ -94,18 +129,72 @@ async function handleSubmit() {
</template>
<UForm @submit.prevent="handleSubmit" class="space-y-4">
<UFormGroup label="Beschreibung" name="description">
<UInput v-model="local.description" placeholder="Was wurde gemacht?" />
<!-- 👥 Mitarbeiter-Auswahl -->
<UFormGroup label="Mitarbeiter" v-if="props.canSelectUser">
<USelectMenu
v-model="local.user_id"
:options="props.users.map(u => ({
label: u.full_name || u.email,
value: u.user_id
}))"
placeholder="Mitarbeiter wählen"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UFormGroup label="Startzeit" name="started_at">
<UInput v-model="local.started_at" type="datetime-local" />
<!-- TYPE -->
<UFormGroup label="Typ">
<USelect v-model="local.type" :options="typeOptions" />
</UFormGroup>
<UFormGroup label="Endzeit" name="stopped_at">
<UInput v-model="local.stopped_at" type="datetime-local" />
</UFormGroup>
<!-- VACATION -->
<template v-if="local.type === 'vacation'">
<UFormGroup label="Urlaubsgrund">
<UInput v-model="local.vacation_reason" />
</UFormGroup>
<UFormGroup label="Start (Tag)">
<UInput v-model="local.started_at" type="date" />
</UFormGroup>
<UFormGroup label="Ende (Tag)">
<UInput v-model="local.stopped_at" type="date" />
</UFormGroup>
</template>
<!-- SICK -->
<template v-else-if="local.type === 'sick'">
<UFormGroup label="Krankheitsgrund">
<UInput v-model="local.sick_reason" />
</UFormGroup>
<UFormGroup label="Start (Tag)">
<UInput v-model="local.started_at" type="date" />
</UFormGroup>
<UFormGroup label="Ende (Tag)">
<UInput v-model="local.stopped_at" type="date" />
</UFormGroup>
</template>
<!-- WORK / OTHER -->
<template v-else>
<UFormGroup label="Beschreibung">
<UInput v-model="local.description" placeholder="Was wurde gemacht?" />
</UFormGroup>
<UFormGroup label="Startzeit">
<UInput v-model="local.started_at" type="datetime-local" />
</UFormGroup>
<UFormGroup label="Endzeit">
<UInput v-model="local.stopped_at" type="datetime-local" />
</UFormGroup>
</template>
<!-- ACTIONS -->
<div class="flex justify-end gap-2 mt-4">
<UButton color="gray" label="Abbrechen" @click="show = false" />
<UButton color="primary" :loading="loading" type="submit" label="Speichern" />
@@ -114,3 +203,4 @@ async function handleSubmit() {
</UCard>
</UModal>
</template>

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

@@ -138,7 +138,7 @@ export const useEntities = (
) => {
if (!idToEq) return null
const res = await useNuxtApp().$api(withInformation ? `/api/resource/${relation}/${idToEq}/${withInformation}` : `/api/resource/${relation}/${idToEq}`, {
const res = await useNuxtApp().$api(withInformation ? `/api/resource/${relation}/${idToEq}` : `/api/resource/${relation}/${idToEq}`, {
method: "GET",
params: { select }
})

View File

@@ -124,6 +124,19 @@ export const useFiles = () => {
}
const dataURLtoFile = (dataurl:string, filename:string) => {
let arr = dataurl.split(","),
//@ts-ignore
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[arr.length - 1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, {type: mime});
}
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile}
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile}
}

View File

@@ -5,4 +5,21 @@ export const useFormatDuration = (durationInMinutes:number,) => {
const mins = Math.floor(durationInMinutes % 60)
return `${String(hrs).padStart(2, "0")}:${String(mins).padStart(2, "0")}`
}
export const useFormatDurationDays = (start,end) => {
const startDate = useNuxtApp().$dayjs(start);
const endDate = useNuxtApp().$dayjs(end);
if(startDate.isBefore(endDate)){
// inkl. beider Tage → +1
const days = endDate.diff(startDate, "day") + 1;
return days + " Tag" + (days > 1 ? "e" : "");
} else {
const days = startDate.diff(endDate, "day") + 1;
return days + " Tag" + (days > 1 ? "e" : "");
}
}

View File

@@ -10,6 +10,7 @@ interface StaffTimeEntry {
export function useStaffTime() {
const { $api } = useNuxtApp()
const auth = useAuthStore()
@@ -46,9 +47,17 @@ export function useStaffTime() {
}
async function approve(id: string) {
return await $api<StaffTimeEntry>(`/api/staff/time/${id}`, {
const auth = useAuthStore()
const now = useNuxtApp().$dayjs().toISOString()
return await $api(`/api/staff/time/${id}`, {
method: 'PUT',
body: { state: 'approved' },
body: {
state: 'approved',
//@ts-ignore
approved_by: auth.user.id,
approved_at: now,
},
})
}

View File

@@ -60,5 +60,10 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
@@ -24,7 +25,15 @@ target 'App' do
end
post_install do |installer|
assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# iOS Deployment Target erzwingen
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
# Alle Warnungen auf inherited setzen, falls Pods Dinge überschreiben
config.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = '$(inherited)'
end
end
end
target 'OneSignalNotificationServiceExtension' do

View File

@@ -1,105 +0,0 @@
PODS:
- Capacitor (7.1.0):
- CapacitorCordova
- CapacitorCordova (7.1.0)
- CapacitorDevice (7.0.0):
- Capacitor
- CapacitorNetwork (7.0.0):
- Capacitor
- CapacitorPluginSafeArea (4.0.0):
- Capacitor
- CapacitorPreferences (6.0.3):
- Capacitor
- CordovaPluginsStatic (7.1.0):
- CapacitorCordova
- OneSignalXCFramework (= 5.2.10)
- OneSignalXCFramework (5.2.10):
- OneSignalXCFramework/OneSignalComplete (= 5.2.10)
- OneSignalXCFramework/OneSignal (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalExtension
- OneSignalXCFramework/OneSignalLiveActivities
- OneSignalXCFramework/OneSignalNotifications
- OneSignalXCFramework/OneSignalOSCore
- OneSignalXCFramework/OneSignalOutcomes
- OneSignalXCFramework/OneSignalUser
- OneSignalXCFramework/OneSignalComplete (5.2.10):
- OneSignalXCFramework/OneSignal
- OneSignalXCFramework/OneSignalInAppMessages
- OneSignalXCFramework/OneSignalLocation
- OneSignalXCFramework/OneSignalCore (5.2.10)
- OneSignalXCFramework/OneSignalExtension (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalOutcomes
- OneSignalXCFramework/OneSignalInAppMessages (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalNotifications
- OneSignalXCFramework/OneSignalOSCore
- OneSignalXCFramework/OneSignalOutcomes
- OneSignalXCFramework/OneSignalUser
- OneSignalXCFramework/OneSignalLiveActivities (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalOSCore
- OneSignalXCFramework/OneSignalUser
- OneSignalXCFramework/OneSignalLocation (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalNotifications
- OneSignalXCFramework/OneSignalOSCore
- OneSignalXCFramework/OneSignalUser
- OneSignalXCFramework/OneSignalNotifications (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalExtension
- OneSignalXCFramework/OneSignalOutcomes
- OneSignalXCFramework/OneSignalOSCore (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalOutcomes (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalUser (5.2.10):
- OneSignalXCFramework/OneSignalCore
- OneSignalXCFramework/OneSignalNotifications
- OneSignalXCFramework/OneSignalOSCore
- OneSignalXCFramework/OneSignalOutcomes
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
- "CapacitorNetwork (from `../../node_modules/@capacitor/network`)"
- CapacitorPluginSafeArea (from `../../node_modules/capacitor-plugin-safe-area`)
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- CordovaPluginsStatic (from `../capacitor-cordova-ios-plugins`)
- OneSignalXCFramework (< 6.0, >= 5.0)
SPEC REPOS:
trunk:
- OneSignalXCFramework
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorDevice:
:path: "../../node_modules/@capacitor/device"
CapacitorNetwork:
:path: "../../node_modules/@capacitor/network"
CapacitorPluginSafeArea:
:path: "../../node_modules/capacitor-plugin-safe-area"
CapacitorPreferences:
:path: "../../node_modules/@capacitor/preferences"
CordovaPluginsStatic:
:path: "../capacitor-cordova-ios-plugins"
SPEC CHECKSUMS:
Capacitor: bceb785fb78f5e81e4a9e37843bc1c24bd9c7194
CapacitorCordova: 866217f32c1d25b326c568a10ea3ed0c36b13e29
CapacitorDevice: 069faf433b3a99c3d5f0e500fbe634f60a8c6a84
CapacitorNetwork: 30c2e78a0ed32530656cb426c8ee6c2caec10dbf
CapacitorPluginSafeArea: 22031c3436269ca80fac90ec2c94bc7c1e59a81d
CapacitorPreferences: f3eadae2369ac3ab8e21743a2959145b0d1286a3
CordovaPluginsStatic: f722d4ff434f50099581e690d579b7c108f490e6
OneSignalXCFramework: 1a3b28dfbff23aabce585796d23c1bef37772774
PODFILE CHECKSUM: d76fcd3d35c3f8c3708303de70ef45a76cc6e2b5
COCOAPODS: 1.16.2

View File

@@ -1,79 +1,59 @@
<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";
import {useAuthStore} from "~/stores/auth.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 +171,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>
@@ -251,9 +235,9 @@ const footerLinks = [/*{
class="w-1/3 mx-auto my-10"
v-else
/>
<div v-if="!auth.activeTenant" class="w-full mx-auto text-center">
<div v-if="!auth.activeTenant && auth.tenants?.length > 0 " class="w-full mx-auto text-center">
<!-- Tenant Selection -->
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. Bitte wählen Sie ein Mandant.</h3>
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. <br>Bitte wählen Sie ein Mandant.</h3>
<div class="mx-auto w-5/6 flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
<span class="text-left">{{tenant.name}}</span>
<UButton
@@ -269,27 +253,23 @@ const footerLinks = [/*{
</div>
<div v-else>
<UProgress animation="carousel" class="w-3/4 mx-auto mt-10" />
<UProgress animation="carousel" class="w-3/4 mx-auto mt-10" />Test
{{auth.tenants}}
<UButton
variant="outline"
color="rose"
@click="auth.logout()"
>Abmelden</UButton>
</div>
</div>
</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,6 +1,9 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const auth = useAuthStore()
console.log(auth)
if (auth.loading) return
// Wenn nicht eingeloggt → auf /login (außer er will schon dahin)

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

@@ -20,6 +20,7 @@
},
"dependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/browser": "^7.0.2",
"@capacitor/core": "^7.0.0",
"@capacitor/device": "^7.0.0",
"@capacitor/ios": "^7.0.0",
@@ -66,7 +67,7 @@
"maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6",
"onesignal-cordova-plugin": "^5.2.11",
"onesignal-cordova-plugin": "^5.2.14",
"papaparse": "^5.4.1",
"pdf-lib": "^1.17.1",
"pinia": "^2.1.7",

View File

@@ -16,9 +16,15 @@ const router = useRouter()
const items = ref([])
const dataLoaded = ref(false)
const statementallocations = ref([])
const incominginvoices = ref([])
const setupPage = async () => {
items.value = await useEntities("accounts").selectSpecial()
statementallocations.value = (await useEntities("statementallocations").select("*, bs_id(*)"))
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
items.value = await Promise.all(items.value.map(async (i) => {
let renderedAllocationsTemp = await renderedAllocations(i.id)
let saldo = getSaldo(renderedAllocationsTemp)
@@ -37,22 +43,22 @@ const setupPage = async () => {
const renderedAllocations = async (account) => {
let statementallocations = (await useEntities("statementallocations").select("*, bs_id(*)")).filter(i => i.account === account)
let incominginvoices = (await useEntities("incominginvoices").select("*, vendor(*)")).filter(i => i.accounts.find(x => x.account === account))
let statementallocationslocal = statementallocations.value.filter(i => i.account === account)
let incominginvoiceslocal = incominginvoices.value.filter(i => i.accounts.find(x => x.account === account))
let tempstatementallocations = statementallocations.map(i => {
let tempstatementallocations = statementallocationslocal.map(i => {
return {
...i,
type: "statementallocation",
date: i.bs_id.date,
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : ''
date: i.bankstatement.date,
partner: i.bankstatement ? (i.bankstatement.debName ? i.bankstatement.debName : (i.bankstatement.credName ? i.bankstatement.credName : '')) : ''
}
})
let incominginvoicesallocations = []
incominginvoices.forEach(i => {
incominginvoiceslocal.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account === account).map(x => {
return {

View File

@@ -65,8 +65,8 @@ const setup = async () => {
console.log(openDocuments.value)
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bs_id === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.filter(i => i.statementallocations.find(x => x.bs_id === itemInfo.value.id))
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
console.log(allocatedDocuments.value)
console.log(allocatedIncomingInvoices.value)
openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i,false))
@@ -611,7 +611,7 @@ const archiveStatement = async () => {
variant="outline"
icon="i-heroicons-check"
:disabled="!accountToSave"
@click="saveAllocation({bs_id: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
/>
<UButton
@click="accountToSave = ''"
@@ -677,7 +677,7 @@ const archiveStatement = async () => {
variant="outline"
icon="i-heroicons-check"
:disabled="!ownAccountToSave"
@click="saveAllocation({bs_id: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave, description: allocationDescription })"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave, description: allocationDescription })"
/>
<UButton
@click="accountToSave = ''"
@@ -715,7 +715,7 @@ const archiveStatement = async () => {
variant="outline"
icon="i-heroicons-check"
:disabled="!customerAccountToSave"
@click="saveAllocation({bs_id: itemInfo.id, amount: manualAllocationSum, customer: customerAccountToSave, description: allocationDescription })"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, customer: customerAccountToSave, description: allocationDescription })"
/>
<UButton
@click="customerAccountToSave = ''"
@@ -753,7 +753,7 @@ const archiveStatement = async () => {
variant="outline"
icon="i-heroicons-check"
:disabled="!vendorAccountToSave"
@click="saveAllocation({bs_id: itemInfo.id, amount: manualAllocationSum, vendor: vendorAccountToSave, description: allocationDescription })"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, vendor: vendorAccountToSave, description: allocationDescription })"
/>
<UButton
@click="vendorAccountToSave = ''"
@@ -811,7 +811,7 @@ const archiveStatement = async () => {
variant="outline"
class="mr-3"
v-if="!itemInfo.statementallocations.find(i => i.cd_id === document.id)"
@click="saveAllocation({cd_id: document.id, bs_id: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
@click="saveAllocation({cd_id: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
/>
<UButton
@@ -836,7 +836,7 @@ const archiveStatement = async () => {
variant="outline"
class="mr-3"
v-if="!itemInfo.statementallocations.find(i => i.ii_id === item.id)"
@click="saveAllocation({ii_id: item.id, bs_id: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
@click="saveAllocation({ii_id: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
/>
<UButton
variant="outline"

View File

@@ -661,7 +661,7 @@ const findDocumentErrors = computed(() => {
if (itemInfo.value.rows.length === 0) {
errors.push({message: "Es sind keine Positionen angegeben", type: "breaking"})
} else {
itemInfo.value.rows.forEach(row => {
itemInfo.value.rows.forEach((row,index) => {
if (itemInfo.value.type !== "quotes" && row.optional) {
errors.push({
@@ -717,6 +717,10 @@ const findDocumentErrors = computed(() => {
}
}
if (index === itemInfo.value.rows.length - 1 && row.mode === "pagebreak") {
errors.push({message: `Die letze Position darf kein Seitenumbruch sein`, type: "breaking"})
}
})
}

View File

@@ -83,7 +83,9 @@
>
<template #type-data="{row}">
{{dataStore.documentTypesForCreation[row.type].labelSingle}}
<!--
<span v-if="row.type === 'cancellationInvoices'"> zu {{row.linkedDocument.documentNumber}}</span>
-->
</template>
<template #state-data="{row}">
<span
@@ -132,19 +134,25 @@
<span v-if="row.documentDate">{{row.documentDate ? dayjs(row.documentDate).format("DD.MM.YY") : ''}}</span>
</template>
<template #dueDate-data="{row}">
<!--
<span v-if="row.state === 'Gebucht' && row.paymentDays && ['invoices','advanceInvoices'].includes(row.type) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)" :class="dayjs(row.documentDate).add(row.paymentDays,'day').diff(dayjs()) <= 0 && !isPaid(row) ? ['text-rose-500'] : '' ">{{row.documentDate ? dayjs(row.documentDate).add(row.paymentDays,'day').format("DD.MM.YY") : ''}}</span>
-->
</template>
<template #paid-data="{row}">
<div v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)">
<!-- <div v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)">
<span v-if="useSum().getIsPaid(row,items)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</div>
</div>-->
</template>
<template #amount-data="{row}">
<!--
<span v-if="row.type !== 'deliveryNotes'">{{displayCurrency(useSum().getCreatedDocumentSum(row,items))}}</span>
-->
</template>
<template #amountOpen-data="{row}">
<!--
<span v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !useSum().getIsPaid(row,items) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id) ">{{displayCurrency(useSum().getCreatedDocumentSum(row, items) - row.statementallocations.reduce((n,{amount}) => n + amount, 0))}}</span>
-->
</template>
</UTable>
</div>

View File

@@ -32,6 +32,8 @@ const costcentres = ref([])
const vendors = ref([])
const accounts = ref([])
const mode = ref(route.params.mode)
const setup = async () => {
let filetype = (await useEntities("filetags").select()).find(i=> i.incomingDocumentType === "invoices").id
console.log(filetype)
@@ -43,6 +45,8 @@ const setup = async () => {
itemInfo.value = await useEntities("incominginvoices").selectSingle(route.params.id, "*, files(*)")
await loadFile(itemInfo.value.files[itemInfo.value.files.length-1].id)
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
}
setup()
@@ -143,7 +147,7 @@ const findIncomingInvoiceErrors = computed(() => {
let errors = []
if(itemInfo.value.vendor === null) errors.push({message: "Es ist kein Lieferant ausgewählt", type: "breaking"})
if(itemInfo.value.reference === null) errors.push({message: "Es ist keine Referenz angegeben", type: "breaking"})
if(itemInfo.value.reference === null || itemInfo.value.reference.length === 0) errors.push({message: "Es ist keine Referenz angegeben", type: "breaking"})
if(itemInfo.value.date === null) errors.push({message: "Es ist kein Datum ausgewählt", type: "breaking"})
if(itemInfo.value.dueDate === null) errors.push({message: "Es ist kein Fälligkeitsdatum ausgewählt", type: "breaking"})
if(itemInfo.value.paymentType === null) errors.push({message: "Es ist keine Zahlart ausgewählt", type: "breaking"})
@@ -168,21 +172,38 @@ const findIncomingInvoiceErrors = computed(() => {
</script>
<template>
<UDashboardNavbar :title="'Eingangsbeleg erstellen'">
<UDashboardNavbar>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
@click="navigateTo(`/incomingInvoices`)"
variant="outline"
>
Eingangsbelege
</UButton>
</template>
<template #center>
<h1
class="text-xl font-medium"
>{{`Eingangsbeleg ${mode === 'show' ? 'anzeigen' : 'bearbeiten'}`}}</h1>
</template>
<template #right>
<ArchiveButton
color="rose"
variant="outline"
type="incominginvoices"
@confirmed="useEntities('incominginvoices').archive(route.params.id)"
v-if="mode !== 'show'"
/>
<UButton
@click="updateIncomingInvoice(false)"
v-if="mode !== 'show'"
>
Speichern
</UButton>
<UButton
@click="updateIncomingInvoice(true)"
v-if="mode !== 'show'"
:disabled="findIncomingInvoiceErrors.filter(i => i.type === 'breaking').length > 0"
>
Speichern & Buchen
@@ -192,6 +213,7 @@ const findIncomingInvoiceErrors = computed(() => {
<UDashboardPanelContent>
<div
class="flex justify-between mt-5 workingContainer"
v-if="loadedFile"
>
<object
v-if="loadedFile"
@@ -200,7 +222,6 @@ const findIncomingInvoiceErrors = computed(() => {
class="mx-5 documentPreview"
/>
<div class="w-3/5 mx-5">
<UAlert
class="mb-5"
title="Vorhandene Probleme und Informationen:"
@@ -218,17 +239,19 @@ const findIncomingInvoiceErrors = computed(() => {
</UAlert>
<div class=" scrollContainer">
<div class="scrollContainer">
<InputGroup class="mb-3">
<UButton
:variant="itemInfo.expense ? 'solid' : 'outline'"
@click="itemInfo.expense = true"
:disabled="mode === 'show'"
>
Ausgabe
</UButton>
<UButton
:variant="!itemInfo.expense ? 'solid' : 'outline'"
@click="itemInfo.expense = false"
:disabled="mode === 'show'"
>
Einnahme
</UButton>
@@ -237,6 +260,7 @@ const findIncomingInvoiceErrors = computed(() => {
<UFormGroup label="Lieferant:" >
<InputGroup>
<USelectMenu
:disabled="mode === 'show'"
v-model="itemInfo.vendor"
:options="vendors"
option-attribute="name"
@@ -258,12 +282,15 @@ const findIncomingInvoiceErrors = computed(() => {
type="vendors"
:id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id"
:button-edit="mode !== 'show'"
:button-create="mode !== 'show'"
/>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="itemInfo.vendor = null"
v-if="itemInfo.vendor && mode !== 'show'"
/>
</InputGroup>
@@ -276,6 +303,7 @@ const findIncomingInvoiceErrors = computed(() => {
>
<UInput
v-model="itemInfo.reference"
:disabled="mode === 'show'"
/>
</UFormGroup>
@@ -287,6 +315,7 @@ const findIncomingInvoiceErrors = computed(() => {
:label="itemInfo.date ? dayjs(itemInfo.date).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:color="!itemInfo.date ? 'rose' : 'primary'"
:disabled="mode === 'show'"
/>
<template #panel="{ close }">
@@ -301,10 +330,11 @@ const findIncomingInvoiceErrors = computed(() => {
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.dueDate ? dayjs(itemInfo.dueDate).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="mode === 'show'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.dueDate" @close="close" />
<LazyDatePicker v-model="itemInfo.dueDate" @close="close"/>
</template>
</UPopover>
</UFormGroup>
@@ -316,12 +346,14 @@ const findIncomingInvoiceErrors = computed(() => {
<USelectMenu
:options="['Einzug','Kreditkarte','Überweisung','Sonstiges']"
v-model="itemInfo.paymentType"
:disabled="mode === 'show'"
/>
</UFormGroup>
<UFormGroup label="Beschreibung:" >
<UTextarea
v-model="itemInfo.description"
:disabled="mode === 'show'"
/>
</UFormGroup>
@@ -329,12 +361,14 @@ const findIncomingInvoiceErrors = computed(() => {
<UButton
:variant="!useNetMode ? 'solid' : 'outline'"
@click="changeNetMode(false)"
:disabled="mode === 'show'"
>
Brutto
</UButton>
<UButton
:variant="useNetMode ? 'solid' : 'outline'"
@click="changeNetMode(true)"
:disabled="mode === 'show'"
>
Netto
</UButton>
@@ -377,6 +411,7 @@ const findIncomingInvoiceErrors = computed(() => {
option-attribute="label"
value-attribute="id"
searchable
:disabled="mode === 'show'"
:search-attributes="['label']"
searchable-placeholder="Suche..."
v-model="item.account"
@@ -398,6 +433,7 @@ const findIncomingInvoiceErrors = computed(() => {
option-attribute="name"
value-attribute="id"
searchable
:disabled="mode === 'show'"
:search-attributes="['label']"
searchable-placeholder="Suche..."
v-model="item.costCentre"
@@ -417,7 +453,7 @@ const findIncomingInvoiceErrors = computed(() => {
<UButton
variant="outline"
color="rose"
v-if="item.costCentre"
v-if="item.costCentre && mode !== 'show'"
icon="i-heroicons-x-mark"
@click="item.costCentre = null"
/>
@@ -431,11 +467,12 @@ const findIncomingInvoiceErrors = computed(() => {
<UInput
v-model="item.description"
class="flex-auto"
:disabled="mode === 'show'"
></UInput>
<UButton
variant="outline"
color="rose"
v-if="item.description"
v-if="item.description && mode !== 'show'"
icon="i-heroicons-x-mark"
@click="item.description = null"
/>
@@ -457,7 +494,7 @@ const findIncomingInvoiceErrors = computed(() => {
step="0.01"
v-model="item.amountNet"
:color="!item.amountNet ? 'rose' : 'primary'"
:disabled="item.taxType === null"
:disabled="item.taxType === null || mode === 'show'"
@keyup="item.amountTax = Number((item.amountNet * (Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountGross = Number(item.amountNet) + NUmber(item.amountTax)"
>
@@ -476,7 +513,7 @@ const findIncomingInvoiceErrors = computed(() => {
<UInput
type="number"
step="0.01"
:disabled="item.taxType === null"
:disabled="item.taxType === null || mode === 'show'"
v-model="item.amountGross"
:color="!item.amountGross ? 'rose' : 'primary'"
:ui-menu="{ width: 'min-w-max' }"
@@ -496,6 +533,8 @@ const findIncomingInvoiceErrors = computed(() => {
>
<USelectMenu
:options="taxOptions"
:disabled="mode === 'show'"
:color="item.taxType === null || item.taxType === '0' ? 'rose' : 'primary'"
v-model="item.taxType"
value-attribute="key"
:ui-menu="{ width: 'min-w-max' }"
@@ -513,12 +552,13 @@ const findIncomingInvoiceErrors = computed(() => {
<UButton
class="mt-3"
v-if="mode !== 'show'"
@click="itemInfo.accounts = [...itemInfo.accounts.slice(0,index+1),{account:null, amountNet: null, amountTax:null, taxType: '19'} , ...itemInfo.accounts.slice(index+1)]"
>
Betrag aufteilen
</UButton>
<UButton
v-if="index !== 0"
v-if="index !== 0 && mode !== 'show'"
class="mt-3"
variant="ghost"
color="rose"
@@ -532,6 +572,7 @@ const findIncomingInvoiceErrors = computed(() => {
</div>
</div>
</div>
<UProgress v-else animation="carousel"/>
</UDashboardPanelContent>
@@ -549,8 +590,6 @@ const findIncomingInvoiceErrors = computed(() => {
.scrollContainer {
overflow-y: scroll;
padding-left: 1em;
padding-right: 1em;
height: 70vh;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */

View File

@@ -1,163 +0,0 @@
<script setup>
import dayjs from "dayjs";
const dataStore = useDataStore()
const profileStore = useProfileStore()
const route = useRoute()
const router = useRouter()
//Working
const mode = ref(route.params.mode || "show")
const itemInfo = ref({
vendor: 0,
expense: true,
reference: "",
date: null,
dueDate: null,
paymentType: "Überweisung",
description: "",
state: "Entwurf",
accounts: [
{
account: null,
amountNet: null,
amountTax: null,
taxType: "19",
costCentre: null
}
]
})
//Functions
const currentDocument = ref(null)
const loading = ref(true)
const setupPage = async () => {
if((mode.value === "show") && route.params.id){
itemInfo.value = await useEntities("incominginvoices").selectSingle(route.params.id,"*, files(*), vendor(*)")
if(process.dev) console.log(itemInfo.value)
currentDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
loading.value = false
}
const setState = async (newState) => {
let item = itemInfo.value
delete item.files
if(item.vendor.id) item.vendor = item.vendor.id
item.state = newState
await useEntities('incominginvoices').update(route.params.id,item)
await router.push("/incomingInvoices")
}
setupPage()
</script>
<template>
<UDashboardNavbar :title="'Eingangsbeleg anzeigen'">
<template #left>
<UButton
to="/incominginvoices"
icon="i-heroicons-chevron-left"
variant="outline"
>
Übersicht
</UButton>
</template>
<template #right>
<UButton
@click="router.push(`/incomingInvoices/edit/${itemInfo.id}`)"
v-if="itemInfo.state !== 'Gebucht'"
>
Bearbeiten
</UButton>
<UButton
@click="setState('Gebucht')"
v-if="itemInfo.state !== 'Gebucht'"
color="rose"
>
Status auf Gebucht
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent v-if="!loading">
<div
class="flex justify-between mt-5"
>
<object
v-if="currentDocument ? currentDocument.url : false"
:data="currentDocument.url + '#toolbar=0&navpanes=0&scrollbar=0&statusbar=0&messages=0&scrollbar=0'"
type="application/pdf"
class="mx-5 w-2/5 documentPreview"
/>
<div class="w-1/2 mx-5">
<UCard class="truncate mb-5">
<p>Status: {{itemInfo.state}}</p>
<p>Datum: {{dayjs(itemInfo.date).format('DD.MM.YYYY')}}</p>
<p>Fälligkeitsdatum: {{dayjs(itemInfo.dueDate).format('DD.MM.YYYY')}}</p>
<p v-if="itemInfo.vendor">Lieferant: <nuxt-link :to="`/standardEntity/vendors/show/${itemInfo.vendor.id}`">{{itemInfo.vendor.name}}</nuxt-link></p>
<p>Bezahlt: {{itemInfo.paid ? "Ja" : "Nein"}}</p>
<p>Beschreibung: {{itemInfo.description}}</p>
<!-- TODO: Buchungszeilen darstellen -->
</UCard>
<UCard class="scrollContainer">
<HistoryDisplay
type="incomingInvoice"
v-if="itemInfo"
:element-id="itemInfo.id"
render-headline
/>
</UCard>
</div>
</div>
</UDashboardPanelContent>
<UProgress class="mt-3 mx-3" v-else animation="carousel"/>
</template>
<style scoped>
.documentPreview {
aspect-ratio: 1 / 1.414;
}
.scrollContainer {
overflow-y: scroll;
padding-left: 1em;
padding-right: 1em;
height: 75vh;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollContainer::-webkit-scrollbar {
display: none;
}
.lineItemRow {
display: flex;
flex-direction: row;
}
</style>

View File

@@ -5,8 +5,9 @@ definePageMeta({
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
const platformIsNative = useCapacitor().getIsNative()
const doLogin = async (data:any) => {
@@ -14,10 +15,10 @@ const doLogin = async (data:any) => {
await auth.login(data.email, data.password)
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Einloggen erfolgreich"})
if(useCapacitor().getIsNative()) {
return navigateTo("/mobile")
if(platformIsNative) {
await router.push("/mobile")
} else {
return navigateTo("/")
await router.push("/")
}
} catch (err: any) {
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"rose"})
@@ -26,7 +27,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 +68,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

@@ -3,12 +3,14 @@ const { $dayjs } = useNuxtApp()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
// 🔹 State
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')
}
@@ -81,10 +84,32 @@ async function generateDocument() {
uri.value = await useFunctions().useCreatePDF({
full_name: profile.value.full_name,
employee_number: profile.value.employee_number ? profile.value.employee_number : "-",
...workingTimeInfo.value}, path, "timesheet")
showDocument.value = true
}
const fileSaved = ref(false)
async function saveFile() {
try {
let fileData = {
auth_profile: profile.value.id,
tenant: auth.activeTenant
}
let file = useFiles().dataURLtoFile(uri.value, `${profile.value.full_name}-${$dayjs(selectedStartDay.value).format("YYYY-MM-DD")}-${$dayjs(selectedEndDay.value).format("YYYY-MM-DD")}.pdf`)
await useFiles().uploadFiles(fileData, [file])
toast.add({title:"Auswertung erfolgreich gespeichert"})
fileSaved.value = true
} catch (error) {
toast.add({title:"Fehler beim Speichern der Auswertung", color: "rose"})
}
}
async function onTabChange(index: number) {
@@ -97,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',
@@ -128,101 +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>
</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"
@@ -231,5 +421,7 @@ changeRange()
</div>
</template>
</UTabs>
</UDashboardPanelContent>
</template>
</template>

View File

@@ -1,23 +1,48 @@
<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'))
const typeLabel = {
work: "Arbeitszeit",
vacation: "Urlaub",
sick: "Krankheit",
holiday: "Feiertag",
other: "Sonstiges"
}
const typeColor = {
work: "gray",
vacation: "yellow",
sick: "rose",
holiday: "blue",
other: "gray"
}
async function loadUsers() {
if (!canViewAll.value) return
// Beispiel: User aus Supabase holen
@@ -25,15 +50,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,161 +89,351 @@ 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: 'user', label: 'Mitarbeiter' },
{ key: 'type', label: 'Typ' },
{ 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 #type-data="{ row }">
<UBadge :color="typeColor[row.type] || 'gray'">
{{ typeLabel[row.type] || row.type }}
</UBadge>
</template>
<!-- START -->
<template #started_at-data="{ row }">
<!-- Urlaub / Krankheit nur Tag -->
<span v-if="row.type === 'vacation' || row.type === 'sick'">
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}
</span>
<!-- Arbeitszeit / andere Datum + Uhrzeit -->
<span v-else>
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}
</span>
</template>
<!-- ENDE -->
<template #stopped_at-data="{ row }">
<span v-if="!row.stopped_at" class="text-primary-500 font-medium">
läuft...
</span>
<!-- Urlaub / Krankheit nur Tag -->
<span v-else-if="row.type === 'vacation' || row.type === 'sick'">
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }}
</span>
<!-- Arbeitszeit / andere Datum + Uhrzeit -->
<span v-else>
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}
</span>
</template>
<template #duration_minutes-data="{ row }">
<!-- Urlaub / Krankheit Tage anzeigen -->
<span v-if="row.type === 'vacation' || row.type === 'sick'">
<!-- {{ useFormatDurationDays(row.startet_at, row.stopped_at) }}-->--
</span>
<!-- Arbeitszeit / andere Minutenformat -->
<span v-else>
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
</span>
</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>
<template #user-data="{ row }">
{{users.find(i => i.user_id === row.user_id) ? users.find(i => i.user_id === row.user_id).full_name : ""}}
</template>
<template #description-data="{ row }">
<span v-if="row.type === 'vacation'">{{row.vacation_reason}}</span>
<span v-else-if="row.type === 'sick'">{{row.sick_reason}}</span>
<span v-else>{{row.description}}</span>
</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">
<div class="font-semibold flex items-center gap-2">
<span>{{ row.description || 'Keine Beschreibung' }}</span>
/>
</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="typeColor[row.type]"
class="text-xs"
>
{{ typeLabel[row.type] }}
</UBadge>
</div>
/>
</UTooltip>
<UTooltip
text="Zeit bearbeiten"
v-if="row.state === 'draft'"
>
<UButton
variant="ghost"
icon="i-heroicons-pencil-square"
@click="handleEdit(row)"
/>
</UTooltip>
<UBadge
:color="{
approved: 'primary',
submitted: 'cyan',
draft: 'red'
}[row.state]"
>
{{
{
approved: 'Genehmigt',
submitted: 'Eingereicht',
draft: 'Entwurf'
}[row.state] || row.state
}}
</UBadge>
</div>
</template>
</UTable>
</UDashboardPanelContent>
<p class="text-sm text-gray-500 mt-1">
Start: {{ useNuxtApp().$dayjs(row.started_at).format('DD.MM.YY HH:mm') }}
</p>
<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" />
<StaffTimeEntryModal
v-model="showModal"
:entry="editEntry"
@saved="load"
:users="users"
:can-select-user="canViewAll"
:default-user-id="selectedUser"
/>
</template>

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>

View File

@@ -4,12 +4,12 @@ export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const api = $fetch.create({
baseURL: config.public.apiBase,/*"http://192.168.1.227:3100" "https://backend.fedeo.io"*/
baseURL: config.public.apiBase,
credentials: "include",
async onRequest({options}) {
// Token aus Cookie holen
let token: string | null | undefined = ""
if (await useCapacitor().getIsNative()) {
if (useCapacitor().getIsNative()) {
const {value} = await Preferences.get({key: 'token'});
token = value
} else {

View File

@@ -63,13 +63,18 @@ export const useAuthStore = defineStore("auth", {
},
async login(email: string, password: string) {
console.log("Auth login")
const { token } = await useNuxtApp().$api("/auth/login", {
method: "POST",
body: { email, password }
})
console.log(token)
await this.fetchMe(token)
try {
console.log("Auth login")
const { token } = await useNuxtApp().$api("/auth/login", {
method: "POST",
body: { email, password }
})
console.log("Token: " + token)
await this.fetchMe(token)
} catch (e) {
console.log("login error:" + e)
}
},
async logout() {
@@ -105,6 +110,7 @@ export const useAuthStore = defineStore("auth", {
jwt
}}
})
console.log(me)
this.user = me.user
this.permissions = me.permissions
this.tenants = me.tenants
@@ -143,7 +149,7 @@ export const useAuthStore = defineStore("auth", {
const {token} = res
if(await useCapacitor().getIsNative()) {
if(useCapacitor().getIsNative()) {
await Preferences.set({
key:"token",
value: token,

View File

@@ -950,7 +950,7 @@ export const useDataStore = defineStore('data', () => {
selectSearchAttributes: ['name'],
},
{
key: "purchasePrice",
key: "purchase_price",
label: "Einkaufspreis",
component: purchasePrice,
inputType: "number",
@@ -963,7 +963,7 @@ export const useDataStore = defineStore('data', () => {
}
}
},{
key: "markupPercentage",
key: "markup_percentage",
label: "Aufschlag",
inputType: "number",
inputTrailing: "%",
@@ -977,7 +977,7 @@ export const useDataStore = defineStore('data', () => {
}
}
},{
key: "sellingPrice",
key: "selling_price",
label: "Verkaufpreispreis",
required: true,
component: sellingPrice,
@@ -991,7 +991,7 @@ export const useDataStore = defineStore('data', () => {
}
}
},{
key: "taxPercentage",
key: "tax_percentage",
label: "Umsatzsteuer",
inputType: "select",
selectOptionAttribute: "label",
@@ -1194,7 +1194,7 @@ export const useDataStore = defineStore('data', () => {
inputType: "bool",
sortable: true
},{
key: 'licensePlate',
key: 'license_plate',
label: "Kennzeichen",
required: true,
inputType: "text",
@@ -1219,18 +1219,18 @@ export const useDataStore = defineStore('data', () => {
component: driver
},*/
{
key: "tankSize",
key: "tank_size",
label: "Tankvolumen",
unit: "L",
inputType: "number"
},
{
key: "buildYear",
key: "build_year",
label: "Baujahr",
inputType: "number"
},
{
key: "towingCapacity",
key: "towing_capacity",
label: "Anhängelast",
unit: "Kg",
inputType: "number",
@@ -1242,7 +1242,7 @@ export const useDataStore = defineStore('data', () => {
inputType: "text"
},
{
key: "powerInKW",
key: "power_in_kw",
label: "Leistung",
unit: "kW",
inputType: "number",
@@ -1459,7 +1459,7 @@ export const useDataStore = defineStore('data', () => {
sortable: true
},
{
key: 'spaceNumber',
key: 'space_number',
label: "Lagerplatznr.",
inputType: "text",
inputIsNumberRange: true,
@@ -1483,7 +1483,7 @@ export const useDataStore = defineStore('data', () => {
sortable: true
},
{
key: "parentSpace",
key: "parent_space",
label: "Übergeordneter Lagerplatz",
inputType: "select",
selectDataType: "spaces",
@@ -1492,21 +1492,21 @@ export const useDataStore = defineStore('data', () => {
inputColumn: "Allgemeines"
},
{
key: "infoData.streetNumber",
key: "info_data.streetNumber",
label: "Straße + Hausnummer",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "infoData.special",
key: "info_data.special",
label: "Adresszusatz",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "infoData.zip",
key: "info_data.zip",
label: "Postleitzahl",
inputType: "number",
disabledInTable: true,
@@ -1518,14 +1518,14 @@ export const useDataStore = defineStore('data', () => {
},
},
{
key: "infoData.city",
key: "info_data.city",
label: "Stadt",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "infoData.country",
key: "info_data.country",
label: "Land",
inputType: "select",
selectDataType: "countrys",
@@ -1574,6 +1574,12 @@ export const useDataStore = defineStore('data', () => {
}
}
}
],
templateColumns: [
{
key: "customer",
distinct: true
}
]
},
tickets: {
@@ -2192,8 +2198,8 @@ export const useDataStore = defineStore('data', () => {
label: "Fahrzeuge",
inputType: "select",
selectDataType: "vehicles",
selectOptionAttribute: "licensePlate",
selectSearchAttributes: ['licensePlate'],
selectOptionAttribute: "license_plate",
selectSearchAttributes: ['license_plate'],
selectMultiple: true,
component: vehiclesWithLoad,
},{