Added Frontend

This commit is contained in:
2026-01-06 12:09:31 +01:00
250 changed files with 29602 additions and 0 deletions

27
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
#PWA
sw.*

18
frontend/.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,18 @@
before_script:
- docker info
stages:
- build
build-web:
stage: build
tags:
- shell
- docker-daemon
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- echo $IMAGE_TAG
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
@bryntum:registry=https://npm.bryntum.com

1
frontend/.nuxtrc Normal file
View File

@@ -0,0 +1 @@
uiPro.license=2A7272BC-085A-40E1-982F-5E31261BB164

15
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
RUN mkdir -p /usr/src/nuxt-app
WORKDIR /usr/src/nuxt-app
COPY . .
RUN npm i
RUN npm run build
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
EXPOSE 3000
ENTRYPOINT ["node", ".output/server/index.mjs"]

BIN
frontend/RechteDoku.xlsx Normal file

Binary file not shown.

38
frontend/app.config.ts Normal file
View File

@@ -0,0 +1,38 @@
export default defineAppConfig({
ui: {
primary: 'green',
gray: 'slate',
tooltip: {
background: '!bg-background'
},
variables: {
dark: {
background: 'var(--color-gray-950)'
},
header: {
height: '5rem'
}
},
notifications: {
// Show toasts at the top right of the screen
position: 'bottom-0 left-0'
},
icons: {
/*dark: 'i-ph-moon-duotone',
light: 'i-ph-sun-duotone',
search: 'i-ph-magnifying-glass-duotone',
external: 'i-ph-arrow-up-right',
chevron: 'i-ph-caret-down',
hash: 'i-ph-hash-duotone'*/
},
header: {
wrapper: 'lg:mb-0 lg:border-0',
popover: {
links: {
active: 'dark:bg-gray-950/50',
inactive: 'dark:hover:bg-gray-950/50'
}
}
}
}
})

150
frontend/app.vue Normal file
View File

@@ -0,0 +1,150 @@
<script setup>
import * as Sentry from "@sentry/browser"
/*watch(viewport.breakpoint, (newBreakpoint, oldBreakpoint) => {
console.log('Breakpoint updated:', oldBreakpoint, '->', newBreakpoint)
})*/
const platform = ref('default')
const setup = async () => {
if(await useCapacitor().getIsPhone()) {
platform.value = "mobile"
}
const dev = process.dev
console.log(dev)
}
setup()
Sentry.init({
dsn: "https://62e62ff08e1a438591fe5eb4dd9de244@glitchtip.federspiel.software/3",
tracesSampleRate: 0.01,
});
useHead({
title:"FEDEO",
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover,maximum-scale=1.0, user-scalable=no' },
],
link: [
{ rel: 'icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang: 'de'
},
script: [
{
defer: true,
src: "/umami.js",
"data-website-id":"2a9782fa-2fdf-4434-981d-93592d39edef",
"data-host-url":"https://umami.federspiel.software"
}
]
})
useSeoMeta({
ogSiteName: 'FEDEO',
twitterCard: 'summary_large_image'
})
</script>
<template>
<div class="safearea">
<NuxtLayout>
<NuxtPage/>
</NuxtLayout>
<UNotifications :class="platform === 'mobile' ? ['mb-14'] : []"/>
<USlideovers />
<UModals/>
</div>
</template>
<style>
/* width */
::-webkit-scrollbar {
width: 3px;
height: 3px;
}
/* Track */
::-webkit-scrollbar-track {
background: rgba(0,0,0,0);
}
/* Handle */
::-webkit-scrollbar-thumb {
background: rgb(226,232,240);
border-radius: 5px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #69c350;
}
#logo img{
height: 15vh;
width: auto;
}
.documentList {
display: flex;
flex-direction: row;
flex-wrap: wrap;
overflow-y: scroll;
}
.scrollList {
overflow-y: scroll;
height: 85vh;
}
#page {
width: 98vw;
height: 95vh;
margin:1em;
}
.listItem {
padding: .1em;
border: 1px solid grey;
border-radius: 15px;
margin-top: 1em;
}
.listItem:hover {
border: 1px solid #69c350;
}
a:hover {
color: #69c350
}
.safearea {
margin-top: env(safe-area-inset-top, 10px) !important;
margin-left: env(safe-area-inset-left, 5px) !important;
margin-right: env(safe-area-inset-right, 5px) !important;
margin-bottom: env(safe-area-inset-bottom, 37px) !important;
/*background-color: grey;*/
}
.scroll {
overflow-y: scroll;
}
</style>

BIN
frontend/asssets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
frontend/asssets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
frontend/asssets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,16 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'software.federspiel.fedeo',
appName: 'FEDEO',
webDir: 'dist',
ios: {
handleApplicationNotifications: false
},
/*server: {
url: "http://192.168.1.226:3000",
cleartext: true
}*/
};
export default config;

View File

@@ -0,0 +1,74 @@
<script setup>
const emit = defineEmits(['confirmed'])
const props = defineProps({
color: {
type: String,
required:false
},
variant: {
type: String,
required:false
},
type: {
type: String,
required:false
}
})
const {color,variant, type} = props
const dataStore = useDataStore()
const dataType = dataStore.dataTypes[type]
const showModal = ref(false)
const emitConfirm = () => {
showModal.value = false
emit('confirmed')
}
</script>
<template>
<UButton
:color="color"
:variant="variant"
@click="showModal = true"
>
Archivieren
</UButton>
<UModal v-model="showModal">
<UCard>
<template #header>
<span class="text-md font-bold">Archivieren bestätigen</span>
</template>
Möchten Sie diese/-s/-n {{dataType.labelSingle}} wirklich archivieren?
<template #footer>
<div class="text-right">
<UButtonGroup>
<UButton
variant="outline"
@click="showModal = false"
>
Abbrechen
</UButton>
<UButton
@click="emitConfirm"
class="ml-2"
color="rose"
>
Archivieren
</UButton>
</UButtonGroup>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,66 @@
<script setup>
const emit = defineEmits(['confirmed'])
const props = defineProps({
color: {
type: String,
required:false
},
variant: {
type: String,
required:false
}
})
const {color,variant} = props
const showModal = ref(false)
const emitConfirm = () => {
showModal.value = false
emit('confirmed')
}
</script>
<template>
<UButton
:color="color"
:variant="variant"
@click="showModal = true"
>
<slot name="button"></slot>
</UButton>
<UModal v-model="showModal">
<UCard>
<template #header>
<slot name="header"></slot>
</template>
<slot/>
<template #footer>
<div class="text-right">
<UButtonGroup>
<UButton
variant="outline"
@click="showModal = false"
>
Abbrechen
</UButton>
<UButton
@click="emitConfirm"
class="ml-2"
color="rose"
>
Archivieren
</UButton>
</UButtonGroup>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
import 'v-calendar/dist/style.css'
const props = defineProps({
modelValue: {
type: Date,
default: null
},
mode: {
type: String,
default: "date"
}
})
const emit = defineEmits(['update:model-value', 'close'])
const colorMode = useColorMode()
const isDark = computed(() => colorMode.value === 'dark')
const date = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:model-value', value)
emit('close')
}
})
const attrs = [{
key: 'today',
highlight: {
fillMode: 'outline',
},
dates: new Date()
}]
</script>
<template>
<VCalendarDatePicker
show-weeknumbers
v-model="date"
:mode="props.mode"
is24hr
transparent
borderless
color="green"
:attributes="attrs"
:is-dark="isDark"
title-position="left"
trim-weeks
:first-day-of-week="2"
/>
</template>

View File

@@ -0,0 +1,95 @@
<script setup>
import DocumentDisplayModal from "~/components/DocumentDisplayModal.vue";
const toast = useToast()
const dataStore = useDataStore()
const modal = useModal()
const profileStore = useProfileStore()
const router = useRouter()
const props = defineProps({
documentData: {
type: Object,
required: true
},
openShowModal: {
type: Boolean,
required: false,
},
returnEmit: {
type: Boolean
}
})
let {documentData, returnEmit } = props;
const showFile = (file) => {
console.log(file)
modal.open(DocumentDisplayModal,{
documentData: file
})
}
</script>
<template>
<div :id="`docDisplay-${documentData.id}`" class="documentListItem" @click="returnEmit ? $emit('clicked', documentData.id) : showFile(documentData)">
<iframe
:src="`${documentData.url}#toolbar=0&navpanes=0&scrollbar=0`"
class="previewEmbed"
v-if="documentData.path.toLowerCase().includes('pdf')"
loading="lazy"
/>
<img
v-else
alt=""
:src="documentData.url"
/>
<!-- TODO: Remove Scrollbar -->
<UTooltip class="w-full" :text="documentData.path.split('/')[documentData.path.split('/').length -1]">
<p class="truncate my-3">{{documentData.path.split("/")[documentData.path.split("/").length -1]}}</p>
</UTooltip>
<InputGroup class="mt-3 flex-wrap">
<UBadge
v-for="tag in documentData.filetags"
><span class="text-nowrap">{{ tag.name }}</span></UBadge>
</InputGroup>
</div>
</template>
<style scoped>
.documentListItem {
display: block;
width: 15vw;
aspect-ratio: 1 / 1.414;
padding: 1em;
margin: 0.7em;
border-radius: 15px;
transition: box-shadow 0.2s ease; /* für smooth hover */
}
.documentListItem:hover {
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); /* sanfter Shadow beim Hover */
}
.previewEmbed {
width: 100%;
aspect-ratio: 1 / 1.414;
overflow: hidden !important;
pointer-events: none !important;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.previewEmbed::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,365 @@
<script setup>
const toast = useToast()
const dataStore = useDataStore()
const modal = useModal()
const props = defineProps({
documentData: {
type: Object,
required: true
},
openShowModal: {
type: Boolean,
required: false,
},
returnEmit: {
type: Boolean
},
})
const emit = defineEmits(["updateNeeded"])
const folders = ref([])
const filetypes = ref([])
const documentboxes = ref([])
const setup = async () => {
const data = await useEntities("folders").select()
data.forEach(folder => {
let name = folder.name
const addParent = (item) => {
name = `${item.name} > ${name}`
if(item.parent){
addParent(data.find(i => i.id === item.parent))
} else {
folders.value.push({
id: folder.id,
name: name,
})
}
}
if(folder.parent) {
addParent(data.find(i => i.id === folder.parent))
} else {
folders.value.push({
id: folder.id,
name: folder.name,
})
}
})
filetypes.value = await useEntities("filetags").select()
documentboxes.value = await useEntities("documentboxes").select()
}
setup()
const updateDocument = async () => {
const {url, ...objData} = props.documentData
delete objData.url
delete objData.filetags
/*console.log(objData)
if(objData.project) objData.project = objData.project.id
if(objData.customer) objData.customer = objData.customer.id
if(objData.contract) objData.contract = objData.contract.id
if(objData.vendor) objData.vendor = objData.vendor.id
if(objData.plant) objData.plant = objData.plant.id
if(objData.createddocument) objData.createddocument = objData.createddocument.id
if(objData.vehicle) objData.vehicle = objData.vehicle.id
if(objData.product) objData.product = objData.product.id
if(objData.profile) objData.profile = objData.profile.id
if(objData.check) objData.check = objData.check.id
if(objData.inventoryitem) objData.inventoryitem = objData.inventoryitem.id
if(objData.incominginvoice) objData.incominginvoice = objData.incominginvoice.id*/
console.log(objData)
const {data,error} = await useEntities("files").update(objData.id, objData)
if(error) {
console.log(error)
} else {
console.log(data)
toast.add({title: "Datei aktualisiert"})
modal.close()
emit("updateNeeded")
//openShowModal.value = false
}
}
const archiveDocument = async () => {
props.documentData.archived = true
await updateDocument()
modal.close()
emit("update")
}
const resourceOptions = ref([
{label: 'Projekt', value: 'project', optionAttr: "name"},
{label: 'Kunde', value: 'customer', optionAttr: "name"},
{label: 'Lieferant', value: 'vendor', optionAttr: "name"},
{label: 'Fahrzeug', value: 'vehicle', optionAttr: "licensePlate"},
{label: 'Objekt', value: 'plant', optionAttr: "name"},
{label: 'Vertrag', value: 'contract', optionAttr: "name"},
{label: 'Produkt', value: 'product', optionAttr: "name"}
])
const resourceToAssign = ref("project")
const itemOptions = ref([])
const idToAssign = ref(null)
const getItemsBySelectedResource = async () => {
if(resourceToAssign.value === "project") {
itemOptions.value = await useEntities("projects").select()
} else if(resourceToAssign.value === "customer") {
itemOptions.value = await useEntities("customers").select()
} else if(resourceToAssign.value === "vendor") {
itemOptions.value = await useEntities("vendors").select()
} else if(resourceToAssign.value === "vehicle") {
itemOptions.value = await useEntities("vehicles").select()
} else if(resourceToAssign.value === "product") {
itemOptions.value = await useEntities("products").select()
} else if(resourceToAssign.value === "plant") {
itemOptions.value = await useEntities("plants").select()
} else if(resourceToAssign.value === "contract") {
itemOptions.value = await useEntities("contracts").select()
} else {
itemOptions.value = []
}
}
getItemsBySelectedResource()
const updateDocumentAssignment = async () => {
props.documentData[resourceToAssign.value] = idToAssign.value
await updateDocument()
}
const folderToMoveTo = ref(null)
const moveFile = async () => {
const res = await useEntities("files").update(props.documentData.id, {folder: folderToMoveTo.value})
modal.close()
}
</script>
<template>
<UModal fullscreen >
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full">
<template #header>
<div class="flex flex-row justify-between">
<div class="flex items-center gap-2">
<UBadge
v-for="tag in props.documentData.filetags"
>
{{tag.name}}
</UBadge>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
</div>
</template>
<div class="flex flex-row">
<div :class="useCapacitor().getIsNative() ? ['w-full'] : ['w-1/3']">
<PDFViewer
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
:file-id="props.documentData.id" />
<img
class=" w-full"
:src="props.documentData.url"
alt=""
v-else
/>
</div>
<div class="w-2/3 p-5" v-if="!useCapacitor().getIsNative()">
<UButtonGroup>
<ArchiveButton
color="rose"
variant="outline"
type="files"
@confirmed="archiveDocument"
/>
<UButton
:to="props.documentData.url"
variant="outline"
icon="i-heroicons-arrow-top-right-on-square"
target="_blank"
>
Öffnen
</UButton>
</UButtonGroup>
<UDivider>Zuweisungen</UDivider>
<table class="w-full">
<tr v-if="props.documentData.project">
<td>Projekt</td>
<td>
<nuxt-link :to="`/standardEntity/projects/show/${props.documentData.project.id}`">{{props.documentData.project.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.customer">
<td>Kunde</td>
<td>
<nuxt-link :to="`/standardEntity/customers/show/${props.documentData.customer.id}`">{{props.documentData.customer.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.vendor">
<td>Lieferant</td>
<td>
<nuxt-link :to="`/standardEntity/vendors/show/${props.documentData.vendor.id}`">{{props.documentData.vendor.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.createddocument">
<td>Ausgangsbeleg</td>
<td>
<nuxt-link :to="`/createDocument/show/${props.documentData.createddocument.id}`">{{props.documentData.createddocument.documentNumber}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.plant">
<td>Objekt</td>
<td>
<nuxt-link :to="`/standardEntity/plants/show/${props.documentData.plant.id}`">{{props.documentData.plant.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.contract">
<td>Vertrag</td>
<td>
<nuxt-link :to="`/standardEntity/contracts/show/${props.documentData.contract.id}`">{{props.documentData.contract.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.vehicle">
<td>Fahrzeug</td>
<td>
<nuxt-link :to="`/standardEntity/vehicles/show/${props.documentData.vehicle.id}`">{{props.documentData.vehicle.licensePlate}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.product">
<td>Artikel</td>
<td>
<nuxt-link :to="`/standardEntity/products/show/${props.documentData.product.id}`">{{props.documentData.product.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.inventoryitem">
<td>Inventarartikel</td>
<td>
<nuxt-link :to="`/standardEntity/inventoryitem/show/${props.documentData.inventoryitem.id}`">{{props.documentData.inventoryitem.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.check">
<td>Überprüfung</td>
<td>
<nuxt-link :to="`/standardEntity/checks/show/${props.documentData.check.id}`">{{props.documentData.check.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.profile">
<td>Mitarbeiter</td>
<td>
<nuxt-link :to="`/profiles/show/${props.documentData.profile.id}`">{{props.documentData.profile.fullName}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.incominginvoice">
<td>Eingangsrechnung</td>
<td>
<nuxt-link :to="`/incomingInvoices/show/${props.documentData.incominginvoice.id}`">{{props.documentData.incominginvoice.reference}}</nuxt-link>
</td>
</tr>
</table>
<UDivider class="my-3">Datei zuweisen</UDivider>
<UFormGroup
label="Resource auswählen"
>
<USelectMenu
:options="resourceOptions"
v-model="resourceToAssign"
value-attribute="value"
option-attribute="label"
@change="getItemsBySelectedResource"
>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Eintrag auswählen:"
>
<USelectMenu
:options="itemOptions"
v-model="idToAssign"
:option-attribute="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
value-attribute="id"
@change="updateDocumentAssignment"
></USelectMenu>
</UFormGroup>
<UDivider class="my-5">Datei verschieben</UDivider>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="folderToMoveTo"
value-attribute="id"
option-attribute="name"
:options="folders"
/>
<UButton
@click="moveFile"
variant="outline"
:disabled="!folderToMoveTo"
>Verschieben</UButton>
</InputGroup>
<UDivider class="my-5">Dateityp</UDivider>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="props.documentData.type"
value-attribute="id"
option-attribute="name"
:options="filetypes"
@change="updateDocument"
/>
</InputGroup>
<UDivider class="my-5">Dokumentenbox</UDivider>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="props.documentData.documentbox"
value-attribute="id"
option-attribute="key"
:options="documentboxes"
@change="updateDocument"
/>
</InputGroup>
</div>
</div>
</UCard>
</UModal>
</template>
<style scoped>
.bigPreview {
width: 100%;
aspect-ratio: 1/ 1.414;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
const props = defineProps({
documents: {
type: Array,
required:true
},
returnDocumentId: {
type: Boolean,
}
})
const dataStore = useDataStore()
const emit = defineEmits(["updateNeeded"])
</script>
<template>
<div class="documentList">
<DocumentDisplay
v-for="item in documents"
:document-data="item"
:key="item.id"
@clicked="(info) => $emit('selectDocument', info)"
:return-emit="returnDocumentId"
@updatedNeeded="emit('updatedNeeded')"
/>
</div>
</template>
<style scoped>
.documentList {
display: flex;
flex-direction: row;
flex-wrap: wrap;
overflow-y: scroll;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.documentList::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup >
import DocumentUploadModal from "~/components/DocumentUploadModal.vue";
const props = defineProps({
type: {
type: String
},
elementId: {
type: String
}
})
const {type, elementId} = props
const emit = defineEmits(["uploadFinished"])
const modal = useModal()
const openModal = () => {
let fileProps = {folder: null, type: null, typeEnabled: true}
fileProps[props.type] = props.elementId
console.log(fileProps)
modal.open(DocumentUploadModal,{fileData: fileProps, onUploadFinished: () => emit("uploadFinished")})
}
</script>
<template>
<UButton
@click="openModal"
icon="i-heroicons-arrow-up-tray"
>
Hochladen
</UButton>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,161 @@
<script setup>
// Falls useDropZone nicht auto-importiert wird:
// import { useDropZone } from '@vueuse/core'
const props = defineProps({
fileData: {
type: Object,
default: {
type: null
}
}
})
const emit = defineEmits(["uploadFinished"])
const modal = useModal()
// const profileStore = useProfileStore() // Wird im Snippet nicht genutzt, aber ich lasse es drin
const uploadInProgress = ref(false)
const availableFiletypes = ref([])
// 1. State für die Dateien und die Dropzone Referenz
const selectedFiles = ref([])
const dropZoneRef = ref(null)
// 2. Setup der Dropzone
const onDrop = (files) => {
// Wenn Dateien gedroppt werden, speichern wir sie
// files ist hier meist ein Array, wir stellen sicher, dass es passt
selectedFiles.value = files || []
}
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop,
// Verhindert, dass der Browser das Bild einfach öffnet
preventDefaultForDrop: true,
})
// 3. Handler für den klassischen Datei-Input Klick
const onFileInputChange = (e) => {
if (e.target.files) {
selectedFiles.value = Array.from(e.target.files)
}
}
const setup = async () => {
availableFiletypes.value = await useEntities("filetags").select()
}
setup()
const uploadFiles = async () => {
// Validierung: Keine Dateien ausgewählt
if (!selectedFiles.value || selectedFiles.value.length === 0) {
alert("Bitte wählen Sie zuerst Dateien aus.") // Oder eine schönere Toast Notification
return
}
uploadInProgress.value = true;
let fileData = props.fileData
delete fileData.typeEnabled
// 4. Hier nutzen wir nun selectedFiles.value statt document.getElementById
await useFiles().uploadFiles(fileData, selectedFiles.value, [], true)
uploadInProgress.value = false;
emit("uploadFinished")
modal.close()
}
// Helper Funktion um Dateinamen anzuzeigen (da das Input Feld leer bleibt beim Droppen)
const fileNames = computed(() => {
if (!selectedFiles.value.length) return ''
return selectedFiles.value.map(f => f.name).join(', ')
})
</script>
<template>
<UModal>
<div ref="dropZoneRef" class="relative h-full flex flex-col">
<div
v-if="isOverDropZone"
class="absolute inset-0 z-50 flex items-center justify-center bg-primary-500/10 border-2 border-primary-500 border-dashed rounded-lg backdrop-blur-sm transition-all"
>
<span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm">
Dateien hier ablegen
</span>
</div>
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Datei hochladen
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="modal.close()"
:disabled="uploadInProgress"
/>
</div>
</template>
<UFormGroup
label="Datei:"
:help="selectedFiles.length > 0 ? `${selectedFiles.length} Datei(en) ausgewählt` : 'Ziehen Sie Dateien hierher oder klicken Sie'"
>
<UInput
v-if="selectedFiles.length === 0"
type="file"
id="fileUploadInput"
multiple
accept="image/jpeg, image/png, image/gif, application/pdf"
@change="onFileInputChange"
/>
<div v-if="selectedFiles.length > 0" class="mt-2 text-sm text-gray-500">
Ausgewählt: <span class="font-medium text-gray-700 dark:text-gray-300">{{ fileNames }}</span>
</div>
</UFormGroup>
<UFormGroup
label="Typ:"
class="mt-3"
>
<USelectMenu
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Suchen..."
:options="availableFiletypes"
v-model="props.fileData.type"
:disabled="!props.fileData.typeEnabled"
>
<template #label>
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span>
<span v-else>Kein Typ ausgewählt</span>
</template>
</USelectMenu>
</UFormGroup>
<template #footer>
<UButton
@click="uploadFiles"
:loading="uploadInProgress"
:disabled="uploadInProgress || selectedFiles.length === 0"
>Hochladen</UButton>
</template>
</UCard>
</div>
</UModal>
</template>
<style scoped>
/* Optional: Animationen für das Overlay */
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
const editor = useEditor({
content: "<p>I'm running Tiptap with Vue.js. 🎉</p>",
extensions: [TiptapStarterKit],
});
</script>
<template>
<div>
<InputGroup>
<UButtonGroup>
<UButton
@click="editor.chain().focus().undo().run()"
:disabled="!editor.can().chain().focus().undo().run()"
icon="i-mdi-undo"
class="px-3"
/>
<UButton
@click="editor.chain().focus().redo().run()"
:disabled="!editor.can().chain().focus().redo().run()"
icon="i-mdi-redo"
class="px-3"
/>
</UButtonGroup>
<UButtonGroup v-if="editor">
<UButton
@click="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
:variant="editor.isActive('bold') ? 'solid' : 'outline'"
>
B
</UButton>
<UButton
@click="editor.chain().focus().toggleItalic().run()"
:disabled="!editor.can().chain().focus().toggleItalic().run()"
:variant="editor.isActive('italic') ? 'solid' : 'outline'"
>
<span class="italic">I</span>
</UButton>
<UButton
@click="editor.chain().focus().toggleStrike().run()"
:disabled="!editor.can().chain().focus().toggleStrike().run()"
:variant="editor.isActive('strike') ? 'solid' : 'outline'"
>
<span class="line-through">D</span>
</UButton>
</UButtonGroup>
<UButtonGroup>
<!-- <UButton
@click="editor.chain().focus().toggleCode().run()"
:disabled="!editor.can().chain().focus().toggleCode().run()"
:class="{ 'is-active': editor.isActive('code') }"
>
code
</UButton>
<UButton @click="editor.chain().focus().unsetAllMarks().run()">
clear marks
</UButton>
<UButton @click="editor.chain().focus().clearNodes().run()">
clear nodes
</UButton>
<UButton
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
>
<span>P</span>
</UButton>-->
<UButton
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
icon="i-mdi-format-header-1"
/>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
icon="i-mdi-format-header-2"
/>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
icon="i-mdi-format-header-3"
/>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
icon="i-mdi-format-header-4"
/>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
icon="i-mdi-format-header-5"
/>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
icon="i-mdi-format-header-6"
/>
<UButton
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
icon="i-mdi-format-list-bulleted"
/>
<UButton
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
icon="i-mdi-format-list-numbered"
/>
<!-- <UButton
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
>
code block
</UButton>
<UButton
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
>
blockquote
</UButton>
<UButton @click="editor.chain().focus().setHorizontalRule().run()">
horizontal rule
</UButton>
<UButton @click="editor.chain().focus().setHardBreak().run()">
hard break
</UButton>-->
</UButtonGroup>
</InputGroup>
<TiptapEditorContent class="mt-5" :editor="editor" />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,855 @@
<script setup>
import dayjs from "dayjs";
import MaterialComposing from "~/components/materialComposing.vue";
import PersonalComposing from "~/components/personalComposing.vue";
const props = defineProps({
type: {
required: true,
type: String
},
mode: {
required: true,
type: String
},
createQuery: {
type: Object
},
item: {
required: true,
type: Object
},
inModal: {
type: Boolean,
},
platform: {
type: String,
}
})
const emit = defineEmits(["returnData"])
const {type} = props
defineShortcuts({
'backspace': () => {
router.push(`/${type}`)
},
'arrowleft': () => {
if(openTab.value > 0){
openTab.value -= 1
}
},
'arrowright': () => {
if(openTab.value < 3) {
openTab.value += 1
}
},
})
const router = useRouter()
const route = useRoute()
const dataStore = useDataStore()
const modal = useModal()
const dataType = dataStore.dataTypes[type]
const openTab = ref(0)
const item = ref(JSON.parse(props.item))
console.log(item.value)
const oldItem = ref(null)
const generateOldItemData = () => {
oldItem.value = JSON.parse(props.item)
}
generateOldItemData()
const setupCreate = () => {
dataType.templateColumns.forEach(datapoint => {
if(datapoint.key.includes(".")){
!item.value[datapoint.key.split(".")[0]] ? item.value[datapoint.key.split(".")[0]] = {} : null
}
if(datapoint.inputType === "editor") {
if(datapoint.key.includes(".")){
item.value[datapoint.key.split(".")[0]][datapoint.key.split(".")[1]] = {}
} else {
item.value[datapoint.key] = {}
}
}
})
}
setupCreate()
const setupQuery = () => {
console.log("setupQuery")
console.log(props.mode)
if(props.mode === "create" && (route.query || props.createQuery)) {
let data = !props.inModal ? route.query : props.createQuery
Object.keys(data).forEach(key => {
if(dataType.templateColumns.find(i => i.key === key)) {
if (["customer", "contract", "plant", "contact", "project"].includes(key)) {
item.value[key] = Number(data[key])
} else {
item.value[key] = data[key]
}
} else if(key === "resources") {
/*item.value[key] = data[key]*/
JSON.parse(data[key]).forEach(async (i) => {
console.log(i)
let type = i.substring(0,1)
let id = i.substring(2,i.length)
console.log(type)
console.log(id)
let holder = ""
if(type === "P"){
holder = "profiles"
} else if(type === "F"){
holder = "vehicles"
id = Number(id)
} else if(type === "I"){
holder = "inventoryitems"
id = Number(id)
} else if(type === "G"){
holder = "inventoryitemgroups"
}
if(typeof item.value[holder] === "object") {
item.value[holder].push(id)
} else {
item.value[holder] = [id]
}
})
}
})
}
}
setupQuery()
const loadedOptions = ref({})
const loadOptions = async () => {
let optionsToLoad = dataType.templateColumns.filter(i => i.selectDataType).map(i => {
return {
option: i.selectDataType,
key: i.key
}
})
for await(const option of optionsToLoad) {
if(option.option === "countrys") {
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial()
} else if(option.option === "units") {
loadedOptions.value[option.option] = useEntities("units").selectSpecial()
} else {
loadedOptions.value[option.option] = (await useEntities(option.option).select())
if(dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter){
loadedOptions.value[option.option] = loadedOptions.value[option.option].filter(i => dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter(i, item))
}
}
}
}
loadOptions()
const contentChanged = (content, datapoint) => {
if(datapoint.key.includes(".")){
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json
} else {
item[datapoint.key].html = content.html
item[datapoint.key].text = content.text
item[datapoint.key].json = content.json
}
}
const saveAllowed = ref(false)
const calcSaveAllowed = (item) => {
let allowedCount = 0
dataType.templateColumns.filter(i => i.inputType).forEach(datapoint => {
if(datapoint.required) {
if(datapoint.key.includes(".")){
if(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]) allowedCount += 1
} else {
if(item[datapoint.key]) allowedCount += 1
}
} else {
allowedCount += 1
}
})
saveAllowed.value = allowedCount >= dataType.templateColumns.filter(i => i.inputType).length
}
//calcSaveAllowed()
watch(item.value, async (newItem, oldItem) => {
calcSaveAllowed(newItem)
})
const createItem = async () => {
let ret = null
if(props.inModal) {
ret = await useEntities(type).create(item.value, true)
} else {
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
}
emit('returnData', ret)
modal.close()
}
const updateItem = async () => {
let ret = null
if(props.inModal) {
ret = await useEntities(type).update(item.value.id, item.value, true)
emit('returnData', ret)
modal.close()
} else {
ret = await useEntities(type).update(item.value.id, item.value)
emit('returnData', ret)
}
}
</script>
<template>
<UDashboardNavbar
v-if="!props.inModal"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #toggle>
<div v-if="platform === 'mobile'"></div>
</template>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.back()/*router.push(`/standardEntity/${type}`)*/"
>
<!-- {{dataType.label}}-->
</UButton>
</template>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium', 'text-center']"
>{{item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
</template>
<template #right>
<ArchiveButton
color="rose"
v-if="platform !== 'mobile'"
variant="outline"
:type="type"
@confirmed="useEntities(type).archive(item.id)"
/>
<UButton
v-if="item.id"
@click="updateItem"
:disabled="!saveAllowed"
>
Speichern
</UButton>
<UButton
v-else
@click="createItem"
:disabled="!saveAllowed"
>
Erstellen
</UButton>
<UButton
@click="router.push(item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`)"
color="red"
class="ml-1"
>
Abbrechen
</UButton>
</template>
</UDashboardNavbar>
<UDashboardNavbar
v-else
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium']"
>{{item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
</template>
<template #right>
<UButton
v-if="item.id"
@click="updateItem"
:disabled="!saveAllowed"
>
Speichern
</UButton>
<UButton
v-else
@click="createItem"
:disabled="!saveAllowed"
>
Erstellen
</UButton>
<UButton
@click="modal.close()"
color="red"
class="ml-2"
icon="i-heroicons-x-mark"
variant="outline"
/>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<UForm
class="p-5"
>
<div :class="platform === 'mobile' ?['flex','flex-col'] : ['flex','flex-row']">
<div
v-for="(columnName,index) in dataType.inputColumns"
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
>
<UDivider>{{columnName}}</UDivider>
<!--
Die Form Group darf nur in der ersten bearbeitet werden und muss dann runterkopiert werden
-->
<div
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)"
>
<UFormGroup
v-if="(datapoint.showFunction ? datapoint.showFunction(item) : true)"
:label="datapoint.label"
>
<template #help>
<component
v-if="datapoint.helpComponent"
:is="datapoint.helpComponent"
:item="item"
/>
</template>
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
<UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:type="datapoint.inputType"
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
>
<template #trailing v-if="datapoint.inputTrailing">
<span class="text-gray-500 dark:text-gray-400 text-xs">{{datapoint.inputTrailing}}</span>
</template>
</UInput>
<UToggle
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<USelectMenu
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute"
:value-attribute="datapoint.selectValueAttribute || 'id'"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
:searchable="datapoint.selectSearchAttributes"
:search-attributes="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple"
>
<template #empty>
Keine Optionen verfügbar
</template>
</USelectMenu>
<UTextarea
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4"
/>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'">
<UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
</template>
</UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime"
/>
</template>
</UPopover>
<!-- TODO: DISABLED FOR TIPTAP -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
:preloadedContent="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html"
/>
<UButton
v-if="['text','number','select','date','datetime','textarea'].includes(datapoint.inputType)"
@click="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] = null"
variant="outline"
color="white"
icon="i-heroicons-x-mark"
/>
</InputGroup>
<InputGroup class="w-full" v-else>
<UInput
class="flex-auto"
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:type="datapoint.inputType"
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
>
<template #trailing v-if="datapoint.inputTrailing">
{{datapoint.inputTrailing}}
</template>
</UInput>
<UToggle
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<USelectMenu
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute"
:value-attribute="datapoint.selectValueAttribute || 'id'"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
:searchable="datapoint.selectSearchAttributes"
:search-attributes="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple"
searchable-placeholder="Suche..."
>
<template #empty>
Keine Optionen verfügbar
</template>
</USelectMenu>
<UTextarea
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4"
/>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'">
<UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]" @close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
</template>
</UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]"
@close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime"
/>
</template>
</UPopover>
<!-- TODO: Color/Required for TipTap and MaterialComposing -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
:preloadedContent="item[datapoint.key].html"
/>
<MaterialComposing
v-else-if="datapoint.inputType === 'materialComposing'"
:item="item"
/>
<PersonalComposing
v-else-if="datapoint.inputType === 'personalComposing'"
:item="item"
/>
<UButton
v-if="['text','number','select','date','datetime','textarea'].includes(datapoint.inputType)"
@click="item[datapoint.key] = null"
variant="outline"
color="white"
icon="i-heroicons-x-mark"
/>
</InputGroup>
<!-- <div
v-if="profileStore.ownTenant.ownFields"
>
<UDivider
class="mt-3"
>Eigene Felder</UDivider>
<UFormGroup
v-for="field in profileStore.ownTenant.ownFields.contracts"
:key="field.key"
:label="field.label"
>
<UInput
v-if="field.type === 'text'"
v-model="item.ownFields[field.key]"
/>
<USelectMenu
v-else-if="field.type === 'select'"
:options="field.options"
v-model="item.ownFields[field.key]"
/>
</UFormGroup>
</div>-->
</UFormGroup>
</div>
</div>
</div>
<UFormGroup
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && !i.inputColumn)"
:label="datapoint.label"
>
<template #help>
<component
v-if="datapoint.helpComponent"
:is="datapoint.helpComponent"
:item="item"
/>
</template>
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
<UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:type="datapoint.inputType"
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
>
<template #trailing v-if="datapoint.inputTrailing">
<span class="text-gray-500 dark:text-gray-400 text-xs">{{datapoint.inputTrailing}}</span>
</template>
</UInput>
<UToggle
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<USelectMenu
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute"
:value-attribute="datapoint.selectValueAttribute || 'id'"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
:searchable="datapoint.selectSearchAttributes"
:search-attributes="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple"
>
<template #empty>
Keine Optionen verfügbar
</template>
</USelectMenu>
<UTextarea
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4"
/>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'">
<UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
</template>
</UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime"
/>
</template>
</UPopover>
<!-- TODO: DISABLED FOR TIPTAP -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
:preloadedContent="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html"
/>
<UButton
v-if="['text','number','select','date','datetime','textarea'].includes(datapoint.inputType)"
@click="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] = null"
variant="outline"
color="white"
icon="i-heroicons-x-mark"
/>
</InputGroup>
<InputGroup class="w-full" v-else>
<UInput
class="flex-auto"
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:type="datapoint.inputType"
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
>
<template #trailing v-if="datapoint.inputTrailing">
{{datapoint.inputTrailing}}
</template>
</UInput>
<UToggle
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<USelectMenu
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute"
:value-attribute="datapoint.selectValueAttribute || 'id'"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
:searchable="datapoint.selectSearchAttributes"
:search-attributes="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple"
searchable-placeholder="Suche..."
>
<template #empty>
Keine Optionen verfügbar
</template>
</USelectMenu>
<UTextarea
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4"
/>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'">
<UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]" @close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
</template>
</UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<template #panel="{ close }">
<LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]"
@close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime"
/>
</template>
</UPopover>
<!-- TODO: Color/Required for TipTap and MaterialComposing -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
:preloadedContent="item[datapoint.key].html"
/>
<MaterialComposing
v-else-if="datapoint.inputType === 'materialComposing'"
:item="item"
/>
<PersonalComposing
v-else-if="datapoint.inputType === 'personalComposing'"
:item="item"
/>
<UButton
v-if="['text','number','select','date','datetime','textarea'].includes(datapoint.inputType)"
@click="item[datapoint.key] = null"
variant="outline"
color="white"
icon="i-heroicons-x-mark"
/>
</InputGroup>
<!-- <div
v-if="profileStore.ownTenant.ownFields"
>
<UDivider
class="mt-3"
>Eigene Felder</UDivider>
<UFormGroup
v-for="field in profileStore.ownTenant.ownFields.contracts"
:key="field.key"
:label="field.label"
>
<UInput
v-if="field.type === 'text'"
v-model="item.ownFields[field.key]"
/>
<USelectMenu
v-else-if="field.type === 'select'"
:options="field.options"
v-model="item.ownFields[field.key]"
/>
</UFormGroup>
</div>-->
</UFormGroup>
</UForm>
</UDashboardPanelContent>
</template>
<style scoped>
td {
border-bottom: 1px solid lightgrey;
vertical-align: top;
padding-bottom: 0.15em;
padding-top: 0.15em;
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup>
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";
const { has } = usePermission()
const props = defineProps({
type: {
required: true,
type: String
},
items: {
required: true,
type: Array
},
platform: {
required: true,
},
loading: {
required: true,
type: Boolean,
default: false
}
})
const emit = defineEmits(["sort"]);
const {type} = props
defineShortcuts({
'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
},
'+': () => {
router.push(`/standardEntity/${type}/create`)
},
'Enter': {
usingInput: true,
handler: () => {
router.push(`/standardEntity/${type}/show/${filteredRows.value[selectedItem.value].id}`)
}
},
'arrowdown': () => {
if(selectedItem.value < filteredRows.value.length - 1) {
selectedItem.value += 1
} else {
selectedItem.value = 0
}
},
'arrowup': () => {
if(selectedItem.value === 0) {
selectedItem.value = filteredRows.value.length - 1
} else {
selectedItem.value -= 1
}
}
})
const router = useRouter()
const dataStore = useDataStore()
const profileStore = useProfileStore()
const tempStore = useTempStore()
const dataType = dataStore.dataTypes[type]
const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable))
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
const searchString = ref(tempStore.searchStrings[props.type] ||'')
const clearSearchString = () => {
tempStore.clearSearchString(type)
searchString.value = ''
}
const selectableFilters = ref(dataType.filters.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const filteredRows = computed(() => {
let tempItems = props.items.map(i => {
return {
...i,
class: i.archived ? 'bg-red-500/50 dark:bg-red-400/50' : null
}
})
if(selectedFilters.value.length > 0) {
selectedFilters.value.forEach(filterName => {
let filter = dataType.filters.find(i => i.name === filterName)
tempItems = tempItems.filter(filter.filterFunction)
})
}
return useSearch(searchString.value, tempItems)
})
</script>
<template>
<FloatingActionButton
:label="`+ ${dataType.labelSingle}`"
variant="outline"
v-if="platform === 'mobile'"
@click="router.push(`/standardEntity/${type}/create`)"
/>
<UDashboardNavbar :title="dataType.label" :badge="filteredRows.length">
<template #toggle>
<div v-if="platform === 'mobile'"></div>
</template>
<template #right>
<UInput
id="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString(type,searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
<UButton
v-if="platform !== 'mobile' && has(`${type}-create`)/*&& useRole().checkRight(`${type}-create`)*/"
@click="router.push(`/standardEntity/${type}/create`)"
class="ml-3"
>+ {{dataType.labelSingle}}</UButton>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left v-if="$slots['left-toolbar']">
<slot name="left-toolbar"/>
</template>
<template #right>
<USelectMenu
v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid"
:options="dataType.templateColumns.filter(i => !i.disabledInTable)"
multiple
class="hidden lg:block"
by="key"
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
@change="tempStore.modifyColumns(type,selectedColumns)"
>
<template #label>
Spalten
</template>
</USelectMenu>
<USelectMenu
v-if="selectableFilters.length > 0"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
v-model="selectedFilters"
:options="selectableFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Filter
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<EntityTableMobile
v-if="platform === 'mobile'"
:type="props.type"
:columns="columns"
:rows="filteredRows"
/>
<EntityTable
v-else
@sort="(i) => emit('sort',i)"
:type="props.type"
:columns="columns"
:rows="filteredRows"
:loading="props.loading"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,83 @@
<script setup>
import StandardEntityModal from "~/components/StandardEntityModal.vue";
const props = defineProps({
type: {
type: String,
required: true
},
id: {
type: String,
},
createQuery: {
type: Object,
default: {}
},
buttonShow: {
type: Boolean,
default: true
},
buttonEdit: {
type: Boolean,
default: true
},
buttonCreate: {
type: Boolean,
default: true
}
})
const emit = defineEmits(["returnData"])
const modal = useModal()
</script>
<template>
<UButton
variant="outline"
class="w-25 ml-2"
v-if="props.id && props.buttonShow"
icon="i-heroicons-eye"
@click="modal.open(StandardEntityModal, {
id: props.id,
type: props.type,
mode: 'show',
})"
/>
<UButton
variant="outline"
class="w-25 ml-2"
v-if="props.id && props.buttonEdit"
icon="i-heroicons-pencil-solid"
@click="modal.open(StandardEntityModal, {
id: props.id,
type: props.type,
mode: 'edit',
onReturnData(data) {
emit('returnData', data)
}
})"
/>
<UButton
variant="outline"
class="w-25 ml-2"
v-if="!props.id && props.buttonCreate"
icon="i-heroicons-plus"
@click="modal.open(StandardEntityModal, {
type: props.type,
mode: 'create',
createQuery: props.createQuery,
onReturnData(data) {
emit('returnData', data)
}
})"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,367 @@
<script setup>
import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue";
const props = defineProps({
type: {
required: true,
type: String
},
item: {
required: true,
type: Object
},
inModal: {
type: Boolean,
},
platform: {
type: String,
}
})
const {type} = props
defineShortcuts({
'backspace': () => {
router.back()
},
'arrowleft': () => {
if(openTab.value > 0){
openTab.value -= 1
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
}
},
'arrowright': () => {
if(openTab.value < dataType.showTabs.length - 1) {
openTab.value += 1
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
}
},
})
const emit = defineEmits(["updateNeeded"])
const router = useRouter()
const route = useRoute()
const dataStore = useDataStore()
const modal = useModal()
const auth = useAuthStore()
const dataType = dataStore.dataTypes[type]
const openTab = ref(route.query.tabIndex || 0)
const getAvailableQueryStringData = (keys) => {
let returnString =""
function addParam (key,value) {
if(returnString.length === 0) {
returnString += `${key}=${value}`
} else {
returnString += `&${key}=${value}`
}
}
if(props.item.customer) {
addParam("customer", props.item.customer.id)
} else if(type === "customers") {
addParam("customer", props.item.id)
}
if(props.item.project) {
addParam("project", props.item.project.id)
} else if(type === "projects") {
addParam("project", props.item.id)
}
if(props.item.plant) {
addParam("plant", props.item.plant.id)
} else if(type === "plants") {
addParam("plant", props.item.id)
}
if(keys) {
Object.keys(keys).forEach(key => {
addParam(key, keys[key])
})
}
return returnString
}
const onTabChange = (index) => {
router.push(`${router.currentRoute.value.path}?tabIndex=${index}`)
}
const changePinned = async () => {
let newPins = []
if(auth.profile.pinned_on_navigation.find(i => i.datatype === type && i.id === props.item.id)){
//Remove Pin
newPins = auth.profile.pinned_on_navigation.filter(i => !(i.datatype === type && i.id === props.item.id))
} else {
//Add Pin
newPins = [
...auth.profile.pinned_on_navigation,
{
id: props.item.id,
icon: "i-heroicons-document",
type: "standardEntity",
datatype: type,
label: props.item[dataType.templateColumns.find(i => i.title).key]
}
]
}
const res = await useNuxtApp().$api(`/api/user/${auth.user.id}/profile`,{
method: "PUT",
body: {
data: {
pinned_on_navigation: newPins
}
}
})
await auth.fetchMe()
}
</script>
<template>
<UDashboardNavbar
v-if="props.inModal"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium']"
>{{item ? `${dataType.labelSingle}${props.item[dataType.templateColumns.find(i => i.title).key] ? ': ' + props.item[dataType.templateColumns.find(i => i.title).key] : ''}`: '' }}</h1>
</template>
<template #right>
<UButton
@click="modal.close()"
color="red"
class="ml-2"
icon="i-heroicons-x-mark"
variant="outline"
/>
</template>
</UDashboardNavbar>
<UDashboardNavbar
v-else-if="!props.inModal && platform !== 'mobile'"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.back()/*router.push(`/standardEntity/${type}`)*/"
>
Zurück
</UButton>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push(`/standardEntity/${type}`)"
>
Übersicht
</UButton>
</template>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium']"
>{{item ? `${dataType.labelSingle}${props.item[dataType.templateColumns.find(i => i.title).key] ? ': ' + props.item[dataType.templateColumns.find(i => i.title).key] : ''}`: '' }}</h1>
</template>
<template #right>
<UButton
v-if="auth.profile"
:variant="auth.profile?.pinned_on_navigation.find(i => i.datatype === type && i.id === props.item.id) ? 'solid' : 'outline'"
icon="i-heroicons-star"
color="yellow"
@click="changePinned"
></UButton>
<UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
>
Bearbeiten
</UButton>
</template>
</UDashboardNavbar>
<UDashboardNavbar
v-else-if="!props.inModal && platform === 'mobile'"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #toggle>
<div></div>
</template>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium','text-truncate']"
>{{item ? `${dataType.labelSingle}${props.item[dataType.templateColumns.find(i => i.title).key] ? ': ' + props.item[dataType.templateColumns.find(i => i.title).key] : ''}`: '' }}</h1>
</template>
<template #right>
<UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
>
Bearbeiten
</UButton>
</template>
</UDashboardNavbar>
<UTabs
:items="dataType.showTabs"
v-if="props.item.id && platform !== 'mobile'"
class="p-5"
v-model="openTab"
@change="onTabChange"
>
<template #item="{item:tab}">
<div v-if="tab.label === 'Informationen'" class="flex flex-row">
<EntityShowSubInformation
:top-level-type="type"
:item="props.item"
class="w-1/2 mr-5"
:platform="platform"
/>
<EntityShowSubHistoryDisplay
:top-level-type="type"
:item="props.item"
class="w-1/2"
:platform="platform"
/>
</div>
<EntityShowSubFiles
:item="props.item"
:query-string-data="getAvailableQueryStringData()"
v-else-if="tab.label === 'Dateien'"
:top-level-type="type"
type="files"
@updateNeeded="emit('updateNeeded')"
:platform="platform"
/>
<!-- TODO Change Active Phase -->
<EntityShowSubPhases
:item="props.item"
:top-level-type="type"
v-else-if="tab.label === 'Phasen'"
:query-string-data="getAvailableQueryStringData()"
@updateNeeded="emit('updateNeeded')"
:platform="platform"
/>
<EntityShowSubCreatedDocuments
:item="props.item"
:top-level-type="type"
v-else-if="tab.label === 'Ausgangsbelege'"
:query-string-data="getAvailableQueryStringData()"
:platform="platform"
/>
<EntityShowSubCostCentreReport
:top-level-type="type"
:item="props.item"
v-else-if="tab.label === 'Auswertung Kostenstelle'"
:platform="platform"
/>
<EntityShowSubOwnAccountsStatements
:top-level-type="type"
:item="props.item"
v-else-if="tab.label === 'Buchungen'"
:platform="platform"
/>
<EntityShowSubTimes
:top-level-type="type"
:item="props.item"
v-else-if="tab.label === 'Zeiten'"
:platform="platform"
/>
<EntityShowSub
:item="props.item"
:query-string-data="getAvailableQueryStringData()"
:tab-label="tab.label"
:top-level-type="type"
:type="tab.key"
v-else
:platform="platform"
/>
</template>
</UTabs>
<UDashboardPanelContent v-else style="overflow-x: hidden;">
<div v-for="sub in dataType.showTabs" :key="sub.key">
<div v-if="sub.label === 'Informationen'">
<EntityShowSubInformation
:top-level-type="type"
:item="props.item"
:platform="platform"
/>
<EntityShowSubHistoryDisplay
:top-level-type="type"
:item="props.item"
:platform="platform"
/>
</div>
<EntityShowSubFiles
:item="props.item"
:query-string-data="getAvailableQueryStringData()"
v-else-if="sub.label === 'Dateien'"
:top-level-type="type"
type="files"
@updateNeeded="emit('updateNeeded')"
:platform="platform"
/>
<!--<EntityShowSubPhases
:item="props.item"
:top-level-type="type"
v-else-if="sub.label === 'Phasen'"
:query-string-data="getAvailableQueryStringData()"
@updateNeeded="emit('updateNeeded')"
:platform="platform"
/>
<EntityShowSubCreatedDocuments
:item="props.item"
:top-level-type="type"
v-else-if="sub.label === 'Ausgangsbelege'"
:query-string-data="getAvailableQueryStringData()"
:platform="platform"
/>
<EntityShowSubCostCentreReport
:top-level-type="type"
:item="props.item"
v-else-if="sub.label === 'Auswertung Kostenstelle'"
:platform="platform"
/>
<EntityShowSub
:item="props.item"
:query-string-data="getAvailableQueryStringData()"
:tab-label="sub.label"
:top-level-type="type"
v-else
:platform="platform"
/>-->
</div>
</UDashboardPanelContent>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,128 @@
<script setup>
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
tabLabel: {
type: String,
required: true
},
type: {
type: String,
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
let type = ref("")
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
let dataType = null
const selectedColumns = ref(null)
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
const loaded = ref(false)
const setup = () => {
if(!props.type && props.tabLabel ) {
if(props.tabLabel === "Aufgaben") {
type.value = "tasks"
} else if(props.tabLabel === "Projekte") {
type.value = "projects"
} else if(props.tabLabel === "Termine") {
type.value = "events"
} else if(props.tabLabel === "Objekte") {
type.value = "plants"
} else if(props.tabLabel === "Ansprechpartner") {
type.value = "contacts"
} else if(props.tabLabel === "Verträge") {
type.value = "contracts"
} else if(props.tabLabel === "Überprüfungen") {
type.value = "checks"
}
} else {
type.value = props.type
}
dataType = dataStore.dataTypes[type.value]
selectedColumns.value = tempStore.columns[type.value] ? tempStore.columns[type.value] : dataType.templateColumns.filter(i => !i.disabledInTable)
loaded.value = true
}
setup()
</script>
<template>
<UCard class="mt-5" v-if="loaded" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<template #header v-if="props.platform === 'mobile'">
<span>{{dataType.label}}</span>
</template>
<Toolbar>
<UButton
@click="router.push(`/standardEntity/${type}/create?${props.queryStringData}`)"
>
+ {{dataType.labelSingle}}
</UButton>
<UButton
v-if="props.topLevelType === 'customers' && type === 'plants'"
@click="router.push(`/standardEntity/plants/create?${props.queryStringData}&name=${encodeURIComponent(`${props.item.infoData.street}, ${props.item.infoData.zip} ${props.item.infoData.city}`)}`)"
>
+ Kundenadresse als Objekt
</UButton>
<template #right>
<USelectMenu
v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid"
:options="dataType.templateColumns.filter(i => !i.disabledInTable)"
multiple
class="hidden lg:block"
by="key"
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
@change="tempStore.modifyColumns(type,selectedColumns)"
>
<template #label>
Spalten
</template>
</USelectMenu>
</template>
</Toolbar>
<div class="scroll" style="height: 70vh">
<EntityTable
:type="type"
:columns="columns"
:rows="props.item[type]"
style
/>
</div>
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
</script>
<template>
<UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<template #header v-if="props.platform === 'mobile'">
<span>Auswertung</span>
</template>
<costcentre-display :item="props.item"/>
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,314 @@
<script setup>
import dayjs from "dayjs";
import {useSum} from "~/composables/useSum.js";
defineShortcuts({
/*'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
},*/
'Enter': {
usingInput: true,
handler: () => {
router.push(`/standardEntity/${props.topLevelType}/show/${props.item.createddocuments.value[selectedItem.value].id}`)
}
},
'arrowdown': () => {
if(selectedItem.value < props.item.createddocuments.length - 1) {
selectedItem.value += 1
} else {
selectedItem.value = 0
}
},
'arrowup': () => {
if(selectedItem.value === 0) {
selectedItem.value = props.item.createddocuments.length - 1
} else {
selectedItem.value -= 1
}
}
})
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
}
})
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const createddocuments = ref([])
const setup = async () => {
//createddocuments.value = (await useSupabaseSelect("createddocuments")).filter(i => !i.archived)
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => !i.archived)
}
setup()
const templateColumns = [
{
key: "reference",
label: "Referenz"
},
{
key: 'type',
label: "Typ"
},{
key: 'state',
label: "Status"
},{
key: 'paid',
label: "Bezahlt"
},{
key: 'amount',
label: "Betrag"
},
{
key: "date",
label: "Datum"
},
{
key: "dueDate",
label: "Fällig"
}
]
const selectedColumns = ref(tempStore.columns["createddocuments"] ? tempStore.columns["createddocuments"] : templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.find(i => i.key === column.key)))
const selectedItem = ref(0)
const getAvailableQueryStringData = (keys) => {
let returnString = props.queryStringData
function addParam (key,value) {
if(returnString.length === 0) {
returnString += `${key}=${value}`
} else {
returnString += `&${key}=${value}`
}
}
if(keys) {
Object.keys(keys).forEach(key => {
addParam(key, keys[key])
})
}
return returnString
}
const invoiceDeliveryNotes = () => {
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => i.type === "deliveryNotes").map(i => i.id)}]`)
}
const showFinalInvoiceConfig = ref(false)
const referenceDocument = ref(null)
const advanceInvoicesToAdd = ref([])
const invoiceAdvanceInvoices = () => {
router.push(`/createDocument/edit?type=invoices&loadMode=finalInvoice&linkedDocuments=[${[referenceDocument.value, ... advanceInvoicesToAdd.value]}]`)
}
const selectItem = (item) => {
console.log(item)
if(item.state === "Entwurf"){
router.push(`/createDocument/edit/${item.id}`)
} else if(item.state !== "Entwurf") {
router.push(`/createDocument/show/${item.id}`)
}
}
</script>
<template>
<UCard class="mt-5" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<template #header v-if="props.platform === 'mobile'">
<span>Ausgangsbelege</span>
</template>
<Toolbar>
<!-- TODO Rendering when Screen is too small -->
<UButton
@click="invoiceDeliveryNotes"
v-if="props.topLevelType === 'projects'"
>
Lieferscheine abrechnen
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
>
+ Angebot
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
>
+ Auftragsbestätigung
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'deliveryNotes'})}`)"
>
+ Lieferschein
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
>
+ Abschlagsrechnung
</UButton>
<UButton
@click="showFinalInvoiceConfig = true"
v-if="props.topLevelType === 'projects'"
:disabled="!props.item.createddocuments?.filter(i => !i.archived && i.type === 'advanceInvoices').length > 0"
>
+ Schlussrechnung
</UButton>
<UModal
prevent-close
v-model="showFinalInvoiceConfig"
>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Schlussrechnung konfigurieren
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
</div>
</template>
<UFormGroup
label="Rechnungsvorlage"
>
<USelectMenu
:options="props.item.createddocuments.filter(i => ['confirmationOrders','quotes'].includes(i.type))"
value-attribute="id"
option-attribute="documentNumber"
v-model="referenceDocument"
/>
</UFormGroup>
<UFormGroup
label="Abschlagsrechnungen"
>
<USelectMenu
:options="props.item.createddocuments.filter(i => ['advanceInvoices'].includes(i.type))"
multiple
value-attribute="id"
option-attribute="documentNumber"
v-model="advanceInvoicesToAdd"
/>
</UFormGroup>
<template #footer>
<UButton
@click="invoiceAdvanceInvoices"
>
Weiter
</UButton>
</template>
</UCard>
</UModal>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'invoices'})}`)"
>
+ Rechnung
</UButton>
<template #right>
<USelectMenu
v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid"
:options="templateColumns"
multiple
class="hidden lg:block"
by="key"
@change="tempStore.modifyColumns('createddocuments',selectedColumns)"
>
<template #label>
Spalten
</template>
</USelectMenu>
</template>
</Toolbar>
<UTable
:rows="props.item.createddocuments.filter(i => !i.archived)"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="selectItem"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
style="height: 70vh"
>
<template #type-data="{row}">
{{dataStore.documentTypesForCreation[row.type].labelSingle}}
</template>
<template #state-data="{row}">
<span
v-if="row.state === 'Entwurf'"
class="text-rose-500"
>
{{row.state}}
</span>
<span
v-if="row.state === 'Gebucht'"
class="text-cyan-500"
>
{{row.state}}
</span>
<span
v-if="row.state === 'Abgeschlossen'"
class="text-primary-500"
>
{{row.state}}
</span>
</template>
<template #paid-data="{row}">
<div v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht'">
<span v-if="useSum().getIsPaid(row,createddocuments)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</div>
</template>
<template #reference-data="{row}">
<span v-if="row === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span>
<span v-else>{{row.documentNumber}}</span>
</template>
<template #date-data="{row}">
<span v-if="row.date">{{row.date ? dayjs(row.date).format("DD.MM.YY") : ''}}</span>
<span v-if="row.documentDate">{{row.documentDate ? dayjs(row.documentDate).format("DD.MM.YY") : ''}}</span>
</template>
<template #dueDate-data="{row}">
<span v-if="row.paymentDays && ['invoices','advanceInvoices'].includes(row.type)" >{{row.documentDate ? dayjs(row.documentDate).add(row.paymentDays,'day').format("DD.MM.YY") : ''}}</span>
</template>
<template #amount-data="{row}">
<span v-if="row.type !== 'deliveryNotes'">{{useCurrency(useSum().getCreatedDocumentSum(row, createddocuments))}}</span>
</template>
</UTable>
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,134 @@
<script setup>
const props = defineProps({
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?.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>
<span>Dateien</span>
</template>
<!-- Upload -->
<Toolbar>
<DocumentUpload
:type="props.topLevelType.substring(0, props.topLevelType.length - 1)"
:element-id="props.item.id"
@uploadFinished="emit('updateNeeded')"
/>
</Toolbar>
<!-- 📱 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>

View File

@@ -0,0 +1,42 @@
<script setup>
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
const dataStore = useDataStore()
const dataType = dataStore.dataTypes[props.topLevelType]
</script>
<template>
<UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<HistoryDisplay
:type="props.topLevelType"
v-if="props.item.id"
:element-id="props.item.id"
render-headline
/>
<!--TODO Workaround für die Höhe finden? Evt unterseite oder Modal oder ganz nach unten -->
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const dataType = dataStore.dataTypes[props.topLevelType]
// const selectedColumns = ref(tempStore.columns[props.topLevelType] ? tempStore.columns[props.topLevelType] : dataType.templateColumns.filter(i => !i.disabledInTable))
// const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
</script>
<template>
<UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<template #header v-if="props.platform === 'mobile'">
<span>Informationen</span>
</template>
<UAlert
v-if="props.item.archived"
color="rose"
variant="outline"
:title="`${dataType.labelSingle} archiviert`"
icon="i-heroicons-archive-box"
class="mb-5"
/>
<div class="text-wrap">
<table class="w-full">
<tbody>
<tr
v-for="datapoint in dataType.templateColumns"
>
<td>{{datapoint.label}}:</td>
<td>
<component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component>
<div v-else>
<span v-if="datapoint.key.includes('.')">{{props.item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]}}{{datapoint.unit}}</span>
<span v-else>{{props.item[datapoint.key]}} {{datapoint.unit}}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</template>
<style scoped>
td {
border-bottom: 1px solid lightgrey;
vertical-align: top;
padding-bottom: 0.15em;
padding-top: 0.15em;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup>
import dayjs from "dayjs";
const supabase = useSupabaseClient()
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
const statementallocations = ref([])
const incominginvoices = ref([])
const setup = async () => {
//statementallocations.value = (await supabase.from("statementallocations").select("*, bs_id(*)").eq("account", route.params.id).eq("tenant",profileStore.currentTenant).order("created_at",{ascending: true})).data
//incominginvoices.value = (await useSupabaseSelect("incominginvoices", "*, vendor(*)")).filter(i => i.accounts.find(x => x.account == route.params.id))
}
setup()
const selectAllocation = (allocation) => {
if(allocation.type === "statementallocation") {
router.push(`/banking/statements/edit/${allocation.bs_id.id}`)
} else if(allocation.type === "incominginvoice") {
router.push(`/incominginvoices/show/${allocation.incominginvoiceid}`)
}
}
const renderedAllocations = computed(() => {
let tempstatementallocations = props.item.statementallocations.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 : '')) : ''
}
})
/*let incominginvoicesallocations = []
incominginvoices.value.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
return {
...x,
incominginvoiceid: i.id,
type: "incominginvoice",
amount: x.amountGross ? x.amountGross : x.amountNet,
date: i.date,
partner: i.vendor.name,
description: i.description,
color: i.expense ? "red" : "green"
}
}))
})*/
return [...tempstatementallocations/*, ... incominginvoicesallocations*/]
})
</script>
<template>
<UCard class="mt-5">
<UTable
v-if="props.item.statementallocations"
:rows="renderedAllocations"
:columns="[{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}]"
@select="(i) => selectAllocation(i)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #amount-data="{row}">
<span class="text-right text-rose-600" v-if="row.amount < 0 || row.color === 'red'">{{useCurrency(row.amount)}}</span>
<span class="text-right text-primary-500" v-else-if="row.amount > 0 || row.color === 'green'">{{useCurrency(row.amount)}}</span>
<span v-else>{{useCurrency(row.amount)}}</span>
</template>
<template #date-data="{row}">
{{row.date ? dayjs(row.date).format('DD.MM.YYYY') : ''}}
</template>
<template #description-data="{row}">
{{row.description ? row.description : ''}}
</template>
</UTable>
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,169 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
const emit = defineEmits(["updateNeeded"]);
const router = useRouter()
const profileStore = useProfileStore()
const supabase = useSupabaseClient()
const renderedPhases = computed(() => {
if(props.topLevelType === "projects" && props.item.phases) {
return props.item.phases.map((phase,index,array) => {
let isAvailable = false
if(phase.active) {
isAvailable = true
} else if(index > 0 && array[index-1].active ){
isAvailable = true
} else if(index > 1 && array[index-1].optional && array[index-2].active){
isAvailable = true
} else if(array.findIndex(i => i.active) > index) {
isAvailable = true
} else if(phase.label === "Abgeschlossen") {
isAvailable = true
}
return {
...phase,
label: phase.optional ? `${phase.label}(optional)`: phase.label,
disabled: !isAvailable,
defaultOpen: phase.active ? true : false
}
})
} else {
return []
}
})
const changeActivePhase = async (key) => {
let item = await useEntities("projects").selectSingle(props.item.id,'*')
let phaseLabel = ""
item.phases = item.phases.map(p => {
if(p.active) p.active = false
if(p.key === key) {
p.active = true
p.activated_at = dayjs().format()
p.activated_by = profileStore.activeProfile.id
phaseLabel = p.label
}
return p
})
const res = await useEntities("projects").update(item.id, {phases:item.phases,active_phase: item.phases.find(i => i.active).label})
//const {error:updateError} = await supabase.from("projects").update({phases: item.phases}).eq("id",item.id)
/*const {error} = await supabase.from("historyitems").insert({
createdBy: profileStore.activeProfile.id,
tenant: profileStore.currentTenant,
text: `Aktive Phase zu "${phaseLabel}" gewechselt`,
project: item.id
})*/
emit("updateNeeded")
}
</script>
<template>
<UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<template #header v-if="props.platform === 'mobile'">
<span>Phasen</span>
</template>
<UAccordion
:items="renderedPhases"
>
<template #default="{item,index,open}">
<UButton
variant="ghost"
:color="item.active ? 'primary' : 'white'"
class="mb-1"
:disabled="true"
>
<template #leading>
<div class="w-6 h-6 flex items-center justify-center -my-1">
<UIcon :name="item.icon" class="w-4 h-4 " />
</div>
</template>
<span class="truncate"> {{item.label}}</span>
<template #trailing>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 ms-auto transform transition-transform duration-200"
:class="[open && 'rotate-90']"
/>
</template>
</UButton>
</template>
<template #item="{item, index}">
<UCard class="mx-5">
<template #header>
<span class="dark:text-white text-black">{{item.label}}</span>
</template>
<InputGroup>
<!-- TODO: Reactive Change Phase -->
<UButton
v-if="!item.activated_at && index !== 0 "
@click="changeActivePhase(item.key)"
>
Phase aktivieren
</UButton>
<UButton
v-if="item.active"
v-for="button in item.quickactions"
@click="router.push(`${button.link}&${props.queryStringData}`)"
>
{{button.label}}
</UButton>
</InputGroup>
<div>
<p v-if="item.activated_at" class="dark:text-white text-black">Aktiviert am: {{dayjs(item.activated_at).format("DD.MM.YY HH:mm")}} Uhr</p>
<p v-if="item.activated_by" class="dark:text-white text-black">Aktiviert durch: {{profileStore.getProfileById(item.activated_by).fullName}}</p>
<p v-if="item.description" class="dark:text-white text-black">Beschreibung: {{item.description}}</p>
</div>
</UCard>
</template>
</UAccordion>
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,117 @@
<script setup>
import dayjs from "dayjs";
const supabase = useSupabaseClient()
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const props = defineProps({
queryStringData: {
type: String
},
item: {
type: Object,
required: true
},
topLevelType: {
type: String,
required: true
},
platform: {
type: String,
required: true
}
})
const setup = async () => {
}
setup()
const columns = [
{
key:"state",
label: "Status",
},
{
key: "user",
label: "Benutzer",
},
{
key:"startDate",
label:"Start",
},
{
key: "endDate",
label: "Ende",
},
{
key: "duration",
label: "Dauer",
},
{
key:"type",
label:"Typ",
},
{
key: "project",
label: "Projekt",
},
{
key: "notes",
label: "Notizen",
}
]
</script>
<template>
<UCard class="mt-5">
<UTable
class="mt-3"
:columns="columns"
:rows="props.item.times"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
>
<template #state-data="{row}">
<span
v-if="row.state === 'Entwurf'"
class="text-rose-500"
>{{row.state}}</span>
<span
v-if="row.state === 'Eingereicht'"
class="text-cyan-500"
>{{row.state}}</span>
<span
v-if="row.state === 'Bestätigt'"
class="text-primary-500"
>{{row.state}}</span>
</template>
<template #user-data="{row}">
{{row.profile ? row.profile.fullName : "" }}
</template>
<template #startDate-data="{row}">
{{dayjs(row.startDate).format("DD.MM.YY HH:mm")}}
</template>
<template #endDate-data="{row}">
{{dayjs(row.endDate).format("DD.MM.YY HH:mm")}}
</template>
<template #duration-data="{row}">
{{Math.floor(dayjs(row.endDate).diff(row.startDate, "minutes")/60)}}:{{String(dayjs(row.endDate).diff(row.startDate, "minutes") % 60).padStart(2,"0")}} h
</template>
<template #project-data="{row}">
{{row.project ? row.project.name : "" }}
</template>
</UTable>
</UCard>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,137 @@
<script setup>
defineShortcuts({
/*'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
},*/
'Enter': {
usingInput: true,
handler: () => {
router.push(`/standardEntity/${props.type}/show/${props.rows.value[selectedItem.value].id}`)
}
},
'arrowdown': () => {
if(selectedItem.value < props.rows.length - 1) {
selectedItem.value += 1
} else {
selectedItem.value = 0
}
},
'arrowup': () => {
if(selectedItem.value === 0) {
selectedItem.value = props.rows.length - 1
} else {
selectedItem.value -= 1
}
}
})
const props = defineProps({
rows: {
type: Array,
required: true,
default: []
},
columns: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
loading: {
type: Boolean,
required: true,
default: false
}
})
const emit = defineEmits(["sort"]);
const dataStore = useDataStore()
const router = useRouter()
const dataType = dataStore.dataTypes[props.type]
const selectedItem = ref(0)
const sort = ref({
column: dataType.supabaseSortColumn || "date",
direction: 'desc'
})
</script>
<template>
<UTable
:loading="props.loading"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
sort-mode="manual"
v-model:sort="sort"
@update:sort="emit('sort',{sort_column: sort.column, sort_direction: sort.direction})"
v-if="dataType && columns"
:rows="props.rows"
:columns="props.columns"
class="w-full"
: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}">
<span class="text-nowrap">{{column.label}}</span>
</template>-->
<template #name-data="{row}">
<span
v-if="row.id === props.rows[selectedItem].id"
class="text-primary-500 font-bold">
<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>
<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}">
<span
v-if="row.id === props.rows[selectedItem].id"
class="text-primary-500 font-bold">{{row.fullName}}
</span>
<span v-else>
{{row.fullName}}
</span>
</template>
<template #licensePlate-data="{row}">
<span
v-if="row.id === props.rows[selectedItem].id"
class="text-primary-500 font-bold">{{row.licensePlate}}
</span>
<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]">
<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>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,129 @@
<script setup>
/*defineShortcuts({
/!*'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
},*!/
'Enter': {
usingInput: true,
handler: () => {
router.push(`/standardEntity/${props.type}/show/${props.rows.value[selectedItem.value].id}`)
}
},
'arrowdown': () => {
if(selectedItem.value < props.rows.length - 1) {
selectedItem.value += 1
} else {
selectedItem.value = 0
}
},
'arrowup': () => {
if(selectedItem.value === 0) {
selectedItem.value = props.rows.length - 1
} else {
selectedItem.value -= 1
}
}
})*/
const props = defineProps({
rows: {
type: Array,
required: true,
default: []
},
columns: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
}
})
const dataStore = useDataStore()
const router = useRouter()
const dataType = dataStore.dataTypes[props.type]
const selectedItem = ref(0)
</script>
<template>
<UDashboardPanelContent class="w-full">
<a
v-for="item in props.rows"
class="my-1"
@click="router.push(`/standardEntity/${type}/show/${item.id}`)"
>
<p class="truncate text-left text-primary text-xl">{{dataType.templateColumns.find(i => i.title).key ? item[dataType.templateColumns.find(i => i.title).key] : null}}</p>
<p class="text-sm">
{{ dataType.numberRangeHolder ? item[dataType.numberRangeHolder] : null}}
<span v-for="secondInfo in dataType.templateColumns.filter(i => i.secondInfo)">{{(secondInfo.secondInfoKey && item[secondInfo.key]) ? item[secondInfo.key][secondInfo.secondInfoKey] : item[secondInfo.key]}}</span>
</p>
</a>
</UDashboardPanelContent>
<!-- <UTable
v-if="dataType && columns"
:rows="props.rows"
:columns="props.columns"
class="w-full"
: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}">
<span class="text-nowrap">{{column.label}}</span>
</template>
<template #name-data="{row}">
<span
v-if="row.id === props.rows[selectedItem].id"
class="text-primary-500 font-bold">{{row.name}}
</span>
<span v-else>
{{row.name}}
</span>
</template>
<template #fullName-data="{row}">
<span
v-if="row.id === props.rows[selectedItem].id"
class="text-primary-500 font-bold">{{row.fullName}}
</span>
<span v-else>
{{row.fullName}}
</span>
</template>
<template #licensePlate-data="{row}">
<span
v-if="row.id === props.rows[selectedItem].id"
class="text-primary-500 font-bold">{{row.licensePlate}}
</span>
<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>{{row[column.key] ? `${row[column.key]} ${column.unit ? column.unit : ''}`: ''}}</span>
</template>
</UTable>-->
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,89 @@
<script setup>
const supabase = useSupabaseClient()
const profileStore = useProfileStore()
const globalMessages = ref([])
const setup = async () => {
let {data} = await supabase.from("globalmessages").select("*, profiles(id)")
data = data.filter((message) => message.profiles.length === 0)
globalMessages.value = data
if(data.length > 0) {
messageToShow.value = data[0]
showMessageModal.value = true
}
}
const showMessageModal = ref(false)
const messageToShow = ref(null)
const showMessage = (message) => {
messageToShow.value = message
showMessageModal.value = true
}
const markMessageAsRead = async () => {
await supabase.from("globalmessagesseen").insert({
profile: profileStore.activeProfile.id,
message: messageToShow.value.id,
})
showMessageModal.value = false
setup()
}
setup()
</script>
<template>
<UModal v-model="showMessageModal" prevent-close>
<UCard>
<template #header>
<span class="font-bold">{{messageToShow.title}}</span>
</template>
<p class=" my-2" v-html="messageToShow.description"></p>
<UButton
variant="outline"
@click="markMessageAsRead"
>Gelesen</UButton>
</UCard>
</UModal>
<!-- <UCard
v-if="globalMessages.length >0"
class="mt-3"
style="border: .75px solid #69c350"
>
<p class="font-bold">{{globalMessages[0].title}}</p>
<UButton
icon="i-heroicons-chevron-right"
variant="ghost"
@click="showMessage(globalMessages[0])"
/>
<UModal v-model="showMessageModal">
<UCard>
<template #header>
<span class="font-bold">{{messageToShow.title}}</span>
</template>
<p class=" my-2" v-html="messageToShow.description"></p>
<UButton
variant="outline"
@click="markMessageAsRead"
>Gelesen</UButton>
</UCard>
</UModal>
</UCard>-->
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,140 @@
<script setup>
const showCommandPalette = ref(false)
const selectedCommand = ref("")
const commandPaletteRef = ref()
const router = useRouter()
const dataStore = useDataStore()
const openCommandPalette = () => {
showCommandPalette.value = true
console.log("Open Command Palette")
}
defineShortcuts({
meta_k: {
usingInput: true,
handler: () => {
openCommandPalette()
}
}
})
const actions = [
{
id: 'new-task',
label: 'Aufgabe hinzufügen',
icon: 'i-heroicons-rectangle-stack',
to: "/tasks/create" ,
},
{
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-project',
label: 'Projekt hinzufügen',
icon: 'i-heroicons-clipboard-document-check',
to: "/projects/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" ,
}
]
const groups = computed(() =>
[{
key: 'actions',
commands: actions
},{
key: "customers",
label: "Kunden",
commands: dataStore.customers.map(item => { return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}})
},{
key: "vendors",
label: "Lieferanten",
commands: dataStore.vendors.map(item => { return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}})
},{
key: "contacts",
label: "Ansprechpartner",
commands: dataStore.contacts.map(item => { return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}})
},{
key: "products",
label: "Artikel",
commands: dataStore.products.map(item => { return {id: item.id, label: item.name, to: `/products/show/${item.id}`}})
},{
key: "tasks",
label: "Aufgaben",
commands: dataStore.tasks.map(item => { return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}})
},{
key: "plants",
label: "Objekte",
commands: dataStore.plants.map(item => { return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}})
},{
key: "projects",
label: "Projekte",
commands: dataStore.projects.map(item => { return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}})
}
].filter(Boolean))
function onSelect (option) {
if (option.click) {
option.click()
} else if (option.to) {
router.push(option.to)
} else if (option.href) {
window.open(option.href, '_blank')
}
showCommandPalette.value = false
}
</script>
<template>
<UButton
icon="i-heroicons-magnifying-glass"
variant="ghost"
@click="openCommandPalette"
/>
<UModal
v-model="showCommandPalette"
>
<UCommandPalette
v-model="selectedCommand"
:groups="groups"
:autoselect="false"
@update:model-value="onSelect"
ref="commandPaletteRef"
/>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,231 @@
<script setup>
const { isHelpSlideoverOpen } = useDashboard()
const { metaSymbol } = useShortcuts()
const shortcuts = ref(false)
const dataStore = useDataStore()
const profileStore = useProfileStore()
const query = ref('')
const supabase = useSupabaseClient()
const toast = useToast()
const router = useRouter()
const links = [{
label: 'Shortcuts',
icon: 'i-heroicons-key',
trailingIcon: 'i-heroicons-arrow-right-20-solid',
onClick: () => {
shortcuts.value = true
}
},/* {
label: 'Tickets',
icon: 'i-heroicons-clipboard-document',
to: '/support',
},*/ {
label: 'Webseite',
icon: 'i-heroicons-globe-europe-africa',
to: 'https://fedeo.de',
target: '_blank'
}, {
label: 'Versionshistorie',
icon: 'i-heroicons-rocket-launch',
to: 'https://fedeo.de/changelog',
target: '_blank'
},/* {
label: 'Roadmap',
icon: 'i-heroicons-rocket-launch',
to: 'https://fedeo.de/roadmap',
target: '_blank'
},*/ {
label: 'Doku',
icon: 'i-heroicons-book-open',
to: 'https://fedeo.de/docs',
target: '_blank'
}, {
label: 'Status',
icon: 'i-heroicons-shield-check',
to: 'https://uptime.fedeo.io/status/fedeo',
target: '_blank'
}/*, {
label: 'Bugs & Features',
icon: 'i-heroicons-book-open',
to: 'https://gitlab.federspiel.software/fedeo/software-features',
target: '_blank'
}, {
label: 'GitHub repository',
icon: 'i-simple-icons-github',
to: 'https://github.com/nuxt/ui-pro',
target: '_blank'
}, {
label: 'Buy Nuxt UI Pro',
icon: 'i-heroicons-credit-card',
to: 'https://ui.nuxt.com/pro/purchase',
target: '_blank'
}*/]
const categories = computed(() => [{
title: 'General',
items: [
{ shortcuts: [metaSymbol.value, 'K'], name: 'Hauptmenü' },
{ shortcuts: ['N'], name: 'Benachrichtigungen' },
{ shortcuts: ['?'], name: 'Help & Support' },
{ shortcuts: ['/'], name: 'Suche' }
]
}, {
title: 'Navigation',
items: [
{ shortcuts: ['G', 'H'], name: 'Gehe zu Dashboard' },
{ shortcuts: ['G', 'A'], name: 'Gehe zu Aufgaben' },
{ shortcuts: ['G', 'D'], name: 'Gehe zu Dokumente' },
{ shortcuts: ['G', 'K'], name: 'Gehe zu Kunden' },
{ shortcuts: ['G', 'L'], name: 'Gehe zu Lieferanten' },
{ shortcuts: ['G', 'P'], name: 'Gehe zu Projekte' },
{ shortcuts: ['G', 'V'], name: 'Gehe zu Verträge' },
{ shortcuts: ['G', 'O'], name: 'Gehe zu Objekte' },/*
{ shortcuts: ['G', 'I'], name: 'Go to Inbox' },
{ shortcuts: ['G', 'U'], name: 'Go to Users' },*/
{ shortcuts: ['G', 'S'], name: 'Gehe zu Einstellungen' },
{ shortcuts: ['↑'], name: 'Vorheriger Eintrag' },
{ shortcuts: ['↓'], name: 'Nächster Eintrag' },
{ shortcuts: ['↵'], name: 'Eintrag Öffnen' },
{ shortcuts: ['←'], name: 'Tab nach links wechseln' },
{ shortcuts: ['→'], name: 'Tab nach rechts wechseln' },
]
}, /*{
title: 'Inbox',
items: [
{ shortcuts: ['↑'], name: 'Prev notification' },
{ shortcuts: ['↓'], name: 'Next notification' }
]
}*/])
const filteredCategories = computed(() => {
return categories.value.map(category => ({
title: category.title,
items: category.items.filter(item => {
return item.name.search(new RegExp(query.value, 'i')) !== -1
})
})).filter(category => !!category.items.length)
})
const contactRequestData = ref({
message: "",
title: "",
})
const loadingContactRequest = ref(false)
const addContactRequest = async () => {
console.log("ADD")
loadingContactRequest.value = true
const retVal = await useFunctions().useCreateTicket(contactRequestData.value.title,contactRequestData.value.message,router.currentRoute.value.fullPath,"helpSlideover",)
if(retVal) {
toast.add({title: "Anfrage erfolgreich erstellt"})
resetContactRequest()
} else {
toast.add({title: "Anfrage konnte nicht erstellt werden",color:"rose"})
}
loadingContactRequest.value = false
}
const resetContactRequest = () => {
contactRequestData.value = {
message: "",
title: "",
}
}
</script>
<template>
<UDashboardSlideover v-model="isHelpSlideoverOpen">
<template #title>
<UButton
v-if="shortcuts"
color="gray"
variant="ghost"
size="sm"
icon="i-heroicons-arrow-left-20-solid"
@click="shortcuts = false"
/>
{{ shortcuts ? 'Shortcuts' : 'Hilfe & Information' }}
</template>
<div v-if="shortcuts" class="space-y-6">
<UInput v-model="query" icon="i-heroicons-magnifying-glass" placeholder="Search..." autofocus color="gray" />
<div v-for="(category, index) in filteredCategories" :key="index">
<p class="mb-3 text-sm text-gray-900 dark:text-white font-semibold">
{{ category.title }}
</p>
<div class="space-y-2">
<div v-for="(item, i) in category.items" :key="i" class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ item.name }}</span>
<div class="flex items-center justify-end flex-shrink-0 gap-0.5">
<UKbd v-for="(shortcut, j) in item.shortcuts" :key="j">
{{ shortcut }}
</UKbd>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex flex-col gap-y-3">
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
</div>
<!-- <div class="mt-5" v-if="!loadingContactRequest">
<h1 class="font-semibold">Kontaktanfrage:</h1>
<UForm
class="p-3"
@submit="addContactRequest"
@reset="resetContactRequest"
>
&lt;!&ndash; <UFormGroup
label="Art:"
>
<USelectMenu
:options="['Hilfe','Software Problem / Bug','Funktionsanfrage','Kontakt','Sonstiges']"
v-model="contactRequestData.contactType"
/>
</UFormGroup>&ndash;&gt;
<UFormGroup
label="Titel:"
>
<UInput
v-model="contactRequestData.title"
/>
</UFormGroup>
<UFormGroup
label="Nachricht:"
>
<UTextarea
v-model="contactRequestData.message"
rows="6"
/>
</UFormGroup>
<InputGroup class="mt-3">
<UButton
type="submit"
:disabled="!contactRequestData.title || !contactRequestData.message"
>
Senden
</UButton>
<UButton
type="reset"
color="rose"
variant="outline"
:disabled="!contactRequestData.title && !contactRequestData.message"
>
Zurücksetzen
</UButton>
</InputGroup>
</UForm>
</div>
<UProgress class="mt-5" animation="carousel" v-else/>-->
</UDashboardSlideover>
</template>

View File

@@ -0,0 +1,165 @@
<script setup>
import dayjs from "dayjs"
const props = defineProps({
type: {
type: String,
required: true
},
elementId: {
type: String,
required: true
},
renderHeadline: {
type: Boolean,
default: false
}
})
const auth = useAuthStore()
const toast = useToast()
const showAddHistoryItemModal = ref(false)
const colorMode = useColorMode()
const items = ref([])
const platform = ref("default")
const setup = async () => {
if(await useCapacitor().getIsPhone()) platform.value = "mobile"
if(props.type && props.elementId){
items.value = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`)
} /*else {
}*/
}
setup()
const addHistoryItemData = ref({
text: ""
})
const addHistoryItem = async () => {
const res = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`, {
method: "POST",
body: addHistoryItemData.value
})
addHistoryItemData.value = {}
toast.add({title: "Eintrag erfolgreich erstellt"})
showAddHistoryItemModal.value = false
await setup()
}
const renderText = (text) => {
const regex = /(@\w*)/g
text = text.replaceAll(regex, "<a class='text-primary-500'>$&</a>")
return text
}
</script>
<template>
<UModal
v-model="showAddHistoryItemModal"
>
<UCard class="h-full">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Eintrag hinzufügen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showAddHistoryItemModal = false" />
</div>
</template>
<UFormGroup
label="Text:"
>
<UTextarea
v-model="addHistoryItemData.text"
@keyup.meta.enter="addHistoryItem"
/>
<!-- TODO: Add Dropdown and Checking for Usernames -->
<!-- <template #help>
<UKbd>{{metaSymbol}}</UKbd> <UKbd>Enter</UKbd> Speichern
</template>-->
</UFormGroup>
<template #footer>
<UButton @click="addHistoryItem">Speichern</UButton>
</template>
</UCard>
</UModal>
<Toolbar
v-if="!props.renderHeadline && props.elementId && props.type"
>
<UButton
@click="showAddHistoryItemModal = true"
>
+ Eintrag
</UButton>
</Toolbar>
<div v-else-if="props.renderHeadline && props.elementId && props.type">
<div :class="`flex justify-between`">
<p class=""><span class="text-xl">Logbuch</span> <UBadge variant="outline">{{items.length}}</UBadge></p>
<UButton
@click="showAddHistoryItemModal = true"
>
+ Eintrag
</UButton>
</div>
<UDivider class="my-3"/>
</div>
<!-- ITEM LIST -->
<div style="height: 90%; overflow-y: scroll">
<div
v-if="items.length > 0"
v-for="(item,index) in items.slice().reverse()"
>
<UDivider
class="my-3"
v-if="index !== 0"
/>
<div class="flex items-center gap-3">
<UAvatar
v-if="!item.created_by"
:src="colorMode.value === 'light' ? '/Logo.png' : '/Logo_Dark.png' "
/>
<UAvatar
:alt="item.created_by_profile?.full_name"
v-else
/>
<div>
<h3 v-if="item.created_by">{{item.created_by_profile?.full_name}}</h3>
<h3 v-else>FEDEO Bot</h3>
<span v-html="renderText(item.text)"/><br>
<span class="text-gray-500">{{dayjs(item.created_at).format("DD.MM.YY HH:mm")}}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { format, isToday } from 'date-fns'
const props = defineProps({
modelValue: {
type: Object ,
default: null
},
mails: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue'])
const mailsRefs = ref<Element[]>([])
const selectedMail = computed({
get() {
return props.modelValue
},
set(value: Mail | null) {
emit('update:modelValue', value)
}
})
watch(selectedMail, () => {
if (!selectedMail.value) {
return
}
const ref = mailsRefs.value[selectedMail.value.id]
if (ref) {
ref.scrollIntoView({ block: 'nearest' })
}
})
defineShortcuts({
arrowdown: () => {
const index = props.mails.findIndex((mail) => mail.id === selectedMail.value?.id)
if (index === -1) {
selectedMail.value = props.mails[0]
} else if (index < props.mails.length - 1) {
selectedMail.value = props.mails[index + 1]
}
},
arrowup: () => {
const index = props.mails.findIndex((mail) => mail.id === selectedMail.value?.id)
if (index === -1) {
selectedMail.value = props.mails[props.mails.length - 1]
} else if (index > 0) {
selectedMail.value = props.mails[index - 1]
}
}
})
</script>
<template>
<UDashboardPanelContent class="p-0">
<div v-for="(mail, index) in mails" :key="index" :ref="el => { mailsRefs[mail.id] = el as Element }">
<div
class="p-4 text-sm cursor-pointer border-l-2"
:class="[
mail.unread ? 'text-gray-900 dark:text-white' : 'text-gray-600 dark:text-gray-300',
selectedMail && selectedMail.id === mail.id ? 'border-primary-500 dark:border-primary-400 bg-primary-100 dark:bg-primary-900/25' : 'border-white dark:border-gray-900 hover:border-primary-500/25 dark:hover:border-primary-400/25 hover:bg-primary-100/50 dark:hover:bg-primary-900/10'
]"
@click="selectedMail = mail"
>
<div class="flex items-center justify-between" :class="[mail.unread && 'font-semibold']">
<div class="flex items-center gap-3">
{{ mail.from.name }}
<UChip v-if="mail.unread" />
</div>
<span>{{ isToday(new Date(mail.date)) ? format(new Date(mail.date), 'HH:mm') : format(new Date(mail.date), 'dd MMM') }}</span>
</div>
<p :class="[mail.unread && 'font-semibold']">
{{ mail.subject }}
</p>
<p class="text-gray-400 dark:text-gray-500 line-clamp-1">
{{ mail.body }}
</p>
</div>
<UDivider />
</div>
</UDashboardPanelContent>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { format, isToday } from 'date-fns'
defineProps({
mail: {
type: Object,
required: true
},
selected: {
type: Boolean,
default: false
}
})
</script>
<template>
<UDashboardPanelContent>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<UAvatar v-bind="mail.from.avatar" :alt="mail.from.name" size="lg" />
<div class="min-w-0">
<p class="text-gray-900 dark:text-white font-semibold">
{{ mail.from.name }}
</p>
<p class="text-gray-500 dark:text-gray-400 font-medium">
{{ mail.subject }}
</p>
</div>
</div>
<p class="font-medium text-gray-900 dark:text-white">
{{ isToday(new Date(mail.date)) ? format(new Date(mail.date), 'HH:mm') : format(new Date(mail.date), 'dd MMM') }}
</p>
</div>
<UDivider class="my-5" />
<div class="flex-1">
<p class="text-lg">
{{ mail.body }}
</p>
</div>
<UDivider class="my-5" />
<form @submit.prevent>
<UTextarea color="gray" required size="xl" :rows="5" :placeholder="`Reply to ${mail.from.name}`">
<UButton type="submit" color="black" label="Send" icon="i-heroicons-paper-airplane" class="absolute bottom-2.5 right-3.5" />
</UTextarea>
</form>
</UDashboardPanelContent>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
const props = defineProps({
gap: {
type: Number,
default: 1
}
})
const {gap} = props
</script>
<template>
<div :class="`flex items-center gap-${gap}`">
<slot/>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
const labelPrinter = useLabelPrinterStore()
const props = defineProps({
context: {
type: Object,
required: true,
default: {}
}
})
defineShortcuts({
meta_p: {
usingInput: true,
handler: () => {
if(!labelPrinter.connected) return
printLabel()
}
},
escape: {
usingInput: true,
handler: () => {
modal.close()
}
}
})
const emit = defineEmits(["printed"])
const modal = useModal()
const { $api } = useNuxtApp()
const loading = ref(true)
const printing = ref(false)
const labelData = ref<any>(null) // gerendertes Bild vom Backend
/** Label vom Backend rendern */
async function loadLabel() {
loading.value = true
try {
labelData.value = await $api(`/api/print/label`, {
method: "POST",
body: JSON.stringify({
context: props.context || null
})
})
} catch (err) {
console.error("Label render error", err)
}
loading.value = false
}
/** Drucken */
async function printLabel() {
if (!labelPrinter.connected) return
printing.value = true
try {
await labelPrinter.print(labelData.value.encoded, { density: 5, pages: 1 })
modal.close()
} catch (err) {
console.error("Print error", err)
}
printing.value = false
}
const handleConnect = async () => {
await loadLabel()
await labelPrinter.connect("ble")
}
onMounted(() => {
if(labelPrinter.connected) {
loadLabel()
}
})
</script>
<template>
<UModal>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Label drucken</h3>
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="modal.close()" />
</div>
</template>
<div v-if="!loading && labelPrinter.connected">
<img
:src="`data:image/png;base64,${labelData.base64}`"
alt="Label Preview"
class="max-w-full max-h-64 object-contain"
/>
</div>
<div v-else-if="loading && !labelPrinter.connected">
Kein Drucker verbunden
<LabelPrinterButton/>
</div>
<UProgress animation="carousel" v-else/>
<template #footer>
<div class="flex justify-between">
<UButton variant="ghost" @click="modal.close()">Abbrechen</UButton>
<UButton
color="primary"
:disabled="!labelPrinter.connected || printing"
:loading="printing"
@click="printLabel"
>
Drucken
<UKbd></UKbd>
<UKbd>P</UKbd>
</UButton>
</div>
</template>
</UCard>
</UModal>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
const labelPrinter = useLabelPrinterStore()
const showPrinterInfo = ref(false)
const handleClick = async () => {
if(labelPrinter.connected) {
showPrinterInfo.value = true
} else {
await labelPrinter.connect('ble')
}
}
</script>
<template>
<!-- Printer Button -->
<UModal v-model="showPrinterInfo">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Drucker Informationen</h3>
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="showPrinterInfo = false" />
</div>
</template>
<p>Seriennummer: {{labelPrinter.info.serial}}</p>
<p>MAC: {{labelPrinter.info.mac}}</p>
<p>Modell: {{labelPrinter.info.modelId}}</p>
<p>Charge: {{labelPrinter.info.charge}}</p>
<p>Hardware Version: {{labelPrinter.info.hardwareVersion}}</p>
<p>Software Version: {{labelPrinter.info.softwareVersion}}</p>
</UCard>
</UModal>
<UButton
:icon="labelPrinter.connected ? 'i-heroicons-printer' : 'i-heroicons-printer'"
:color="labelPrinter.connected ? 'green' : 'gray'"
variant="soft"
class="w-full justify-start"
:loading="labelPrinter.connectLoading"
@click="handleClick"
>
<span v-if="labelPrinter.connected">Drucker verbunden</span>
<span v-else>Drucker verbinden</span>
</UButton>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,470 @@
<script setup>
const route = useRoute()
const auth = useAuthStore()
const {has} = usePermission()
const links = computed(() => {
return [
...(auth.profile?.pinned_on_navigation || []).map(pin => {
if(pin.type === "external") {
return {
label: pin.label,
to: pin.link,
icon: pin.icon,
target: "_blank",
pinned: true
}
}else if(pin.type === "standardEntity") {
return {
label: pin.label,
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
icon: pin.icon,
pinned: true
}
}
}),
... false ? [{
label: "Support Tickets",
to: "/support",
icon: "i-heroicons-rectangle-stack",
}] : [],
{
id: 'dashboard',
label: "Dashboard",
to: "/",
icon: "i-heroicons-home"
}, {
id: 'historyitems',
label: "Logbuch",
to: "/historyitems",
icon: "i-heroicons-book-open",
disabled: true
},
{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
... has("tasks") ? [{
label: "Aufgaben",
to: "/standardEntity/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
/*... true ? [{
label: "Plantafel",
to: "/calendar/timeline",
icon: "i-heroicons-calendar-days"
}] : [],
... true ? [{
label: "Kalender",
to: "/calendar/grid",
icon: "i-heroicons-calendar-days"
}] : [],
... true ? [{
label: "Termine",
to: "/standardEntity/events",
icon: "i-heroicons-calendar-days"
}] : [],*/
/*{
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
},*/
]
},
{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
{
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",
disabled: true
},
]
},
{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: [
{
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
},
{
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
}/*, {
label: "Logbücher",
to: "/communication/historyItems",
icon: "i-heroicons-book-open"
}, {
label: "Chats",
to: "/chats",
icon: "i-heroicons-chat-bubble-left"
}*/
]
},
... (has("customers") || has("vendors") || has("contacts")) ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
... has("customers") ? [{
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
}] : [],
... has("vendors") ? [{
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
}] : [],
... has("contacts") ? [{
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
}] : [],
]
},] : [],
{
label: "Mitarbeiter",
defaultOpen:false,
icon: "i-heroicons-user-group",
children: [
... true ? [{
label: "Anwesenheiten",
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
/*... has("absencerequests") ? [{
label: "Abwesenheiten",
to: "/standardEntity/absencerequests",
icon: "i-heroicons-document-text"
}] : [],*/
/*{
label: "Fahrten",
to: "/trackingTrips",
icon: "i-heroicons-map"
},*/
]
},
... [{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: [
{
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
},{
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
},{
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
},{
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
},{
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
},{
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
},
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
},
]
}],
... has("inventory") ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: [
/*{
label: "Vorgänge",
to: "/inventory",
icon: "i-heroicons-square-3-stack-3d"
},{
label: "Bestände",
to: "/inventory/stocks",
icon: "i-heroicons-square-3-stack-3d"
},*/
... has("spaces") ? [{
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
}] : [],
]
},] : [],
{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: [
... has("products") ? [{
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
}] : [],
... has("productcategories") ? [{
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
}] : [],
... has("services") ? [{
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
... has("servicecategories") ? [{
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
{
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
},
{
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
},
... has("vehicles") ? [{
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
}] : [],
... has("inventoryitems") ? [{
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
}] : [],
... has("inventoryitems") ? [{
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
}] : [],
]
},
... has("projects") ? [{
label: "Projekte",
to: "/standardEntity/projects",
icon: "i-heroicons-clipboard-document-check"
},] : [],
... has("contracts") ? [{
label: "Verträge",
to: "/standardEntity/contracts",
icon: "i-heroicons-clipboard-document"
}] : [],
... has("plants") ? [{
label: "Objekte",
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
},] : [],
/*... has("checks") ? [{
label: "Überprüfungen",
to: "/standardEntity/checks",
icon: "i-heroicons-magnifying-glass"
},] : [],*/
{
label: "Einstellungen",
defaultOpen: false,
icon: "i-heroicons-cog-8-tooth",
children: [
{
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
},/*{
label: "Rollen",
to: "/roles",
icon: "i-heroicons-key"
},*/{
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
},{
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
},{
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
},/*{
label: "Eigene Felder",
to: "/settings/ownfields",
icon: "i-heroicons-clipboard-document-list"
},*/{
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
},{
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
},{
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
}
]
}
]
})
// nur Items mit Children → für Accordion
const accordionItems = computed(() =>
links.value.filter(item => Array.isArray(item.children) && item.children.length > 0)
)
// nur Items ohne Children → als Buttons
const buttonItems = computed(() =>
links.value.filter(item => !item.children || item.children.length === 0)
)
</script>
<template>
<!-- Standalone Buttons -->
<div class="flex flex-col gap-1">
<UButton
v-for="item in buttonItems"
:key="item.label"
:variant="item.pinned ? 'ghost' : 'ghost'"
:color="(item.to && route.path === item.to) ? 'primary' : (item.pinned ? 'amber' : 'gray')"
:icon="item.pinned ? 'i-heroicons-star' : item.icon"
class="w-full"
:to="item.to"
:target="item.target"
>
<UIcon
v-if="item.pinned"
:name="item.icon"
class="w-5 h-5 me-2"
/>
{{ item.label }}
</UButton>
</div>
<UDivider/>
<!-- Accordion für die Items mit Children -->
<UAccordion
:items="accordionItems"
:multiple="false"
class="mt-2"
>
<template #default="{ item, open }">
<UButton
:variant="'ghost'"
:color="(item.children?.some(c => route.path.includes(c.to))) ? 'primary' : 'gray'"
:icon="item.icon"
class="w-full"
>
{{ item.label }}
<template #trailing>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 ms-auto transform transition-transform duration-200"
:class="[open && 'rotate-90']"
/>
</template>
</UButton>
</template>
<template #item="{ item }">
<div class="flex flex-col">
<UButton
v-for="child in item.children"
:key="child.label"
variant="ghost"
:color="child.to === route.path ? 'primary' : 'gray'"
:icon="child.icon"
class="ml-4"
:to="child.to"
:target="child.target"
:disabled="child.disabled"
>
{{ child.label }}
</UButton>
</div>
</template>
</UAccordion>
<!-- <UAccordion
:items="links"
:multiple="false"
>
<template #default="{ item, index, open }">
<UButton
:variant="item.pinned ? 'ghost' : 'ghost'"
:color="(item.to && route.path === item.to) || (item.children?.some(c => route.path.includes(c.to))) ? 'primary' : (item.pinned ? 'amber' : 'gray')"
:icon="item.pinned ? 'i-heroicons-star' : item.icon"
class="w-full"
:to="item.to"
:target="item.target"
>
<UIcon
v-if="item.pinned"
:name="item.icon" class="w-5 h-5 me-2" />
{{ item.label }}
<template v-if="item.children" #trailing>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 ms-auto transform transition-transform duration-200"
:class="[open && 'rotate-90']"
/>
</template>
</UButton>
</template>
<template #item="{ item }">
<div class="flex flex-col" v-if="item.children?.length > 0">
<UButton
v-for="child in item.children"
:key="child.label"
variant="ghost"
:color="child.to === route.path ? 'primary' : 'gray'"
:icon="child.icon"
class="ml-4"
:to="child.to"
:target="child.target"
>
{{ child.label }}
</UButton>
</div>
</template>
</UAccordion>-->
</template>

View File

@@ -0,0 +1,64 @@
<script setup>
const props = defineProps({
startMarker: {
type: Array,
required: true
},
endMarker: {
type: Array,
required: true
}
})
const {startMarker,endMarker} = props
const zoom =ref(2)
</script>
<template>
<div class="h-full w-full">
<LMap
style="height: 300px;"
:center="startMarker"
:use-global-leaflet="false"
:zoom="10"
ref="map"
>
<LTileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&amp;copy; <a href=&quot;https://www.openstreetmap.org/&quot;>OpenStreetMap</a> contributors"
layer-type="base"
name="OpenStreetMap"
/>
<LMarker
:lat-lng="startMarker"
>
<LIcon
icon-url="https://api.iconify.design/heroicons:map-pin-solid.svg"
/>
</LMarker>
<LMarker
:lat-lng="endMarker"
>
<LIcon
icon-url="https://api.iconify.design/heroicons:flag-solid.svg"
/>
</LMarker>
</LMap>
<div class="w-2/3 mx-auto mt-3 flex flex-row justify-between">
<p><UIcon name="i-heroicons-map-pin-solid" class="mr-3"/>Start</p>
<p><UIcon name="i-heroicons-flag-solid" class="mr-3"/>Ziel</p>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
import {formatTimeAgo} from '@vueuse/core'
const supabase = useSupabaseClient()
const { isNotificationsSlideoverOpen } = useDashboard()
watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
if(newVal === true) {
await setup()
}
})
const notifications = ref([])
const setup = async () => {
notifications.value = (await supabase.from("notifications").select()).data
}
setup()
const setNotificationAsRead = async (notification) => {
console.log(notification)
const {data,error} = await supabase.from("notifications").update({read: true}).eq("id", notification.id)
console.log(error)
setup()
}
</script>
<template>
<UDashboardSlideover v-model="isNotificationsSlideoverOpen" title="Benachrichtigungen">
<NuxtLink
v-for="notification in notifications"
:key="notification.id"
:to="notification.link"
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
@click="setNotificationAsRead(notification)"
>
<UChip color="primary" :show="!notification.read" inset>
<UAvatar alt="FEDEO" size="md" />
</UChip>
<div class="text-sm flex-1">
<p class="flex items-center justify-between">
<span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span>
<time :datetime="notification.date" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.created_at))" />
</p>
<p class="text-gray-500 dark:text-gray-400">
{{ notification.message }}
</p>
</div>
</NuxtLink>
</UDashboardSlideover>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
resource: {
required: true,
type: String
},
elementId: {
required: true,
type: String
},
edit: {
type: Boolean
}
})
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,243 @@
<script setup>
import { ref, onMounted, watch } from "vue"
import { VPdfViewer, useLicense } from "@vue-pdf-viewer/viewer"
const props = defineProps({
// Beispiel: "FEDEO/26/filesbyid/11990345-8711-4e23-8851-c50f028fc915/RE25-1081.pdf"
fileId: {
type: String,
},
uri: {
type: String,
},
scale: {
type: Number,
default: 1.2,
},
location: {
type: String,
},
noControls: {
type: Boolean,
default: false,
}
})
const config = useRuntimeConfig()
useLicense(config.public.pdfLicense)
const tempStore = useTempStore()
const pdfSrc = ref(null) // ObjectURL fürs Viewer
const { $api } = useNuxtApp()
async function loadPdf(id) {
try {
const arrayBuffer = await $api(`/api/files/download/${id}`, {
method: "POST",
responseType: "arrayBuffer", // wichtig für pdf.js
})
const blob = new Blob([arrayBuffer], { type: "application/pdf" })
pdfSrc.value = URL.createObjectURL(blob)
} catch (err) {
console.error("Fehler beim Laden der PDF:", err)
}
}
onMounted(() => {
if(props.fileId) {
loadPdf(props.fileId)
} else if(props.uri) {
pdfSrc.value = props.uri
}
//window.addEventListener("resize", handleZoomTool("pageWidth"), true);
})
watch(() => props.fileId, (newPath) => {
if (newPath) loadPdf(newPath)
})
const vpvRef = ref(null);
//Zoom Control
const zoomControl = computed(() => vpvRef.value?.zoomControl)
const currentScale = computed(() => {
return zoomControl.value?.scale
})
const handleZoomTool = (type, rawScale) => {
console.log(type)
const zoomCtrl = unref(zoomControl)
if (!zoomCtrl) return
const scale = unref(currentScale)
if(!type ){
zoomCtrl.zoom(rawScale)
} else if (type === "in") {
scale && zoomCtrl.zoom(scale + 0.25)
} else if (type === "out") {
scale && zoomCtrl.zoom(scale - 0.25)
} else {
zoomCtrl.zoom(type)
}
if(["in","out"].includes(type)){
tempStore.modifySettings(`pdfviewer-scale-${props.location}`,scale)
}
}
//Page Control
const pageControl = computed(() => vpvRef.value?.pageControl)
const currentPageInput = computed(() => pageControl.value?.currentPage)
const searchControl = computed(() => vpvRef.value?.searchControl)
const totalMatches = computed(() => searchControl.value?.searchMatches?.totalMatches)
const isNextPageButtonDisable = computed(() =>
pageControl.value?.currentPage === pageControl.value?.totalPages
)
const isPreviousPageButtonDisable = computed(() =>
pageControl.value?.currentPage === 1
)
const prevPage = () => {
const isFirstPage = pageControl.value?.currentPage === 1
if (isFirstPage) return
pageControl.value?.goToPage(pageControl.value?.currentPage - 1)
}
const nextPage = () => {
const isLastPage = pageControl.value?.currentPage === pageControl.value?.totalPages
if (isLastPage) return
pageControl.value?.goToPage(pageControl.value?.currentPage + 1)
}
const handleKeyPress = (event) => {
if (event.key === "Enter") {
handlePageInput(event)
}
}
//Handle Download
const downloadControl = computed(() => vpvRef.value?.downloadControl)
const handleDownloadFile = async () => {
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
downloadCtrl.download()*/
}
watch(downloadControl, (downloadCtrl) => {
if (!downloadCtrl) return
downloadCtrl.onError = (error) => {
console.log("Download error", error)
}
downloadCtrl.onComplete = () => {
console.log("Download completed")
}
})
</script>
<template>
<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 -->
<UButton
@click="() => handleZoomTool('pageWidth')"
icon="i-heroicons-document-text"
variant="outline"
></UButton>
<UButton
@click="() => handleZoomTool('out')"
icon="i-heroicons-magnifying-glass-minus"
variant="outline"
></UButton>
<!-- Zoom in button -->
<UButton
@click="() => handleZoomTool('in')"
icon="i-heroicons-magnifying-glass-plus"
variant="outline"
></UButton>
<UButton
v-if="props.fileId || props.uri"
@click="handleDownloadFile"
variant="outline"
icon="i-heroicons-arrow-down-on-square"
/>
<UButton
@click="prevPage"
:disabled="isPreviousPageButtonDisable"
icon="i-heroicons-chevron-up"
variant="outline"
></UButton>
<!-- Page number input and total pages display -->
<div class="flex items-center text-sm font-normal">
<UInput
v-model="currentPageInput"
class="w-24 h-8 rounded-sm focus:outline-none"
@change="handleKeyPress"
>
<template #trailing>
/ {{ pageControl?.totalPages }}
</template>
</UInput>
</div>
<!-- Next page button -->
<UButton
@click="nextPage"
:disabled="isNextPageButtonDisable"
icon="i-heroicons-chevron-down"
variant="outline"
></UButton>
</div>
</div>
<div class="pdf-container">
<VPdfViewer
v-if="pdfSrc"
:src="pdfSrc"
style="height: 78vh; width: 100%;"
:toolbar-options="false"
ref="vpvRef"
@loaded="handleZoomTool(null,tempStore.settings[`pdfviewer-scale-${props.location}`] || 1)"
/>
<div v-else>
<UProgress
class="mt-5 w-2/3 mx-auto"
animation="carousel"></UProgress>
</div>
</div></template>
<style scoped>
.pdf-container {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
// Wir erwarten eine Prop, die sagt, ob geschützt werden soll
const props = defineProps<{
when: boolean // z.B. true, wenn Formular dirty ist
}>()
const showModal = ref(false)
const pendingNext = ref<null | ((val?: boolean) => void)>(null)
// --- 1. Interne Navigation (Nuxt) ---
onBeforeRouteLeave((to, from, next) => {
if (!props.when) {
next()
return
}
// Navigation pausieren & Modal zeigen
pendingNext.value = next
showModal.value = true
})
const confirmLeave = () => {
if (pendingNext.value) pendingNext.value()
showModal.value = false
}
const cancelLeave = () => {
showModal.value = false
// Navigation wird implizit abgebrochen
}
// --- 2. Externe Navigation (Browser Tab schließen) ---
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (props.when) {
e.preventDefault()
e.returnValue = ''
}
}
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
</script>
<template>
<Teleport to="body">
<div v-if="showModal" class="guard-overlay">
<div class="guard-modal">
<div class="guard-header">
<slot name="title">Seite wirklich verlassen?</slot>
</div>
<div class="guard-body">
<slot>
Du hast ungespeicherte Änderungen. Diese gehen verloren, wenn du die Seite verlässt.
</slot>
</div>
<div class="guard-actions">
<button @click="cancelLeave" class="btn-cancel">
Nein, bleiben
</button>
<button @click="confirmLeave" class="btn-confirm">
Ja, verlassen
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* Basis-Styling - passe dies an dein Design System an */
.guard-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 9999; backdrop-filter: blur(2px);
}
.guard-modal {
background: white; padding: 24px; border-radius: 12px;
width: 90%; max-width: 400px; box-shadow: 0 10px 25px rgba(0,0,0,0.2);
font-family: sans-serif;
}
.guard-header { font-size: 1.25rem; font-weight: bold; margin-bottom: 1rem; }
.guard-body { margin-bottom: 1.5rem; color: #4a5568; }
.guard-actions { display: flex; justify-content: flex-end; gap: 12px; }
/* Buttons */
button { padding: 8px 16px; border-radius: 6px; cursor: pointer; border: none; font-weight: 600;}
.btn-cancel { background: #edf2f7; color: #2d3748; }
.btn-cancel:hover { background: #e2e8f0; }
.btn-confirm { background: #e53e3e; color: white; }
.btn-confirm:hover { background: #c53030; }
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="relative overflow-hidden rounded border border-dashed border-gray-400 dark:border-gray-500 opacity-75 px-4 flex items-center justify-center">
<svg class="absolute inset-0 h-full w-full stroke-gray-900/10 dark:stroke-white/10" fill="none">
<defs>
<pattern
id="pattern-5c1e4f0e-62d5-498b-8ff0-cf77bb448c8e"
x="0"
y="0"
width="10"
height="10"
patternUnits="userSpaceOnUse"
>
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3" />
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-5c1e4f0e-62d5-498b-8ff0-cf77bb448c8e)" width="100%" height="100%" />
</svg>
<slot />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
const profileStore = useProfileStore()
</script>
<template>
<div class="w-1/2 mx-auto">
<p class="text-2xl mb-5 text-center">Bitte wähle dein gewünschtes Profil</p>
<div v-for="profile in profileStore.ownProfiles" class="flex justify-between my-3">
{{profileStore.tenants.find(i => i.id === profile.tenant).name}}
<UButton
variant="outline"
@click="profileStore.changeProfile(profile.id)"
>Auswählen</UButton>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,201 @@
<script setup>
import dayjs from 'dayjs'
const props = defineProps({
context: { type: Object, required: true },
token: { type: String, required: true },
pin: { type: String, default: '' }
})
const emit = defineEmits(['success'])
const { $api } = useNuxtApp()
const toast = useToast()
const config = computed(() => props.context.config)
const data = computed(() => props.context.data)
// Initiale Werte setzen
const form = ref({
profile: props.context.meta?.defaultProfileId || null,
project: null,
service: config.value?.defaults?.serviceId || null,
// Wenn manualTime erlaubt, setze Startzeit auf jetzt, sonst null (wird im Backend gesetzt)
startDate: config.value?.features?.timeTracking?.allowManualTime ? new Date() : null,
endDate: config.value?.features?.timeTracking?.allowManualTime ? dayjs().add(1, 'hour').toDate() : null,
dieselUsage: 0,
description: ''
})
const isSubmitting = ref(false)
const errors = ref({})
// Validierung basierend auf JSON Config
const validate = () => {
errors.value = {}
let isValid = true
const validationRules = config.value.validation || {}
// Standard-Validierung
if (!form.value.project && data.value.projects?.length > 0) {
errors.value.project = 'Pflichtfeld'
isValid = false
}
if (!form.value.service && data.value.services?.length > 0) {
errors.value.service = 'Pflichtfeld'
isValid = false
}
// Profil nur validieren, wenn Auswahl möglich ist
if (!form.value.profile && data.value.profiles?.length > 0 && !props.context.meta.defaultProfileId) {
errors.value.profile = 'Bitte Mitarbeiter wählen'
isValid = false
}
// Feature: Agriculture
if (config.value.features?.agriculture?.showDieselUsage && validationRules.requireDiesel) {
if (!form.value.dieselUsage || form.value.dieselUsage <= 0) {
errors.value.diesel = 'Dieselverbrauch erforderlich'
isValid = false
}
}
return isValid
}
const submit = async () => {
if (!validate()) return
isSubmitting.value = true
try {
const payload = { ...form.value }
// Headers vorbereiten (PIN mitsenden!)
const headers = {}
if (props.pin) headers['x-public-pin'] = props.pin
// An den Submit-Endpunkt senden (den müssen wir im Backend noch bauen!)
await $fetch(`http://localhost:3100/workflows/submit/${props.token}`, {
method: 'POST',
body: payload,
headers
})
emit('success')
} catch (e) {
console.error(e)
toast.add({ title: 'Fehler beim Speichern', color: 'red' })
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<UCard :ui="{ body: { padding: 'p-6 sm:p-8' } }" v-if="props.context && props.token">
<template #header>
<div class="text-center">
<h1 class="text-xl font-bold text-gray-900">{{ config?.ui?.title || 'Erfassung' }}</h1>
<p v-if="config?.ui?.description" class="text-sm text-gray-500 mt-1">{{ config?.ui?.description }}</p>
</div>
</template>
<div class="space-y-5">
<UFormGroup
v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0"
label="Mitarbeiter"
:error="errors.profile"
required
>
<USelectMenu
v-model="form.profile"
:options="data.profiles"
option-attribute="fullName"
value-attribute="id"
placeholder="Name auswählen..."
searchable
searchable-placeholder="Suchen..."
/>
</UFormGroup>
<UFormGroup
v-if="data?.projects?.length > 0"
:label="config.ui?.labels?.project || 'Projekt'"
:error="errors.project"
required
>
<USelectMenu
v-model="form.project"
:options="data.projects"
option-attribute="name"
value-attribute="id"
placeholder="Wählen..."
searchable
/>
</UFormGroup>
<UFormGroup
v-if="data?.services?.length > 0"
:label="config?.ui?.labels?.service || 'Leistung'"
:error="errors.service"
required
>
<USelectMenu
v-model="form.service"
:options="data.services"
option-attribute="name"
value-attribute="id"
placeholder="Wählen..."
/>
</UFormGroup>
<div v-if="config?.features?.timeTracking?.allowManualTime" class="grid grid-cols-2 gap-3">
<UFormGroup label="Start">
<input
type="datetime-local"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
:value="dayjs(form.startDate).format('YYYY-MM-DDTHH:mm')"
@input="e => form.startDate = new Date(e.target.value)"
/>
</UFormGroup>
<UFormGroup label="Ende">
<input
type="datetime-local"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
:value="dayjs(form.endDate).format('YYYY-MM-DDTHH:mm')"
@input="e => form.endDate = new Date(e.target.value)"
/>
</UFormGroup>
</div>
<UFormGroup
v-if="config?.features?.agriculture?.showDieselUsage"
label="Dieselverbrauch"
:error="errors.diesel"
:required="config?.validation?.requireDiesel"
>
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0">
<template #trailing>
<span class="text-gray-500 text-xs">Liter</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz'">
<UTextarea v-model="form.description" :rows="3" />
</UFormGroup>
</div>
<template #footer>
<UButton
block
size="xl"
:loading="isSubmitting"
@click="submit"
:label="config?.ui?.submitButtonText || 'Speichern'"
/>
</template>
</UCard>
</template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
import { useStaffTime } from '~/composables/useStaffTime'
const props = defineProps({
modelValue: { type: Boolean, default: false },
entry: { type: Object, default: null },
defaultUserId: { type: String, default: null }
})
const emit = defineEmits(['update:modelValue', 'saved'])
// 💡 createEntry importieren
const { update, createEntry } = useStaffTime()
const { $dayjs } = useNuxtApp()
const toast = useToast()
const loading = ref(false)
const types = [
{ label: 'Arbeitszeit', value: 'work' },
{ label: 'Pause', value: 'pause' },
{ label: 'Urlaub', value: 'vacation' },
{ label: 'Krankheit', value: 'sick' },
{ label: 'Feiertag', value: 'holiday' },
{ label: 'Sonstiges', value: 'other' }
]
const state = reactive({
start_date: '',
start_time: '',
end_date: '',
end_time: '',
type: 'work',
description: ''
})
const schema = z.object({
start_date: z.string().min(1, 'Datum erforderlich'),
start_time: z.string().min(1, 'Zeit erforderlich'),
type: z.string(),
description: z.string().optional()
})
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const toDateStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('YYYY-MM-DD') : ''
const toTimeStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('HH:mm') : ''
watch(() => props.entry, (newVal) => {
if (newVal) {
// EDIT
state.start_date = toDateStr(newVal.started_at)
state.start_time = toTimeStr(newVal.started_at)
state.end_date = newVal.stopped_at ? toDateStr(newVal.stopped_at) : ''
state.end_time = newVal.stopped_at ? toTimeStr(newVal.stopped_at) : ''
state.type = newVal.type || 'work'
state.description = newVal.description || ''
} else {
// CREATE (Standardwerte: Heute)
const now = $dayjs()
state.start_date = now.format('YYYY-MM-DD')
state.start_time = now.format('HH:mm')
state.end_date = ''
state.end_time = ''
state.type = 'work'
state.description = ''
}
}, { immediate: true })
async function onSubmit(event: FormSubmitEvent<any>) {
loading.value = true
try {
// 1. Datum und Zeit kombinieren
const startIso = $dayjs(`${state.start_date} ${state.start_time}`).toISOString()
let endIso = null
if (state.end_date && state.end_time) {
endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString()
if ($dayjs(endIso).isBefore($dayjs(startIso))) {
throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.")
}
}
if (props.entry) {
// 🟢 UPDATE (Bearbeiten)
await update(props.entry, {
start: startIso,
end: endIso,
type: state.type,
description: state.description
})
toast.add({ title: 'Eintrag aktualisiert', color: 'green' })
} else {
// 🟢 CREATE (Neu Erstellen)
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
await createEntry({
start: startIso, // Die eingegebene Startzeit
end: endIso, // Die eingegebene Endzeit (oder null)
type: state.type,
description: state.description
})
toast.add({ title: 'Zeit manuell erfasst', color: 'green' })
}
emit('saved')
isOpen.value = false
} catch (error: any) {
toast.add({ title: 'Fehler', description: error.message, color: 'red' })
} finally {
loading.value = false
}
}
</script>
<template>
<UModal v-model="isOpen">
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
{{ entry ? 'Eintrag bearbeiten' : 'Neue Zeit erfassen' }}
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
</div>
</template>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormGroup label="Typ" name="type">
<USelectMenu v-model="state.type" :options="types" value-attribute="value" option-attribute="label" />
</UFormGroup>
<div class="grid grid-cols-2 gap-4">
<UFormGroup label="Start Datum" name="start_date">
<UInput type="date" v-model="state.start_date" />
</UFormGroup>
<UFormGroup label="Start Zeit" name="start_time">
<UInput type="time" v-model="state.start_time" />
</UFormGroup>
</div>
<div class="grid grid-cols-2 gap-4">
<UFormGroup label="Ende Datum" name="end_date">
<UInput type="date" v-model="state.end_date" />
</UFormGroup>
<UFormGroup label="Ende Zeit" name="end_time">
<UInput type="time" v-model="state.end_time" />
</UFormGroup>
</div>
<p class="text-xs text-gray-500 -mt-2">Leer lassen, wenn die Zeit noch läuft.</p>
<UFormGroup label="Beschreibung / Notiz" name="description">
<UTextarea v-model="state.description" placeholder="Was wurde gemacht?" />
</UFormGroup>
<div class="flex justify-end gap-2 pt-4">
<UButton label="Abbrechen" color="gray" variant="ghost" @click="isOpen = false" />
<UButton type="submit" label="Speichern" color="primary" :loading="loading" />
</div>
</UForm>
</UCard>
</UModal>
</template>

View File

@@ -0,0 +1,98 @@
<script setup>
const toast = useToast()
const dataStore = useDataStore()
const supabase = useSupabaseClient()
const modal = useModal()
const props = defineProps({
type: {
type: String,
required: true
},
mode: {
type: String,
required: true,
default: "show"
},
createQuery: {
type: Object
},
id: {
type: String,
},
})
const emit = defineEmits(["updateNeeded","returnData"])
const dataType = dataStore.dataTypes[props.type]
const loaded = ref(false)
const items = ref([])
const item = ref({})
const setupPage = async () => {
if(props.mode === "show") {
//Load Data for Show
item.value = await useEntities(props.type).selectSingle(props.id, dataType.supabaseSelectWithInformation || "*")
} else if(props.mode === "edit") {
//Load Data for Edit
const data = JSON.stringify(await useEntities(props.type).selectSingle(props.id)/*(await supabase.from(props.type).select().eq("id", props.id).single()).data*/)
//await useSupabaseSelectSingle(type, route.params.id)
item.value = data
} else if(props.mode === "create") {
//Load Data for Create
item.value = JSON.stringify({})
} else if(props.mode === "list") {
//Load Data for List
items.value = await useEntities(props.type).select(dataType.supabaseSelectWithInformation || "*", dataType.supabaseSortColumn,dataType.supabaseSortAscending || false)
}
loaded.value = true
}
setupPage()
</script>
<template>
<UModal :fullscreen="props.mode === 'show'">
<EntityShow
v-if="loaded && props.mode === 'show'"
:type="props.type"
:item="item"
@updateNeeded="setupPage"
:key="item"
:in-modal="true"
/>
<EntityEdit
v-else-if="loaded && (props.mode === 'edit' || props.mode === 'create')"
:type="props.type"
:item="item"
:inModal="true"
@return-data="(data) => emit('return-data',data)"
:createQuery="props.createQuery"
:mode="props.mode"
/>
<!-- <EntityList
v-else-if="loaded && props.mode === 'list'"
:type="props.type"
:items="items"
/>-->
<UProgress
v-else
animation="carousel"
class="p-5 mt-10"
/>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,27 @@
<script setup>
const auth = useAuthStore()
const selectedTenant = ref(auth.user.tenant_id)
</script>
<template>
<USelectMenu
:options="auth.tenants"
value-attribute="id"
class="w-40"
@change="auth.switchTenant(selectedTenant)"
v-model="selectedTenant"
>
<UButton color="gray" variant="ghost" :class="[open && 'bg-gray-50 dark:bg-gray-800']" class="w-full">
<UAvatar :alt="auth.activeTenantData?.name" size="md" />
<span class="truncate text-gray-900 dark:text-white font-semibold">{{auth.tenants.find(i => auth.activeTenant === i.id).name}}</span>
</UButton>
<template #option="{option}">
{{option.name}}
</template>
</USelectMenu>
</template>

View File

@@ -0,0 +1,172 @@
<template>
<div class="p-3 editor">
<div v-if="editor">
<InputGroup class="mb-3">
<UButton
@click="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
icon="i-heroicons-bold"
variant="outline"
/>
<UButton
@click="editor.chain().focus().toggleItalic().run()"
:disabled="!editor.can().chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
icon="i-heroicons-italic"
variant="outline"
/>
<UButton
@click="editor.chain().focus().toggleStrike().run()"
:disabled="!editor.can().chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
icon="i-mdi-format-strikethrough"
variant="outline"
/>
<!--<UButton
@click="editor.chain().focus().toggleCode().run()"
:disabled="!editor.can().chain().focus().toggleCode().run()"
:class="{ 'is-active': editor.isActive('code') }"
icon="i-heroicons-code-bracket"
variant="outline"
/>
<UButton @click="editor.chain().focus().unsetAllMarks().run()">
clear marks
</UButton>
<UButton @click="editor.chain().focus().clearNodes().run()">
clear nodes
</UButton>
<UButton
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
variant="outline"
>
paragraph
</UButton>-->
<UButton
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
variant="outline"
>h1</UButton>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
variant="outline"
>h2</UButton>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
variant="outline"
>
h3
</UButton>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
variant="outline"
>
h4
</UButton>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
variant="outline"
>
h5
</UButton>
<UButton
@click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
variant="outline"
>
h6
</UButton>
<UButton
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
icon="i-heroicons-list-bullet"
variant="outline"
/>
<UButton
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
icon="i-mdi-format-list-numbered"
variant="outline"
/>
<!-- <UButton
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
>
code block
</UButton>
<UButton
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
>
blockquote
</UButton>
<UButton @click="editor.chain().focus().setHorizontalRule().run()">
horizontal rule
</UButton>
<UButton @click="editor.chain().focus().setHardBreak().run()">
hard break
</UButton>
<UButton
@click="editor.chain().focus().undo().run()"
:disabled="!editor.can().chain().focus().undo().run()"
>
undo
</UButton>
<UButton
@click="editor.chain().focus().redo().run()"
:disabled="!editor.can().chain().focus().redo().run()"
>
redo
</UButton>-->
</InputGroup>
</div>
<TiptapEditorContent :editor="editor" />
</div>
</template>
<script setup>
const emit = defineEmits(['updateContent'])
const props = defineProps({
preloadedContent: {
type: String,
required:false
}
})
const {preloadedContent} = props
const editor = useEditor({
content: "<p>I'm running Tiptap with Vue.js. 🎉</p>",
extensions: [TiptapStarterKit],
onUpdate({editor}) {
//console.log(editor.getJSON())
//console.log(editor.getHTML())
//console.log(editor.getText())
emit('updateContent',{json: editor.getJSON(),html: editor.getHTML(), text: editor.getText()})
},
onCreate({editor}) {
editor.commands.setContent(preloadedContent)
emit('updateContent',{json: editor.getJSON(),html: editor.getHTML(), text: editor.getText()})
}
});
onBeforeUnmount(() => {
unref(editor).destroy();
});
</script>
<style scoped>
.editor {
border: 1px solid #69c350;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
</script>
<template>
<div class="flex flex-row justify-between">
<InputGroup>
<slot />
</InputGroup>
<InputGroup>
<slot name="right"/>
</InputGroup>
</div>
<UDivider class="my-3"/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,62 @@
<script setup>
const { isHelpSlideoverOpen } = useDashboard()
const { isDashboardSearchModalOpen } = useUIState()
const { metaSymbol } = useShortcuts()
const user = useSupabaseUser()
const dataStore = useDataStore()
const profileStore = useProfileStore()
const supabase = useSupabaseClient()
const router = useRouter()
const auth = useAuthStore()
const items = computed(() => [
[{
slot: 'account',
label: '',
disabled: true
}], [/*{
label: 'Mein Profil',
icon: 'i-heroicons-user',
to: `/profiles/show/${profileStore.activeProfile.id}`
},*/{
label: 'Passwort ändern',
icon: 'i-heroicons-shield-check',
to: `/password-change`
},{
label: 'Abmelden',
icon: 'i-heroicons-arrow-left-on-rectangle',
click: async () => {
await auth.logout()
}
}]
])
</script>
<template>
<UDropdown mode="hover" :items="items" :ui="{ width: 'w-full', item: { disabled: 'cursor-text select-text' } }" :popper="{ strategy: 'absolute', placement: 'top' }" class="w-full">
<template #default="{ open }">
<UButton color="gray" variant="ghost" class="w-full" :label="auth.user.email" :class="[open && 'bg-gray-50 dark:bg-gray-800']">
<!-- <template #leading>
<UAvatar :alt="auth.user.email" size="xs" />
</template>-->
<template #trailing>
<UIcon name="i-heroicons-ellipsis-vertical" class="w-5 h-5 ml-auto" />
</template>
</UButton>
</template>
<template #account>
<div class="text-left">
<p>
Angemeldet als
</p>
<p class="truncate font-medium text-gray-900 dark:text-white">
{{auth.user.email}}
</p>
</div>
</template>
</UDropdown>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.active" class="text-primary">Ja</span>
<span v-else class="text-rose-600">Nein</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.infoData.streetNumber">{{props.row.infoData.streetNumber}},</span>
<span v-if="props.row.infoData.street">{{props.row.infoData.street}},</span>
<span v-if="props.row.infoData.special">{{props.row.infoData.special}},</span>
<span v-if="props.row.infoData.zip">{{props.row.infoData.zip}},</span>
<span v-if="props.row.infoData.city">{{props.row.infoData.city}}</span>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
</script>
<template>
<div v-if="props.row.contact">
<nuxt-link v-if="props.inShow" :to="`/standardEntity/contacts/show/${props.row.contact.id}`">{{props.row.contact ? props.row.contact.name : ''}}</nuxt-link>
<span v-else>{{props.row.contact ? props.row.contact.name : ''}}</span>
</div></template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.created_at">{{dayjs(props.row.created_at).format("DD.MM.YYYY HH:mm")}}</span>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
</script>
<template>
<div v-if="props.row.customer">
<nuxt-link v-if="props.inShow" :to="`/standardEntity/customers/show/${props.row.customer.id}`">{{props.row.customer ? props.row.customer.name : ''}}</nuxt-link>
<span v-else>{{props.row.customer ? props.row.customer.name : ''}}</span>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<div v-if="props.row.description" v-html="props.row.description.html"/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.driver">{{props.row.driver ? "" : props.row.driver}}</span>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.endDate">{{dayjs(props.row.endDate).format("DD.MM.YYYY")}}</span>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.endDate">{{dayjs(props.row.endDate).format("DD.MM.YYYY HH:mm")}}</span>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const supabase = useSupabaseClient()
let inventoryitemgroups = await Promise.all(props.row.inventoryitemgroups.map(async (i) => {
return (await supabase.from("inventoryitemgroups").select("id,name").eq("id",i).single()).data.name
}))
</script>
<template>
<div v-if="props.row.inventoryitemgroups">
{{props.row.inventoryitemgroups ? inventoryitemgroups.join(", ") : ''}}
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const supabase = useSupabaseClient()
let inventoryitems = await Promise.all(props.row.inventoryitems.map(async (i) => {
return (await supabase.from("inventoryitems").select("id,name").eq("id",i).single()).data.name
}))
</script>
<template>
<div v-if="props.row.inventoryitems">
{{props.row.inventoryitems ? inventoryitems.join(", ") : ''}}
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{props.row.isCompany ? 'Unternehmen' : 'Privat'}}</span>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.phases && props.row.phases.length > 0">{{props.row.phases.find(i => i.active).label}}</span>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
</script>
<template>
<div v-if="props.row.plant">
<nuxt-link v-if="props.inShow " :to="`/standardEntity/plants/show/${props.row.plant.id}`">{{props.row.plant ? props.row.plant.name : ''}}</nuxt-link>
<span v-else>{{props.row.plant ? props.row.plant.name : ''}}</span>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const profileStore = useProfileStore()
</script>
<template>
<span v-if="props.row.profile">{{props.row.profile.id ? profileStore.getProfileById(props.row.profile.id).fullName : profileStore.getProfileById(props.row.profile).fullName}}</span>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
const profileStore = useProfileStore()
const profiles = computed(() => props.row.profiles.map(id => profileStore.getProfileById(id).fullName).join(', '))
</script>
<template>
<div v-if="props.row.profiles">
<div v-if="props.inShow">
<nuxt-link v-for="(profileId, index) in props.row.profiles" :to="`/profiles/show/${profileId}`">{{profileStore.getProfileById(profileId).fullName}}{{index < props.row.profiles.length - 1 ? "," : ""}}</nuxt-link>
</div>
<span v-else>{{props.row.profiles ? profiles : ''}}</span>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
</script>
<template>
<div v-if="props.row.project">
<nuxt-link v-if="props.inShow" :to="`/standardEntity/projects/show/${props.row.project.id}`">{{props.row.project ? props.row.project.name : ''}}</nuxt-link>
<span v-else>{{props.row.project ? props.row.project.name : ''}}</span>
</div></template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{props.row.projecttype ? props.row.projecttype.name : ''}}</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{props.row.purchase_price ? useCurrency(props.row.purchase_price) : ''}}</span>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.recurring">Ja</span>
<span v-else>Nein</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{props.row.selling_price ? useCurrency(props.row.selling_price) : ''}}</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.sellingPriceComposed">{{ useCurrency(props.row.sellingPriceComposed.material)}}</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.sellingPriceComposed">{{ useCurrency(props.row.sellingPriceComposed.total) }}</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.sellingPriceComposed">{{ useCurrency(props.row.sellingPriceComposed.worker) }}</span>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.hasSEPA">Ja</span>
<span v-else>Nein</span>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.sepaDate">{{dayjs(props.row.sepaDate).format("DD.MM.YYYY")}}</span>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const servicecategories = ref([])
const setup = async () => {
servicecategories.value = await useEntities("servicecategories").select()
}
setup()
</script>
<template>
<span v-if="props.row.servicecategories && servicecategories.length > 0">{{props.row.servicecategories.map(i => servicecategories.find(x => x.id === i).name).join(", ")}}</span>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.signDate">{{dayjs(props.row.signDate).format("DD.MM.YYYY")}}</span>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.currentSpace">{{props.row.currentSpace.name}}</span>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.startDate">{{dayjs(props.row.startDate).format("DD.MM.YYYY")}}</span>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.startDate">{{dayjs(props.row.startDate).format("DD.MM.YYYY HH:mm")}}</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{props.row.unit ? props.row.unit.name : ''}}</span>
</template>

Some files were not shown because too many files have changed in this diff Show More