Added Frontend
27
frontend/.gitignore
vendored
Normal 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
@@ -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
@@ -0,0 +1 @@
|
||||
@bryntum:registry=https://npm.bryntum.com
|
||||
1
frontend/.nuxtrc
Normal file
@@ -0,0 +1 @@
|
||||
uiPro.license=2A7272BC-085A-40E1-982F-5E31261BB164
|
||||
15
frontend/Dockerfile
Normal 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
38
frontend/app.config.ts
Normal 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
@@ -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
|
After Width: | Height: | Size: 69 KiB |
BIN
frontend/asssets/icons/icon-128.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/asssets/icons/icon-192.webp
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/asssets/icons/icon-256.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/asssets/icons/icon-48.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/asssets/icons/icon-512.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/asssets/icons/icon-72.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/asssets/icons/icon-96.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
frontend/asssets/logo-dark.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
frontend/asssets/logo.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
frontend/asssets/splash.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
16
frontend/capacitor.config.ts
Normal 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;
|
||||
74
frontend/components/ArchiveButton.vue
Normal 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>
|
||||
66
frontend/components/ButtonWithConfirm.vue
Normal 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>
|
||||
54
frontend/components/DatePicker.vue
Normal 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>
|
||||
95
frontend/components/DocumentDisplay.vue
Normal 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>
|
||||
365
frontend/components/DocumentDisplayModal.vue
Normal 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>
|
||||
43
frontend/components/DocumentList.vue
Normal 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>
|
||||
42
frontend/components/DocumentUpload.vue
Normal 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>
|
||||
161
frontend/components/DocumentUploadModal.vue
Normal 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>
|
||||
141
frontend/components/Editor.client.vue
Normal 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>
|
||||
855
frontend/components/EntityEdit.vue
Normal 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>
|
||||
203
frontend/components/EntityList.vue
Normal 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>
|
||||
83
frontend/components/EntityModalButtons.vue
Normal 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>
|
||||
367
frontend/components/EntityShow.vue
Normal 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>
|
||||
128
frontend/components/EntityShowSub.vue
Normal 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>
|
||||
35
frontend/components/EntityShowSubCostCentreReport.vue
Normal 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>
|
||||
314
frontend/components/EntityShowSubCreatedDocuments.vue
Normal 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>
|
||||
134
frontend/components/EntityShowSubFiles.vue
Normal 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>
|
||||
42
frontend/components/EntityShowSubHistoryDisplay.vue
Normal 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>
|
||||
77
frontend/components/EntityShowSubInformation.vue
Normal 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>
|
||||
108
frontend/components/EntityShowSubOwnAccountsStatements.vue
Normal 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>
|
||||
169
frontend/components/EntityShowSubPhases.vue
Normal 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>
|
||||
117
frontend/components/EntityShowSubTimes.vue
Normal 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>
|
||||
137
frontend/components/EntityTable.vue
Normal 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>
|
||||
129
frontend/components/EntityTableMobile.vue
Normal 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>
|
||||
89
frontend/components/GlobalMessages.vue
Normal 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>
|
||||
140
frontend/components/GlobalSearch.vue
Normal 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>
|
||||
231
frontend/components/HelpSlideover.vue
Normal 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"
|
||||
>
|
||||
<!– <UFormGroup
|
||||
label="Art:"
|
||||
>
|
||||
<USelectMenu
|
||||
:options="['Hilfe','Software Problem / Bug','Funktionsanfrage','Kontakt','Sonstiges']"
|
||||
v-model="contactRequestData.contactType"
|
||||
/>
|
||||
</UFormGroup>–>
|
||||
<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>
|
||||
165
frontend/components/HistoryDisplay.vue
Normal 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>
|
||||
92
frontend/components/InboxList.vue
Normal 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>
|
||||
53
frontend/components/InboxMail.vue
Normal 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>
|
||||
20
frontend/components/InputGroup.vue
Normal 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>
|
||||
130
frontend/components/LabelPrintModal.vue
Normal 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>
|
||||
53
frontend/components/LabelPrinterButton.vue
Normal 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>
|
||||
470
frontend/components/MainNav.vue
Normal 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>
|
||||
64
frontend/components/Map.vue
Normal 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="&copy; <a href="https://www.openstreetmap.org/">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>
|
||||
60
frontend/components/NotificationsSlideover.vue
Normal 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>
|
||||
23
frontend/components/OwnFields.vue
Normal 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>
|
||||
243
frontend/components/PDFViewer.client.vue
Normal 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>
|
||||
98
frontend/components/PageLeaveGuard.vue
Normal 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>
|
||||
21
frontend/components/Placeholder.vue
Normal 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>
|
||||
24
frontend/components/ProfileSelection.vue
Normal 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>
|
||||
201
frontend/components/PublicDynamicForm.vue
Normal 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>
|
||||
170
frontend/components/StaffTimeEntryModal.vue
Normal 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>
|
||||
98
frontend/components/StandardEntityModal.vue
Normal 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>
|
||||
27
frontend/components/TenantDropdown.vue
Normal 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>
|
||||
172
frontend/components/Tiptap.vue
Normal 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>
|
||||
19
frontend/components/Toolbar.vue
Normal 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>
|
||||
62
frontend/components/UserDropdown.vue
Normal 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>
|
||||
14
frontend/components/columnRenderings/active.vue
Normal 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>
|
||||
18
frontend/components/columnRenderings/address.vue
Normal 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>
|
||||
19
frontend/components/columnRenderings/contact.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/created_at.vue
Normal 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>
|
||||
20
frontend/components/columnRenderings/customer.vue
Normal 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>
|
||||
13
frontend/components/columnRenderings/description.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/driver.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/endDate.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/endDateTime.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
13
frontend/components/columnRenderings/isCompany.vue
Normal 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>
|
||||
14
frontend/components/columnRenderings/phase.vue
Normal 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>
|
||||
20
frontend/components/columnRenderings/plant.vue
Normal 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>
|
||||
16
frontend/components/columnRenderings/profile.vue
Normal 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>
|
||||
27
frontend/components/columnRenderings/profiles.vue
Normal 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>
|
||||
19
frontend/components/columnRenderings/project.vue
Normal 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>
|
||||
13
frontend/components/columnRenderings/projecttype.vue
Normal 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>
|
||||
13
frontend/components/columnRenderings/purchasePrice.vue
Normal 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>
|
||||
14
frontend/components/columnRenderings/recurring.vue
Normal 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>
|
||||
13
frontend/components/columnRenderings/sellingPrice.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
14
frontend/components/columnRenderings/sepa.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/sepaDate.vue
Normal 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>
|
||||
21
frontend/components/columnRenderings/serviceCategories.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/signDate.vue
Normal 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>
|
||||
14
frontend/components/columnRenderings/space.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/startDate.vue
Normal 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>
|
||||
15
frontend/components/columnRenderings/startDateTime.vue
Normal 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>
|
||||
13
frontend/components/columnRenderings/unit.vue
Normal 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>
|
||||