Added Frontend

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

View File

@@ -0,0 +1,222 @@
<script setup>
import dayjs from "dayjs";
defineShortcuts({
'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
}
})
const tempStore = useTempStore()
const router = useRouter()
const items = ref([])
const dataLoaded = ref(false)
const statementallocations = ref([])
const incominginvoices = ref([])
const setupPage = async () => {
items.value = await useEntities("accounts").selectSpecial()
statementallocations.value = (await useEntities("statementallocations").select("*, bs_id(*)"))
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
items.value = await Promise.all(items.value.map(async (i) => {
// let renderedAllocationsTemp = await renderedAllocations(i.id)
// let saldo = getSaldo(renderedAllocationsTemp)
return {
...i,
// saldo: saldo,
// allocations: renderedAllocationsTemp.length,
}
}))
dataLoaded.value = true
}
const renderedAllocations = async (account) => {
let statementallocationslocal = statementallocations.value.filter(i => i.account === account)
let incominginvoiceslocal = incominginvoices.value.filter(i => i.accounts.find(x => x.account === account))
let tempstatementallocations = statementallocationslocal.map(i => {
return {
...i,
type: "statementallocation",
date: i.bankstatement.date,
partner: i.bankstatement ? (i.bankstatement.debName ? i.bankstatement.debName : (i.bankstatement.credName ? i.bankstatement.credName : '')) : ''
}
})
let incominginvoicesallocations = []
incominginvoiceslocal.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account === account).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",
expense: i.expense
}
}))
})
return [...tempstatementallocations, ... incominginvoicesallocations]
}
const getSaldo = (allocations) => {
let value = 0
allocations.forEach(i => {
if(i.incominginvoiceid) {
if(i.expense) {
value = value - i.amount
} else {
value += i.amount
}
} else {
value += i.amount
}
})
return value
}
const templateColumns = [
{
key: "number",
label: "Nummer"
},{
key: "label",
label: "Name"
},/*{
key: "allocations",
label: "Buchungen"
},{
key: "saldo",
label: "Saldo"
},*/ {
key: "description",
label: "Beschreibung"
},
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
const searchString = ref(tempStore.searchStrings["bankstatements"] ||'')
const clearSearchString = () => {
tempStore.clearSearchString("bankstatements")
searchString.value = ''
}
const selectedFilters = ref(['Nur offene anzeigen'])
const filteredRows = computed(() => {
let temp = items.value
return useSearch(searchString.value, temp)
})
setupPage()
</script>
<template>
<UDashboardNavbar title="Buchungskonten" :badge="filteredRows.length">
<template #right>
<UInput
id="searchinput"
name="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('bankstatements',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<!-- <USelectMenu
v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid"
:options="templateColumns"
multiple
class="hidden lg:block"
by="key"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Spalten
</template>
</USelectMenu>-->
<USelectMenu
icon="i-heroicons-adjustments-horizontal-solid"
multiple
v-model="selectedFilters"
:options="['Nur offene anzeigen']"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Filter
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<UTable
:rows="filteredRows"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/accounts/show/${i.id}`)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #allocations-data="{row}">
<span v-if="dataLoaded">{{row.allocations ? row.allocations : null}}</span>
<USkeleton v-else class="h-4 w-[250px]" />
</template>
<template #saldo-data="{row}">
<span v-if="dataLoaded">{{row.allocations ? useCurrency(row.saldo) : null}}</span>
<USkeleton v-else class="h-4 w-[250px]" />
</template>
</UTable>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,170 @@
<script setup>
import dayjs from "dayjs";
const route = useRoute()
const router = useRouter()
const itemInfo = ref(null)
const statementallocations = ref([])
const incominginvoices = ref([])
const setup = async () => {
itemInfo.value = (await useEntities("accounts").selectSpecial("*")).find(i => i.id === Number(route.params.id))
statementallocations.value = (await useEntities("statementallocations").select("*, bs_id(*)")).filter(i => i.account === Number(route.params.id))
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)")).filter(i => i.accounts.find(x => x.account === Number(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 = statementallocations.value.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",
expense: i.expense
}
}))
})
return [...tempstatementallocations, ... incominginvoicesallocations]
})
const saldo = computed(() => {
let value = 0
renderedAllocations.value.forEach(i => {
if(i.incominginvoiceid) {
if(i.expense) {
value = value - i.amount
} else {
value += i.amount
}
} else {
value += i.amount
}
})
return value
})
</script>
<template>
<UDashboardNavbar>
<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(`/accounts`)"
>
Übersicht
</UButton>
</template>
<template #center>
<h1
v-if="itemInfo"
:class="['text-xl','font-medium']"
>{{itemInfo ? `Buchungskonto: ${itemInfo.number} - ${itemInfo.label}`: '' }}</h1>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<UTabs :items="[{label: 'Information'},{label: 'Buchungen'}]">
<template #item="{item}">
<UCard class="mt-5" v-if="item.label === 'Information'">
<div class="text-wrap">
<table class="w-full" v-if="itemInfo">
<tr>
<td>Nummer:</td>
<td>{{itemInfo.number}}</td>
</tr>
<tr>
<td>Name:</td>
<td>{{itemInfo.label}}</td>
</tr>
<tr>
<td>Anzahl:</td>
<td>{{renderedAllocations.length}}</td>
</tr>
<tr>
<td>Saldo:</td>
<td>{{useCurrency(saldo)}}</td>
</tr>
<tr>
<td>Beschreibung:</td>
<td>{{itemInfo.description}}</td>
</tr>
</table>
</div>
</UCard>
<UCard class="mt-5" v-if="item.label === 'Buchungen'">
<UTable
v-if="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>
</UTabs>
</UDashboardPanelContent>
</template>
<style scoped>
td {
border-bottom: 1px solid lightgrey;
vertical-align: top;
padding-bottom: 0.15em;
padding-top: 0.15em;
}
</style>

View File

@@ -0,0 +1,255 @@
<script setup>
import dayjs from "dayjs";
// Zugriff auf $api und Toast Notification
const { $api } = useNuxtApp()
const toast = useToast()
defineShortcuts({
'/': () => {
document.getElementById("searchinput").focus()
}
})
const tempStore = useTempStore()
const router = useRouter()
const route = useRoute()
const bankstatements = ref([])
const bankaccounts = ref([])
const filterAccount = ref([])
// Status für den Lade-Button
const isSyncing = ref(false)
const setupPage = async () => {
bankstatements.value = (await useEntities("bankstatements").select("*, statementallocations(*)", "date", false))
bankaccounts.value = await useEntities("bankaccounts").select()
if(bankaccounts.value.length > 0) filterAccount.value = bankaccounts.value
}
// Funktion für den Bankabruf
const syncBankStatements = async () => {
isSyncing.value = true
try {
await $api('/api/functions/services/bankstatementsync', { method: 'POST' })
toast.add({
title: 'Erfolg',
description: 'Bankdaten wurden erfolgreich synchronisiert.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Wichtig: Daten neu laden, damit die neuen Buchungen direkt sichtbar sind
await setupPage()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Beim Abrufen der Bankdaten ist ein Fehler aufgetreten.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
} finally {
isSyncing.value = false
}
}
const templateColumns = [
{
key: "account",
label: "Konto"
},{
key: "valueDate",
label: "Valuta"
},
{
key: "amount",
label: "Betrag"
},
{
key: "openAmount",
label: "Offener Betrag"
},
{
key: "partner",
label: "Name"
},
{
key: "text",
label: "Beschreibung"
}
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
const searchString = ref(tempStore.searchStrings["bankstatements"] ||'')
const clearSearchString = () => {
tempStore.clearSearchString("bankstatements")
searchString.value = ''
}
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
}
const calculateOpenSum = (statement) => {
let startingAmount = 0
statement.statementallocations.forEach(item => {
startingAmount += item.amount
})
return (statement.amount - startingAmount).toFixed(2)
}
const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] ? tempStore.filters["banking"]["main"] : ['Nur offene anzeigen'])
const filteredRows = computed(() => {
let temp = bankstatements.value
if(route.query.filter) {
console.log(route.query.filter)
temp = temp.filter(i => JSON.parse(route.query.filter).includes(i.id))
} else {
if(selectedFilters.value.includes("Nur offene anzeigen")){
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
}
if(selectedFilters.value.includes("Nur positive anzeigen")){
temp = temp.filter(i => i.amount >= 0)
}
if(selectedFilters.value.includes("Nur negative anzeigen")){
temp = temp.filter(i => i.amount < 0)
}
}
return useSearch(searchString.value, temp.filter(i => filterAccount.value.find(x => x.id === i.account)))
})
setupPage()
</script>
<template>
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
<template #right>
<UButton
label="Bankabruf"
icon="i-heroicons-arrow-path"
color="primary"
variant="solid"
:loading="isSyncing"
@click="syncBankStatements"
class="mr-2"
/>
<UInput
id="searchinput"
name="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('bankstatements',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<USelectMenu
:options="bankaccounts"
v-model="filterAccount"
option-attribute="iban"
multiple
by="id"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Konto
</template>
</USelectMenu>
</template>
<template #right>
<USelectMenu
icon="i-heroicons-adjustments-horizontal-solid"
multiple
v-model="selectedFilters"
:options="['Nur offene anzeigen','Nur positive anzeigen','Nur negative anzeigen']"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
@change="tempStore.modifyFilter('banking','main',selectedFilters)"
>
<template #label>
Filter
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<UTable
:rows="filteredRows"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/banking/statements/edit/${i.id}`)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #account-data="{row}">
{{row.account ? bankaccounts.find(i => i.id === row.account).iban : ""}}
</template>
<template #valueDate-data="{row}">
{{dayjs(row.valueDate).format("DD.MM.YY")}}
</template>
<template #amount-data="{row}">
<span
v-if="row.amount >= 0"
class="text-primary-500"
>{{String(row.amount.toFixed(2)).replace(".",",")}} </span>
<span
v-else-if="row.amount < 0"
class="text-rose-500"
>{{String(row.amount.toFixed(2)).replace(".",",")}} </span>
</template>
<template #openAmount-data="{row}">
{{displayCurrency(calculateOpenSum(row))}}
</template>
<template #partner-data="{row}">
<span
v-if="row.amount < 0"
>
{{row.credName}}
</span>
<span
v-else-if="row.amount > 0"
>
{{row.debName}}
</span>
</template>
</UTable>
<PageLeaveGuard :when="isSyncing"/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,876 @@
<script setup>
import dayjs from "dayjs";
import {filter} from "vuedraggable/dist/vuedraggable.common.js";
defineShortcuts({
'backspace': () => {
router.push("/banking")
}
})
const dataStore = useDataStore()
const tempStore = useTempStore()
const route = useRoute()
const router = useRouter()
const mode = ref(route.params.mode || "show")
const itemInfo = ref({statementallocations:[]})
const oldItemInfo = ref({})
const openDocuments = ref([])
const allocatedDocuments = ref([])
const openIncomingInvoices = ref([])
const allocatedIncomingInvoices = ref([])
const customers = ref([])
const vendors = ref([])
const createddocuments = ref([])
const incominginvoices = ref([])
const accounts = ref([])
const ownaccounts = ref([])
const loading = ref(true)
const setup = async () => {
loading.value = true
if(route.params.id) {
itemInfo.value = await useEntities("bankstatements").selectSingle(route.params.id,"*, statementallocations(*, cd_id(*), ii_id(*))", undefined, undefined, true)
}
if(itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
manualAllocationSum.value = calculateOpenSum.value
createddocuments.value = (await useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"))
const documents = createddocuments.value.filter(i => i.type === "invoices" ||i.type === "advanceInvoices")
incominginvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(id,name)")).filter(i => i.state === "Gebucht")
accounts.value = (await useEntities("accounts").selectSpecial("*","number",true))
ownaccounts.value = (await useEntities("ownaccounts").select())
customers.value = (await useEntities("customers").select())
vendors.value = (await useEntities("vendors").select())
openDocuments.value = documents.filter(i => i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i,createddocuments.value).toFixed(2))
openDocuments.value = openDocuments.value.map(i => {
return {
...i,
docTotal: useSum().getCreatedDocumentSum(i,createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n,{amount}) => n + amount, 0)),
openSum: (useSum().getCreatedDocumentSum(i,createddocuments.value) - Number(i.statementallocations.reduce((n,{amount}) => n + amount, 0))).toFixed(2)
}
})
console.log(openDocuments.value)
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
console.log(allocatedDocuments.value)
console.log(allocatedIncomingInvoices.value)
openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i,false))
//console.log(openIncomingInvoices.value)
// return incominginvoices.value.filter(i => bankstatements.value.filter(x => x.assignments.find(y => y.type === 'incomingInvoice' && y.id === i.id)).length === 0)
loading.value = false
}
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
}
const separateIBAN = (input = "") => {
const separates = input.match(/.{1,4}/g)
return separates.join(" ")
}
const getInvoiceSum = (invoice, onlyOpenSum) => {
console.log(invoice)
let sum = 0
invoice.accounts.forEach(account => {
sum += account.amountTax
sum += account.amountNet
})
console.log(sum)
if(onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n,{amount}) => n + amount, 0))
if(invoice.expense) {
return (sum * -1).toFixed(2)
} else {
return sum.toFixed(2)
}
}
const calculateOpenSum = computed(() => {
let startingAmount = 0
itemInfo.value.statementallocations.forEach(item => {
startingAmount += item.amount
})
return (itemInfo.value.amount - startingAmount).toFixed(2)
})
const saveAllocations = async () => {
let allocationsToBeSaved = itemInfo.value.statementallocations.filter(i => !i.id)
for await (let i of allocationsToBeSaved) {
await dataStore.createNewItem("statementallocations", i)
}
}
const showAccountSelection = ref(false)
const accountToSave = ref("")
const ownAccountToSave = ref("")
const customerAccountToSave = ref("")
const vendorAccountToSave = ref("")
const selectAccount = (id) => {
accountToSave.value = id
showAccountSelection.value = false
}
const manualAllocationSum = ref(itemInfo.value.amount || 0)
const allocationDescription = ref("")
const showMoreWithoutRecipe = ref(false)
const showMoreText = ref(false)
const saveAllocation = async (allocation) => {
//TODO: BACKEND CHANGE SAVE/REMOVE
console.log(allocation)
const res = await useNuxtApp().$api("/api/banking/statements",{
method: "POST",
body: {
data: allocation
}
})
if(res) {
await setup()
accountToSave.value = null
vendorAccountToSave.value = null
customerAccountToSave.value = null
ownAccountToSave.value = null
allocationDescription.value = null
}
}
const removeAllocation = async (allocationId) => {
const res = await useNuxtApp().$api(`/api/banking/statements/${allocationId}`,{
method: "DELETE"
})
await setup()
}
const searchString = ref(tempStore.searchStrings["bankstatementsedit"] ||'')
const clearSearchString = () => {
searchString.value = ''
tempStore.clearSearchString("bankstatementsedit")
}
const filteredDocuments = computed(() => {
return useSearch(searchString.value, openDocuments.value.filter(i => i.state === "Gebucht"))
})
const filteredIncomingInvoices = computed(() => {
return useSearch(searchString.value, openIncomingInvoices.value.filter(i => i.state === "Gebucht"))
})
setup()
const archiveStatement = async () => {
let temp = itemInfo.value
delete temp.statementallocations
await useEntities("bankstatements").archive(temp.id)
}
</script>
<template>
<UDashboardNavbar
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.back()/*router.push(`/standardEntity/${type}`)*/"
>
Zurück
</UButton>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push(`/banking`)"
>
Kontobewegungen
</UButton>
</template>
<template #center>
<h1
:class="['text-xl','font-medium']"
>Kontobewegung bearbeiten</h1>
</template>
<template #badge v-if="itemInfo">
<UBadge
v-if="itemInfo.incomingInvoice || itemInfo.createdDocument"
>
Gebucht
</UBadge>
<UBadge
v-else
color="red"
>
Offen
</UBadge>
</template>
<template #right>
<ArchiveButton
color="rose"
variant="outline"
type="bankstatements"
@confirmed="archiveStatement"
/>
</template>
</UDashboardNavbar>
<UDashboardPanelContent
class="flex flex-row workingContainer"
v-if="!loading"
>
<div
class="mx-auto w-2/5"
>
<div class="px-2">
<UCard
v-if="itemInfo"
>
<template #header>
<div class="flex flex-row justify-between">
<span
v-if="itemInfo.amount > 0"
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{itemInfo.debName}}
</span>
<span
v-else-if="itemInfo.amount < 0"
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{itemInfo.credName}}
</span>
<span v-if="itemInfo.amount > 0" class="text-primary-500 font-semibold text-nowrap">{{displayCurrency(itemInfo.amount)}}</span>
<span v-else-if="itemInfo.amount < 0" class="text-rose-600 font-semibold text-nowrap">{{displayCurrency(itemInfo.amount)}}</span>
<span v-else>{{displayCurrency(itemInfo.amount)}}</span>
</div>
</template>
<UAlert
v-if="itemInfo.archived"
color="rose"
variant="outline"
:title="`Kontobewegung archiviert`"
icon="i-heroicons-archive-box"
class="mb-5"
/>
<table class="w-full" v-if="itemInfo.id">
<tbody>
<tr class="flex-row flex justify-between">
<td>
<span class="font-semibold">Buchungsdatum:</span>
</td>
<td>
{{dayjs(itemInfo.date).format("DD.MM.YYYY")}}
</td>
</tr>
<tr class="flex-row flex justify-between">
<td>
<span class="font-semibold">Wertstellungsdatum:</span>
</td>
<td>
{{dayjs(itemInfo.valueDate).format("DD.MM.YYYY")}}
</td>
</tr>
<tr class="flex-row flex justify-between text-right">
<td>
<span class="font-semibold">Partner:</span>
</td>
<td>
<span v-if="itemInfo.debName">{{itemInfo.debName}}</span>
<span v-else-if="itemInfo.credName">{{itemInfo.credName}}</span>
<span v-else>-</span>
</td>
</tr>
<tr class="flex-row flex justify-between">
<td>
<span class="font-semibold">Partner IBAN:</span>
</td>
<td>
<span v-if="itemInfo.debIban">{{separateIBAN(itemInfo.debIban)}}</span>
<span v-else-if="itemInfo.credIban">{{separateIBAN(itemInfo.credIban)}}</span>
<span v-else>-</span>
</td>
</tr>
<tr class="flex-row flex justify-between">
<td>
<span class="font-semibold">Konto:</span>
</td>
<td>
<!--
{{dataStore.getBankAccountById(itemInfo.account) ? dataStore.getBankAccountById(itemInfo.account).name || separateIBAN(dataStore.getBankAccountById(itemInfo.account).iban) : ""}}
-->
</td>
</tr>
<!-- <tr class="flex-row flex justify-between">
<td colspan="2">
<span class="font-semibold">Buchungen:</span>
</td>
</tr>
<tr
class="flex-row flex justify-between mb-3"
v-for="item in itemInfo.statementallocations"
>
<td>
&lt;!&ndash; <span v-if="itemInfo.createdDocument"><nuxt-link :to="`/createDocument/show/${itemInfo.createdDocument}`">{{dataStore.createddocuments.find(i => i.id === itemInfo.createdDocument).documentNumber}}</nuxt-link></span>
<span v-else-if="itemInfo.incomingInvoice"><nuxt-link :to="`/incominInvoices/show/${itemInfo.incomingInvoice}`">{{dataStore.getIncomingInvoiceById(itemInfo.incomingInvoice).reference}}</nuxt-link></span>&ndash;&gt;
<span v-if="item.cd_id">
{{dataStore.getCreatedDocumentById(item.cd_id).documentNumber}}
</span>
<span v-else-if="item.ii_id">
&lt;!&ndash; {{dataStore.getVendorById(dataStore.getIncomingInvoiceById(item.ii_id).vendor).name}} - {{dataStore.getIncomingInvoiceById(item.ii_id).reference}}&ndash;&gt;
</span>
<span v-else-if="item.account">
Buchungskonto: {{accounts.find(i => i.id === item.account).number}} {{accounts.find(i => i.id === item.account).label}}
</span>
</td>
<td>
<UButton
variant="outline"
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="router.push(`/createDocument/show/${item.cd_id}`)"
v-if="item.cd_id"
/>
<UButton
variant="outline"
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="router.push(`/incominginvoices/show/${item.ii_id}`)"
v-else-if="item.ii_id"
/>
</td>
</tr>-->
<tr class="flex-row flex justify-between">
<td colspan="2">
<span class="font-semibold">Beschreibung:</span>
</td>
</tr>
<tr>
<td colspan="2">
<UButton
class="mt-3"
@click="showMoreText = !showMoreText"
variant="outline"
>{{ showMoreText ? "Weniger anzeigen" : "Mehr anzeigen" }}</UButton>
<p>{{showMoreText ? itemInfo.text : itemInfo.text.substring(0,200)}}</p>
</td>
</tr>
</tbody>
</table>
</UCard>
<UAlert
class="mb-3 mt-3"
:color="calculateOpenSum != 0 ? 'rose' : 'primary'"
variant="outline"
:title="calculateOpenSum != 0 ? `${displayCurrency(Math.abs(calculateOpenSum))} von ${displayCurrency(Math.abs(itemInfo.amount))} nicht zugewiesen` : 'Kontobewegung vollständig zugewiesen'"
>
<template #description>
<UProgress
:value="Math.abs(itemInfo.amount) - Math.abs(calculateOpenSum)"
:max="Math.abs(itemInfo.amount)"
:color="calculateOpenSum != 0 ? 'rose' : 'primary'"
/>
</template>
</UAlert>
</div>
<div class="scrollList mt-3 px-2 pb-3" style="height: 35vh">
<UDivider>Vorhandene Buchungen<UBadge v-if="itemInfo.statementallocations.length > 0" variant="outline" class="ml-2">{{itemInfo.statementallocations.length}}</UBadge></UDivider>
<UCard
class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.account)"
>
<template #header>
<div class="flex flex-row justify-between">
<span>{{accounts.find(i => i.id === item.account).number}} - {{accounts.find(i => i.id === item.account).label}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div>
</template>
<p class="font-bold">Beschreibung:</p>
<p>{{item.description}}</p>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
class="mr-3 mt-3"
@click="removeAllocation(item.id)"
/>
</UCard>
<UCard
class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.ownaccount)"
>
<template #header>
<div class="flex flex-row justify-between">
<span>{{ownaccounts.find(i => i.id === item.ownaccount).number}} - {{ownaccounts.find(i => i.id === item.ownaccount).name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div>
</template>
<p class="font-bold">Beschreibung:</p>
<p>{{item.description}}</p>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
class="mr-3 mt-3"
@click="removeAllocation(item.id)"
/>
</UCard>
<UCard
class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.customer)"
>
<template #header>
<div class="flex flex-row justify-between">
<span>{{customers.find(i => i.id === item.customer).customerNumber}} - {{customers.find(i => i.id === item.customer).name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div>
</template>
<p class="font-bold">Beschreibung:</p>
<p>{{item.description}}</p>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
class="mr-3 mt-3"
@click="removeAllocation(item.id)"
/>
</UCard>
<UCard
class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.vendor)"
>
<template #header>
<div class="flex flex-row justify-between">
<span>{{vendors.find(i => i.id === item.vendor).vendorNumber}} - {{vendors.find(i => i.id === item.vendor).name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div>
</template>
<p class="font-bold">Beschreibung:</p>
<p>{{item.description}}</p>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
class="mr-3 mt-3"
@click="removeAllocation(item.id)"
/>
</UCard>
<UCard
class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.incominginvoice)"
>
<template #header>
<div class="flex flex-row justify-between">
<span> {{incominginvoices.find(i => i.id === item.incominginvoice).reference}} - {{incominginvoices.find(i => i.id === item.incominginvoice).vendor?.name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div>
</template>
<p class="font-bold">Beschreibung:</p>
<p>{{item.description}}</p>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
class="mr-3 mt-3"
@click="removeAllocation(item.id)"
/>
</UCard>
<UCard
class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.createddocument)"
>
<template #header>
<div class="flex flex-row justify-between">
<span v-if="customers.find(i => i.id === createddocuments.find(i => i.id === item.createddocument).customer?.id)">{{createddocuments.find(i => i.id === item.createddocument).documentNumber}} - {{createddocuments.find(i => i.id === item.createddocument).customer?.name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div>
</template>
<p class="font-bold">Beschreibung:</p>
<p>{{item.description}}</p>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
class="mr-3 mt-3"
@click="removeAllocation(item.id)"
/>
<UButton
icon="i-heroicons-eye"
variant="outline"
color="primary"
class="mr-3 mt-3"
@click="navigateTo(`/createDocument/show/${item.createddocument}`)"
/>
</UCard>
</div>
</div>
<div class="w-2/5 mx-auto">
<div class="px-2">
<UDivider class="mt-3">Buchungsdaten</UDivider>
<UFormGroup
label="Summe:"
>
<UInput
v-model="manualAllocationSum"
type="number"
step="0.01"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">EUR</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup
label="Beschreibung:"
class="mt-3"
>
<UTextarea
v-model="allocationDescription"
rows="3"
/>
</UFormGroup>
<UDivider class="my-3">Ohne Beleg buchen</UDivider>
<UFormGroup
label="Buchungskonten"
class="mt-3"
>
<InputGroup class="mt-3 w-full">
<USelectMenu
class="w-full"
:options="accounts"
value-attribute="id"
option-attribute="label"
:ui-menu="{ width: 'min-w-max' }"
v-model="accountToSave"
searchable
:search-attributes="['number','label']"
>
<template #label>
<span v-if="accountToSave">{{accounts.find(i => i.id === accountToSave).number}} - {{accounts.find(i => i.id === accountToSave).label}}</span>
<span v-else>Kein Konto ausgewählt</span>
</template>
<template #option="{option}">
{{option.number}} - {{option.label}}
</template>
</USelectMenu>
<UButton
@click="showAccountSelection = true"
icon="i-heroicons-magnifying-glass"
variant="outline"
/>
<UButton
variant="outline"
icon="i-heroicons-check"
:disabled="!accountToSave"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
/>
<UButton
@click="accountToSave = ''"
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
/>
</InputGroup>
<UModal
v-model="showAccountSelection"
>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Konto auswählen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showAccountSelection = false" />
</div>
</template>
<UButton
v-for="selectableAccount in accounts"
variant="outline"
class="m-3"
@click="selectAccount(selectableAccount.id)"
>
{{selectableAccount.label}}
</UButton>
</UCard>
</UModal>
</UFormGroup>
<UButton
class="mt-3"
@click="showMoreWithoutRecipe = !showMoreWithoutRecipe"
variant="outline"
>{{ showMoreWithoutRecipe ? "Weniger anzeigen" : "Mehr anzeigen" }}</UButton>
<UFormGroup
v-if="showMoreWithoutRecipe"
label="zusätzliche Buchungskonten"
class="mt-3"
>
<InputGroup class="w-full">
<USelectMenu
class="w-full"
:options="ownaccounts"
value-attribute="id"
option-attribute="label"
:ui-menu="{ width: 'min-w-max' }"
v-model="ownAccountToSave"
searchable
:search-attributes="['number','label']"
>
<template #label>
<span v-if="ownAccountToSave">{{ownaccounts.find(i => i.id === ownAccountToSave).number}} - {{ownaccounts.find(i => i.id === ownAccountToSave).name}}</span>
<span v-else>Kein Konto ausgewählt</span>
</template>
<template #option="{option}">
{{option.number}} - {{option.name}}
</template>
</USelectMenu>
<UButton
variant="outline"
icon="i-heroicons-check"
:disabled="!ownAccountToSave"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave, description: allocationDescription })"
/>
<UButton
@click="accountToSave = ''"
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
/>
</InputGroup>
</UFormGroup>
<UFormGroup
v-if="showMoreWithoutRecipe"
label="Kunden"
class="mt-3"
>
<InputGroup class="w-full">
<USelectMenu
class="w-full"
:options="customers"
value-attribute="id"
option-attribute="label"
:ui-menu="{ width: 'min-w-max' }"
v-model="customerAccountToSave"
searchable
:search-attributes="['number','name']"
>
<template #label>
<span v-if="customerAccountToSave">{{customers.find(i => i.id === customerAccountToSave).customerNumber}} - {{customers.find(i => i.id === customerAccountToSave).name}}</span>
<span v-else>Kein Konto ausgewählt</span>
</template>
<template #option="{option}">
{{option.customerNumber}} - {{option.name}}
</template>
</USelectMenu>
<UButton
variant="outline"
icon="i-heroicons-check"
:disabled="!customerAccountToSave"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, customer: customerAccountToSave, description: allocationDescription })"
/>
<UButton
@click="customerAccountToSave = ''"
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
/>
</InputGroup>
</UFormGroup>
<UFormGroup
v-if="showMoreWithoutRecipe"
label="Lieferanten"
class="mt-3"
>
<InputGroup class="w-full">
<USelectMenu
class="w-full"
:options="vendors"
value-attribute="id"
option-attribute="label"
:ui-menu="{ width: 'min-w-max' }"
v-model="vendorAccountToSave"
searchable
:search-attributes="['number','name']"
>
<template #label>
<span v-if="vendorAccountToSave">{{vendors.find(i => i.id === vendorAccountToSave).vendorNumber}} - {{vendors.find(i => i.id === vendorAccountToSave).name}}</span>
<span v-else>Kein Konto ausgewählt</span>
</template>
<template #option="{option}">
{{option.vendorNumber}} - {{option.name}}
</template>
</USelectMenu>
<UButton
variant="outline"
icon="i-heroicons-check"
:disabled="!vendorAccountToSave"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, vendor: vendorAccountToSave, description: allocationDescription })"
/>
<UButton
@click="vendorAccountToSave = ''"
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
/>
</InputGroup>
</UFormGroup>
<UDivider
class="my-3"
>
Auf Beleg buchen
</UDivider>
<InputGroup
class="mt-3 w-full"
>
<UInput
id="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block w-full mr-1"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('bankstatementsedit',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
variant="outline"
icon="i-heroicons-x-mark"
color="rose"
@click="clearSearchString"
/>
</InputGroup>
</div>
<div class="scrollList mt-3 px-2" style="height: 40vh;">
<UCard
class="mt-5"
v-for="document in filteredDocuments"
>
<template #header>
<div class="flex flex-row justify-between">
<span>{{document.customer ? document.customer.name : ""}} - {{document.documentNumber}}</span>
<span class="font-semibold text-primary-500 text-nowrap">{{displayCurrency(document.openSum)}}</span>
</div>
</template>
<UButton
icon="i-heroicons-check"
variant="outline"
class="mr-3"
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id)"
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
/>
<UButton
variant="outline"
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="router.push(`/createDocument/show/${document.id}`)"
/>
</UCard>
<UCard
class="mt-5"
v-for="item in filteredIncomingInvoices"
>
<template #header>
<div class="flex flex-row justify-between">
<span>{{item.vendor ? item.vendor.name : ''}} - {{item.reference}}</span>
<span class="font-semibold text-rose-600 text-nowrap">{{displayCurrency(getInvoiceSum(item,true))}}</span>
</div>
</template>
<UButton
icon="i-heroicons-check"
variant="outline"
class="mr-3"
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id)"
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
/>
<UButton
variant="outline"
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="router.push(`/incominginvoices/show/${item.id}`)"
/>
</UCard>
</div>
</div>
</UDashboardPanelContent>
</template>
<style scoped>
.workingContainer {
height: 90vh;
overflow-y: hidden;
}
</style>

View File

@@ -0,0 +1,388 @@
<script setup>
import deLocale from "@fullcalendar/core/locales/de";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import interactionPlugin from "@fullcalendar/interaction";
import dayjs from "dayjs";
//TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING
//Config
const route = useRoute()
const router = useRouter()
const mode = ref(route.params.mode || "grid")
const dataStore = useDataStore()
const profileStore = useProfileStore()
//Working
const newEventData = ref({
resources: [],
resourceId: "",
resourceType: "",
title: "",
type: "Umsetzung",
start: "",
end: null
})
const showNewEventModal = ref(false)
const showEventModal = ref(false)
const selectedEvent = ref({})
const selectedResources = ref([])
const events = ref([])
const calendarOptionsGrid = computed(() => {
return {
locale: deLocale,
plugins: [dayGridPlugin, interactionPlugin, timeGridPlugin],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
initialView: "timeGridWeek",
initialEvents: events.value,
nowIndicator: true,
height: "80vh",
selectable: true,
weekNumbers: true,
select: function (info) {
console.log(info)
router.push(`/standardEntity/events/create/?startDate=${encodeURIComponent(info.startStr)}&endDate=${encodeURIComponent(info.endStr)}`)
},
eventClick: function (info) {
console.log(info.event)
if(info.event.extendedProps.entrytype === "absencerequest"){
router.push(`/standardEntity/absencerequests/show/${info.event.extendedProps.absencerequestId}`)
} else {
router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`)
}
},
}
})
const calendarOptionsTimeline = ref({
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
locale: deLocale,
plugins: [resourceTimelinePlugin, interactionPlugin],
initialView: "resourceTimeline3Hours",
headerToolbar: {
left: 'prev,next',
center: 'title',
right: 'resourceTimelineDay,resourceTimeline3Hours,resourceTimelineMonth'
},
initialEvents: [{
title:"Test",
resourceId:"F-27",
start:"2025-01-01"
}],
selectable: true,
select: function (info) {
router.push(`/standardEntity/events/create?startDate=${encodeURIComponent(info.startStr)}&endDate=${encodeURIComponent(info.endStr)}&resources=${encodeURIComponent(JSON.stringify([info.resource.id]))}&source=timeline`)
},
eventClick: function (info){
console.log(info.event)
if(info.event.extendedProps.entrytype === "absencerequest"){
router.push(`/standardEntity/absencerequests/show/${info.event.extendedProps.absencerequestId}`)
} else {
router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`)
}
},
resourceGroupField: "type",
resourceOrder: "-type",
resources: [],
nowIndicator:true,
views: {
resourceTimeline3Hours: {
type: 'resourceTimeline',
duration: {weeks: 1},
weekends: false,
slotDuration: {hours: 3},
slotMinTime: "06:00:00",
slotMaxTime: "21:00:00",
buttonText: "Woche",
visibleRange: function(currentDate) {
// Generate a new date for manipulating in the next step
var startDate = new Date(currentDate.valueOf());
var endDate = new Date(currentDate.valueOf());
// Adjust the start & end dates, respectively
console.log(startDate.getDay())
startDate.setDate(startDate.getDate() - 1); // One day in the past
endDate.setDate(endDate.getDate() + 2); // Two days into the future
return { start: startDate, end: endDate };
}
}
},
height: '80vh',
})
const loaded = ref(false)
const setupPage = async () => {
let tempData = (await useEntities("events").select()).filter(i => !i.archived)
let absencerequests = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived)
let projects = (await useEntities("projects").select( "*")).filter(i => !i.archived)
let inventoryitems = (await useEntities("inventoryitems").select()).filter(i => !i.archived)
let inventoryitemgroups = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived)
let profiles = (await useEntities("profiles").select()).filter(i => !i.archived)
let vehicles = (await useEntities("vehicles").select()).filter(i => !i.archived)
calendarOptionsGrid.value.initialEvents = [
...tempData.map(event => {
let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color
let title = ""
if (event.name) {
title = event.name
} else if (event.project) {
title = projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : ""
}
return {
...event,
start: event.startDate,
end: event.endDate,
title: title,
borderColor: eventColor,
textColor: eventColor,
backgroundColor: "black",
entrytype: "event",
eventId: event.id
}
}),
...absencerequests.map(absence => {
return {
id: absence.id,
resourceId: absence.user,
resourceType: "person",
title: `${absence.reason} - ${absence.profile.fullName}`,
start: dayjs(absence.startDate).toDate(),
end: dayjs(absence.endDate).add(1, 'day').toDate(),
allDay: true,
absencerequestId: absence.id,
entrytype: "absencerequest",
}
})
]
calendarOptionsTimeline.value.resources = [
...profiles.filter(i => i.tenant === profileStore.currentTenant).map(profile => {
return {
type: 'Mitarbeiter',
title: profile.fullName,
id: `P-${profile.id}`
}
}),
...vehicles.map(vehicle => {
return {
type: 'Fahrzeug',
title: vehicle.licensePlate,
id: `F-${vehicle.id}`
}
}),
...inventoryitems.filter(i=> i.usePlanning).map(item => {
return {
type: 'Inventar',
title: item.name,
id: `I-${item.id}`
}
}),
...inventoryitemgroups.filter(i=> i.usePlanning).map(item => {
return {
type: 'Inventargruppen',
title: item.name,
id: `G-${item.id}`
}
})
]
/*
calendarOptionsTimeline.value.initialEvents = [
...events.value.map(event => {
let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color
let title = ""
if (event.name) {
title = event.name
} else if (event.project) {
projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : ""
}
let resource = ""
let resourceType = ""
return {
...event,
start: event.startDate,
end: event.endDate,
title: title,
borderColor: eventColor,
textColor: eventColor,
backgroundColor: "black"
}
})
]
*/
let tempEvents = []
tempData.forEach(event => {
console.log(event)
let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color
let title = ""
if (event.name) {
title = event.name
} else if (event.project) {
projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : ""
}
let returnData = {
title: title,
borderColor: eventColor,
textColor: "white",
backgroundColor: eventColor,
start: event.startDate,
end: event.endDate,
resourceIds: [],
entrytype: "event",
eventId: event.id
}
if(event.profiles.length > 0) {
event.profiles.forEach(profile => {
returnData.resourceIds.push(`P-${profile}`)
})
}
if(event.vehicles.length > 0) {
event.vehicles.forEach(vehicle => {
returnData.resourceIds.push(`F-${vehicle}`)
})
}
if(event.inventoryitems.length > 0) {
event.inventoryitems.forEach(inventoryitem => {
returnData.resourceIds.push(`I-${inventoryitem}`)
})
}
if(event.inventoryitemgroups.length > 0) {
event.inventoryitemgroups.forEach(inventoryitemgroup => {
returnData.resourceIds.push(`G-${inventoryitemgroup}`)
})
}
console.log(returnData)
tempEvents.push(returnData)
})
absencerequests.forEach(absencerequest => {
let returnData = {
title: `${absencerequest.reason}`,
backgroundColor: "red",
borderColor: "red",
start: absencerequest.startDate,
end: absencerequest.endDate,
resourceIds: [`P-${absencerequest.profile.id}`],
entrytype: "absencerequest",
allDay: true,
absencerequestId: absencerequest.id
}
tempEvents.push(returnData)
})
console.log(tempEvents)
calendarOptionsTimeline.value.initialEvents = tempEvents
console.log(calendarOptionsTimeline.value)
loaded.value = true
}
setupPage()
//Functions
/*
const convertResourceIds = () => {
newEventData.value.resources = selectedResources.value.map(i => {
/!*if(i.type !== 'Mitarbeiter') {
return {id: Number(i.id.split('-')[1]), type: i.type}
} else {
return {id: i.id, type: i.type}
}*!/
if((String(i).match(/-/g) || []).length > 1) {
return {type: "Mitarbeiter", id: i}
} else {
if(i.split('-')[0] === 'I') {
return {id: Number(i.split('-')[1]), type: "Inventar"}
} else if(i.split('-')[0] === 'F') {
return {id: Number(i.split('-')[1]), type: "Fahrzeug"}
}
}
})
}
*/
//Calendar Config
</script>
<template>
<UDashboardNavbar title="Kalender">
<template #center>
<h1
:class="['text-xl','font-medium']"
>Kalender</h1>
</template>
<template #right>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div v-if="mode === 'grid' && loaded" class="p-5">
<FullCalendar
:options="calendarOptionsGrid"
/>
</div>
<div v-else-if="mode === 'timeline' && loaded" class="p-5">
<FullCalendar
:options="calendarOptionsTimeline"
/>
</div>
</UDashboardPanelContent>
</template>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
<template>
<UDashboardNavbar :badge="filteredRows.length" title="Ausgangsbelege">
<template #right>
<UInput
id="searchinput"
v-model="searchString"
autocomplete="off"
class="hidden lg:block"
icon="i-heroicons-funnel"
placeholder="Suche..."
@change="tempStore.modifySearchString('createddocuments',searchString)"
@keydown.esc="$event.target.blur()"
>
<template #trailing>
<UKbd value="/"/>
</template>
</UInput>
<UButton
v-if="searchString.length > 0"
color="rose"
icon="i-heroicons-x-mark"
variant="outline"
@click="clearSearchString()"
/>
<UButton
@click="router.push(`/createDocument/edit`)"
>
+ Ausgangsbeleg
</UButton>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<!-- <USelectMenu
v-model="selectedColumns"
:options="templateColumns"
by="key"
class="hidden lg:block"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
@change="tempStore.modifyColumns('createddocuments',selectedColumns)"
>
<template #label>
Spalten
</template>
</USelectMenu>-->
<USelectMenu
v-if="selectableFilters.length > 0"
v-model="selectedFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:options="selectableFilters"
:ui-menu="{ width: 'min-w-max' }"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
>
<template #label>
Filter
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<UTabs :items="selectedTypes" class="m-3">
<template #default="{item}">
{{ item.label }}
<UBadge
class="ml-2"
variant="outline"
>
{{ getRowsForTab(item.key).length }}
</UBadge>
</template>
<template #item="{item}">
<div style="height: 80vh; overflow-y: scroll">
<UTable
:columns="getColumnsForTab(item.key)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
:rows="getRowsForTab(item.key)"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
class="w-full"
@select="selectItem"
>
<template #type-data="{row}">
<span v-if="row.type === 'cancellationInvoices'" class="text-cyan-500">{{
dataStore.documentTypesForCreation[row.type].labelSingle
}} für {{ filteredRows.find(i => row.createddocument?.id === i.id)?.documentNumber }}</span>
<span v-else>{{ dataStore.documentTypesForCreation[row.type].labelSingle }}</span>
</template>
<template #state-data="{row}">
<span v-if="row.state === 'Entwurf'" class="text-rose-500">{{ row.state }}</span>
<span
v-if="row.state === 'Gebucht' && !items.find(i => i.createddocument && i.createddocument.id === row.id)"
class="text-primary-500"
>
{{ row.state }}
</span>
<span
v-else-if="row.state === 'Gebucht' && items.find(i => i.createddocument && i.createddocument.id === row.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type)"
class="text-cyan-500"
>
Storniert mit {{ items.find(i => i.createddocument && i.createddocument.id === row.id).documentNumber }}
</span>
<span v-else-if="row.state === 'Gebucht'" class="text-primary-500">{{ row.state }}</span>
</template>
<template #partner-data="{row}">
<span v-if="row.customer && row.customer.name.length < 21">{{ row.customer ? row.customer.name : "" }}</span>
<UTooltip v-else-if="row.customer && row.customer.name.length > 20" :text="row.customer.name">
{{ row.customer.name.substring(0, 20) }}...
</UTooltip>
</template>
<template #reference-data="{row}">
<span v-if="row === filteredRows[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.state === 'Gebucht' && row.paymentDays && ['invoices','advanceInvoices'].includes(row.type) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)"
:class="dayjs(row.documentDate).add(row.paymentDays,'day').diff(dayjs()) <= 0 && !isPaid(row) ? ['text-rose-500'] : '' "
>
{{ row.documentDate ? dayjs(row.documentDate).add(row.paymentDays, 'day').format("DD.MM.YY") : '' }}
</span>
</template>
<template #paid-data="{row}">
<div
v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)">
<span v-if="useSum().getIsPaid(row,items)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</div>
</template>
<template #amount-data="{row}">
<span v-if="row.type !== 'deliveryNotes'">{{ displayCurrency(useSum().getCreatedDocumentSum(row, items)) }}</span>
</template>
<template #amountOpen-data="{row}">
<span
v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !useSum().getIsPaid(row,items) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id) ">
{{ displayCurrency(useSum().getCreatedDocumentSum(row, items) - row.statementallocations.reduce((n, {amount}) => n + amount, 0)) }}
</span>
</template>
</UTable>
</div>
</template>
</UTabs>
</template>
<script setup>
import dayjs from "dayjs";
defineShortcuts({
'/': () => {
document.getElementById("searchinput").focus()
},
'+': () => {
router.push('/createDocument/edit')
},
'Enter': {
usingInput: true,
handler: () => {
// Zugriff auf das aktuell sichtbare Element basierend auf Tab und Selektion
const currentList = getRowsForTab(selectedTypes.value[activeTabIndex.value]?.key || 'drafts')
// Fallback auf globale Liste falls nötig, aber Logik sollte auf Tab passen
if (filteredRows.value[selectedItem.value]) {
router.push(`/createDocument/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 dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const type = "createddocuments"
const dataType = dataStore.dataTypes[type]
const items = ref([])
const selectedItem = ref(0)
const activeTabIndex = ref(0)
const setupPage = async () => {
items.value = (await useEntities("createddocuments").select("*, customer(id,name), statementallocations(id,amount),linkedDocument(*)", "documentNumber", true, true))
}
setupPage()
const templateColumns = [
{key: "reference", label: "Referenz"},
{key: 'type', label: "Typ"},
{key: 'state', label: "Status"},
{key: "amount", label: "Betrag"},
{key: 'partner', label: "Kunde"},
{key: "date", label: "Datum"},
{key: "amountOpen", label: "Offener Betrag"},
{key: "paid", label: "Bezahlt"},
{key: "dueDate", label: "Fällig"}
]
// Eigene Spalten für Entwürfe: Referenz raus, Status rein
const draftColumns = [
{key: 'type', label: "Typ"},
{key: 'state', label: "Status"}, // Status wieder drin
{key: 'partner', label: "Kunde"},
{key: "date", label: "Erstellt am"},
{key: "amount", label: "Betrag"}
]
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 getColumnsForTab = (tabKey) => {
if (tabKey === 'drafts') {
return draftColumns
}
return columns.value
}
const templateTypes = [
{
key: "drafts",
label: "Entwürfe"
},
{
key: "invoices",
label: "Rechnungen"
},
/*,{
key: "cancellationInvoices",
label: "Stornorechnungen"
},{
key: "advanceInvoices",
label: "Abschlagsrechnungen"
},*/
{
key: "quotes",
label: "Angebote"
}, {
key: "deliveryNotes",
label: "Lieferscheine"
}, {
key: "confirmationOrders",
label: "Auftragsbestätigungen"
}
]
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
const types = computed(() => {
return templateTypes.filter((type) => selectedTypes.value.find(i => i.key === type.key))
})
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}`)
}
}
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
}
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const clearSearchString = () => {
tempStore.clearSearchString('createddocuments')
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 = items.value.filter(i => types.value.find(x => {
// 1. Draft Tab Logic
if (x.key === 'drafts') return i.state === 'Entwurf'
// 2. Global Draft Exclusion (drafts shouldn't be in other tabs)
if (i.state === 'Entwurf' && x.key !== 'drafts') return false
// 3. Normal Type Logic
if (x.key === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type)
return x.key === i.type
}))
tempItems = tempItems.filter(i => i.type !== "serialInvoices")
tempItems = tempItems.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)
})
}
tempItems = useSearch(searchString.value, tempItems)
return useSearch(searchString.value, tempItems.slice().reverse())
})
const getRowsForTab = (tabKey) => {
return filteredRows.value.filter(row => {
if (tabKey === 'drafts') {
return row.state === 'Entwurf'
}
if (row.state === 'Entwurf') return false
if (tabKey === 'invoices') {
return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(row.type)
}
return row.type === tabKey
})
}
const isPaid = (item) => {
let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,560 @@
<template>
<UDashboardNavbar title="Serienrechnungen" :badge="filteredRows.length">
<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()"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-queue-list"
color="gray"
variant="ghost"
label="Ausführungen"
@click="openExecutionsSlideover"
/>
<UButton
icon="i-heroicons-play"
color="white"
variant="solid"
label="Ausführen"
@click="openExecutionModal"
/>
<UButton
@click="router.push(`/createDocument/edit?type=serialInvoices`)"
>
+ Serienrechnung
</UButton>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<USelectMenu
v-model="selectedFilters"
icon="i-heroicons-adjustments-horizontal-solid"
:options="filterOptions"
option-attribute="name"
value-attribute="name"
multiple
class="hidden lg:block"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Filter
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<div v-if="runningExecutions.length > 0" class="p-4 space-y-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200 flex items-center gap-2">
<UIcon name="i-heroicons-arrow-path" class="animate-spin text-primary" />
Laufende Ausführungen
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<UCard v-for="exec in runningExecutions" :key="exec.id" :ui="{ body: { padding: 'p-4' } }">
<div class="flex justify-between items-start mb-2">
<div>
<p class="text-sm font-medium">Start: {{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}</p>
</div>
<UBadge color="primary" variant="subtle">Läuft</UBadge>
</div>
<div class="text-xs text-gray-500 flex justify-between mb-3">
<span>{{exec.summary}}</span>
</div>
<div class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
<UButton
size="xs"
color="white"
variant="solid"
icon="i-heroicons-check"
label="Fertigstellen"
:loading="finishingId === exec.id"
@click="finishExecution(exec.id)"
/>
</div>
</UCard>
</div>
</div>
<UTable
:rows="filteredRows"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(row) => router.push(`/createDocument/edit/${row.id}`)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
>
<template #actions-data="{ row }">
<div @click.stop>
<UDropdown :items="getActionItems(row)" :popper="{ placement: 'bottom-end' }">
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-ellipsis-horizontal-20-solid"
/>
</UDropdown>
</div>
</template>
<template #type-data="{row}">
{{dataStore.documentTypesForCreation[row.type].labelSingle}}
</template>
<template #partner-data="{row}">
<span v-if="row.customer">{{row.customer ? row.customer.name : ""}}</span>
</template>
<template #amount-data="{row}">
{{displayCurrency(calculateDocSum(row))}}
</template>
<template #serialConfig.active-data="{row}">
<span v-if="row.serialConfig.active" class="text-primary">Ja</span>
<span v-else class="text-rose-600">Nein</span>
</template>
<template #contract-data="{row}">
<span v-if="row.contract">{{row.contract.contractNumber}} - {{row.contract.name}}</span>
</template>
<template #serialConfig.intervall-data="{row}">
<span v-if="row.serialConfig?.intervall === 'monatlich'">Monatlich</span>
<span v-if="row.serialConfig?.intervall === 'vierteljährlich'">Quartalsweise</span>
</template>
<template #payment_type-data="{row}">
<span v-if="row.payment_type === 'transfer'">Überweisung</span>
<span v-else-if="row.payment_type === 'direct-debit'">SEPA - Einzug</span>
</template>
</UTable>
<UModal v-model="showExecutionModal" :ui="{ width: 'sm:max-w-4xl' }">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Serienrechnungen manuell ausführen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showExecutionModal = false" />
</div>
</template>
<div class="space-y-4">
<UFormGroup label="Ausführungsdatum (Belegdatum)" help="Dieses Datum steuert auch den Leistungszeitraum (z.B. Vormonat bei 'Rückwirkend').">
<UInput type="date" v-model="executionDate" />
</UFormGroup>
<UDivider label="Vorlagen auswählen" />
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
class="w-full sm:w-64"
size="sm"
/>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 hidden sm:inline">
{{ filteredExecutionList.length }} sichtbar
</span>
<UButton
size="2xs"
color="gray"
variant="soft"
label="Alle auswählen"
@click="selectAllTemplates"
/>
<UButton
size="2xs"
color="gray"
variant="ghost"
label="Keine"
@click="selectedExecutionRows = []"
/>
</div>
</div>
<div class="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-800 rounded-md">
<UTable
v-model="selectedExecutionRows"
:rows="filteredExecutionList"
:columns="executionColumns"
:ui="{ th: { base: 'whitespace-nowrap' } }"
>
<template #partner-data="{row}">
{{row.customer ? row.customer.name : "-"}}
</template>
<template #amount-data="{row}">
{{displayCurrency(calculateDocSum(row))}}
</template>
<template #serialConfig.intervall-data="{row}">
{{ row.serialConfig?.intervall }}
</template>
<template #contract-data="{row}">
{{row.contract?.contractNumber}} - {{row.contract?.name}}
</template>
</UTable>
</div>
<div class="text-sm text-gray-500">
{{ selectedExecutionRows.length }} Vorlage(n) ausgewählt.
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="white" @click="showExecutionModal = false">Abbrechen</UButton>
<UButton
color="primary"
:loading="isExecuting"
:disabled="selectedExecutionRows.length === 0"
@click="executeSerialInvoices"
>
Jetzt ausführen
</UButton>
</div>
</template>
</UCard>
</UModal>
<USlideover v-model="showExecutionsSlideover" :ui="{ width: 'w-screen max-w-md' }">
<UCard class="flex flex-col flex-1" :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">
Alle Ausführungen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showExecutionsSlideover = false" />
</div>
</template>
<div class="space-y-4 overflow-y-auto h-full p-1">
<div v-if="executionsLoading" class="flex justify-center py-4">
<UIcon name="i-heroicons-arrow-path" class="animate-spin text-gray-400 w-6 h-6" />
</div>
<div v-else-if="completedExecutions.length === 0" class="text-center text-gray-500 py-8">
Keine abgeschlossenen Ausführungen gefunden.
</div>
<div v-for="exec in completedExecutions" :key="exec.id" class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<div class="flex justify-between items-start mb-2">
<span class="text-sm font-semibold">{{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}</span>
<UBadge :color="getStatusColor(exec.status)" variant="subtle" size="xs">
{{ getStatusLabel(exec.status) }}
</UBadge>
</div>
<div class="text-xs text-gray-500 grid grid-cols-2 gap-2 mt-2">
<div>
<UIcon name="i-heroicons-check-circle" class="text-green-500 w-3 h-3 align-text-bottom" />
{{exec.summary}}
</div>
</div>
</div>
</div>
</UCard>
</USlideover>
</template>
<script setup>
import dayjs from "dayjs";
const router = useRouter()
const { $api } = useNuxtApp()
const toast = useToast()
const dataStore = useDataStore()
const items = ref([])
const selectedItem = ref(0)
// --- Execution State ---
const showExecutionModal = ref(false)
const executionDate = ref(dayjs().format('YYYY-MM-DD'))
const selectedExecutionRows = ref([])
const isExecuting = ref(false)
const modalSearch = ref("") // NEU: Suchstring für das Modal
// --- SerialExecutions State ---
const showExecutionsSlideover = ref(false)
const executionItems = ref([])
const executionsLoading = ref(false)
const finishingId = ref(null)
const setupPage = async () => {
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber)","documentDate",undefined,true)
await fetchExecutions()
}
// --- Logic für Ausführungen ---
const fetchExecutions = async () => {
executionsLoading.value = true
try {
const res = await useEntities("serialexecutions").select("*", "createdAt", true)
executionItems.value = res || []
} catch (e) {
console.error("Fehler beim Laden der serialexecutions", e)
} finally {
executionsLoading.value = false
}
}
const runningExecutions = computed(() => {
return executionItems.value.filter(i => i.status === 'draft')
})
const completedExecutions = computed(() => {
return executionItems.value.filter(i => i.status !== 'running' && i.status !== 'pending' && i.status !== 'draft')
})
const openExecutionsSlideover = () => {
showExecutionsSlideover.value = true
fetchExecutions()
}
const finishExecution = async (executionId) => {
if (!executionId) return
finishingId.value = executionId
try {
await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' })
toast.add({
title: 'Ausführung beendet',
description: 'Der Prozess wurde erfolgreich als fertig markiert.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
await fetchExecutions()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Die Ausführung konnte nicht beendet werden.',
icon: 'i-heroicons-exclamation-triangle',
color: 'red'
})
} finally {
finishingId.value = null
}
}
const getStatusColor = (status) => {
switch (status) {
case 'completed': return 'green'
case 'error': return 'red'
case 'running':
case 'draft': return 'primary'
default: return 'gray'
}
}
const getStatusLabel = (status) => {
switch (status) {
case 'completed': return 'Abgeschlossen'
case 'error': return 'Fehlerhaft'
case 'draft': return 'Gestartet'
default: return status
}
}
// --- Bestehende Logik ---
const searchString = ref("")
const filteredRows = computed(() => {
let temp = items.value.filter(i => i.type === "serialInvoices").map(i => {
return {
...i,
class: i.archived ? 'bg-red-500/50 dark:bg-red-400/50' : null
}
})
selectedFilters.value.forEach(filterName => {
let filter = filterOptions.value.find(i => i.name === filterName)
temp = temp.filter(filter.filterFunction)
})
return useSearch(searchString.value, temp.slice().reverse())
})
// Basis Liste für das Modal (nur Aktive)
const activeTemplates = computed(() => {
return items.value
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active)
.map(i => ({...i}))
})
// NEU: Gefilterte Liste für das Modal basierend auf der Suche
const filteredExecutionList = computed(() => {
if (!modalSearch.value) return activeTemplates.value
const term = modalSearch.value.toLowerCase()
return activeTemplates.value.filter(row => {
const customerName = row.customer?.name?.toLowerCase() || ""
const contractNum = row.contract?.contractNumber?.toLowerCase() || ""
const contractName = row.contract?.name?.toLowerCase() || ""
return customerName.includes(term) ||
contractNum.includes(term) ||
contractName.includes(term)
})
})
// NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten)
const selectAllTemplates = () => {
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
// Hier ersetzen wir die Auswahl komplett mit dem aktuellen Filterergebnis
selectedExecutionRows.value = [...filteredExecutionList.value]
}
const getActionItems = (row) => {
const isActive = row.serialConfig && row.serialConfig.active
return [
[{
label: isActive ? 'Deaktivieren' : 'Aktivieren',
icon: isActive ? 'i-heroicons-pause' : 'i-heroicons-play',
class: isActive ? 'text-red-500' : 'text-primary',
click: () => toggleActiveState(row)
}]
]
}
const toggleActiveState = async (row) => {
const newState = !row.serialConfig.active
row.serialConfig.active = newState
try {
await useEntities('createddocuments').update(row.id, {
serialConfig: { ...row.serialConfig, active: newState }
})
toast.add({
title: newState ? 'Aktiviert' : 'Deaktiviert',
description: `Die Vorlage wurde ${newState ? 'aktiviert' : 'deaktiviert'}.`,
icon: 'i-heroicons-check',
color: 'green'
})
} catch (e) {
console.error(e)
row.serialConfig.active = !newState
toast.add({
title: 'Fehler',
description: 'Status konnte nicht gespeichert werden.',
color: 'red'
})
}
}
const templateColumns = [
{ key: 'actions', label: '' },
{ key: 'serialConfig.active', label: "Aktiv" },
{ key: "amount", label: "Betrag" },
{ key: 'partner', label: "Kunde" },
{ key: 'contract', label: "Vertrag" },
{ key: 'serialConfig.intervall', label: "Rhythmus" },
{ key: 'payment_type', label: "Zahlart" }
]
const executionColumns = [
{key: 'partner', label: "Kunde"},
{key: 'contract', label: "Vertrag"},
{key: 'serialConfig.intervall', label: "Intervall"},
{key: "amount", label: "Betrag"},
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
const filterOptions = ref([
{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived;
}
}, {
name: "Inaktive ausblenden",
"filterFunction": function (row) {
return !!row.serialConfig.active;
}
}
])
const selectedFilters = ref(filterOptions.value.filter(i => i.default).map(i => i.name) || [])
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
}
const calculateDocSum = (row) => {
let sum = 0
row.rows.forEach(row => {
if (row.mode === "normal" || row.mode === "service" || row.mode === "free") {
sum += row.quantity * row.price * (1 - row.discountPercent / 100) * (1 + row.taxPercent / 100)
}
})
return sum.toFixed(2)
}
const openExecutionModal = () => {
executionDate.value = dayjs().format('YYYY-MM-DD')
selectedExecutionRows.value = []
modalSearch.value = "" // Reset Search
showExecutionModal.value = true
}
const executeSerialInvoices = async () => {
if (selectedExecutionRows.value.length === 0) return;
isExecuting.value = true
try {
const payload = {
tenantId: dataStore.currentTenantId || items.value[0]?.tenant,
executionDate: executionDate.value,
templateIds: selectedExecutionRows.value.map(row => row.id)
}
const res = await $api('/api/functions/serial/start', {
method: 'POST',
body: payload
})
toast.add({
title: 'Ausführung gestartet',
description: `${res.length} Rechnungen werden im Hintergrund generiert.`,
icon: 'i-heroicons-check-circle',
color: 'green'
})
showExecutionModal.value = false
selectedExecutionRows.value = []
await fetchExecutions()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Die Ausführung konnte nicht gestartet werden.',
icon: 'i-heroicons-exclamation-triangle',
color: 'red'
})
} finally {
isExecuting.value = false
}
}
setupPage()
</script>

View File

@@ -0,0 +1,153 @@
<script setup>
import CopyCreatedDocumentModal from "~/components/copyCreatedDocumentModal.vue";
defineShortcuts({
'backspace': () => {
router.push("/createDocument")
},
})
const modal = useModal()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const dataStore = useDataStore()
const itemInfo = ref({})
const linkedDocument =ref({})
const setupPage = async () => {
if(route.params) {
if(route.params.id) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,"*,files(*),linkedDocument(*), statementallocations(bs_id)")
console.log(itemInfo.value)
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
}
setupPage()
const openEmail = () => {
if(["invoices","advanceInvoices"].includes(itemInfo.value.type)){
router.push(`/email/new?loadDocuments=["${linkedDocument.value.id}"]&bcc=${encodeURIComponent(auth.activeTenantData.standardEmailForInvoices || "")}`)
} else {
router.push(`/email/new?loadDocuments=["${linkedDocument.value.id}"]`)
}
}
const openBankstatements = () => {
if(itemInfo.value.statementallocations.length > 1) {
navigateTo(`/banking/?filter=${JSON.stringify(itemInfo.value.statementallocations.map(i => i.bankstatement))}`)
} else {
navigateTo(`/banking/statements/edit/${itemInfo.value.statementallocations[0].bankstatement}`)
}
}
</script>
<template>
<UDashboardNavbar
title="Erstelltes Dokument anzeigen"
>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UButton
@click="router.push(`/createDocument/edit/${itemInfo.id}`)"
v-if="itemInfo.state === 'Entwurf'"
>
Bearbeiten
</UButton>
<!-- <UButton
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
target="_blank"
>In neuen Tab anzeigen</UButton>-->
<UButton
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="modal.open(CopyCreatedDocumentModal, {
id: itemInfo.id,
type: itemInfo.type
})"
variant="outline"
>
Kopieren
</UButton>
<UButton
@click="openEmail"
icon="i-heroicons-envelope"
>
E-Mail
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?linkedDocument=${itemInfo.id}&loadMode=storno`)"
variant="outline"
color="rose"
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
>
Stornieren
</UButton>
<UButton
v-if="itemInfo.project"
@click="router.push(`/standardEntity/projects/show/${itemInfo.project}`)"
icon="i-heroicons-link"
variant="outline"
>
Projekt
</UButton>
<UButton
v-if="itemInfo.customer"
@click="router.push(`/standardEntity/customers/show/${itemInfo.customer}`)"
icon="i-heroicons-link"
variant="outline"
>
Kunde
</UButton>
<UButton
v-if="itemInfo.createddocument"
@click="router.push(`/createDocument/show/${itemInfo.createddocument}`)"
icon="i-heroicons-link"
variant="outline"
>
{{dataStore.documentTypesForCreation[itemInfo.createddocument.type].labelSingle}} - {{itemInfo.createddocument.documentNumber}}
</UButton>
<UButton
v-for="item in itemInfo.createddocuments"
v-if="itemInfo.createddocuments"
@click="router.push(`/createDocument/show/${item.id}`)"
icon="i-heroicons-link"
variant="outline"
>
{{dataStore.documentTypesForCreation[item.type].labelSingle}} - {{item.documentNumber}}
</UButton>
<UButton
v-if="itemInfo.statementallocations?.length > 0"
@click="openBankstatements"
icon="i-heroicons-link"
variant="outline"
>
Bankbuchungen
</UButton>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<!-- <object
:data="linkedDocument.url"
class="w-full previewDocumentMobile"
/>-->
<PDFViewer v-if="linkedDocument.id" :file-id="linkedDocument.id" location="show_create_document" />
</UDashboardPanelContent>
</template>
<style scoped>
.previewDocumentMobile {
aspect-ratio: 1 / 1.414;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup>
import {useFunctions} from "~/composables/useFunctions.js";
import dayjs from "dayjs";
const profileStore = useProfileStore();
const preloadedContent = ref("")
const letterheads = ref([])
const itemInfo = ref({
contentHTML: "",
contentJSON: {},
contentText: ""
})
const showDocument = ref(false)
const uri = ref("")
const setupPage = async () => {
letterheads.value = await useSupabaseSelect("letterheads","*")
preloadedContent.value = `<p></p><p></p><p></p>`
}
setupPage()
const onChangeTab = (index) => {
if(index === 1) {
generateDocument()
}
}
const getDocumentData = () => {
/*const returnData = {
adressLine: `${businessInfo.name}, ${businessInfo.street}, ${businessInfo.zip} ${businessInfo.city}`,
recipient: [
customerData.name,
... customerData.nameAddition ? [customerData.nameAddition] : [],
... contactData ? [`${contactData.firstName} ${contactData.lastName}`] : [],
itemInfo.value.address.street,
... itemInfo.value.address.special ? [itemInfo.value.address.special] : [],
`${itemInfo.value.address.zip} ${itemInfo.value.address.city}`,
],
}*/
const returnData = {
adressLine: `Federspiel Technology UG, Am Schwarzen Brack 14, 26452 Sande`,
recipient: [
"Federspiel Technology",
"UG haftungsbeschränkt",
"Florian Federspiel",
"Am Schwarzen Brack 14",
"Zusatz",
"26452 Sande",
],
contentJSON: itemInfo.value.contentJSON,
}
return returnData
}
const generateDocument = async () => {
const ownTenant = profileStore.ownTenant
const path = letterheads.value[0].path
uri.value = await useFunctions().useCreateLetterPDF(getDocumentData(), path)
showDocument.value = true
}
const contentChanged = (content) => {
itemInfo.value.contentHTML = content.html
itemInfo.value.contentJSON = content.json
itemInfo.value.contentText = content.text
}
</script>
<template>
<UDashboardNavbar title="Anschreiben bearbeiten"/>
{{itemInfo}}
<UDashboardPanelContent>
<UTabs @change="onChangeTab" :items="[{label: 'Editor'},{label: 'Vorschau'}]">
<template #item="{item}">
<div v-if="item.label === 'Editor'">
<Tiptap
class="mt-3"
@updateContent="contentChanged"
:preloadedContent="preloadedContent"
/>
</div>
<div v-else-if="item.label === 'Vorschau'">
<object
:data="uri"
v-if="showDocument"
type="application/pdf"
class="w-full previewDocumentMobile"
/>
</div>
</template>
</UTabs>
</UDashboardPanelContent>
</template>
<style scoped>
.previewDocumentMobile {
aspect-ratio: 1 / 1.414;
}
</style>

View File

@@ -0,0 +1,349 @@
<script setup>
//TODO: BACKENDCHANGE EMAIL SENDING
const dataStore = useDataStore()
const profileStore = useProfileStore()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const auth = useAuthStore()
const emailData = ref({
to:"",
cc:null,
bcc: null,
subject: "",
html: "",
text: "",
account: "",
})
const emailAccounts = ref([])
const preloadedContent = ref("")
const loadedDocuments = ref([])
const loaded = ref(false)
const noAccountsPresent = ref(false)
const setupPage = async () => {
//emailAccounts.value = await useEntities("emailAccounts").select()
emailAccounts.value = await useNuxtApp().$api("/api/email/accounts")
if(emailAccounts.value.length === 0) {
noAccountsPresent.value = true
} else {
emailData.value.account = emailAccounts.value[0].id
preloadedContent.value = `<p></p><p></p><p></p>${auth.profile.email_signature || ""}`
//Check Query
if(route.query.to) emailData.value.to = route.query.to
if(route.query.cc) emailData.value.cc = route.query.cc
if(route.query.bcc) emailData.value.bcc = route.query.bcc
if(route.query.subject) emailData.value.to = route.query.subject
if(route.query.loadDocuments) {
console.log(JSON.parse(route.query.loadDocuments))
const data = await useFiles().selectSomeDocuments(JSON.parse(route.query.loadDocuments))
console.log(data)
if(data) loadedDocuments.value = data
loadedDocuments.value = await Promise.all(loadedDocuments.value.map(async doc => {
const document = await useEntities("createddocuments").selectSingle(doc.createddocument)
console.log(document)
return {
...doc,
createddocument: document
}}))
//console.log(loadedDocuments.value)
if(loadedDocuments.value.length > 0) {
console.log(loadedDocuments.value[0])
emailData.value.subject = `${loadedDocuments.value[0].createddocument.title} von ${auth.activeTenantData.businessInfo.name}`
if(loadedDocuments.value[0].createddocument.contact && loadedDocuments.value[0].createddocument.contact.email) {
console.log("Contact")
emailData.value.to = loadedDocuments.value[0].createddocument.contact.email
} else if(loadedDocuments.value[0].createddocument.customer && loadedDocuments.value[0].createddocument.customer.infoData.invoiceEmail) {
emailData.value.to = loadedDocuments.value[0].createddocument.customer.infoData.invoiceEmail
} else if(loadedDocuments.value[0].createddocument.customer && loadedDocuments.value[0].createddocument.customer.infoData.email) {
emailData.value.to = loadedDocuments.value[0].createddocument.customer.infoData.email
}
}
}
loaded.value = true
}
}
setupPage()
const contentChanged = (content) => {
emailData.value.html = content.html
emailData.value.text = content.text
}
const selectedAttachments = ref([])
const renderAttachments = () => {
selectedAttachments.value = Array.from(document.getElementById("inputAttachments").files).map(i => {
return {
filename: i.name,
type: i.type
}})
}
const toBase64 = file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result.split(",")[1]);
reader.onerror = reject;
});
function blobToBase64(blob) {
return new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(blob);
});
}
const sendEmail = async () => {
loaded.value = false
let body = {
...emailData.value,
attachments: []
}
for await (const file of Array.from(document.getElementById("inputAttachments").files)) {
body.attachments.push({
filename: file.name,
content: await toBase64(file),
contentType: file.type,
encoding: "base64",
contentDisposition: "attachment"
})
}
for await (const doc of loadedDocuments.value) {
const res = await useFiles().downloadFile(doc.id, null, true)
body.attachments.push({
filename: doc.path.split("/")[doc.path.split("/").length -1],
content: await blobToBase64(res),
contentType: res.type,
encoding: "base64",
contentDisposition: "attachment"
})
}
console.log(body)
const res = await useNuxtApp().$api("/api/email/send",{
method: "POST",
body: body,
})
console.log(res)
if(!res.success) {
toast.add({title: "Fehler beim Absenden der E-Mail", color: "rose"})
} else {
navigateTo("/")
toast.add({title: "E-Mail zum Senden eingereiht"})
}
loaded.value = true
}
</script>
<template>
<div v-if="noAccountsPresent" class="mx-auto mt-5 flex flex-col justify-center">
<span class="font-bold text-2xl">Keine E-Mail Konten vorhanden</span>
<UButton
@click="router.push(`/settings/emailAccounts`)"
class="mx-auto mt-5"
>
+ E-Mail Konto
</UButton>
</div>
<div v-else>
<UProgress animation="carousel" v-if="!loaded" class="mt-5 w-2/3 mx-auto"/>
<div v-else>
<UDashboardNavbar
title="Neue E-Mail"
>
<template #right>
<UButton
@click="sendEmail"
:disabled="!emailData.to || !emailData.subject"
>
Senden
</UButton>
</template>
</UDashboardNavbar>
<div class="scrollContainer mt-3">
<div class="flex-col flex w-full">
<UFormGroup
label="Absender"
>
<USelectMenu
:options="emailAccounts"
option-attribute="email"
value-attribute="id"
v-model="emailData.account"
/>
</UFormGroup>
<UDivider class="my-3"/>
<UFormGroup
label="Empfänger"
>
<UInput
class="w-full my-1"
v-model="emailData.to"
/>
</UFormGroup>
<UFormGroup
label="Kopie"
>
<UInput
class="w-full my-1"
v-model="emailData.cc"
/>
</UFormGroup>
<UFormGroup
label="Blindkopie"
>
<UInput
class="w-full my-1"
placeholder=""
v-model="emailData.bcc"
/>
</UFormGroup>
<UFormGroup
label="Betreff"
>
<UInput
class="w-full my-1"
v-model="emailData.subject"
/>
</UFormGroup>
</div>
<UDivider class="my-3"/>
<div id="parentAttachments" class="flex flex-col justify-center mt-3">
<span class="font-medium mb-2 text-xl">Anhänge</span>
<!-- <UIcon
name="i-heroicons-paper-clip"
class="mx-auto w-10 h-10"
/>
<span class="text-center text-2xl">Anhänge hochladen</span>-->
<UInput
id="inputAttachments"
type="file"
multiple
@change="renderAttachments"
/>
<ul class="mx-5 mt-3">
<li
class="list-disc"
v-for="file in selectedAttachments"
> Datei - {{file.filename}}</li>
<li
class="list-disc"
v-for="doc in loadedDocuments"
>
<span v-if="doc.createddocument">Dokument - {{doc.createddocument.documentNumber}}</span>
</li>
</ul>
</div>
<Tiptap
class="mt-3"
@updateContent="contentChanged"
:preloadedContent="preloadedContent"
/>
</div>
</div>
</div>
</template>
<style scoped>
#parentAttachments {
border: 1px dashed #69c350;
border-radius: 10px;
padding: 1em;
}
#inputAttachments {
/*
display: none;
*/
display: inline-block;
cursor: pointer;
opacity: 100;/*
width: 100%;
height: 5%;
position: relative;
top: 0;
bottom: 0;
left: 0;
right: 0;*/
}
#inputAttachments::file-selector-button {
background-color: white;
border: 1px solid #69c350;
border-radius: 5px;
padding: 5px 10px 5px 10px;
}
.fileListItem {
border: 1px solid #69c350;
border-radius: 5px;
padding: .5rem;
}
.scrollContainer {
overflow-y: scroll;
padding-left: 1em;
padding-right: 1em;
height: 90vh;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup>
const createddocuments = ref([])
const selected = ref([])
const setup = async () => {
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => i.payment_type === "direct-debit")
selected.value = createddocuments.value
}
setup()
const createExport = async () => {
//NUMMERN MAPPEN ZU IDS UND AN BACKEND FUNKTION ÜBERGEBEN
const ids = selected.value.map((i) => i.id)
const res = await useNuxtApp().$api("/api/exports/sepa", {
method: "POST",
body: {
idsToExport: ids
}
})
}
</script>
<template>
<UDashboardNavbar title="SEPA Export erstellen">
<template #right>
<UButton @click="createExport">
Erstellen
</UButton>
</template>
</UDashboardNavbar>
<UTable
v-if="createddocuments.length > 0"
:loading="true"
v-model="selected"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
:rows="createddocuments" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import dayjs from "dayjs"
const exports = ref([])
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
const setupPage = async () => {
exports.value = await useNuxtApp().$api("/api/exports",{
method: "GET"
})
}
setupPage()
function downloadFile(row) {
const a = document.createElement("a")
a.href = row.url
a.download = row.file_path.split("/").pop()
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const showCreateExportModal = ref(false)
const createExportData = ref({
start_date: null,
end_date: null,
beraternr:null,
mandantennr: null
})
const createExport = async () => {
const res = await useNuxtApp().$api("/api/exports/datev",{
method: "POST",
body: createExportData.value
})
showCreateExportModal.value = false
if(res.success) {
toast.add({title: "Export wird erstellt. Sie erhalten eine Benachrichtigung sobald es soweit ist."})
} else {
toast.add({title: "Es gab einen Fehler beim erstellen", color: "rose"})
}
}
</script>
<template>
<UDashboardNavbar
title="Exporte"
>
<template #right>
<UButton
@click="showCreateExportModal = true"
>+ DATEV</UButton>
<UButton
@click="router.push('/export/create/sepa')"
>+ SEPA</UButton>
</template>
</UDashboardNavbar>
<UTable
:rows="exports"
:columns="[
{
key: 'created_at',
label: 'Erstellt am',
},{
key: 'start_date',
label: 'Start',
},{
key: 'end_date',
label: 'Ende',
},{
key: 'valid_until',
label: 'Gültig bis',
},{
key: 'type',
label: 'Typ',
},{
key: 'download',
label: 'Download',
},
]"
>
<template #created_at-data="{row}">
{{dayjs(row.created_at).format("DD.MM.YYYY HH:mm")}}
</template>
<template #start_date-data="{row}">
{{dayjs(row.start_date).format("DD.MM.YYYY HH:mm")}}
</template>
<template #end_date-data="{row}">
{{dayjs(row.end_date).format("DD.MM.YYYY HH:mm")}}
</template>
<template #valid_until-data="{row}">
{{dayjs(row.valid_until).format("DD.MM.YYYY HH:mm")}}
</template>
<template #download-data="{row}">
<UButton
@click="downloadFile(row)"
>
Download
</UButton>
</template>
</UTable>
<UModal v-model="showCreateExportModal">
<UCard>
<template #header>
Export erstellen
</template>
<UFormGroup
label="Start:"
>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="createExportData.start_date ? dayjs(createExportData.start_date).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
class="mx-auto"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="createExportData.start_date" @close="close" />
</template>
</UPopover>
</UFormGroup>
<UFormGroup
label="Ende:"
>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="createExportData.end_date ? dayjs(createExportData.end_date).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
class="mx-auto"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="createExportData.end_date" @close="close" />
</template>
</UPopover>
</UFormGroup>
<template #footer>
<UButton
@click="createExport"
>
Erstellen
</UButton>
</template>
</UCard>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,535 @@
<script setup>
import {BlobReader, BlobWriter, ZipWriter} from "@zip.js/zip.js";
import DocumentDisplayModal from "~/components/DocumentDisplayModal.vue";
import DocumentUploadModal from "~/components/DocumentUploadModal.vue";
import dayjs from "dayjs";
import arraySort from "array-sort";
defineShortcuts({
'+': () => {
//Hochladen
uploadModalOpen.value = true
},
'Enter': {
usingInput: true,
handler: () => {
let entry = renderedFileList.value[selectedFileIndex.value]
if(entry.type === "file") {
showFile(entry.id)
} else if(createFolderModalOpen.value === false && entry.type === "folder") {
changeFolder(currentFolders.value.find(i => i.id === entry.id))
} else if(createFolderModalOpen.value === true) {
createFolder()
}
}
},
'arrowdown': () => {
if(selectedFileIndex.value < renderedFileList.value.length - 1) {
selectedFileIndex.value += 1
} else {
selectedFileIndex.value = 0
}
},
'arrowup': () => {
if(selectedFileIndex.value === 0) {
selectedFileIndex.value = renderedFileList.value.length - 1
} else {
selectedFileIndex.value -= 1
}
}
})
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const route = useRoute()
const modal = useModal()
const auth = useAuthStore()
const uploadModalOpen = ref(false)
const createFolderModalOpen = ref(false)
const uploadInProgress = ref(false)
const fileUploadFormData = ref({
tags: ["Eingang"],
path: "",
tenant: auth.activeTenant,
folder: null
})
const files = useFiles()
const displayMode = ref("list")
const displayModes = ref([{label: 'Liste',key:'list', icon: 'i-heroicons-list-bullet'},{label: 'Kacheln',key:'rectangles', icon: 'i-heroicons-squares-2x2'}])
const documents = ref([])
const folders = ref([])
const filetags = ref([])
const currentFolder = ref(null)
const loadingDocs = ref(false)
const isDragTarget = ref(false)
const loaded = ref(false)
const setupPage = async () => {
folders.value = await useEntities("folders").select()
documents.value = await files.selectDocuments()
filetags.value = await useEntities("filetags").select()
if(route.query) {
if(route.query.folder) {
currentFolder.value = await useEntities("folders").selectSingle(route.query.folder)
}
}
const dropZone = document.getElementById("drop_zone")
dropZone.ondragover = function (event) {
modal.open(DocumentUploadModal,{fileData: {folder: currentFolder.value.id, type: currentFolder.value.standardFiletype, typeEnabled: currentFolder.value.standardFiletypeIsOptional}, onUploadFinished: () => {
setupPage()
}})
event.preventDefault()
}
dropZone.ondragleave = function (event) {
isDragTarget.value = false
}
dropZone.ondrop = async function (event) {
event.preventDefault()
}
loadingDocs.value = false
loaded.value = true
}
setupPage()
const currentFolders = computed(() => {
if(folders.value.length > 0) {
let tempFolders = folders.value.filter(i => currentFolder.value ? i.parent === currentFolder.value.id : !i.parent)
return tempFolders
} else return []
})
const breadcrumbLinks = computed(() => {
if(currentFolder.value) {
let parents = []
const addParent = (parent) => {
parents.push(parent)
if(parent.parent) {
addParent(folders.value.find(i => i.id === parent.parent))
}
}
if(currentFolder.value.parent) {
addParent(folders.value.find(i => i.id === currentFolder.value.parent))
}
return [{
label: "Home",
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
},
...parents.map(i => {
return {
label: folders.value.find(x => x.id === i.id).name,
click: () => {
changeFolder(i)
},
icon: "i-heroicons-folder"
}
}).reverse(),
{
label: currentFolder.value.name,
click: () => {
changeFolder(currentFolder.value)
},
icon: "i-heroicons-folder"
}]
} else {
return [{
label: "Home",
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
}]
}
})
const filteredDocuments = computed(() => {
return documents.value.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
})
const changeFolder = async (newFolder) => {
loadingDocs.value = true
currentFolder.value = newFolder
if(newFolder) {
fileUploadFormData.value.folder = newFolder.id
await router.push(`/files?folder=${newFolder.id}`)
} else {
fileUploadFormData.value.folder = null
await router.push(`/files`)
}
setupPage()
}
const createFolderData = ref({})
const createFolder = async () => {
const res = await useEntities("folders").create({
parent: currentFolder.value ? currentFolder.value.id : undefined,
name: createFolderData.value.name,
})
createFolderModalOpen.value = false
setupPage()
}
const downloadSelected = async () => {
let files = []
files = filteredDocuments.value.filter(i => selectedFiles.value[i.id] === true).map(i => i.path)
await useFiles().downloadFile(undefined,Object.keys(selectedFiles.value))
}
const searchString = ref(tempStore.searchStrings["files"] ||'')
const renderedFileList = computed(() => {
let files = filteredDocuments.value.map(i => {
return {
label: i.path.split("/")[i.path.split("/").length -1],
id: i.id,
type: "file"
}
})
arraySort(files, (a,b) => {
let aVal = a.path ? a.path.split("/")[a.path.split("/").length -1] : null
let bVal = b.path ? b.path.split("/")[b.path.split("/").length -1] : null
if(aVal && bVal) {
return aVal.localeCompare(bVal)
} else if(!aVal && bVal) {
return 1
} else {
return -1
}
}, {reverse: true})
if(searchString.value.length > 0) {
files = useSearch(searchString.value, files)
}
let folders = currentFolders.value.map(i => {
return {
label: i.name,
id: i.id,
type: "folder"
}
})
arraySort(folders, "label")
/*folders.sort(function(a, b) {
// Compare the 2 dates
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});*/
return [...folders,...files]
})
const selectedFileIndex = ref(0)
const showFile = (fileId) => {
modal.open(DocumentDisplayModal,{
documentData: documents.value.find(i => i.id === fileId),
onUpdatedNeeded: setupPage()
})
}
const selectedFiles = ref({});
const selectAll = () => {
if(Object.keys(selectedFiles.value).find(i => selectedFiles.value[i] === true)) {
selectedFiles.value = {}
} else {
selectedFiles.value = Object.fromEntries(filteredDocuments.value.map(i => i.id).map(k => [k,true]))
}
}
const clearSearchString = () => {
tempStore.clearSearchString("files")
searchString.value = ''
}
</script>
<template>
<UDashboardNavbar
title="Dateien"
>
<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('files',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UBreadcrumb
:links="breadcrumbLinks"
/>
</template>
<template #right>
<USelectMenu
:options="displayModes"
value-attribute="key"
option-attribute="label"
v-model="displayMode"
:ui-menu="{ width: 'min-w-max'}"
>
<template #label>
<UIcon class="w-5 h-5" :name="displayModes.find(i => i.key === displayMode).icon"/>
</template>
</USelectMenu>
<UButton
:disabled="!currentFolder"
@click="modal.open(DocumentUploadModal,{fileData: {folder: currentFolder.id, type: currentFolder.standardFiletype, typeEnabled: currentFolder.standardFiletypeIsOptional}, onUploadFinished: () => {setupPage()}})"
>+ Datei</UButton>
<UButton
@click="createFolderModalOpen = true"
variant="outline"
>+ Ordner</UButton>
<UButton
@click="downloadSelected"
icon="i-heroicons-cloud-arrow-down"
variant="outline"
v-if="Object.keys(selectedFiles).find(i => selectedFiles[i] === true)"
>Herunterladen</UButton>
<UModal v-model="createFolderModalOpen">
<UCard :ui="{ body: { base: 'space-y-4' } }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Ordner Erstellen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="createFolderModalOpen = false" />
</div>
</template>
<UFormGroup label="Name des Ordners" required>
<UInput
v-model="createFolderData.name"
placeholder="z.B. Rechnungen 2024"
autofocus
/>
</UFormGroup>
<UFormGroup
label="Standard Dateityp"
>
<USelectMenu
v-model="createFolderData.standardFiletype"
:options="filetags"
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Typ suchen..."
placeholder="Kein Standard-Typ"
clear-search-on-close
>
<template #label>
<span v-if="createFolderData.standardFiletype">
{{ filetags.find(t => t.id === createFolderData.standardFiletype)?.name }}
</span>
<span v-else class="text-gray-400">Kein Typ ausgewählt</span>
</template>
</USelectMenu>
</UFormGroup>
<div v-if="createFolderData.standardFiletype">
<UCheckbox
v-model="createFolderData.standardFiletypeIsOptional"
name="isOptional"
label="Dateityp ist optional"
help="Wenn deaktiviert, MUSS der Nutzer beim Upload diesen Typ verwenden."
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="ghost" @click="createFolderModalOpen = false">
Abbrechen
</UButton>
<UButton @click="createFolder" :disabled="!createFolderData.name">
Erstellen
</UButton>
</div>
</template>
</UCard>
</UModal>
</template>
</UDashboardToolbar>
<div id="drop_zone" class="h-full scrollList" >
<div v-if="loaded">
<UDashboardPanelContent>
<div v-if="displayMode === 'list'">
<table class="w-full">
<thead>
<tr>
<td>
<UCheckbox
v-if="renderedFileList.find(i => i.type === 'file')"
@change="selectAll"
/>
</td>
<td class="font-bold">Name</td>
<td class="font-bold">Erstellt am</td>
</tr>
</thead>
<tr v-for="(entry,index) in renderedFileList">
<td>
<UCheckbox
v-if="entry.type === 'file'"
v-model="selectedFiles[entry.id]"
/>
</td>
<td>
<UIcon class="mr-1" :name="entry.type === 'folder' ? 'i-heroicons-folder' : 'i-heroicons-document'"/>
<a
style="cursor: pointer"
:class="[...index === selectedFileIndex ? ['text-primary', 'text-xl'] : ['dark:text-white','text-black','text-xl']]"
@click="entry.type === 'folder' ? changeFolder(currentFolders.find(i => i.id === entry.id)) : showFile(entry.id)"
>{{entry.label}}</a>
</td>
<td>
<span v-if="entry.type === 'file'" class="text-xl">{{dayjs(documents.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
<span v-if="entry.type === 'folder'" class="text-xl">{{dayjs(currentFolders.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
</td>
</tr>
</table>
</div>
<div v-else-if="displayMode === 'rectangles'">
<div class="flex flex-row w-full flex-wrap" v-if="currentFolders.length > 0">
<a
class="w-1/6 folderIcon flex flex-col p-5 m-2"
v-for="folder in currentFolders"
@click="changeFolder(folder)"
>
<UIcon
name="i-heroicons-folder"
class="w-20 h-20"
/>
<span class="text-center truncate">{{folder.name}}</span>
</a>
</div>
<UDivider class="my-5" v-if="currentFolder">{{currentFolder.name}}</UDivider>
<UDivider class="my-5" v-else>Ablage</UDivider>
<div v-if="!loadingDocs">
<DocumentList
v-if="filteredDocuments.length > 0"
:documents="filteredDocuments"
@selectDocument="(info) => console.log(info)"
/>
<UAlert
v-else
class="mt-5 w-1/2 mx-auto"
icon="i-heroicons-light-bulb"
title="Keine Dokumente vorhanden"
color="primary"
variant="outline"
/>
</div>
<UProgress
animation="carousel"
v-else
class="w-2/3 my-5 mx-auto"
/>
</div>
</UDashboardPanelContent>
</div>
<UProgress animation="carousel" v-else class="w-5/6 mx-auto mt-5"/>
</div>
</template>
<style scoped>
.folderIcon {
border: 1px solid lightgrey;
border-radius: 10px;
color: dimgrey;
}
.folderIcon:hover {
border: 1px solid #69c350;
color: #69c350;
}
tr:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
</style>

11
frontend/pages/forms.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import {ref, onMounted, watch} from 'vue'
import {format, isToday, formatDistanceToNow} from 'date-fns'
import {de as deLocale} from 'date-fns/locale'
const {getConversations, getMessages, sendMessage, replyMessage, updateConversationStatus} = useHelpdeskApi()
const conversations = ref<any[]>([])
const selectedConversation = ref<any>(null)
const messages = ref<any[]>([])
const messageText = ref('')
const filterStatus = ref('')
const loading = ref(false)
const route = useRoute()
// Referenzen für Scroll + Shortcuts
const convRefs = ref<Element[]>([])
async function loadConversations() {
loading.value = true
conversations.value = await getConversations(filterStatus.value)
if(route.params.id){
await selectConversation(conversations.value.find(i => i.id === route.params.id))
}
loading.value = false
}
async function selectConversation(conv: any) {
selectedConversation.value = conv
messages.value = await getMessages(conv.id)
}
async function send() {
if (!messageText.value || !selectedConversation.value) return
await replyMessage(selectedConversation.value.id, messageText.value)
messageText.value = ''
messages.value = await getMessages(selectedConversation.value.id)
}
// Keyboard navigation
defineShortcuts({
arrowdown: () => {
const index = conversations.value.findIndex(c => c.id === selectedConversation.value?.id)
if (index === -1) selectedConversation.value = conversations.value[0]
else if (index < conversations.value.length - 1)
selectedConversation.value = conversations.value[index + 1]
},
arrowup: () => {
const index = conversations.value.findIndex(c => c.id === selectedConversation.value?.id)
if (index === -1) selectedConversation.value = conversations.value.at(-1)
else if (index > 0)
selectedConversation.value = conversations.value[index - 1]
}
})
watch(selectedConversation, () => {
if (!selectedConversation.value) return
const ref = convRefs.value[selectedConversation.value.id]
if (ref) ref.scrollIntoView({block: 'nearest'})
})
onMounted(loadConversations)
watch(filterStatus, loadConversations)
// Gruppierung nach aufeinanderfolgenden gleichen Autoren
const groupedMessages = computed(() => {
if (!messages.value.length) return []
const groups: any[] = []
let current: any = null
for (const msg of messages.value) {
const authorKey = `${msg.direction}-${msg.author_user_id || msg.author_name || 'anon'}`
if (!current || current.key !== authorKey) {
current = {
key: authorKey,
direction: msg.direction,
author_name: msg.direction === 'outgoing' ? 'Du' : msg.author_name || 'Kunde',
author_avatar: msg.author_avatar || null,
messages: [msg],
latest_created_at: msg.created_at,
}
groups.push(current)
} else {
current.messages.push(msg)
current.latest_created_at = msg.created_at
}
}
return groups
})
</script>
<template>
<UPage>
<!-- === NAVBAR === -->
<UDashboardNavbar>
<template #title>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="w-5 h-5 text-primary-600"/>
<span class="text-lg font-semibold">Helpdesk</span>
</div>
</template>
<template #right>
<UButton
icon="i-heroicons-arrow-path"
color="gray"
size="xs"
variant="ghost"
@click="loadConversations"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<!-- === TOOLBAR === -->
<UDashboardToolbar>
<template #left>
<USelect
v-model="filterStatus"
size="sm"
:options="[
{ label: 'Alle', value: '' },
{ label: 'Offen', value: 'open' },
{ label: 'In Bearbeitung', value: 'in_progress' },
{ label: 'Geschlossen', value: 'closed' }
]"
placeholder="Status filtern"
class="min-w-[180px]"
/>
</template>
<template #right>
<UButton
icon="i-heroicons-plus"
size="sm"
label="Konversation"
color="primary"
variant="solid"
:disabled="true"
/>
</template>
</UDashboardToolbar>
<!-- === CONTENT === -->
<div class="flex h-[calc(100vh-150px)] overflow-x-hidden">
<!-- 📬 Resizable Sidebar -->
<div
class="relative flex-shrink-0 border-r border-(--ui-border) bg-(--ui-bg) overflow-y-auto"
style="width: 340px; resize: horizontal; min-width: 260px; max-width: 600px;"
>
<div v-if="loading" class="p-4 space-y-2">
<USkeleton v-for="i in 6" :key="i" class="h-14"/>
</div>
<div v-else class="divide-y divide-(--ui-border)">
<div
v-for="(conv, index) in conversations"
:key="conv.id"
:ref="el => { convRefs[conv.id] = el as Element }"
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
:class="[
selectedConversation && selectedConversation.id === conv.id
? 'border-primary bg-primary/10'
: 'border-(--ui-bg) hover:border-primary hover:bg-primary/5'
]"
@click="selectConversation(conv)"
>
<div class="flex items-center justify-between font-medium">
<div class="flex items-center gap-2 truncate">
{{ conv.helpdesk_contacts?.display_name || 'Unbekannt' }}
</div>
<span class="text-xs text-gray-500">
{{
isToday(new Date(conv.last_message_at || conv.created_at))
? format(new Date(conv.last_message_at || conv.created_at), 'HH:mm')
: format(new Date(conv.last_message_at || conv.created_at), 'dd MMM')
}}
</span>
</div>
<p class="truncate text-sm font-semibold">
{{conv.ticket_number}} | {{ conv.subject || 'Ohne Betreff' }}
</p>
<p class="text-xs text-dimmed line-clamp-1">
{{ conv.last_message_preview || '...' }}
</p>
</div>
</div>
</div>
<!-- 💬 Conversation Panel -->
<div class="flex-1 flex flex-col overflow-x-hidden" v-if="selectedConversation">
<UCard class="relative flex flex-col flex-1 rounded-none border-0 border-l border-(--ui-border)">
<template #header>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-start">
<div>
<h2 class="font-semibold truncate text-gray-800 dark:text-gray-200">
{{selectedConversation.ticket_number}} | {{ selectedConversation.subject || 'Ohne Betreff' }}
</h2>
</div>
<USelect
v-model="selectedConversation.status"
size="xs"
:options="[
{ label: 'Offen', value: 'open' },
{ label: 'In Bearbeitung', value: 'in_progress' },
{ label: 'Geschlossen', value: 'closed' }
]"
@update:model-value="val => updateConversationStatus(selectedConversation.id, val)"
/>
</div>
<!-- Kundenzuordnung -->
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<UIcon
:name="selectConversation.customer_id?.isCompany ? 'i-heroicons-building-office-2' : 'i-heroicons-user'"
class="w-4 h-4 text-gray-400"/>
<span>
<strong>{{ selectedConversation.customer_id?.name || 'Kein Kunde zugeordnet' }}</strong>
</span>
<EntityModalButtons
type="customers"
v-if="selectedConversation?.customer_id?.id"
:id="selectedConversation?.customer_id?.id"
:button-edit="false"
/>
<template v-if="selectedConversation.contact_person">
<UIcon name="i-heroicons-user" class="w-4 h-4 text-gray-400 ml-3"/>
<span>{{ selectedConversation.contact_person.name }}</span>
</template>
</div>
</div>
</template>
<!-- Nachrichten -->
<div class="flex-1 overflow-y-auto space-y-4 p-4 pb-24">
<template v-for="(group, gIndex) in groupedMessages" :key="gIndex">
<!-- Avatar + Name + Zeit -->
<div
class="flex items-center gap-2 mb-1"
:class="group.direction === 'outgoing' ? 'flex-row-reverse text-right' : 'flex-row text-left'"
>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ group.direction === 'outgoing' ? 'Du' : group.author_name || 'Kunde' }}
{{ formatDistanceToNow(new Date(group.latest_created_at), {addSuffix: true, locale: deLocale}) }}
</span>
</div>
<!-- Nachrichten des Autors -->
<div class="space-y-1">
<div
v-for="msg in group.messages"
:key="msg.id"
class="flex"
:class="group.direction === 'outgoing' ? 'justify-end' : 'justify-start'"
>
<div
:class="[
'inline-block px-3 py-2 rounded-xl max-w-[80%] text-sm whitespace-pre-wrap break-words',
msg.direction === 'outgoing'
? 'bg-primary-500 text-white ml-auto'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-50'
]"
>
{{ msg.payload.text }}
<!-- Gelesen-Indikator (nur outgoing letzte Nachricht) -->
<span
v-if="group.direction === 'outgoing' && msg.id === group.messages.at(-1).id"
class="absolute -bottom-4 right-1 text-[10px] text-gray-400 flex items-center gap-0.5"
>
<UIcon
v-if="msg.read"
name="i-heroicons-check-double-16-solid"
class="text-primary-400 w-3 h-3"
/>
<UIcon
v-else
name="i-heroicons-check-16-solid"
class="text-gray-400 w-3 h-3"
/>
<span>{{ msg.read ? 'Gelesen' : 'Gesendet' }}</span>
</span>
</div>
</div>
</div>
</template>
<div v-if="!messages.length" class="text-center text-gray-500 text-sm mt-4">
Keine Nachrichten vorhanden
</div>
</div>
<!-- Nachricht senden (jetzt sticky unten) -->
<form class="sticky bottom-0 border-t flex gap-2 p-3 bg-white dark:bg-gray-900" @submit.prevent="send">
<UInput
v-model="messageText"
placeholder="Nachricht eingeben..."
class="flex-1"
size="sm"
/>
<UButton
:disabled="!messageText"
size="sm"
type="submit"
color="primary"
label="Senden"
/>
</form>
</UCard>
</div>
</div>
</UPage>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
</script>
<template>
<UDashboardNavbar>
<template #center>
<h1
:class="['text-xl','font-medium']"
>Zentrales Logbuch</h1>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<HistoryDisplay/>
</UDashboardPanelContent>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,619 @@
<script setup>
import InputGroup from "~/components/InputGroup.vue";
import dayjs from "dayjs";
import HistoryDisplay from "~/components/HistoryDisplay.vue";
const dataStore = useDataStore()
const route = useRoute()
const itemInfo = ref({
vendor: 0,
expense: true,
reference: "",
date: null,
dueDate: null,
paymentType: "Überweisung",
description: "",
state: "Entwurf",
accounts: [
{
account: null,
amountNet: null,
amountTax: null,
taxType: "19",
costCentre: null
}
]
})
const costcentres = ref([])
const vendors = ref([])
const accounts = ref([])
const mode = ref(route.params.mode)
const setup = async () => {
let filetype = (await useEntities("filetags").select()).find(i=> i.incomingDocumentType === "invoices").id
console.log(filetype)
costcentres.value = await useEntities("costcentres").select()
vendors.value = await useEntities("vendors").select()
accounts.value = await useEntities("accounts").selectSpecial()
itemInfo.value = await useEntities("incominginvoices").selectSingle(route.params.id, "*, files(*)")
//TODO: Dirty Fix
itemInfo.value.vendor = itemInfo.value.vendor?.id
await loadFile(itemInfo.value.files[itemInfo.value.files.length-1].id)
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
}
setup()
const useNetMode = ref(false)
const loadedFile = ref(null)
const loadFile = async (id) => {
console.log(id)
loadedFile.value = await useFiles().selectDocument(id)
console.log(loadedFile.value)
}
const changeNetMode = (mode) => {
useNetMode.value = mode
//itemInfo.value.accounts = [{account: null,amountNet: null,amountTax: null,taxType: '19'}]
}
const taxOptions = ref([
{
label: "19% USt",
percentage: 19,
key: "19"
},{
label: "7% USt",
percentage: 7,
key: "7"
},{
label: "Innergemeintschaftlicher Erwerb 19%",
percentage: 0,
key: "19I"
},{
label: "Innergemeintschaftlicher Erwerb 7%",
percentage: 0,
key: "7I"
},{
label: "§13b UStG",
percentage: 0,
key: "13B"
},{
label: "Keine USt",
percentage: 0,
key: "null"
},
])
const totalCalculated = computed(() => {
let totalNet = 0
let totalAmount19Tax = 0
let totalAmount7Tax = 0
let totalAmount0Tax = 0
let totalGross = 0
itemInfo.value.accounts.forEach(account => {
if(account.amountNet) totalNet += account.amountNet
if(account.taxType === "19" && account.amountTax) {
totalAmount19Tax += account.amountTax
}
})
totalGross = Number(totalNet + totalAmount19Tax)
return {
totalNet,
totalAmount19Tax,
totalGross
}
})
const updateIncomingInvoice = async (setBooked = false) => {
let item = itemInfo.value
delete item.files
if(item.state === "Vorbereitet" && !setBooked) {
item.state = "Entwurf"
} else if(item.state === "Vorbereitet" && setBooked) {
item.state = "Gebucht"
} else if(item.state === "Entwurf" && setBooked) {
item.state = "Gebucht"
} else {
item.state = "Entwurf"
}
const data = await useEntities('incominginvoices').update(itemInfo.value.id,item)
}
const findIncomingInvoiceErrors = computed(() => {
let errors = []
if(itemInfo.value.vendor === null) errors.push({message: "Es ist kein Lieferant ausgewählt", type: "breaking"})
if(itemInfo.value.reference === null || itemInfo.value.reference.length === 0) errors.push({message: "Es ist keine Referenz angegeben", type: "breaking"})
if(itemInfo.value.date === null) errors.push({message: "Es ist kein Datum ausgewählt", type: "breaking"})
if(itemInfo.value.dueDate === null) errors.push({message: "Es ist kein Fälligkeitsdatum ausgewählt", type: "breaking"})
if(itemInfo.value.paymentType === null) errors.push({message: "Es ist keine Zahlart ausgewählt", type: "breaking"})
if(itemInfo.value.description === null) errors.push({message: "Es ist keine Beschreibung angegeben", type: "info"})
itemInfo.value.accounts.forEach(account => {
if(account.account === null) errors.push({message: "Es ist keine Kategorie ausgewählt", type: "breaking"})
if(!accounts.value.find(i => i.id === account.account)) errors.push({message: "Es ist keine Kategorie ausgewählt", type: "breaking"})
if(account.amountNet === null) errors.push({message: "Es ist kein Nettobetrag angegeben", type: "breaking"})
if(account.taxType === null) errors.push({message: "Es ist kein Steuertyp ausgewählt", type: "breaking"})
if(account.costCentre === null) errors.push({message: "Es ist keine Kostenstelle ausgewählt", type: "info"})
if(account.taxType === null || account.taxType === "0") errors.push({message: "Es ist keine Steuerart ausgewählt", type: "breaking"})
})
return errors.sort((a,b) => (a.type === "breaking") ? -1 : 1)
})
</script>
<template>
<UDashboardNavbar>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
@click="navigateTo(`/incomingInvoices`)"
variant="outline"
>
Eingangsbelege
</UButton>
</template>
<template #center>
<h1
class="text-xl font-medium"
>{{`Eingangsbeleg ${mode === 'show' ? 'anzeigen' : 'bearbeiten'}`}}</h1>
</template>
<template #right>
<ArchiveButton
color="rose"
variant="outline"
type="incominginvoices"
@confirmed="useEntities('incominginvoices').archive(route.params.id)"
v-if="mode !== 'show'"
/>
<UButton
@click="updateIncomingInvoice(false)"
v-if="mode !== 'show'"
>
Speichern
</UButton>
<UButton
@click="updateIncomingInvoice(true)"
v-if="mode !== 'show'"
:disabled="findIncomingInvoiceErrors.filter(i => i.type === 'breaking').length > 0"
>
Speichern & Buchen
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div
class="flex justify-between mt-5 workingContainer"
v-if="loadedFile"
>
<object
v-if="loadedFile"
:data="loadedFile.url + '#toolbar=0&navpanes=0&scrollbar=0&statusbar=0&messages=0&scrollbar=0'"
type="application/pdf"
class="mx-5 documentPreview"
/>
<div class="w-3/5 mx-5">
<UAlert
class="mb-5"
title="Vorhandene Probleme und Informationen:"
:color="findIncomingInvoiceErrors.filter(i => i.type === 'breaking').length > 0 ? 'rose' : 'white'"
variant="outline"
v-if="findIncomingInvoiceErrors.length > 0"
>
<template #description>
<ul class="list-disc ml-5">
<li v-for="error in findIncomingInvoiceErrors" :class="[...error.type === 'breaking' ? ['text-rose-600'] : ['dark:text-white','text-black']]">
{{error.message}}
</li>
</ul>
</template>
</UAlert>
<div class="scrollContainer">
<InputGroup class="mb-3">
<UButton
:variant="itemInfo.expense ? 'solid' : 'outline'"
@click="itemInfo.expense = true"
:disabled="mode === 'show'"
>
Ausgabe
</UButton>
<UButton
:variant="!itemInfo.expense ? 'solid' : 'outline'"
@click="itemInfo.expense = false"
:disabled="mode === 'show'"
>
Einnahme
</UButton>
</InputGroup>
<UFormGroup label="Lieferant:" >
<InputGroup>
<USelectMenu
:disabled="mode === 'show'"
v-model="itemInfo.vendor"
:options="vendors"
option-attribute="name"
value-attribute="id"
searchable
:search-attributes="['name','vendorNumber']"
class="flex-auto"
searchable-placeholder="Suche..."
:color="!itemInfo.vendor ? 'rose' : 'primary'"
>
<template #option="{option}">
{{option.vendorNumber}} - {{option.name}}
</template>
<template #label>
{{vendors.find(vendor => vendor.id === itemInfo.vendor) ? vendors.find(vendor => vendor.id === itemInfo.vendor).name : 'Lieferant auswählen'}}
</template>
</USelectMenu>
<EntityModalButtons
type="vendors"
:id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id"
:button-edit="mode !== 'show'"
:button-create="mode !== 'show'"
/>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="itemInfo.vendor = null"
v-if="itemInfo.vendor && mode !== 'show'"
/>
</InputGroup>
</UFormGroup>
<UFormGroup
class="mt-3"
label="Rechnungsreferenz:"
>
<UInput
v-model="itemInfo.reference"
:disabled="mode === 'show'"
/>
</UFormGroup>
<InputGroup class="mt-3" gap="2">
<UFormGroup label="Rechnungsdatum:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.date ? dayjs(itemInfo.date).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:color="!itemInfo.date ? 'rose' : 'primary'"
:disabled="mode === 'show'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.date" @close="itemInfo.dueDate = itemInfo.date" />
</template>
</UPopover>
</UFormGroup>
<UFormGroup label="Fälligkeitsdatum:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.dueDate ? dayjs(itemInfo.dueDate).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="mode === 'show'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.dueDate" @close="close"/>
</template>
</UPopover>
</UFormGroup>
</InputGroup>
<UFormGroup label="Zahlart:" >
<USelectMenu
:options="['Einzug','Kreditkarte','Überweisung','Sonstiges']"
v-model="itemInfo.paymentType"
:disabled="mode === 'show'"
/>
</UFormGroup>
<UFormGroup label="Beschreibung:" >
<UTextarea
v-model="itemInfo.description"
:disabled="mode === 'show'"
/>
</UFormGroup>
<InputGroup class="my-3">
<UButton
:variant="!useNetMode ? 'solid' : 'outline'"
@click="changeNetMode(false)"
:disabled="mode === 'show'"
>
Brutto
</UButton>
<UButton
:variant="useNetMode ? 'solid' : 'outline'"
@click="changeNetMode(true)"
:disabled="mode === 'show'"
>
Netto
</UButton>
<!-- Brutto
<UToggle
v-model="useNetMode"
@update:model-value="itemInfo.accounts = [{account: null,amountNet: null,amountTax: null,taxType: '19'}]"
/>
Netto-->
</InputGroup>
<table v-if="itemInfo.accounts.length > 1" class="w-full">
<tr>
<td>Gesamt exkl. Steuer: </td>
<td class="text-right">{{totalCalculated.totalNet.toFixed(2).replace(".",",")}} €</td>
</tr>
<tr>
<td>19% Steuer: </td>
<td class="text-right">{{totalCalculated.totalAmount19Tax.toFixed(2).replace(".",",")}} €</td>
</tr>
<tr>
<td>Gesamt inkl. Steuer: </td>
<td class="text-right">{{totalCalculated.totalGross.toFixed(2).replace(".",",")}} €</td>
</tr>
</table>
<div
class="my-3"
v-for="(item,index) in itemInfo.accounts"
>
<UFormGroup
label="Kategorie"
class="mb-3"
>
<USelectMenu
:options="accounts"
option-attribute="label"
value-attribute="id"
searchable
:disabled="mode === 'show'"
:search-attributes="['label','number']"
searchable-placeholder="Suche..."
v-model="item.account"
:color="(item.account && accounts.find(i => i.id === item.account)) ? 'primary' : 'rose'"
>
<template #label>
{{accounts.find(account => account.id === item.account) ? accounts.find(account => account.id === item.account).label : "Keine Kategorie ausgewählt" }}
</template>
<template #option="{ option}">
{{option.number}} - {{option.label}}
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Kostenstelle"
class="w-full mb-3"
>
<InputGroup class="w-full">
<USelectMenu
:options="costcentres"
option-attribute="name"
value-attribute="id"
searchable
:disabled="mode === 'show'"
:search-attributes="['label']"
searchable-placeholder="Suche..."
v-model="item.costCentre"
class="flex-auto"
>
<template #label>
{{costcentres.find(i => i.id === item.costCentre) ? costcentres.find(i => i.id === item.costCentre).name : "Keine Kostenstelle ausgewählt" }}
</template>
<template #option="{option}">
<span v-if="option.vehicle">Fahrzeug - {{option.name}}</span>
<span v-else-if="option.project">Projekt - {{option.name}}</span>
<span v-else-if="option.inventoryitem">Inventarartikel - {{option.name}}</span>
<span v-else>{{option.name}}</span>
</template>
</USelectMenu>
<UButton
variant="outline"
color="rose"
v-if="item.costCentre && mode !== 'show'"
icon="i-heroicons-x-mark"
@click="item.costCentre = null"
/>
</InputGroup>
</UFormGroup>
<UFormGroup
label="Beschreibung"
class="w-full mb-3"
>
<InputGroup class="w-full">
<UInput
v-model="item.description"
class="flex-auto"
:disabled="mode === 'show'"
></UInput>
<UButton
variant="outline"
color="rose"
v-if="item.description && mode !== 'show'"
icon="i-heroicons-x-mark"
@click="item.description = null"
/>
</InputGroup>
</UFormGroup>
<InputGroup>
<UFormGroup
v-if="useNetMode"
label="Gesamtbetrag exkl. Steuer in EUR"
class="flex-auto truncate"
:help="item.taxType !== null ? `Betrag inkl. Steuern: ${String(Number(item.amountNet + item.amountTax).toFixed(2)).replace('.',',')}` : 'Zuerst Steuertyp festlegen' "
>
<UInput
type="number"
step="0.01"
v-model="item.amountNet"
:color="!item.amountNet ? 'rose' : 'primary'"
:disabled="item.taxType === null || mode === 'show'"
@keyup="item.amountTax = Number((item.amountNet * (Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountGross = Number(item.amountNet) + NUmber(item.amountTax)"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">EUR</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup
v-else
label="Gesamtbetrag inkl. Steuer in EUR"
class="flex-auto"
:help="item.taxType !== null ? `Betrag exkl. Steuern: ${item.amountNet ? String(item.amountNet.toFixed(2)).replace('.',',') : '0,00'}` : 'Zuerst Steuertyp festlegen' "
>
<UInput
type="number"
step="0.01"
:disabled="item.taxType === null || mode === 'show'"
v-model="item.amountGross"
:color="!item.amountGross ? 'rose' : 'primary'"
:ui-menu="{ width: 'min-w-max' }"
@keyup="item.amountNet = Number((item.amountGross / (1 + Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountTax = Number((item.amountGross - item.amountNet).toFixed(2))"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">EUR</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup
label="Umsatzsteuer"
class="w-32"
:help="`Betrag: ${item.amountTax ? String(item.amountTax).replace('.',',') : '0,00'}`"
>
<USelectMenu
:options="taxOptions"
:disabled="mode === 'show'"
:color="item.taxType === null || item.taxType === '0' ? 'rose' : 'primary'"
v-model="item.taxType"
value-attribute="key"
:ui-menu="{ width: 'min-w-max' }"
option-attribute="label"
@change="item.amountNet = Number((item.amountGross / (1 + Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountTax = Number(((item.amountNet ? item.amountNet : 0) * (Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2))"
>
<template #label>
<span class="truncate">{{taxOptions.find(i => i.key === item.taxType) ? taxOptions.find(i => i.key === item.taxType).label : ""}}</span>
</template>
</USelectMenu>
</UFormGroup>
</InputGroup>
<UButton
class="mt-3"
v-if="mode !== 'show'"
@click="itemInfo.accounts = [...itemInfo.accounts.slice(0,index+1),{account:null, amountNet: null, amountTax:null, taxType: '19'} , ...itemInfo.accounts.slice(index+1)]"
>
Betrag aufteilen
</UButton>
<UButton
v-if="index !== 0 && mode !== 'show'"
class="mt-3"
variant="ghost"
color="rose"
@click="itemInfo.accounts = itemInfo.accounts.filter((account,itemIndex) => itemIndex !== index)"
>
Position entfernen
</UButton>
</div>
</div>
</div>
</div>
<UProgress v-else animation="carousel"/>
</UDashboardPanelContent>
<PageLeaveGuard :when="true"/>
</template>
<style scoped>
.documentPreview {
aspect-ratio: 1 / 1.414;
height: 80vh;
}
.scrollContainer {
overflow-y: scroll;
height: 70vh;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollContainer::-webkit-scrollbar {
display: none;
}
.lineItemRow {
display: flex;
flex-direction: row;
}
.workingContainer {
height: 80vh;
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup>
import dayjs from "dayjs"
import {useSum} from "~/composables/useSum.js";
// Zugriff auf API und Toast
const { $api } = useNuxtApp()
const toast = useToast()
defineShortcuts({
'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
},
'+': () => {
router.push("/incomingInvoices/[mode]")
},
'Enter': {
usingInput: true,
handler: () => {
router.push(`/incomingInvoices/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 dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const sum = useSum()
const items = ref([])
const selectedItem = ref(0)
const sort = ref({
column: 'date',
direction: 'desc'
})
// Status für den Button
const isPreparing = ref(false)
const type = "incominginvoices"
const dataType = dataStore.dataTypes[type]
const setupPage = async () => {
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
}
// Funktion zum Vorbereiten der Belege
const prepareInvoices = async () => {
isPreparing.value = true
try {
await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
toast.add({
title: 'Erfolg',
description: 'Eingangsbelege wurden vorbereitet.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Liste neu laden
await setupPage()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
} finally {
isPreparing.value = false
}
}
setupPage()
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 selectableFilters = ref(dataType.filters.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const searchString = ref(tempStore.searchStrings[type] ||'')
const clearSearchString = () => {
tempStore.clearSearchString(type)
searchString.value = ''
}
const filteredRows = computed(() => {
let tempItems = items.value.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)
})
}
tempItems = useSearch(searchString.value, tempItems)
return [...tempItems.filter(i => i.state === "Vorbereitet"), ...tempItems.filter(i => i.state !== "Vorbereitet")]
})
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
}
const getInvoiceSum = (invoice) => {
let sum = 0
invoice.accounts.forEach(account => {
sum += account.amountTax
sum += account.amountNet
})
return sum.toFixed(2)
}
const isPaid = (item) => {
let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Math.abs(amountPaid) === Math.abs(Number(getInvoiceSum(item)))
}
const selectIncomingInvoice = (invoice) => {
if(invoice.state === "Vorbereitet" ) {
router.push(`/incomingInvoices/edit/${invoice.id}`)
} else {
router.push(`/incomingInvoices/show/${invoice.id}`)
}
}
</script>
<template>
<UDashboardNavbar title="Eingangsbelege" :badge="filteredRows.length">
<template #right>
<UButton
label="Belege vorbereiten"
icon="i-heroicons-sparkles"
color="primary"
variant="solid"
:loading="isPreparing"
@click="prepareInvoices"
class="mr-2"
/>
<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"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<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>
<UTabs
class="m-3"
:items="[{label: 'In Bearbeitung'},{label: 'Gebucht'}]"
>
<template #default="{item}">
{{item.label}}
<UBadge
variant="outline"
class="ml-2"
>
{{filteredRows.filter(i => item.label === 'Gebucht' ? i.state === 'Gebucht' : i.state !== 'Gebucht' ).length}}
</UBadge>
</template>
<template #item="{item}">
<div style="height: 80dvh; overflow-y: scroll">
<UTable
v-model:sort="sort"
sort-mode="manual"
@update:sort="setupPage"
:rows="filteredRows.filter(i => item.label === 'Gebucht' ? i.state === 'Gebucht' : i.state !== 'Gebucht' )"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => selectIncomingInvoice(i) "
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
>
<template #reference-data="{row}">
<span v-if="row === filteredRows[selectedItem]" class="text-primary-500 font-bold">{{row.reference}}</span>
<span v-else>{{row.reference}}</span>
</template>
<template #state-data="{row}">
<span v-if="row.state === 'Vorbereitet'" class="text-cyan-500">{{row.state}}</span>
<span v-else-if="row.state === 'Entwurf'" class="text-red-500">{{row.state}}</span>
<span v-else-if="row.state === 'Gebucht'" class="text-primary-500">{{row.state}}</span>
</template>
<template #date-data="{row}">
{{dayjs(row.date).format("DD.MM.YYYY")}}
</template>
<template #vendor-data="{row}">
{{row.vendor ? row.vendor.name : ""}}
</template>
<template #amount-data="{row}">
{{displayCurrency(sum.getIncomingInvoiceSum(row))}}
</template>
<template #dueDate-data="{row}">
<span v-if="row.dueDate">{{dayjs(row.dueDate).format("DD.MM.YYYY")}}</span>
</template>
<template #paid-data="{row}">
<span v-if="isPaid(row)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</template>
</UTable>
</div>
</template>
</UTabs>
</template>
<style scoped>
</style>

95
frontend/pages/index.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<UDashboardNavbar title="Home">
<template #right>
<!-- <UTooltip text="Notifications" :shortcuts="['N']">
<UButton color="gray" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
<UChip :show="unreadMessages" color="primary" inset>
<UIcon name="i-heroicons-bell" class="w-5 h-5" />
</UChip>
</UButton>
</UTooltip>-->
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="mb-5">
<UDashboardCard
title="Einnahmen und Ausgaben(netto)"
class="mt-3"
>
<display-income-and-expenditure/>
</UDashboardCard>
</div>
<UPageGrid>
<UDashboardCard
title="Buchhaltung"
>
<display-open-balances/>
</UDashboardCard>
<UDashboardCard
title="Bank"
>
<display-bankaccounts/>
</UDashboardCard>
<UDashboardCard
title="Projekte"
>
<display-projects-in-phases/>
</UDashboardCard>
<!--<UDashboardCard
title="Anwesende"
>
<display-present-profiles/>
</UDashboardCard>
<UDashboardCard
title="Projektzeiten"
>
<display-running-time/>
</UDashboardCard>
<UDashboardCard
title="Anwesenheiten"
>
<display-running-working-time/>
</UDashboardCard>-->
<UDashboardCard
title="Aufgaben"
>
<display-open-tasks/>
</UDashboardCard>
<UDashboardCard
title="Label Test"
>
<UButton
@click="modal.open(LabelPrintModal, {
context: {
datamatrix: '1234',
text: 'FEDEO TEST'
}
})"
icon="i-heroicons-printer"
>
Label Drucken
</UButton>
</UDashboardCard>
</UPageGrid>
</UDashboardPanelContent>
</template>
<script setup>
import Nimbot from "~/components/nimbot.vue";
import LabelPrintModal from "~/components/LabelPrintModal.vue";
definePageMeta({
middleware: 'redirect-to-mobile-index'
})
const modal = useModal();
const { isNotificationsSlideoverOpen } = useDashboard()
</script>
<style scoped>
</style>

102
frontend/pages/login.vue Normal file
View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
definePageMeta({
layout: "notLoggedIn"
})
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
const platformIsNative = useCapacitor().getIsNative()
const doLogin = async (data:any) => {
try {
await auth.login(data.email, data.password)
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Einloggen erfolgreich"})
if(platformIsNative) {
await router.push("/mobile")
} else {
await router.push("/")
}
} catch (err: any) {
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"rose"})
}
}
</script>
<template>
<UCard class="max-w-sm w-full mx-auto mt-5" v-if="!platformIsNative">
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
/>
<UAlert
title="Achtung"
description="Es wurden alle Benutzerkonten zurückgesetzt. Bitte fordert über Passwort vergessen ein neues Passwort an."
color="rose"
variant="outline"
class="my-5"
>
</UAlert>
<UAuthForm
title="Login"
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}, {
name: 'password',
label: 'Passwort',
type: 'password',
placeholder: 'Dein Passwort'
}]"
:loading="false"
@submit="doLogin"
:submit-button="{label: 'Weiter'}"
divider="oder"
>
<template #password-hint>
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
</template>
</UAuthForm>
</UCard>
<div v-else class="mt-20 m-2 p-2">
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Login"
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}, {
name: 'password',
label: 'Passwort',
type: 'password',
placeholder: 'Dein Passwort'
}]"
:loading="false"
@submit="doLogin"
:submit-button="{label: 'Weiter'}"
divider="oder"
>
<template #password-hint>
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
</template>
</UAuthForm>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
definePageMeta({
layout: 'mobile'
})
const auth = useAuthStore()
const pinnedLinks = computed(() => {
return (auth.profile?.pinned_on_navigation || [])
.map((pin) => {
if (pin.type === "external") {
return {
label: pin.label,
to: pin.link,
icon: pin.icon,
external: true,
}
} else if (pin.type === "standardEntity") {
return {
label: pin.label,
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
icon: pin.icon,
external: false,
}
}
})
.filter(Boolean)
})
</script>
<template>
<UDashboardPanelContent>
<UPageGrid>
<UDashboardCard>
<display-welcome/>
</UDashboardCard>
<UDashboardCard
title="Aufgaben"
>
<display-open-tasks/>
</UDashboardCard>
<!--<UDashboardCard
title="Anwesenheit"
>
<display-running-working-time/>
</UDashboardCard>
<UDashboardCard
title="Zeit"
>
<display-running-time/>
</UDashboardCard>
<UDashboardCard
title="Buchhaltung"
v-if="profileStore.ownTenant.features.accounting"
>
<display-open-balances/>
</UDashboardCard>-->
<UDashboardCard
title="Projekte"
>
<display-projects-in-phases/>
</UDashboardCard>
<display-pinnend-links :links="pinnedLinks"/>
</UPageGrid>
</UDashboardPanelContent>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,81 @@
<script setup>
definePageMeta({
layout: 'mobile',
})
const auth = useAuthStore()
</script>
<template>
<UDashboardPanelContent>
<UDivider class="mb-3">Weiteres</UDivider>
<UButton
class="w-full my-1"
to="/staff/time"
icon="i-heroicons-clock"
>
Zeiten
</UButton>
<!-- <UButton
class="w-full my-1"
to="/standardEntity/absencerequests"
icon="i-heroicons-document-text"
>
Abwesenheiten
</UButton>-->
<UButton
class="w-full my-1"
to="/standardEntity/customers"
icon="i-heroicons-user-group"
>
Kunden
</UButton>
<UButton
class="w-full my-1"
to="/standardEntity/vendors"
icon="i-heroicons-truck"
>
Lieferanten
</UButton>
<UButton
class="w-full my-1"
to="/standardEntity/contacts"
icon="i-heroicons-user-group"
>
Ansprechpartner
</UButton>
<UButton
class="w-full my-1"
to="/standardEntity/plants"
icon="i-heroicons-clipboard-document"
>
Objekte
</UButton>
<UButton
class="w-full my-1"
@click="auth.logout()"
color="rose"
variant="outline"
>
Abmelden
</UButton>
<UDivider class="my-5">Unternehmen wechseln</UDivider>
<div class="w-full flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
<span class="text-left">{{tenant.name}}</span>
<UButton
@click="auth.switchTenant(tenant.id)"
>Wechseln</UButton>
</div>
</UDashboardPanelContent>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
definePageMeta({
layout: "notLoggedIn"
})
const auth = useAuthStore()
const toast = useToast()
const doChange = async (data:any) => {
try {
const res = await useNuxtApp().$api("/api/auth/password/change", {
method: "POST",
body: {
old_password: data.oldPassword,
new_password: data.newPassword,
}
})
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Ändern erfolgreich"})
await auth.logout()
return navigateTo("/login")
} catch (err: any) {
toast.add({title:"Es gab ein Problem beim ändern",color:"rose"})
}
}
</script>
<template>
<UCard class="max-w-sm w-full mx-auto mt-5">
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Passwort zurücksetzen"
description="Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten."
align="bottom"
:fields="[{
name: 'oldPassword',
label: 'Altes Passwort',
type: 'password',
placeholder: 'Dein altes Passwort'
},{
name: 'newPassword',
label: 'Neues Passwort',
type: 'password',
placeholder: 'Dein neues Passwort'
}]"
:loading="false"
@submit="doChange"
:submit-button="{label: 'Ändern'}"
divider="oder"
>
</UAuthForm>
</UCard>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
definePageMeta({
layout: "notLoggedIn"
})
const auth = useAuthStore()
const toast = useToast()
const doReset = async (data:any) => {
try {
const res = await useNuxtApp().$api("/auth/password/reset", {
method: "POST",
body: {
email: data.email
}
})
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Zurücksetzen erfolgreich"})
return navigateTo("/login")
} catch (err: any) {
toast.add({title:"Problem beim zurücksetzen",color:"rose"})
}
}
</script>
<template>
<UCard class="max-w-sm w-full mx-auto mt-5">
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Passwort zurücksetzen"
description="Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}]"
:loading="false"
@submit="doReset"
:submit-button="{label: 'Zurücksetzen'}"
divider="oder"
>
</UAuthForm>
</UCard>
</template>

View File

@@ -0,0 +1,304 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
defineShortcuts({
'backspace': () => {
router.push("/projecttypes")
},
'arrowleft': () => {
if(openTab.value > 0){
openTab.value -= 1
}
},
'arrowright': () => {
if(openTab.value < 3) {
openTab.value += 1
}
},
})
const openTab = ref(0)
const route = useRoute()
const router = useRouter()
const toast = useToast()
const mode = ref(route.params.mode || "show")
const itemInfo = ref({
name: "",
initialPhases: [{ "key": "f31f6fcb-34d5-41a0-9b8f-6c85062f19be", "icon": "i-heroicons-clipboard-document", "label": "Erstkontakt", "active": true, "quickactions": [] }, { "key": "41995d1f-78fa-448b-b6ea-e206645ffb89", "icon": "i-heroicons-wrench-screwdriver", "label": "Umsetzung", "quickactions": [] }, { "key": "267e78ac-9eab-4736-b9c8-4b94c1724494", "icon": "i-heroicons-document-text", "label": "Rechnungsstellung", "quickactions": [] }, { "key": "038df888-53f2-4985-b08e-776ab82df5d3", "icon": "i-heroicons-check", "label": "Abgeschlossen", "quickactions": [] } ]
})
const oldItemInfo = ref({})
const openQuickActionModal = ref(false)
const selectedKeyForQuickAction = ref("")
const setKeys = () => {
itemInfo.value.initialPhases = itemInfo.value.initialPhases.map(i => {
return {
...i,
key: uuidv4(),
quickactions: i.quickactions || []
}
})
itemInfo.value.initialPhases[0].active = true
}
const setupPage = async() => {
if(mode.value === "show" ){
itemInfo.value = await useEntities("projecttypes").selectSingle(route.params.id,"*")
} else if (mode.value === "edit") {
itemInfo.value = await useEntities("projecttypes").selectSingle(route.params.id,"*")
}
if(mode.value === "create") {
let query = route.query
}
if(itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
setKeys()
}
setupPage()
const addPhase = () => {
itemInfo.value.initialPhases.push({label: '', icon: ''}),
setKeys
}
</script>
<template>
<UDashboardNavbar :title="itemInfo ? itemInfo.name : (mode === 'create' ? 'Projekt erstellen' : 'Projekt bearbeiten')">
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push(`/projecttypes`)"
>
Projekttypen
</UButton>
</template>
<template #center>
<h1
v-if="itemInfo"
class="text-xl font-medium"
>{{itemInfo.name ? `Projekttyp: ${itemInfo.name}` : (mode === 'create' ? 'Projekttyp erstellen' : 'Projekttyp bearbeiten')}}</h1>
</template>
<template #right>
<UButton
v-if="mode === 'edit'"
@click="useEntities('projecttypes').update(itemInfo.id, itemInfo)"
>
Speichern
</UButton>
<UButton
v-else-if="mode === 'create'"
@click="useEntities('projecttypes').create( itemInfo)"
>
Erstellen
</UButton>
<UButton
@click="router.push(itemInfo.id ? `/projecttypes/show/${itemInfo.id}` : `/projecttypes`)"
color="red"
class="ml-2"
v-if="mode === 'edit' || mode === 'create'"
>
Abbrechen
</UButton>
<UButton
v-if="mode === 'show'"
@click="router.push(`/projecttypes/edit/${itemInfo.id}`)"
>
Bearbeiten
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<UTabs
:items="[{label: 'Informationen'}]"
v-if="itemInfo.id && mode == 'show'"
v-model="openTab"
>
<template #item="{ item }">
<div v-if="item.label === 'Informationen'" class="flex flex-row">
<div class="w-1/2 mr-3">
<UCard class="mt-5">
{{itemInfo}}
</UCard>
</div>
<div class="w-1/2">
<UCard class="mt-5">
<HistoryDisplay
type="project"
v-if="itemInfo"
:element-id="itemInfo.id"
render-headline
/>
</UCard>
</div>
</div>
</template>
</UTabs>
<UForm v-else-if="mode === 'edit' || mode === 'create'">
<UAlert
color="rose"
variant="outline"
class="mb-5"
v-if="mode === 'edit'"
description="Achtung Änderungen an diesem Projekttypen betreffen nur Projekte die damit neu erstellt werden. Bestehende Projekte bleiben unverändert."
/>
<UFormGroup
label="Name:"
>
<UInput
v-model="itemInfo.name"
/>
</UFormGroup>
<UDivider class="mt-5">
Initiale Phasen
</UDivider>
<UButton
class="mt-3"
@click="addPhase"
>
+ Phase
</UButton>
<table class="mt-3">
<thead>
<tr>
<th></th>
<th class="text-left"><span class="ml-2">Name</span></th>
<th class="text-left"><span class="ml-2">Icon</span></th>
<th class="text-left"><span class="ml-2">Optional</span></th>
<th class="text-left"><span class="ml-2">Beschreibung</span></th>
<th class="text-left"><span class="ml-2">Schnellaktionen</span></th>
<th></th>
</tr>
</thead>
<draggable
v-model="itemInfo.initialPhases"
handle=".handle"
tag="tbody"
itemKey="pos"
@end="setKeys"
>
<template #item="{element: phase}">
<tr>
<td>
<UIcon
class="handle"
name="i-mdi-menu"
/>
</td>
<td>
<UInput
class="my-2 ml-2"
v-model="phase.label"
placeholder="Name"
/>
</td>
<td>
<UInput
class="my-2 ml-2"
v-model="phase.icon"
placeholder="Icon"
/>
</td>
<td>
<UCheckbox
class="my-2 ml-2"
v-model="phase.optional"
/>
</td>
<td>
<UInput
class="my-2 ml-2"
v-model="phase.description"
placeholder="Beschreibung"
/>
</td>
<td>
<UButton
class="my-2 ml-2"
variant="outline"
@click="openQuickActionModal = true,
selectedKeyForQuickAction = phase.key"
>+ Schnellaktion</UButton>
<UButton
@click="phase.quickactions = phase.quickactions.filter(i => i.label !== button.label)"
v-for="button in phase.quickactions"
class="ml-1"
>
{{ button.label }}
</UButton>
<UModal v-model="openQuickActionModal">
<UCard>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Schnellaktion hinzufügen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="openQuickActionModal = false" />
</div>
<div class="flex flex-col">
<UButton
class="my-1"
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Angebot',link:'/createDocument/edit/?type=quotes'})">Angebot Erstellen</UButton>
<UButton
class="my-1"
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Auftrag',link:'/createDocument/edit/?type=confirmationOrders'})">Auftrag Erstellen</UButton>
<UButton
class="my-1"
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Lieferschein',link:'/createDocument/edit/?type=deliveryNotes'})">Lieferschein Erstellen</UButton>
<UButton
class="my-1"
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Rechnung',link:'/createDocument/edit/?type=invoices'})">Rechnung Erstellen</UButton>
<UButton
class="my-1"
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Aufgabe',link:'/tasks/create'})">Aufgabe Erstellen</UButton>
<UButton
class="my-1"
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Termin',link:'/events/edit'})">Termin Erstellen</UButton>
</div>
</UCard>
</UModal>
</td>
<td>
<UButton
class="my-2 ml-2"
variant="outline"
color="rose"
@click="itemInfo.initialPhases = itemInfo.initialPhases.filter(i => i !== phase)"
>X</UButton>
</td>
</tr>
</template>
</draggable>
</table>
</UForm>
</UDashboardPanelContent>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,107 @@
<script setup>
defineShortcuts({
'/': () => {
//console.log(searchinput)
//searchinput.value.focus()
document.getElementById("searchinput").focus()
},
'+': () => {
router.push("/projects/create")
},
'Enter': {
usingInput: true,
handler: () => {
router.push(`/projecttypes/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 tempStore = useTempStore()
const items = ref([])
const selectedItem = ref(0)
const setup = async () => {
items.value = await useEntities("projecttypes").select()
}
setup()
const templateColumns = [
{
key: "name",
label: "Name"
},
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
const searchString = ref(tempStore.searchStrings["projecttypes"] || '')
const filteredRows = computed(() => {
return useListFilter(searchString.value, items.value)
})
</script>
<template>
<UDashboardNavbar title="Projekttypen" :badge="filteredRows.length">
<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('projecttypes',searchString)"
>
<template #trailing>
<UKbd value="/"/>
</template>
</UInput>
<UButton @click="router.push(`/projecttypes/create`)">+ Projekttyp</UButton>
</template>
</UDashboardNavbar>
<UTable
:rows="filteredRows"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/projecttypes/show/${i.id}`) "
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Projekttypen anzuzeigen' }"
>
<template #name-data="{row}">
<span class="text-primary-500 font-bold" v-if="row === filteredRows[selectedItem]">{{ row.name }}</span>
<span v-else>{{ row.name }}</span>
</template>
</UTable>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,198 @@
<script setup>
import HistoryDisplay from "~/components/HistoryDisplay.vue";
defineShortcuts({
'backspace': () => {
router.push("/roles")
},
'arrowleft': () => {
if(openTab.value > 0){
openTab.value -= 1
}
},
'arrowright': () => {
if(openTab.value < 3) {
openTab.value += 1
}
},
})
const dataStore = useDataStore()
const supabase = useSupabaseClient()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const id = ref(route.params.id ? route.params.id : null )
const role = useRole()
//Working
const mode = ref(route.params.mode || "show")
const itemInfo = ref({
rights: []
})
const openTab = ref(0)
//Functions
const setupPage = async () => {
if(mode.value === "show" || mode.value === "edit"){
itemInfo.value = await useSupabaseSelectSingle("roles",route.params.id,"*")
}
}
const rightOptions = computed(() => {
console.log(Object.keys(useRole().generalAvailableRights.value))
return Object.keys(useRole().generalAvailableRights.value).map(i => {
return {
key: i,
label: role.generalAvailableRights.value[i].label
}
})
})
const rightList = computed(() => {
console.log(rightOptions.value)
return itemInfo.value.rights.map(i => {
if(rightOptions.value.find(x => x.key === i)) {
return rightOptions.value.find(x => x.key === i).label
} else {
return i
}
}).join(", ")
})
setupPage()
</script>
<template>
<UDashboardNavbar
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push(`/roles`)"
>
Artikel
</UButton>
</template>
<template #center>
<h1
v-if="itemInfo"
class="text-xl font-medium"
>{{itemInfo.name ? `Rolle: ${itemInfo.name}` : (mode === 'create' ? 'Rolle erstellen' : 'Rolle bearbeiten')}}</h1>
</template>
<template #right>
<UButton
v-if="mode === 'edit'"
@click="dataStore.updateItem('roles',itemInfo)"
>
Speichern
</UButton>
<UButton
v-else-if="mode === 'create'"
@click="dataStore.createNewItem('roles',itemInfo)"
>
Erstellen
</UButton>
<UButton
@click="router.push(itemInfo.id ? `/roles/show/${itemInfo.value.id}` : `/products/`)"
color="red"
class="ml-2"
v-if="mode === 'edit' || mode === 'create'"
>
Abbrechen
</UButton>
<UButton
v-if="mode === 'show'"
@click=" router.push(`/roles/edit/${itemInfo.id}`)"
>
Bearbeiten
</UButton>
</template>
</UDashboardNavbar>
<UTabs
:items="[{label: 'Informationen'}]"
v-if="mode === 'show' && itemInfo"
class="p-5"
v-model="openTab"
>
<template #item="{item}">
<div v-if="item.label === 'Informationen'" class="mt-5 flex flex-row">
<div class="w-1/2 mr-5">
<UCard>
<table class="w-full">
<tr>
<td>Name:</td>
<td>{{itemInfo.name}}</td>
</tr>
<tr>
<td>Rechte:</td>
<td>{{rightList}}</td>
</tr>
<tr>
<td>Beschreibung:</td>
<td>{{itemInfo.description}}</td>
</tr>
</table>
</UCard>
</div>
<div class="w-1/2">
<UCard>
<HistoryDisplay
type="role"
v-if="itemInfo"
:element-id="id"
render-headline
/>
</UCard>
</div>
</div>
</template>
</UTabs>
<UForm
v-else-if="mode == 'edit' || mode == 'create'"
class="p-5"
>
<UFormGroup
label="Name:"
>
<UInput
v-model="itemInfo.name"
autofocus
/>
</UFormGroup>
<UFormGroup
label="Rechte:"
>
<USelectMenu
:options="rightOptions"
value-attribute="key"
option-attribute="label"
v-model="itemInfo.rights"
multiple
>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Beschreibung:"
>
<UTextarea
v-model="itemInfo.description"
/>
</UFormGroup>
</UForm>
</template>
<style scoped>
td {
border-bottom: 1px solid lightgrey;
vertical-align: top;
padding-bottom: 0.15em;
padding-top: 0.15em;
}
</style>

View File

@@ -0,0 +1,22 @@
<script setup>
const items = ref([])
const setup = async () => {
items.value = await useSupabaseSelect("roles","*")
}
setup()
</script>
<template>
<EntityList
:items="items"
type="roles"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,223 @@
<script setup>
const dataStore = useDataStore()
const profileStore = useProfileStore()
const route = useRoute()
const router = useRouter()
const url = useRequestURL()
const supabase = useSupabaseClient()
const toast = useToast()
const showAddBankRequisition = ref(false)
const bicBankToAdd = ref("")
const bankData = ref({})
const showAlert = ref(false)
const reqData = ref({})
const bankaccounts = ref([])
const showReqData = ref(false)
const setupPage = async () => {
if(route.query.ref) {
reqData.value = await useFunctions().useBankingListRequisitions(route.query.ref)
if(reqData.value.accounts.length > 0){
showReqData.value = true
}
}
bankaccounts.value = await useEntities("bankaccounts").select()
}
const checkBIC = async () => {
bankData.value = await useFunctions().useBankingCheckInstitutions(bicBankToAdd.value)
showAlert.value = true
}
const generateLink = async (bankId) => {
try {
const link = await useFunctions().useBankingGenerateLink(bankId || bankData.value.id)
await navigateTo(link, {
open: {
target: "_blank"
}
})
} catch (error) {
console.log(error)
}
}
const addAccount = async (account) => {
let accountData = {
accountId: account.id,
ownerName: account.owner_name,
iban: account.iban,
tenant: profileStore.currentTenant,
bankId: account.institution_id
}
const {data,error} = await supabase.from("bankaccounts").insert(accountData).select()
if(error) {
toast.add({title: "Es gab einen Fehler bei hinzufügen des Accounts", color:"rose"})
} else if(data) {
toast.add({title: "Account erfolgreich hinzugefügt"})
}
}
const updateAccount = async (account) => {
let bankaccountId = bankaccounts.value.find(i => i.iban === account.iban).id
const res = await useEntities("bankaccounts").update(bankaccountId, {accountId: account.id, expired: false})
if(!res) {
console.log(error)
toast.add({title: "Es gab einen Fehler bei aktualisieren des Accounts", color:"rose"})
} else {
toast.add({title: "Account erfolgreich aktualisiert"})
reqData.value = null
setupPage()
}
}
setupPage()
</script>
<template>
<UDashboardNavbar title="Bankkonten">
<template #right>
<UButton
@click="showAddBankRequisition = true"
>
+ Bankverbindung
</UButton>
<USlideover
v-model="showAddBankRequisition"
>
<UCard
class="h-full"
>
<template #header>
<p>Bankverbindung hinzufügen</p>
</template>
<UFormGroup
label="BIC:"
class="flex-auto"
>
<InputGroup class="w-full">
<UInput
v-model="bicBankToAdd"
class="flex-auto"
@keydown.enter="checkBIC"
/>
<UButton
@click="checkBIC"
>
Check
</UButton>
</InputGroup>
</UFormGroup>
<UAlert
v-if="showAlert && bankData.id && bankData.countries.includes('DE')"
title="Bank gefunden"
icon="i-heroicons-check-circle"
color="primary"
variant="outline"
class="mt-3"
:actions="[{ variant: 'solid', color: 'primary', label: 'Verbinden',click: generateLink }]"
/>
<UAlert
v-else-if="showAlert && !bankData.id"
title="Bank nicht gefunden"
icon="i-heroicons-x-circle"
color="rose"
variant="outline"
class="mt-3"
/>
</UCard>
</USlideover>
</template>
</UDashboardNavbar>
<UModal v-model="showReqData">
<UCard>
<template #header>
Verfügbare Bankkonten
</template>
<div
v-for="account in reqData.accounts"
class="p-2 m-3 flex justify-between"
>
{{account.iban}} - {{account.owner_name}}
<UButton
@click="addAccount(account)"
v-if="!bankaccounts.find(i => i.iban === account.iban)"
>
Hinzufügen
</UButton>
<UButton
@click="updateAccount(account)"
v-else
>
Aktualisieren
</UButton>
</div>
</UCard>
</UModal>
<!-- <UButton @click="setupPage">Setup</UButton>
<div v-if="route.query.reqId">
{{reqData}}
</div>-->
<UTable
:rows="bankaccounts"
:columns="[
{
key: 'expired',
label: 'Aktiv'
},{
key: 'iban',
label: 'IBAN'
},{
key: 'bankId',
label: 'Bank'
},{
key: 'ownerName',
label: 'Kontoinhaber'
},{
key: 'balance',
label: 'Saldo'
},
]"
>
<template #expired-data="{row}">
<span v-if="row.expired" class="text-rose-600">Ausgelaufen</span>
<span v-else class="text-primary">Aktiv</span>
<UButton
v-if="row.expired"
variant="outline"
class="ml-2"
@click="generateLink(row.bankId)"
>Aktualisieren</UButton>
</template>
<template #balance-data="{row}">
{{row.balance ? row.balance.toFixed(2).replace(".",",") + ' €' : '-'}}
</template>
<template #iban-data="{row}">
{{row.iban.match(/.{1,5}/g).join(" ")}}
</template>
</UTable>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
const route = useRoute()
const mode = route.params.mode
const itemInfo = ref({})
const setup = async () => {
if(mode === "create") {
} else {
itemInfo.value = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`)
}
}
setup()
const createAccount = async () => {
const res = await useNuxtApp().$api(`/api/email/accounts`, {
method: "POST",
body: itemInfo.value,
})
if(res.success) {
navigateTo("/settings/emailaccounts")
}
}
const saveAccount = async () => {
const res = await useNuxtApp().$api(`/api/email/accounts/${route.params.id}`, {
method: "POST",
body: itemInfo.value,
})
if(res.success) {
navigateTo("/settings/emailaccounts")
}
}
</script>
<template>
<UDashboardNavbar :title="`E-Mail Konto ${mode === 'create' ? 'erstellen' : 'bearbeiten'}`">
<template #right>
<UButton
v-if="mode === 'create'"
@click="createAccount"
>
Erstellen
</UButton>
<UButton
v-if="mode === 'edit'"
@click="saveAccount"
>
Speichern
</UButton>
</template>
</UDashboardNavbar>
<UForm class="w-2/3 mx-auto mt-5">
<UFormGroup
label="E-Mail Adresse"
>
<UInput
v-model="itemInfo.email"
/>
</UFormGroup>
<UFormGroup
label="Passwort"
>
<UInput
type="password"
v-model="itemInfo.password"
placeholder="********"
/>
</UFormGroup>
<UDivider> IMAP </UDivider>
<UFormGroup
label="IMAP Host"
>
<UInput
v-model="itemInfo.imap_host"
/>
</UFormGroup>
<UFormGroup
label="IMAP Port"
>
<UInput
type="number"
v-model="itemInfo.imap_port"
/>
</UFormGroup>
<UFormGroup
label="IMAP SSL"
>
<UToggle
v-model="itemInfo.imap_ssl"
/>
</UFormGroup>
<UDivider> SMTP </UDivider>
<UFormGroup
label="SMTP Host"
>
<UInput
v-model="itemInfo.smtp_host"
/>
</UFormGroup>
<UFormGroup
label="SMTP Port"
>
<UInput
type="number"
v-model="itemInfo.smtp_port"
/>
</UFormGroup>
<UFormGroup
label="SMTP SSL"
>
<UToggle
v-model="itemInfo.smtp_ssl"
/>
</UFormGroup>
</UForm>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,99 @@
<script setup>
import axios from "axios"
const supabase = useSupabaseClient()
const dataStore = useDataStore()
const profileStore = useProfileStore()
const createEMailAddress = ref("")
const createEMailType = ref("imap")
const showEmailAddressModal = ref(false)
const items = ref([])
const setupPage = async () => {
items.value = await useNuxtApp().$api("/api/email/accounts")
}
const createAccount = async () => {
showEmailAddressModal.value = false
}
setupPage()
const templateColumns = [
{
key: "email",
label: "E-Mail Adresse:"
},
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
</script>
<template>
<UModal
v-model="showEmailAddressModal"
>
<UCard>
<template #header>
E-Mail Adresse
</template>
<!-- <UFormGroup
label="E-Mail Adresse:"
>
</UFormGroup>-->
<UInput
v-model="createEMailAddress"
/>
<!-- <UFormGroup
label="Account Typ:"
>
<USelectMenu
:options="[{key: 'imap',label:'IMAP'}]"
option-attribute="label"
value-attribute="key"
v-model="createEMailType"
/>
</UFormGroup>-->
<template #footer>
<UButton
@click="createAccount"
>
Erstellen
</UButton>
</template>
</UCard>
</UModal>
<UDashboardNavbar title="E-Mail Konten">
<template #right>
<UTooltip title="In der Beta nicht verfügbar">
<UButton
@click="navigateTo('/settings/emailaccounts/create')"
>
+ E-Mail Konto
</UButton>
</UTooltip>
</template>
</UDashboardNavbar>
<UTable
:rows="items"
:columns="columns"
class="w-full"
@select="(i) => navigateTo(`/settings/emailaccounts/edit/${i.id}`)"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine E-Mail Konten anzuzeigen' }"
>
</UTable>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,66 @@
<script setup>
import axios from "axios"
const {ownTenant} = storeToRefs(useDataStore())
const setupPrinter = async () => {
console.log(ownTenant.value.labelPrinterIp)
let printerUri = `http://${ownTenant.value.labelPrinterIp}/pstprnt`
labelPrinterURI.value = printerUri
console.log(printerUri)
}
/*const printLabel = async () => {
axios
.post(labelPrinterURI.value, `^XA^FO10,20^BCN,100^FD${}^XZ` )
.then(console.log)
.catch(console.log)
}*/
const labelPrinterURI = ref("")
</script>
<template>
<UPage>
<UCard>
<template #header>
Etikettendrucker
</template>
<UFormGroup
label="IP-Adresse:"
>
<UInput
v-model="labelPrinterURI"
/>
</UFormGroup>
<UButton
@click="setupPrinter"
>
Drucker Setup
</UButton>
<UButton
@click="printLabel"
>
Druck
</UButton>
</UCard>
<!-- <UCard>
<template #header>
A4 Drucker
</template>
</UCard>-->
</UPage>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,89 @@
<script setup>
const dataStore = useDataStore()
const profileStore = useProfileStore()
const router = useRouter()
const items = [{
label: 'Profil',
},{
label: 'Projekte',
content: 'This is the content shown for Tab1'
}, {
label: 'E-Mail',
content: 'And, this is the content for Tab2'
}, {
label: 'Dokumente'
}]
const colorMode = useColorMode()
const isLight = computed({
get() {
return colorMode.value !== 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
</script>
<template>
<UDashboardNavbar title="Einstellungen">
</UDashboardNavbar>
<UTabs
:items="items"
class="h-100 p-5"
>
<template #item="{item}">
<UCard class="mt-5">
<div v-if="item.label === 'Profil'">
<UDivider
class="my-3"
label="Profil"
/>
<InputGroup>
<UButton
:icon="!isLight ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'"
color="white"
variant="outline"
aria-label="Theme"
@click="isLight = !isLight"
/>
</InputGroup>
</div>
<div v-else-if="item.label === 'Projekte'">
<UDivider
label="Phasenvorlagen"
/>
</div>
<div v-else-if="item.label === 'Dokumente'">
<UDivider
label="Tags"
class="mb-3"
/>
<InputGroup>
<UBadge
v-for="tag in profileStore.ownTenant.tags.documents"
>
{{tag}}
</UBadge>
</InputGroup>
{{profileStore.ownTenant.tags}}
</div>
</UCard>
</template>
</UTabs>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,112 @@
<script setup>
const auth = useAuthStore()
const resources = {
customers: {
label: "Kunden"
},
vendors: {
label: "Lieferanten"
},
products: {
label: "Artikel"
},
spaces: {
label: "Lagerplätze"
},
invoices: {
label: "Rechnungen"
},
quotes: {
label: "Angebote"
},
inventoryitems: {
label: "Inventarartikel"
},
projects: {
label: "Projekte"
},
confirmationOrders: {
label: "Auftragsbestätigungen"
},
deliveryNotes: {
label: "Lieferscheine"
},
costcentres: {
label: "Kostenstellen"
}
}
const numberRanges = ref(auth.activeTenantData.numberRanges)
const updateNumberRanges = async (range) => {
const res = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
method: "PUT",
body: {
numberRange: numberRanges.value[range]
}
})
console.log(res)
}
</script>
<template>
<UDashboardNavbar
title="Nummernkreise bearbeiten"
>
</UDashboardNavbar>
<UDashboardToolbar>
<UAlert
title="Änderungen an diesen Werten betreffen nur neu Erstellte Einträge."
color="rose"
variant="outline"
icon="i-heroicons-exclamation-triangle"
/>
</UDashboardToolbar>
<table
class="m-3"
>
<tr class="text-left">
<th>Typ</th>
<th>Prefix</th>
<th>Nächste Nummer</th>
<th>Suffix</th>
</tr>
<tr
v-for="key in Object.keys(resources)"
>
<td>{{resources[key].label}}</td>
<td>
<UInput
v-model="numberRanges[key].prefix"
@change="updateNumberRanges(key)"
/>
</td>
<td>
<UInput
v-model="numberRanges[key].nextNumber"
@change="updateNumberRanges(key)"
type="number"
step="1"
/>
</td>
<td>
<UInput
v-model="numberRanges[key].suffix"
@change="updateNumberRanges(key)"
/>
</td>
</tr>
</table>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,23 @@
<script setup>
const dataStore = useDataStore()
const setupPage = async () => {
console.log()
}
</script>
<template>
<UDashboardNavbar
title="Eigene Felder"
>
</UDashboardNavbar>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,169 @@
<script setup>
const auth = useAuthStore()
const itemInfo = ref({
features: {},
businessInfo: {},
projectTypes: []
})
const setupPage = async () => {
itemInfo.value = auth.activeTenantData
console.log(itemInfo.value)
}
const features = ref(auth.activeTenantData.features)
const businessInfo = ref(auth.activeTenantData.businessInfo)
const updateTenant = async (newData) => {
const res = await useNuxtApp().$api(`/api/tenant/other/${auth.activeTenant}`, {
method: "PUT",
body: {
data: newData,
}
})
}
setupPage()
</script>
<template>
<UDashboardNavbar title="Firmeneinstellungen">
</UDashboardNavbar>
<UTabs
class="p-5"
:items="[
{
label: 'Dokubox'
},{
label: 'Rechnung & Kontakt'
}
]"
>
<template #item="{item}">
<div v-if="item.label === 'Dokubox'">
<UAlert
class="mt-5"
title="DOKUBOX"
>
<template #description>
<p>Die Dokubox ist eine E-Mail Inbox um deine Anhänge direkt als Dokumente zu importieren. Leite Deine E-Mails einfach an die folgende E-Mail Adresse weiter, diese ist für dein Unternehmen einzigartig. Die E-Mails werden dann alle 5 min abgerufen.</p>
<br><br>
<a
v-if="itemInfo.id"
:href="`mailto:${itemInfo.dokuboxkey}@fedeo-dokubox.de`"
>
{{itemInfo.dokuboxkey}}@fedeo-dokubox.de
</a>
</template>
</UAlert>
</div>
<div v-if="item.label === 'Rechnung & Kontakt'">
<UCard class="mt-5">
<UForm class="w-1/2">
<UFormGroup
label="Firmenname:"
>
<UInput v-model="businessInfo.name"/>
</UFormGroup>
<UFormGroup
label="Straße + Hausnummer:"
>
<UInput v-model="businessInfo.street"/>
</UFormGroup>
<UFormGroup
label="PLZ + Ort"
class="w-full"
>
<InputGroup class="w-full">
<UInput v-model="businessInfo.zip"/>
<UInput v-model="businessInfo.city" class="flex-auto"/>
</InputGroup>
</UFormGroup>
<UButton
class="mt-3"
@click="updateTenant({businessInfo: businessInfo})"
>
Speichern
</UButton>
</UForm>
</UCard>
</div>
<div v-else-if="item.label === 'Funktionen'">
<UCard class="mt-5">
<UAlert
title="Funktionen ausblenden"
description="Nur Funktionen mit gesetztem Haken sind im Unternehmen verfügbar. Diese Einstellungen gelten für alle Mitarbeiter und sind unabhängig von Berechtigungen."
color="rose"
variant="outline"
class="mb-5"
/>
<UCheckbox
label="Kalendar"
v-model="features.calendar"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Kontakte"
v-model="features.contacts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Plantafel"
v-model="features.planningBoard"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Zeiterfassung"
v-model="features.timeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Anwesenheiten"
v-model="features.workingTimeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Lager"
v-model="features.inventory"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Fahrzeuge"
v-model="features.vehicles"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Buchhaltung"
v-model="features.accounting"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Projekte"
v-model="features.projects"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Verträge"
v-model="features.contracts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Objekte"
v-model="features.objects"
@change="updateTenant({features: features})"
/>
</UCard>
</div>
</template>
</UTabs>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,220 @@
<script setup>
const dataStore = useDataStore()
defineShortcuts({
'+': () => {
editTemplateModalOpen.value = true
}
})
const editTemplateModalOpen = ref(false)
const itemInfo = ref({})
const texttemplates = ref([])
const loading = ref(true)
const setup = async () => {
texttemplates.value = (await useEntities("texttemplates").select()).filter(i => !i.archived)
loading.value = false
}
setup()
const expand = ref({
openedRows: [],
row: {}
})
</script>
<template>
<UDashboardNavbar
title="Text Vorlagen"
>
<template #right>
<UButton
@click="editTemplateModalOpen = true, itemInfo = {}"
>
+ Erstellen
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<UCard class="mx-5">
<template #header>
Variablen
</template>
<table>
<tr>
<th class="text-left">Variable</th>
<th class="text-left">Beschreibung</th>
</tr>
<tr>
<td>vorname</td>
<td>Vorname</td>
</tr>
<tr>
<td>nachname</td>
<td>Nachname</td>
</tr>
<tr>
<td>zahlungsziel_in_tagen</td>
<td>Zahlungsziel in Tagen</td>
</tr>
<tr>
<td>lohnkosten</td>
<td>Lohnkosten Verkauf</td>
</tr>
<tr>
<td>titel</td>
<td>Titel</td>
</tr>
<tr>
<td>anrede</td>
<td>Anrede</td>
</tr>
</table>
</UCard>
<UTable
class="mt-3"
:rows="texttemplates"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
:loading="loading"
v-model:expand="expand" :empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Textvorlagen anzuzeigen' }"
:columns="[{key:'name',label:'Name'},{key:'documentType',label:'Dokumententyp'},{key:'default',label:'Standard'},{key:'pos',label:'Position'}]"
>
<template #documentType-data="{row}">
{{dataStore.documentTypesForCreation[row.documentType].label}}
</template>
<template #default-data="{row}">
{{row.default ? "Ja" : "Nein"}}
</template>
<template #pos-data="{row}">
<span v-if="row.pos === 'startText'">Einleitung</span>
<span v-else-if="row.pos === 'endText'">Endtext</span>
</template>
<template #expand="{ row }">
<div class="p-4">
<p class="text-2xl">{{dataStore.documentTypesForCreation[row.documentType].label}}</p>
<p class="text-xl mt-3">{{row.pos === 'startText' ? 'Einleitung' : 'Ende'}}</p>
<p class="text-justify mt-3">{{row.text}}</p>
<UButton
class="mt-3 mr-3"
@click="itemInfo = row;
editTemplateModalOpen = true"
variant="outline"
>Bearbeiten</UButton>
<ButtonWithConfirm
color="rose"
variant="outline"
@confirmed="dataStore.updateItem('texttemplates',{id: row.id,archived: true}),
setup"
>
<template #button>
Archivieren
</template>
<template #header>
<span class="text-md dark:text-whitetext-black font-bold">Archivieren bestätigen</span>
</template>
Möchten Sie diesen Ausgangsbeleg wirklich archivieren?
</ButtonWithConfirm>
</div>
</template>
</UTable>
<!-- <div class="w-3/4 mx-auto mt-5">
<UCard
v-for="template in dataStore.texttemplates"
class="mb-3"
>
<p class="text-2xl">{{dataStore.documentTypesForCreation[template.documentType].label}}</p>
<p class="text-xl">{{template.pos === 'startText' ? 'Einleitung' : 'Ende'}}</p>
<p class="text-justify">{{template.text}}</p>
<UButton
@click="itemInfo = template;
editTemplateModalOpen = true"
icon="i-heroicons-pencil-solid"
variant="outline"
/>
</UCard>
</div>-->
</UDashboardPanelContent>
<UModal
v-model="editTemplateModalOpen"
>
<UCard class="h-full">
<template #header>
{{itemInfo.id ? 'Vorlage bearbeiten' : 'Vorlage erstellen'}}
</template>
<UForm class="h-full">
<UFormGroup
label="Name:"
>
<UInput
v-model="itemInfo.name"
/>
</UFormGroup>
<UFormGroup
label="Dokumententyp:"
>
<USelectMenu
:options="Object.keys(dataStore.documentTypesForCreation).filter(i => i !== 'serialInvoices').map(i => {
return {
label: dataStore.documentTypesForCreation[i].label,
key: i
}
})"
option-attribute="label"
value-attribute="key"
v-model="itemInfo.documentType"
/>
</UFormGroup>
<UFormGroup
label="Position:"
>
<USelectMenu
:options="[{label:'Einleitung',key: 'startText'},{label:'Ende',key: 'endText'}]"
option-attribute="label"
value-attribute="key"
v-model="itemInfo.pos"
/>
</UFormGroup>
<UFormGroup
label="Text:"
>
<UTextarea
v-model="itemInfo.text"
/>
</UFormGroup>
</UForm>
<!-- TODO: Update und Create -->
<template #footer>
<UButton
@click="dataStore.createNewItem('texttemplates',itemInfo);
editTemplateModalOpen = false"
v-if="!itemInfo.id"
>Erstellen</UButton>
<UButton
@click="dataStore.updateItem('texttemplates',itemInfo);
editTemplateModalOpen = false"
v-if="itemInfo.id"
>Speichern</UButton>
</template>
</UCard>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
const route = useRoute()
const toast = useToast()
const { $api } = useNuxtApp()
const id = route.params.id as string
const profile = ref<any>(null)
const pending = ref(true)
const saving = ref(false)
/** Profil laden **/
async function fetchProfile() {
pending.value = true
try {
profile.value = await $api(`/api/profiles/${id}`)
ensureWorkingHoursStructure()
} catch (err: any) {
console.error('[fetchProfile]', err)
toast.add({
title: 'Fehler beim Laden',
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
color: 'red'
})
} finally {
pending.value = false
}
}
/** Profil speichern **/
async function saveProfile() {
if (saving.value) return
saving.value = true
try {
await $api(`/api/profiles/${id}`, {
method: 'PUT',
body: profile.value
})
toast.add({ title: 'Profil gespeichert', color: 'green' })
fetchProfile()
} catch (err: any) {
console.error('[saveProfile]', err)
toast.add({
title: 'Fehler beim Speichern',
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
color: 'red'
})
} finally {
saving.value = false
}
}
const weekdays = [
{ key: '1', label: 'Montag' },
{ key: '2', label: 'Dienstag' },
{ key: '3', label: 'Mittwoch' },
{ key: '4', label: 'Donnerstag' },
{ key: '5', label: 'Freitag' },
{ key: '6', label: 'Samstag' },
{ key: '7', label: 'Sonntag' }
]
const bundeslaender = [
{ code: 'DE-BW', name: 'Baden-Württemberg' },
{ code: 'DE-BY', name: 'Bayern' },
{ code: 'DE-BE', name: 'Berlin' },
{ code: 'DE-BB', name: 'Brandenburg' },
{ code: 'DE-HB', name: 'Bremen' },
{ code: 'DE-HH', name: 'Hamburg' },
{ code: 'DE-HE', name: 'Hessen' },
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'DE-NI', name: 'Niedersachsen' },
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
{ code: 'DE-SL', name: 'Saarland' },
{ code: 'DE-SN', name: 'Sachsen' },
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
{ code: 'DE-TH', name: 'Thüringen' }
]
// Sicherstellen, dass das JSON-Feld existiert
function ensureWorkingHoursStructure() {
if (!profile.value.weekly_regular_working_hours) {
profile.value.weekly_regular_working_hours = {}
}
for (const { key } of weekdays) {
if (profile.value.weekly_regular_working_hours[key] == null) {
profile.value.weekly_regular_working_hours[key] = 0
}
}
}
function recalculateWeeklyHours() {
if (!profile.value?.weekly_regular_working_hours) return
const total = Object.values(profile.value.weekly_regular_working_hours).reduce(
(sum: number, val: any) => {
const num = parseFloat(val)
return sum + (isNaN(num) ? 0 : num)
},
0
)
profile.value.weekly_working_hours = Number(total.toFixed(2))
}
const checkZip = async () => {
const zipData = await useFunctions().useZipCheck(profile.value.address_zip)
profile.value.address_city = zipData.short
profile.value.state_code = zipData.state_code
}
onMounted(fetchProfile)
</script>
<template>
<!-- Haupt-Navigation -->
<UDashboardNavbar title="Mitarbeiter">
<template #left>
<UButton
color="primary"
variant="outline"
@click="navigateTo(`/staff/profiles`)"
icon="i-heroicons-chevron-left"
>
Mitarbeiter
</UButton>
</template>
<template #center>
<h1 class="text-xl font-medium truncate">
Mitarbeiter bearbeiten: {{ profile?.full_name || '' }}
</h1>
</template>
</UDashboardNavbar>
<!-- Toolbar -->
<UDashboardToolbar>
<template #right>
<UButton
icon="i-mdi-content-save"
color="primary"
:loading="saving"
@click="saveProfile"
>
Speichern
</UButton>
</template>
</UDashboardToolbar>
<!-- Inhalt -->
<UDashboardPanelContent>
<UCard v-if="!pending && profile">
<div class="flex items-center gap-4 mb-6">
<UAvatar size="xl" :alt="profile.full_name" />
<div>
<h2 class="text-xl font-semibold text-gray-900">{{ profile.full_name }}</h2>
<p class="text-sm text-gray-500">{{ profile.employee_number || '' }}</p>
</div>
</div>
<UDivider label="Persönliche Daten" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormGroup label="Vorname">
<UInput v-model="profile.first_name" />
</UFormGroup>
<UFormGroup label="Nachname">
<UInput v-model="profile.last_name" />
</UFormGroup>
<UFormGroup label="E-Mail">
<UInput v-model="profile.email" />
</UFormGroup>
<UFormGroup label="Telefon (Mobil)">
<UInput v-model="profile.mobile_tel" />
</UFormGroup>
<UFormGroup label="Telefon (Festnetz)">
<UInput v-model="profile.fixed_tel" />
</UFormGroup>
<UFormGroup label="Geburtstag">
<UInput type="date" v-model="profile.birthday" />
</UFormGroup>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<UDivider label="Vertragsinformationen" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormGroup label="Vertragsart">
<UInput v-model="profile.contract_type"/>
</UFormGroup>
<UFormGroup label="Status">
<UInput v-model="profile.status"/>
</UFormGroup>
<UFormGroup label="Position">
<UInput v-model="profile.position"/>
</UFormGroup>
<UFormGroup label="Qualifikation">
<UInput v-model="profile.qualification"/>
</UFormGroup>
<UFormGroup label="Eintrittsdatum">
<UInput type="date" v-model="profile.entry_date" />
</UFormGroup>
<UFormGroup label="Wöchentliche Arbeitszeit (Std)">
<UInput type="number" v-model="profile.weekly_working_hours" />
</UFormGroup>
<UFormGroup label="Bezahlte Urlaubstage (Jahr)">
<UInput type="number" v-model="profile.annual_paid_leave_days" />
</UFormGroup>
<UFormGroup label="Aktiv">
<div class="flex items-center gap-3">
<UToggle v-model="profile.active" color="primary" />
<span class="text-sm text-gray-600">
</span>
</div>
</UFormGroup>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<UDivider label="Adresse & Standort" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormGroup label="Straße und Hausnummer">
<UInput v-model="profile.address_street"/>
</UFormGroup>
<UFormGroup label="PLZ">
<UInput type="text" v-model="profile.address_zip" @focusout="checkZip"/>
</UFormGroup>
<UFormGroup label="Ort">
<UInput v-model="profile.address_city"/>
</UFormGroup>
<UFormGroup label="Bundesland">
<USelectMenu
v-model="profile.state_code"
:options="bundeslaender"
value-attribute="code"
option-attribute="name"
placeholder="Bundesland auswählen"
/>
</UFormGroup>
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<UDivider label="Wöchentliche Arbeitsstunden" />
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="day in weekdays"
:key="day.key"
:class="[...profile.weekly_regular_working_hours[day.key] === 0 ? ['bg-gray-100'] : ['bg-gray-100','border-green-400'], 'flex items-center justify-between border rounded-lg p-3 bg-gray-50']"
>
<span class="font-medium text-gray-700">{{ day.label }}</span>
<div class="flex items-center gap-2">
<UInput
type="number"
size="sm"
min="0"
max="24"
step="0.25"
v-model.number="profile.weekly_regular_working_hours[day.key]"
placeholder="0"
class="w-24"
@change="recalculateWeeklyHours"
/>
<span class="text-gray-400 text-sm">Std</span>
</div>
</div>
</div>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<UDivider label="Sonstiges" />
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormGroup label="Kleidergröße (Oberteil)">
<UInput v-model="profile.clothing_size_top" />
</UFormGroup>
<UFormGroup label="Kleidergröße (Hose)">
<UInput v-model="profile.clothing_size_bottom" />
</UFormGroup>
<UFormGroup label="Schuhgröße">
<UInput v-model="profile.clothing_size_shoe" />
</UFormGroup>
<UFormGroup label="Token-ID">
<UInput v-model="profile.token_id" />
</UFormGroup>
</UForm>
</UCard>
<USkeleton v-if="pending" height="300px" />
</UDashboardPanelContent>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
const router = useRouter()
const items = ref([])
const setupPage = async () => {
items.value = (await useNuxtApp().$api("/api/tenant/users")).users
items.value = items.value.map(i => i.profile)
}
setupPage()
const templateColumns = [
{
key: 'employee_number',
label: "MA-Nummer",
},{
key: 'full_name',
label: "Name",
},{
key: "email",
label: "E-Mail",
}
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
</script>
<template>
<UDashboardNavbar title="Benutzer Einstellungen">
<template #right>
<UButton
@click="router.push(`/profiles/create`)"
disabled
>
+ Mitarbeiter
</UButton>
</template>
</UDashboardNavbar>
<UTable
:rows="items"
:columns="columns"
@select="(i) => navigateTo(`/staff/profiles/${i.id}`)"
>
</UTable>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,482 @@
<script setup lang="ts">
const { $dayjs } = useNuxtApp()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
// 🔹 State
const workingTimeInfo = ref<{
userId: string;
spans: any[]; // Neue Struktur für die Detailansicht/Tabelle
summary: {
sumWorkingMinutesSubmitted: number;
sumWorkingMinutesApproved: number;
sumWorkingMinutesRecreationDays: number;
sumRecreationDays: number;
sumWorkingMinutesVacationDays: number;
sumVacationDays: number;
sumWorkingMinutesSickDays: number;
sumSickDays: number;
timeSpanWorkingMinutes: number;
saldoApproved: number;
saldoSubmitted: number;
} | null; // Neue Struktur für die Zusammenfassung
} | null>(null)
const platformIsNative = ref(useCapacitor().getIsNative())
const selectedPresetRange = ref("Dieser Monat bis heute")
const selectedStartDay = ref("")
const selectedEndDay = ref("")
const openTab = ref(0)
const showDocument = ref(false)
const uri = ref("")
const itemInfo = ref({})
const profile = ref(null)
// 💡 Die ID des Benutzers, dessen Daten wir abrufen (aus der Route)
const evaluatedUserId = computed(() => route.params.id as string)
/**
* Konvertiert Minuten in das Format HH:MM h
*/
function formatMinutesToHHMM(minutes = 0) {
const h = Math.floor(minutes / 60)
const m = Math.floor(minutes % 60)
return `${h}:${String(m).padStart(2, "0")} h`
}
/**
* Berechnet die Dauer zwischen startedAt und endedAt in Minuten.
*/
function calculateDurationMinutes(start: string, end: string): number {
const startTime = $dayjs(start);
const endTime = $dayjs(end);
return endTime.diff(startTime, 'minute');
}
/**
* Formatiert die Dauer (in Minuten) in HH:MM h
*/
function formatSpanDuration(start: string, end: string): string {
const minutes = calculateDurationMinutes(start, end);
return formatMinutesToHHMM(minutes);
}
// 📅 Zeitraumumschaltung
function changeRange() {
const rangeMap = {
"Diese Woche": { selector: "isoWeek", subtract: 0 },
"Dieser Monat": { selector: "M", subtract: 0 },
"Dieser Monat bis heute": { selector: "M", subtract: 0 },
"Dieses Jahr": { selector: "y", subtract: 0 },
"Letzte Woche": { selector: "isoWeek", subtract: 1 },
"Letzter Monat": { selector: "M", subtract: 1 },
"Letztes Jahr": { selector: "y", subtract: 1 }
}
const { selector, subtract } = rangeMap[selectedPresetRange.value] || { selector: "M", subtract: 0 }
selectedStartDay.value = $dayjs()
.subtract(subtract, selector === "isoWeek" ? "week" : selector)
.startOf(selector)
.format("YYYY-MM-DD")
selectedEndDay.value =
selectedPresetRange.value === "Dieser Monat bis heute"
? $dayjs().format("YYYY-MM-DD")
: $dayjs()
.subtract(subtract, selector === "isoWeek" ? "week" : selector)
.endOf(selector)
.format("YYYY-MM-DD")
loadWorkingTimeInfo()
}
// 📊 Daten laden (Initialisierung)
async function setupPage() {
await changeRange()
// Lade das Profil des Benutzers, der ausgewertet wird (route.params.id)
try {
const response = await useNuxtApp().$api(`/api/tenant/profiles`);
// Findet das Profil des Benutzers, dessen ID in der Route steht
profile.value = response.data.find(i => i.user_id === evaluatedUserId.value);
} catch (error) {
console.error("Fehler beim Laden des Profils:", error);
}
console.log(profile.value)
setPageLayout(platformIsNative.value ? 'mobile' : 'default')
}
// 💡 ANGEPASST: Ruft den neuen Endpunkt ab und speichert das gesamte Payload-Objekt
async function loadWorkingTimeInfo() {
// Erstellt Query-Parameter für den neuen Backend-Endpunkt
const queryParams = new URLSearchParams({
from: selectedStartDay.value,
to: selectedEndDay.value,
targetUserId: evaluatedUserId.value,
});
const url = `/api/staff/time/evaluation?${queryParams.toString()}`;
// Führt den GET-Request zum neuen Endpunkt aus und speichert das gesamte Payload-Objekt { userId, spans, summary }
const data = await useNuxtApp().$api(url);
workingTimeInfo.value = data;
openTab.value = 0
}
// 📄 PDF generieren
// Frontend (index.vue)
async function generateDocument() {
if (!workingTimeInfo.value || !workingTimeInfo.value.summary) return;
const path = (await useEntities("letterheads").select("*"))[0].path
uri.value = await useFunctions().useCreatePDF({
full_name: profile.value.full_name,
employee_number: profile.value.employee_number || "-",
// Wir übergeben das summary-Objekt flach (für Header-Daten)
...workingTimeInfo.value.summary,
// UND wir müssen die Spans explizit übergeben, damit die Tabelle generiert werden kann
spans: workingTimeInfo.value.spans
}, path, "timesheet")
showDocument.value = true
}
const fileSaved = ref(false)
// 💾 Datei speichern
async function saveFile() {
try {
let fileData = {
auth_profile: profile.value.id,
tenant: auth.activeTenant
}
let file = useFiles().dataURLtoFile(uri.value, `${profile.value.full_name}-${$dayjs(selectedStartDay.value).format("YYYY-MM-DD")}-${$dayjs(selectedEndDay.value).format("YYYY-MM-DD")}.pdf`)
await useFiles().uploadFiles(fileData, [file])
toast.add({title:"Auswertung erfolgreich gespeichert"})
fileSaved.value = true
} catch (error) {
toast.add({title:"Fehler beim Speichern der Auswertung", color: "rose"})
}
}
async function onTabChange(index: number) {
if (index === 1) await generateDocument()
}
// Initialisierung
await setupPage()
</script>
<template>
<template v-if="!platformIsNative">
<UDashboardNavbar :ui="{ center: 'flex items-stretch gap-1.5 min-w-0' }">
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.push('/staff/time')"
>
Anwesenheiten
</UButton>
</template>
<template #center>
<h1 class="text-xl font-medium truncate">
Auswertung Anwesenheiten: {{ profile?.full_name || '' }}
</h1>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UFormGroup label="Zeitraum:">
<USelectMenu
:options="[
'Dieser Monat bis heute',
'Diese Woche',
'Dieser Monat',
'Dieses Jahr',
'Letzte Woche',
'Letzter Monat',
'Letztes Jahr'
]"
v-model="selectedPresetRange"
@change="changeRange"
/>
</UFormGroup>
<UFormGroup label="Start:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="selectedStartDay ? $dayjs(selectedStartDay).format('DD.MM.YYYY') : 'Datum wählen'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</UFormGroup>
<UFormGroup label="Ende:">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="selectedEndDay ? $dayjs(selectedEndDay).format('DD.MM.YYYY') : 'Datum wählen'"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</UFormGroup>
</template>
<template #right>
<UTooltip
:text="fileSaved ? 'Bericht bereits gespeichert' : 'Bericht speichern'"
v-if="openTab === 1 && uri"
>
<UButton
icon="i-mdi-content-save"
:disabled="fileSaved"
@click="saveFile"
>Bericht</UButton>
</UTooltip>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<UTabs
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
v-model="openTab"
@change="onTabChange"
>
<template #item="{ item }">
<div v-if="item.label === 'Information'">
<UCard v-if="workingTimeInfo && workingTimeInfo.summary" class="my-5">
<template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3>
</template>
<div class="grid grid-cols-2 gap-3 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSubmitted) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesApproved) }}</b></p>
<p>Feiertagsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesRecreationDays) }}</b> / {{ workingTimeInfo.summary.sumRecreationDays }} Tage</p>
<p>Urlaubs-/Berufsschulausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesVacationDays) }}</b> / {{ workingTimeInfo.summary.sumVacationDays }} Tage</p>
<p>Krankheitsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSickDays) }}</b> / {{ workingTimeInfo.summary.sumSickDays }} Tage</p>
<p>Soll-Stunden: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.timeSpanWorkingMinutes) }}</b></p>
<p class="col-span-2">
Inoffizielles Saldo: <b>{{ (workingTimeInfo.summary.saldoSubmitted >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoSubmitted)) }}</b>
</p>
<p class="col-span-2">
Saldo: <b>{{ (workingTimeInfo.summary.saldoApproved >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoApproved)) }}</b>
</p>
</div>
</UCard>
<UDashboardPanel>
<UTable
v-if="workingTimeInfo"
:rows="workingTimeInfo.spans"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }"
:columns="[
{ key: 'status', label: 'Status' },
{ key: 'startedAt', label: 'Start' },
{ key: 'endedAt', label: 'Ende' },
{ key: 'duration', label: 'Dauer' },
{ key: 'type', label: 'Typ' }
]"
@select="(row) => router.push(`/workingtimes/edit/${row.sourceEventIds[0]}`)"
>
<template #status-data="{row}">
<span v-if="row.status === 'approved'" class="text-primary-500">Genehmigt</span>
<span v-else-if="row.status === 'submitted'" class="text-cyan-500">Eingereicht</span>
<span v-else-if="row.status === 'factual'" class="text-gray-500">Faktisch</span>
<span v-else-if="row.status === 'draft'" class="text-red-500">Entwurf</span>
<span v-else>{{ row.status }}</span>
</template>
<template #startedAt-data="{ row }">
{{ $dayjs(row.startedAt).format('HH:mm DD.MM.YY') }} Uhr
</template>
<template #endedAt-data="{ row }">
{{ $dayjs(row.endedAt).format('HH:mm DD.MM.YY') }} Uhr
</template>
<template #duration-data="{ row }">
{{ formatSpanDuration(row.startedAt, row.endedAt) }}
</template>
<template #type-data="{ row }">
{{ row.type.charAt(0).toUpperCase() + row.type.slice(1).replace('_', ' ') }}
</template>
</UTable>
</UDashboardPanel>
</div>
<div v-else-if="item.label === 'Bericht'">
<PDFViewer
v-if="showDocument"
:uri="uri"
location="show_time_evaluation"
/>
</div>
</template>
</UTabs>
</UDashboardPanelContent>
</template>
<template v-else>
<UDashboardNavbar title="Auswertung">
<template #toggle><div></div></template>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
variant="ghost"
@click="router.push('/staff/time')"
/>
</template>
</UDashboardNavbar>
<div class="p-4 space-y-4 border-b bg-white dark:bg-gray-900">
<USelectMenu
v-model="selectedPresetRange"
:options="[
'Dieser Monat bis heute',
'Diese Woche',
'Dieser Monat',
'Dieses Jahr',
'Letzte Woche',
'Letzter Monat',
'Letztes Jahr'
]"
@change="changeRange"
placeholder="Zeitraum wählen"
class="w-full"
/>
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-xs text-gray-500 mb-1">Start</p>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar"
class="w-full"
:label="$dayjs(selectedStartDay).format('DD.MM.YYYY')"
/>
<template #panel>
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</div>
<div>
<p class="text-xs text-gray-500 mb-1">Ende</p>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar"
class="w-full"
:label="$dayjs(selectedEndDay).format('DD.MM.YYYY')"
/>
<template #panel>
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
</template>
</UPopover>
</div>
</div>
</div>
<UTabs
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
v-model="openTab"
@change="onTabChange"
class="mt-3 mx-3"
>
<template #item="{ item }">
<div v-if="item.label === 'Information'" class="space-y-4">
<UCard v-if="workingTimeInfo && workingTimeInfo.summary" class="mt-3">
<template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3>
</template>
<div class="space-y-2 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSubmitted) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesApproved) }}</b></p>
<p>
Feiertagsausgleich:
<b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesRecreationDays) }}</b>
/ {{ workingTimeInfo.summary.sumRecreationDays }} Tage
</p>
<p>
Urlaubs-/Berufsschule:
<b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesVacationDays) }}</b>
/ {{ workingTimeInfo.summary.sumVacationDays }} Tage
</p>
<p>
Krankheitsausgleich:
<b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSickDays) }}</b>
/ {{ workingTimeInfo.summary.sumSickDays }} Tage
</p>
<p>Soll: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.timeSpanWorkingMinutes) }}</b></p>
<p>
Inoffizielles Saldo:
<b>{{ (workingTimeInfo.summary.saldoSubmitted >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoSubmitted)) }}</b>
</p>
<p>
Saldo:
<b>{{ (workingTimeInfo.summary.saldoApproved >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoApproved)) }}</b>
</p>
</div>
</UCard>
</div>
<div v-else-if="item.label === 'Bericht'">
<UButton
v-if="uri && !fileSaved"
icon="i-mdi-content-save"
color="primary"
class="w-full mb-3"
@click="saveFile"
>
Bericht speichern
</UButton>
<PDFViewer
v-if="showDocument"
:uri="uri"
location="show_time_evaluation"
/>
</div>
</template>
</UTabs>
</template>
</template>

View File

@@ -0,0 +1,473 @@
<script setup>
import { useStaffTime } from '~/composables/useStaffTime'
import { useAuthStore } from '~/stores/auth'
import FloatingActionButton from "~/components/mobile/FloatingActionButton.vue";
definePageMeta({
layout: "default",
})
const { list, start, stop, submit, approve, reject } = useStaffTime()
const auth = useAuthStore()
const router = useRouter()
const toast = useToast()
const { $dayjs } = useNuxtApp()
// MOBILE DETECTION
const platformIsNative = useCapacitor().getIsNative()
// STATE
const loading = ref(false)
const view = ref('list') // 'list' | 'timeline'
// MODAL STATES
const showEditModal = ref(false)
const entryToEdit = ref(null)
const showRejectModal = ref(false)
const entryToReject = ref(null)
const rejectReason = ref("")
// FILTER & USER
const users = ref([])
const selectedUser = ref(auth.user.id)
const canViewAll = computed(() => auth.permissions.includes('staff.time.read_all'))
// DATA
const entries = ref([])
const active = computed(() => entries.value.find(e => !e.stopped_at))
const isViewingSelf = computed(() => selectedUser.value === auth.user.id)
// GROUPING
const groupedEntries = computed(() => {
const groups = {}
entries.value.forEach(entry => {
const dateKey = $dayjs(entry.started_at).format('YYYY-MM-DD')
if (!groups[dateKey]) groups[dateKey] = []
groups[dateKey].push(entry)
})
return groups
})
// CONFIG
const typeLabel = {
work: "Arbeitszeit",
vacation: "Urlaub",
sick: "Krankheit",
holiday: "Feiertag",
other: "Sonstiges"
}
const typeColor = {
work: "gray",
vacation: "yellow",
sick: "rose",
holiday: "blue",
other: "gray"
}
// ACTIONS
async function loadUsers() {
if (!canViewAll.value) return
const res = (await useNuxtApp().$api("/api/tenant/profiles")).data
users.value = res
}
async function load() {
if (!selectedUser.value) return
entries.value = await list({ user_id: selectedUser.value })
}
async function handleStart() {
if (!isViewingSelf.value) return
loading.value = true
await start("Arbeitszeit gestartet")
await load()
loading.value = false
}
async function handleStop() {
if (!active.value) return
loading.value = true
await stop(active.value.id)
await load()
loading.value = false
}
function handleEdit(entry) {
entryToEdit.value = entry
showEditModal.value = true
}
async function handleSubmit(entry) {
loading.value = true
await submit(entry)
await load()
loading.value = false
toast.add({ title: 'Zeit eingereicht', color: 'green' })
}
async function handleApprove(entry) {
loading.value = true
await approve(entry)
await load()
loading.value = false
toast.add({ title: 'Zeit genehmigt', color: 'green' })
}
function openRejectModal(entry) {
entryToReject.value = entry
rejectReason.value = ""
showRejectModal.value = true
}
async function confirmReject() {
if (!entryToReject.value) return
loading.value = true
try {
await reject(entryToReject.value, rejectReason.value || "Vom Administrator abgelehnt")
toast.add({ title: 'Zeit abgelehnt', color: 'green' })
showRejectModal.value = false
await load()
} catch (e) {
toast.add({ title: 'Fehler beim Ablehnen', description: e.message, color: 'red' })
} finally {
loading.value = false
entryToReject.value = null
}
}
watch(selectedUser, () => { load() })
onMounted(async () => {
await loadUsers()
await load()
setPageLayout(platformIsNative ? 'mobile' : 'default')
})
</script>
<template>
<template v-if="!platformIsNative">
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 border-r pr-4 mr-2">
<UIcon name="i-heroicons-clock" class="w-5 h-5" :class="active ? 'text-primary-500 animate-pulse' : 'text-gray-400'" />
<div class="flex flex-col">
<span class="text-xs text-gray-500 uppercase font-bold">Status</span>
<span v-if="active" class="text-sm font-medium text-primary-600">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
</span>
<span v-else class="text-sm text-gray-600">Nicht aktiv</span>
</div>
</div>
<div v-if="canViewAll" class="flex items-center gap-2">
<USelectMenu
v-model="selectedUser"
:options="users.map(u => ({ label: u.full_name || u.email, value: u.user_id }))"
placeholder="Benutzer auswählen"
value-attribute="value"
option-attribute="label"
class="min-w-[220px]"
:clearable="false"
/>
<UTooltip text="Anwesenheiten auswerten">
<UButton
:disabled="!selectedUser"
color="gray"
icon="i-heroicons-chart-bar"
variant="ghost"
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
/>
</UTooltip>
</div>
</div>
</template>
<template #right>
<div class="flex gap-2 items-center">
<div class="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
<UTooltip text="Listenansicht">
<UButton
size="xs"
:color="view === 'list' ? 'white' : 'gray'"
variant="ghost"
icon="i-heroicons-table-cells"
@click="view = 'list'"
/>
</UTooltip>
<UTooltip text="Zeitstrahl">
<UButton
size="xs"
:color="view === 'timeline' ? 'white' : 'gray'"
variant="ghost"
icon="i-heroicons-list-bullet"
@click="view = 'timeline'"
/>
</UTooltip>
</div>
<template v-if="isViewingSelf">
<UButton v-if="active" color="red" icon="i-heroicons-stop" :loading="loading" label="Stoppen" @click="handleStop" />
<UButton v-else color="green" icon="i-heroicons-play" :loading="loading" label="Starten" @click="handleStart" />
</template>
<template v-else-if="active && canViewAll">
<UButton color="red" variant="soft" icon="i-heroicons-stop" :loading="loading" label="Mitarbeiter stoppen" @click="handleStop" />
</template>
<UButton color="gray" variant="solid" icon="i-heroicons-plus" label="Erfassen" @click="() => { entryToEdit = null; showEditModal = true }" />
</div>
</template>
</UDashboardToolbar>
<UDashboardPanelContent class="p-0 sm:p-4">
<UCard v-if="view === 'list'" :ui="{ body: { padding: 'p-0 sm:p-0' } }">
<UTable
:rows="entries"
:columns="[
{ key: 'actions', label: 'Aktionen', class: 'w-32' },
{ key: 'state', label: 'Status' },
{ key: 'started_at', label: 'Start' },
{ key: 'stopped_at', label: 'Ende' },
{ key: 'duration_minutes', label: 'Dauer' },
{ key: 'type', label: 'Typ' },
{ key: 'description', label: 'Beschreibung' },
]"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Zeiten anzuzeigen' }"
>
<template #state-data="{ row }">
<UBadge v-if="row.state === 'approved'" color="green" variant="subtle">Genehmigt</UBadge>
<UBadge v-else-if="row.state === 'submitted'" color="cyan" variant="subtle">Eingereicht</UBadge>
<UBadge v-else-if="row.state === 'rejected'" color="red" variant="subtle">Abgelehnt</UBadge>
<UBadge v-else color="gray" variant="subtle">Entwurf</UBadge>
</template>
<template #type-data="{ row }">
<UBadge :color="typeColor[row.type] || 'gray'" variant="soft">{{ typeLabel[row.type] || row.type }}</UBadge>
</template>
<template #started_at-data="{ row }">
<span v-if="['vacation','sick'].includes(row.type)">{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}</span>
<span v-else>{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}</span>
</template>
<template #stopped_at-data="{ row }">
<span v-if="!row.stopped_at" class="text-primary-500 font-medium animate-pulse">läuft...</span>
<span v-else-if="['vacation','sick'].includes(row.type)">{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }}</span>
<span v-else>{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}</span>
</template>
<template #duration_minutes-data="{ row }">
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
</template>
<template #actions-data="{ row }">
<div class="flex items-center gap-1">
<UTooltip text="Einreichen" v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at">
<UButton size="xs" color="cyan" variant="ghost" icon="i-heroicons-paper-airplane" @click="handleSubmit(row)" :loading="loading" />
</UTooltip>
<UTooltip text="Genehmigen" v-if="row.state === 'submitted' && canViewAll">
<UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row)" :loading="loading" />
</UTooltip>
<UTooltip text="Ablehnen" v-if="(row.state === 'submitted' || row.state === 'approved') && canViewAll">
<UButton size="xs" color="red" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row)" :loading="loading" />
</UTooltip>
<UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.state)">
<UButton size="xs" color="gray" variant="ghost" icon="i-heroicons-pencil-square" @click="handleEdit(row)" />
</UTooltip>
</div>
</template>
<template #description-data="{ row }">
<span v-if="row.type === 'vacation'">{{row.vacation_reason}}</span>
<span v-else-if="row.type === 'sick'">{{row.sick_reason}}</span>
<span v-else>{{row.description}}</span>
</template>
</UTable>
</UCard>
<div v-else class="max-w-5xl mx-auto pb-20">
<div v-for="(group, date) in groupedEntries" :key="date" class="relative group/date">
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900 py-4 mb-4 border-b border-gray-200 dark:border-gray-800 flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full bg-gray-400"></div>
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 capitalize">
{{ useNuxtApp().$dayjs(date).format('dddd, DD. MMMM') }}
</h3>
<span class="text-xs text-gray-500 font-normal mt-0.5">
{{ group.length }} Einträge
</span>
</div>
<div class="absolute left-[5px] top-14 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-last/date:bottom-auto group-last/date:h-full"></div>
<div class="space-y-6 pb-8">
<div v-for="entry in group" :key="entry.id" class="relative pl-8">
<div
class="absolute left-0 top-6 w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 shadow-sm z-0"
:class="{
'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30': !entry.stopped_at,
'bg-gray-400': entry.stopped_at && entry.type === 'work',
'bg-yellow-400': entry.type === 'vacation',
'bg-red-400': entry.type === 'sick'
}"
></div>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
<div class="flex flex-wrap justify-between items-center p-4 border-b border-gray-100 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800">
<div class="flex items-center gap-3">
<div class="font-mono text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
{{ useNuxtApp().$dayjs(entry.started_at).format('HH:mm') }}
<span class="text-gray-400 text-sm">bis</span>
<span v-if="entry.stopped_at">{{ useNuxtApp().$dayjs(entry.stopped_at).format('HH:mm') }}</span>
<span v-else class="text-primary-500 animate-pulse text-sm uppercase font-bold tracking-wider">Läuft</span>
</div>
<UBadge :color="typeColor[entry.type]" variant="soft" size="xs">{{ typeLabel[entry.type] }}</UBadge>
</div>
<div class="flex items-center gap-2">
<span v-if="entry.duration_minutes" class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ useFormatDuration(entry.duration_minutes) }}
</span>
<UBadge v-if="entry.state === 'approved'" color="green" size="xs" variant="solid">Genehmigt</UBadge>
<UBadge v-else-if="entry.state === 'submitted'" color="cyan" size="xs" variant="solid">Eingereicht</UBadge>
<UBadge v-else-if="entry.state === 'rejected'" color="red" size="xs" variant="solid">Abgelehnt</UBadge>
<UBadge v-else color="gray" size="xs" variant="subtle">Entwurf</UBadge>
</div>
</div>
<div class="p-4">
<p class="text-gray-700 dark:text-gray-300 text-sm whitespace-pre-wrap">
{{ entry.description || 'Keine Beschreibung angegeben.' }}
</p>
<p v-if="entry.type === 'vacation'" class="text-sm text-gray-500 italic mt-1">
Grund: {{ entry.vacation_reason }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900/30 px-4 py-2 flex justify-end gap-2 border-t border-gray-100 dark:border-gray-700">
<UButton
v-if="(entry.state === 'draft' || entry.state === 'factual') && entry.stopped_at"
size="xs" color="cyan" variant="solid" icon="i-heroicons-paper-airplane" label="Einreichen"
@click="handleSubmit(entry)" :loading="loading"
/>
<UButton
v-if="entry.state === 'submitted' && canViewAll"
size="xs" color="green" variant="solid" icon="i-heroicons-check" label="Genehmigen"
@click="handleApprove(entry)" :loading="loading"
/>
<UButton
v-if="(entry.state === 'submitted' || entry.state === 'approved') && canViewAll"
size="xs" color="red" variant="ghost" icon="i-heroicons-x-mark" label="Ablehnen"
@click="openRejectModal(entry)" :loading="loading"
/>
<UButton
v-if="['draft', 'factual', 'submitted'].includes(entry.state)"
size="xs" color="gray" variant="ghost" icon="i-heroicons-pencil-square" label="Bearbeiten"
@click="handleEdit(entry)"
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="entries.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400">
<UIcon name="i-heroicons-calendar" class="w-12 h-12 mb-2 opacity-50" />
<p>Keine Einträge im gewählten Zeitraum.</p>
</div>
</div>
</UDashboardPanelContent>
</template>
<template v-else>
<UDashboardNavbar title="Zeiterfassung" />
<div class="relative flex flex-col h-[100dvh] overflow-hidden">
<div v-if="isViewingSelf" class="p-4 bg-white dark:bg-gray-900 border-b sticky top-0 z-20 shadow-sm">
<UCard class="p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Aktive Zeit</p>
<p v-if="active" class="text-primary-600 font-semibold animate-pulse">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
</p>
<p v-else class="text-gray-600">Keine aktive Zeit</p>
</div>
<div class="flex gap-2">
<template v-if="isViewingSelf">
<UButton v-if="active" color="red" icon="i-heroicons-stop" :loading="loading" @click="handleStop" />
<UButton v-else color="green" icon="i-heroicons-play" :loading="loading" @click="handleStart" />
</template>
</div>
</div>
</UCard>
</div>
<div class="px-3 mt-3">
<UButton color="gray" icon="i-heroicons-chart-bar" label="Auswertung" class="w-full" variant="soft" @click="router.push(`/staff/time/${selectedUser}/evaluate`)" />
</div>
<UDashboardPanelContent class="flex-1 overflow-y-auto p-3 space-y-4 pb-24">
<UCard v-for="row in entries" :key="row.id" class="p-4 border rounded-xl" @click="handleEdit(row)">
<div class="flex justify-between items-center">
<div class="font-semibold flex items-center gap-2">
<span class="truncate max-w-[150px]">{{ row.description || 'Keine Beschreibung' }}</span>
<UBadge :color="typeColor[row.type]" class="text-xs">{{ typeLabel[row.type] }}</UBadge>
</div>
<UBadge v-if="row.state === 'approved'" color="green">Genehmigt</UBadge>
<UBadge v-else-if="row.state === 'submitted'" color="cyan">Eingereicht</UBadge>
<UBadge v-else-if="row.state === 'rejected'" color="red">Abgelehnt</UBadge>
<UBadge v-else color="gray">Entwurf</UBadge>
</div>
<p class="text-sm text-gray-500 mt-1">Start: {{ useNuxtApp().$dayjs(row.started_at).format('DD.MM.YY HH:mm') }}</p>
<p class="text-sm text-gray-500">Ende: <span v-if="row.stopped_at">{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}</span></p>
<div class="flex gap-2 mt-3 justify-end">
<UButton v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at" color="cyan" size="sm" icon="i-heroicons-paper-airplane" label="Einreichen" variant="soft" @click.stop="handleSubmit(row)" :loading="loading" />
<UButton v-if="row.state === 'submitted' && canViewAll" color="green" size="sm" icon="i-heroicons-check" label="Genehmigen" variant="soft" @click.stop="handleApprove(row)" :loading="loading" />
</div>
</UCard>
</UDashboardPanelContent>
<FloatingActionButton icon="i-heroicons-plus" class="!fixed bottom-6 right-6 z-50" color="primary" @click="() => { entryToEdit = null; showEditModal = true }" />
</div>
</template>
<StaffTimeEntryModal
v-model="showEditModal"
:entry="entryToEdit"
@saved="load"
:default-user-id="selectedUser"
/>
<UModal v-model="showRejectModal">
<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">
Zeiteintrag ablehnen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showRejectModal = false" />
</div>
</template>
<div class="space-y-4">
<p class="text-sm text-gray-500">
Der Eintrag wird als "Rejected" markiert und nicht mehr zur Arbeitszeit gezählt.
</p>
<UFormGroup label="Grund (optional)" name="reason">
<UTextarea v-model="rejectReason" placeholder="Falsche Buchung, Doppelt, etc." autofocus />
</UFormGroup>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="showRejectModal = false">Abbrechen</UButton>
<UButton color="red" :loading="loading" @click="confirmReject">Bestätigen</UButton>
</div>
</template>
</UCard>
</UModal>
</template>

View File

@@ -0,0 +1,94 @@
<script setup>
import {setPageLayout} from "#app";
import {useCapacitor} from "~/composables/useCapacitor.js";
const route = useRoute()
const dataStore = useDataStore()
const api = useNuxtApp().$api
const type = route.params.type
const platform = await useCapacitor().getIsNative() ? "mobile" : "default"
const dataType = dataStore.dataTypes[route.params.type]
const loaded = ref(false)
const mode = ref("list")
const items = ref([])
const item = ref({})
const setupPage = async (sort_column = null, sort_direction = null) => {
loaded.value = false
setPageLayout(platform)
if (route.params.mode) mode.value = route.params.mode
if (mode.value === "show") {
//Load Data for Show
item.value = await useEntities(type).selectSingle(route.params.id, "*", true)
} else if (mode.value === "edit") {
//Load Data for Edit
item.value = JSON.stringify(await useEntities(type).selectSingle(route.params.id,"*",false))
console.log(item.value)
} else if (mode.value === "create") {
//Load Data for Create
item.value = JSON.stringify({})
console.log(item.value)
} else if (mode.value === "list") {
//Load Data for List
//items.value = await useEntities(type).select(dataType.supabaseSelectWithInformation, sort_column || dataType.supabaseSortColumn, sort_direction === "asc", true)
items.value = await useEntities(type).select({
filters: {},
sort: [],
page:1
})
}
loaded.value = true
}
setupPage()
</script>
<template>
<EntityShow
v-if="loaded && mode === 'show'"
:type="route.params.type"
:item="item"
@updateNeeded="setupPage"
:key="item"
:platform="platform"
/>
<EntityEdit
v-else-if="loaded && (mode === 'edit' || mode === 'create')"
:type="route.params.type"
:item="item"
:mode="mode"
:platform="platform"
/>
<EntityList
:loading="!loaded"
v-else-if="mode === 'list'"
:type="type"
:items="items"
:platform="platform"
@sort="(i) => setupPage(i.sort_column, i.sort_direction)"
/>
<UProgress
v-else
animation="carousel"
class="p-5 mt-10"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,627 @@
<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";
import {setPageLayout} from "#app";
const { has } = usePermission()
const platformIsNative = useCapacitor().getIsNative()
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/${items.value[selectedItem.value].id}`)
}
},
'arrowdown': () => {
if(selectedItem.value < items.value.length - 1) {
selectedItem.value += 1
} else {
selectedItem.value = 0
}
},
'arrowup': () => {
if(selectedItem.value === 0) {
selectedItem.value = items.value.length - 1
} else {
selectedItem.value -= 1
}
}
})
const router = useRouter()
const route = useRoute()
const dataStore = useDataStore()
const tempStore = useTempStore()
//VARS
const type = route.params.type
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[type] ||'')
const items = ref([])
const itemsMeta = ref({})
const selectedItem = ref(0)
const loading = ref(true)
const initialSetupDone = ref(false)
const pageLimit = ref(15)
const page = ref(tempStore.pages[type] || 1)
const sort = ref({
column: dataType.supabaseSortColumn || "created_at",
direction: 'desc'
})
const columnsToFilter = ref({})
const showMobileFilter = ref(false)
//Functions
function resetMobileFilters() {
if (!itemsMeta.value?.distinctValues) return
Object.keys(itemsMeta.value.distinctValues).forEach(key => {
columnsToFilter.value[key] = [...itemsMeta.value.distinctValues[key]]
})
showMobileFilter.value = false
setupPage()
}
function applyMobileFilters() {
Object.keys(columnsToFilter.value).forEach(key => {
tempStore.modifyFilter(type, key, columnsToFilter.value[key])
})
showMobileFilter.value = false
setupPage()
}
const clearSearchString = () => {
tempStore.clearSearchString(type)
searchString.value = ''
setupPage()
}
const performSearch = async () => {
tempStore.modifySearchString(type,searchString)
changePage(1,true)
setupPage()
}
const changePage = (number, noSetup = false) => {
page.value = number
tempStore.modifyPages(type, number)
if(!noSetup) setupPage()
}
const changeSort = (column) => {
if(sort.value.column === column) {
sort.value.direction = sort.value.direction === 'desc' ? 'asc' : 'desc'
} else {
sort.value.direction = "asc"
sort.value.column = column
}
changePage(1)
}
const isFiltered = computed(() => {
if (!itemsMeta.value?.distinctValues) return false
return Object.keys(columnsToFilter.value).some(key => {
const allValues = itemsMeta.value.distinctValues[key]
const selected = columnsToFilter.value[key]
if (!allValues || !selected) return false
return selected.length !== allValues.length
})
})
//SETUP
const setupPage = async () => {
loading.value = true
setPageLayout(platformIsNative ? "mobile" : "default")
const filters = {
archived:false
}
Object.keys(columnsToFilter.value).forEach((column) => {
if(columnsToFilter.value[column].length !== itemsMeta.value.distinctValues[column].length) {
filters[column] = columnsToFilter.value[column]
}
})
const {data,meta} = await useEntities(type).selectPaginated({
select: dataType.supabaseSelectWithInformation || "*",
filters: filters,
sort: [{field: sort.value.column, direction: sort.value.direction}],
page: page.value,
limit: pageLimit.value,
search: searchString.value,
searchColumns: columns.value.map((i) => i.key),
distinctColumns: dataType.templateColumns.filter(i => i.distinct).map(i => i.key),
})
items.value = data
itemsMeta.value = meta
if(!initialSetupDone.value){
Object.keys(tempStore.filters[type] || {}).forEach((column) => {
columnsToFilter.value[column] = tempStore.filters[type][column]
})
Object.keys(itemsMeta.value.distinctValues).filter(i => !Object.keys(tempStore.filters[type] || {}).includes(i)).forEach(distinctValue => {
columnsToFilter.value[distinctValue] = itemsMeta.value.distinctValues[distinctValue]
})
}
loading.value = false
initialSetupDone.value = true
}
setupPage()
const handleFilterChange = async (action,column) => {
if(action === 'reset') {
columnsToFilter.value[column] = itemsMeta.value.distinctValues[column]
} else if(action === 'change') {
tempStore.modifyFilter(type,column,columnsToFilter.value[column])
}
setupPage()
}
</script>
<template>
<UDashboardNavbar :title="dataType.label" :badge="itemsMeta.total">
<template #toggle>
<div v-if="platformIsNative"></div>
</template>
<template #right v-if="!platformIsNative">
<UTooltip :text="`${dataType.label} durchsuchen`">
<UInput
id="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@keyup="performSearch"
@change="performSearch"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
</UTooltip>
<UTooltip text="Suche löschen" v-if="searchString.length > 0">
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
/>
</UTooltip>
<UTooltip :text="`${dataType.labelSingle} erstellen`">
<UButton
v-if="platform !== 'mobile' && has(`${type}-create`)/*&& useRole().checkRight(`${type}-create`)*/"
@click="router.push(`/standardEntity/${type}/create`)"
class="ml-3"
>+ {{dataType.labelSingle}}</UButton>
</UTooltip>
</template>
</UDashboardNavbar>
<UDashboardToolbar v-if="!platformIsNative">
<template #left>
<UTooltip :text="`${dataType.label} pro Seite`">
<USelectMenu
:options="[{value:10},{value:15, disabled: itemsMeta.total < 15},{value:25, disabled: itemsMeta.total < 25},{value:50, disabled: itemsMeta.total < 50},{value:100, disabled: itemsMeta.total < 100},{value:250, disabled: itemsMeta.total < 250}]"
v-model="pageLimit"
value-attribute="value"
option-attribute="value"
@change="setupPage"
/>
</UTooltip>
<UPagination
v-if="initialSetupDone && items.length > 0"
:disabled="loading"
v-model="page"
:page-count="pageLimit"
:total="itemsMeta.total"
@update:modelValue="(i) => changePage(i)"
show-first
show-last
:first-button="{ icon: 'i-heroicons-chevron-double-left', color: 'gray' }"
:last-button="{ icon: 'i-heroicons-chevron-double-right', trailing: true, color: 'gray' }"
/>
</template>
<template #right>
<UTooltip text="Angezeigte Spalten auswählen">
<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>
</UTooltip>
</template>
</UDashboardToolbar>
<div v-if="!platformIsNative">
<UTable
:loading="loading"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
sort-mode="manual"
v-model:sort="sort"
@update:sort="setupPage"
v-if="dataType && columns && items.length > 0 && !loading"
:rows="items"
:columns="columns"
class="w-full"
style="height: 85dvh"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/standardEntity/${type}/show/${i.id}`) "
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }"
>
<template
v-for="column in dataType.templateColumns.filter(i => !i.disabledInTable)"
v-slot:[`${column.key}-header`]="{row}">
<InputGroup>
<UTooltip v-if="column.sortable">
<UButton
variant="outline"
@click="changeSort(column.key)"
:color="sort.column === column.key ? 'primary' : 'white'"
:icon="sort.column === column.key ? (sort.direction === 'asc' ? 'i-heroicons-arrow-up' : 'i-heroicons-arrow-down') : 'i-heroicons-arrows-up-down'"
>
</UButton>
</UTooltip>
<UTooltip
v-if="column.distinct"
:text="!columnsToFilter[column.key]?.length > 0 ? `Keine Einträge für ${column.label} verfügbar` : `${column.label} Spalte nach Einträgen filtern`"
>
<USelectMenu
:options="itemsMeta?.distinctValues?.[column.key]"
v-model="columnsToFilter[column.key]"
multiple
@change="handleFilterChange('change', column.key)"
searchable
searchable-placeholder="Suche..."
:search-attributes="[column.key]"
:ui-menu="{ width: 'min-w-max' }"
clear-search-on-close
>
<template #empty>
Keine Einträge in der Spalte {{column.label}}
</template>
<template #default="{open}">
<UButton
:disabled="!columnsToFilter[column.key]?.length > 0"
:variant="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'outline' : 'solid'"
:color="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'primary' : 'white'"
>
<span class="truncate">{{ column.label }}</span>
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-5 h-5 transition-transform text-gray-400 dark:text-gray-500" :class="[open && 'transform rotate-90']" />
</UButton>
</template>
</USelectMenu>
</UTooltip>
<UButton
variant="solid"
color="white"
v-else
class="mr-2 truncate"
>{{column.label}}</UButton>
<UTooltip
text="Filter zurücksetzen"
v-if="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length && column.distinct"
>
<UButton
@click="handleFilterChange('reset',column.key)"
variant="outline"
color="rose"
>
X
</UButton>
</UTooltip>
</InputGroup>
</template>
<template #name-data="{row}">
<span
v-if="row.id === items[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 === items[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 === items[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>
<UCard
class="w-1/3 mx-auto mt-10"
v-else-if="!loading"
>
<div
class="flex flex-col text-center"
>
<UIcon
class="mx-auto w-10 h-10 mb-5"
name="i-heroicons-circle-stack-20-solid"/>
<span class="font-bold">Keine {{dataType.label}} anzuzeigen</span>
</div>
</UCard>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto mt-5"></UProgress>
</div>
<div v-else class="relative flex flex-col h-[calc(100dvh-80px)]">
<!-- Mobile Searchbar (sticky top) -->
<div class="p-2 bg-white dark:bg-gray-900 border-b sticky top-0 z-20">
<InputGroup>
<UInput
v-model="searchString"
icon="i-heroicons-magnifying-glass"
placeholder="Suche..."
@keyup="performSearch"
@change="performSearch"
class="w-full"
/>
<UButton
v-if="searchString.length > 0"
icon="i-heroicons-x-mark"
variant="ghost"
color="rose"
@click="clearSearchString()"
/>
<UButton
icon="i-heroicons-funnel"
variant="ghost"
:color="isFiltered ? 'primary' : 'gray'"
@click="showMobileFilter = true"
/>
</InputGroup>
</div>
<!-- Scroll Area -->
<UDashboardPanelContent class="flex-1 overflow-y-auto px-2 py-3 space-y-3 pb-[calc(8vh+env(safe-area-inset-bottom))] mobile-scroll-area">
<UCard
v-for="item in items"
:key="item.id"
class="p-4 rounded-xl shadow-sm border cursor-pointer active:scale-[0.98] transition"
@click="router.push(`/standardEntity/${type}/show/${item.id}`)"
>
<div class="flex items-center justify-between mb-1">
<p class="text-base font-semibold truncate text-primary-600">
{{
dataType.templateColumns.find(i => i.title)?.key
? item[dataType.templateColumns.find(i => i.title).key]
: null
}}
</p>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="text-gray-400 w-5 h-5 flex-shrink-0"
/>
</div>
<p class="text-sm text-gray-500 truncate">
{{ dataType.numberRangeHolder ? item[dataType.numberRangeHolder] : null }}
</p>
<div
v-for="secondInfo in dataType.templateColumns.filter(i => i.secondInfo)"
:key="secondInfo.key"
class="text-sm text-gray-400 truncate"
>
{{
(secondInfo.secondInfoKey && item[secondInfo.key])
? item[secondInfo.key][secondInfo.secondInfoKey]
: item[secondInfo.key]
}}
</div>
</UCard>
<div
v-if="!loading && items.length > 0"
class="p-4 bg-white dark:bg-gray-900 border-t flex items-center justify-center mt-4 rounded-xl"
>
<UPagination
v-if="initialSetupDone && items.length > 0"
:disabled="loading"
v-model="page"
:page-count="pageLimit"
:total="itemsMeta.total"
@update:modelValue="(i) => changePage(i)"
show-first
show-last
:first-button="{ icon: 'i-heroicons-chevron-double-left', color: 'gray' }"
:last-button="{ icon: 'i-heroicons-chevron-double-right', trailing: true, color: 'gray' }"
/>
</div>
<!-- Empty -->
<UCard
v-if="!loading && items.length === 0"
class="mx-auto mt-10 p-6 text-center"
>
<UIcon name="i-heroicons-circle-stack-20-solid" class="mx-auto w-10 h-10 mb-3"/>
<p class="font-bold">Keine {{ dataType.label }} gefunden</p>
</UCard>
<div v-if="loading" class="mt-5">
<UProgress animation="carousel" class="w-3/4 mx-auto"></UProgress>
</div>
</UDashboardPanelContent>
<!-- Mobile Filter Slideover -->
<USlideover
v-model="showMobileFilter"
side="bottom"
:ui="{ width: '100%', height: 'auto', maxHeight: '90vh' }"
class="pb-[env(safe-area-inset-bottom)]"
>
<!-- Header -->
<div class="p-4 border-b flex items-center justify-between flex-shrink-0">
<h2 class="text-xl font-bold">Filter</h2>
<UButton
icon="i-heroicons-x-mark"
variant="ghost"
@click="showMobileFilter = false"
/>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<div
v-for="column in dataType.templateColumns.filter(c => c.distinct)"
:key="column.key"
class="space-y-2"
>
<p class="font-semibold">{{ column.label }}</p>
<USelectMenu
v-model="columnsToFilter[column.key]"
:options="itemsMeta?.distinctValues?.[column.key]"
multiple
searchable
:search-attributes="[column.key]"
placeholder="Auswählen…"
:ui-menu="{ width: '100%' }"
/>
</div>
</div>
<!-- Footer FIXED in card -->
<div
class="
flex justify-between gap-3
px-4 py-4 border-t flex-shrink-0
bg-white dark:bg-gray-900
rounded-b-2xl
"
>
<UButton
color="rose"
variant="outline"
class="flex-1"
@click="resetMobileFilters"
>
Zurücksetzen
</UButton>
<UButton
color="primary"
class="flex-1"
@click="applyMobileFilters"
>
Anwenden
</UButton>
</div>
</USlideover>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,146 @@
<script setup>
import {useFunctions} from "~/composables/useFunctions.js";
import dayjs from "dayjs";
const supabase = useSupabaseClient()
const profileStore = useProfileStore()
const toast = useToast()
const auth = useAuthStore()
const itemInfo = ref({})
const loaded = ref(false)
const setup = async () => {
itemInfo.value = await useEntities("tickets").selectSingle(useRoute().params.id,"*, ticketmessages(*), created_by(*)")
loaded.value = true
}
setup()
const messageContent = ref("")
const addMessage = async () => {
const res = await useEntities("ticketmessages").create({
auth_user: auth.user.user_id,
content: messageContent.value,
ticket: itemInfo.value.id,
internal: false,
type: "Nachricht"
})
}
</script>
<template>
<UDashboardNavbar title="Support Ticket">
<template #badge>
<UBadge
variant="outline"
v-if="itemInfo.status === 'Offen'"
color="yellow"
>{{itemInfo.status}}</UBadge>
<UBadge
variant="outline"
v-if="itemInfo.status === 'Geschlossen'"
color="primary"
>{{itemInfo.status}}</UBadge>
</template>
<template #center>
{{itemInfo.title}}
</template>
<template #right>
<!-- <UButton
v-if="profileStore.currentTenant === 5"
variant="outline"
@click="closeTicket"
>
Ticket Schließen
</UButton>
<UButton
v-if="profileStore.currentTenant === 5"
variant="outline"
@click="showAddEntryModal = true"
>
+ Eintrag
</UButton>
<UModal v-model="showAddEntryModal">
<UCard>
<template #header>
Eintrag hinzufügen
</template>
<UFormGroup
label="Intern:"
>
<UToggle
v-model="addEntryData.internal"
/>
</UFormGroup>
<UFormGroup
label="Typ:"
>
<USelectMenu
v-model="addEntryData.type"
:options="['Nachricht','Notiz','Anruf','Externe Kommunikation']"
/>
</UFormGroup>
<UFormGroup
label="Inhalt:"
>
<UTextarea
v-model="addEntryData.content"
/>
</UFormGroup>
<template #footer>
<UButton
@click="addEntry"
>
Erstellen
</UButton>
</template>
</UCard>
</UModal>-->
</template>
</UDashboardNavbar>
<UDashboardPanelContent v-if="loaded">
<UAlert
v-for="item in itemInfo.ticketmessages.filter(i => !i.internal)"
:title="`${item.type}`"
class="mb-3"
:color="item.profile.tenant === 5 ? 'primary' : 'white'"
variant="outline"
>
<template #description>
<p>{{item.content}}</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">{{dayjs(item.created_at).format("DD.MM.YYYY HH:mm")}}</p>
</template>
</UAlert>
<InputGroup>
<UTextarea
class="w-full mr-2"
placeholder="Neue Nachricht senden"
v-model="messageContent"
/>
<UButton
@click="addMessage"
>
Senden
</UButton>
</InputGroup>
</UDashboardPanelContent>
<UProgress animation="carousel" v-else class="w-3/4 mx-auto"/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,62 @@
<script setup>
const router = useRouter()
const itemInfo = ref({})
const auth = useAuthStore()
const createTicket = async () => {
const ticketRes = await useEntities("tickets").create({
title: itemInfo.value.title,
})
console.log(ticketRes)
const ticketMsgRes = await useEntities("ticketmessages").create({
ticket: ticketRes.id,
created_by: auth.user.user_id,
content: itemInfo.value.content,
internal: false
})
await router.push(`/support/${ticketRes.id}`)
}
</script>
<template>
<UDashboardNavbar title="Neues Ticket erstellen">
<template #right>
<UButton
@click="createTicket"
>
Erstellen
</UButton>
</template>
</UDashboardNavbar>
<UForm class="w-2/3 mx-auto mt-5">
<UFormGroup
label="Titel:"
>
<UInput
v-model="itemInfo.title"
/>
</UFormGroup>
<UFormGroup
label="Nachricht:"
>
<UTextarea
v-model="itemInfo.content"
/>
</UFormGroup>
</UForm>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,110 @@
<script setup>
import dayjs from "dayjs";
const profileStore = useProfileStore()
const router = useRouter()
const tickets = ref([])
const tenants = ref([])
const showClosedTickets = ref(false)
const selectedTenant = ref(null)
const setup = async () => {
/*if(profileStore.currentTenant === 5) {
tickets.value = (await supabase.from("tickets").select("*,created_by(*), ticketmessages(*), tenant(*)").order("created_at", {ascending: false})).data
} else {
tickets.value = (await supabase.from("tickets").select("*,created_by(*), ticketmessages(*)").eq("tenant",profileStore.currentTenant).order("created_at", {ascending: false})).data
}
if(profileStore.currentTenant === 5) {
tenants.value = (await supabase.from("tenants").select().order("id")).data
}*/
tickets.value = await useEntities("tickets").select("*,created_by(*), ticketmessages(*)", "created_at", false)
}
setup()
const filteredRows = computed(() => {
let items = tickets.value
if(!showClosedTickets.value) {
items = items.filter(i => i.status !== "Geschlossen")
}
if(selectedTenant.value) {
console.log(selectedTenant.value)
console.log(items)
console.log(items.filter(i => i.tenant.id === selectedTenant.value))
items = items.filter(i => i.tenant.id === selectedTenant.value)
}
return items
})
</script>
<template>
<UDashboardNavbar
title="Support Tickets"
:badge="filteredRows.length"
>
<template #right>
<UButton
@click="router.push('/support/create')"
>
+ Ticket
</UButton>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UCheckbox
label="Geschlossene Tickets anzeigen"
v-model="showClosedTickets"
/>
<USelectMenu
v-if="profileStore.currentTenant === 5"
:ui-menu="{ width: 'min-w-max' }"
:options="tenants"
option-attribute="name"
value-attribute="id"
v-model="selectedTenant"
>
<template #label>
{{selectedTenant ? tenants.find(i => i.id === selectedTenant).name : "Nicht nach Tenant filtern"}}
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<UTable
:rows="filteredRows"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine Tickets anzuzeigen` }"
@select="(i) => router.push(`/support/${i.id}`)"
:columns="[{key:'created_at',label:'Datum'}, ...profileStore.currentTenant === 5 ? [{key:'tenant',label:'Tenant'}] : [],{key:'status',label:'Status'},{key:'title',label:'Titel'},{key:'created_by',label:'Ersteller'},{key:'ticketmessages',label:'Nachrichten'}]"
>
<template #tenant-data="{ row }">
{{row.tenant.name}}
</template>
<template #status-data="{ row }">
<span v-if="row.status === 'Offen'" class="text-yellow-500">Offen</span>
<span v-else-if="row.status === 'Geschlossen'" class="text-primary">Geschlossen</span>
</template>
<template #created_by-data="{ row }">
{{row.created_by.fullName}}
</template>
<template #created_at-data="{ row }">
{{dayjs(row.created_at).format('DD.MM.YYYY HH:mm')}}
</template>
<template #ticketmessages-data="{ row }">
{{row.ticketmessages.length}}
</template>
</UTable>
</template>
<style scoped>
</style>

15
frontend/pages/test.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
async function handleSingle() {
await useFiles().downloadFile("f60e8466-7136-4492-ad94-a60603bc3c38") // Einzel-Download
}
async function handleMulti() {
await useFiles().downloadFile(undefined, ["f60e8466-7136-4492-ad94-a60603bc3c38", "f60e8466-7136-4492-ad94-a60603bc3c38"]) // Multi-Download ZIP
}
</script>
<template>
<button @click="handleSingle">Einzeldatei</button>
<button @click="handleMulti">Mehrere als ZIP</button>
</template>

View File

@@ -0,0 +1,503 @@
<script setup>
import dayjs from "dayjs";
import VueDatePicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
import {setPageLayout} from "#app";
const dataStore = useDataStore()
const profileStore = useProfileStore()
const supabase = useSupabaseClient()
const user = useSupabaseUser()
const toast = useToast()
const timeTypes = profileStore.ownTenant.timeConfig.timeTypes
const timeInfo = ref({
profile: "",
startDate: "",
endDate: null,
notes: null,
project: null,
type: null
})
const filterUser = ref(profileStore.activeProfile.id || "")
const times = ref([])
const runningTimeInfo = ref({})
const showConfigTimeModal = ref(false)
const configTimeMode = ref("create")
const platform = ref("default")
const projects = ref([])
const setup = async () => {
times.value = await useSupabaseSelect("times","*, profile(*), project(id, name)")
projects.value = await useSupabaseSelect("projects","*")
if(await useCapacitor().getIsPhone()) {
platform.value = "mobile"
setPageLayout("mobile")
}
runningTimeInfo.value = (await supabase
.from("times")
.select()
.eq("tenant", profileStore.currentTenant)
.eq("profile", profileStore.activeProfile.id)
.is("endDate",null)
.single()).data
}
setup()
const filteredRows = computed(() => {
//let times = times.value
/*if(dataStore.hasRight('viewTimes')) {
if(filterUser.value !== "") {
times = times.filter(i => i.user === filterUser.value)
}
} else if(dataStore.hasRight('viewOwnTimes')) {
times = times.filter(i => i.user === user.value.id)
} else {
times = []
}*/
return times.value
})
const itemInfo = ref({
user: "",
notes: null,
project: null,
type: null,
state: "Entwurf"
})
const columns = [
{
key:"state",
label: "Status",
},
{
key: "user",
label: "Benutzer",
},
{
key:"startDate",
label:"Start",
},
{
key: "endDate",
label: "Ende",
},
{
key:"type",
label:"Typ",
},
{
key: "project",
label: "Projekt",
},
{
key: "notes",
label: "Notizen",
}
]
const startTime = async () => {
console.log("started")
timeInfo.value.profile = profileStore.activeProfile.id
timeInfo.value.startDate = dayjs()
timeInfo.value.tenant = profileStore.currentTenant
const {data,error} = await supabase
.from("times")
.insert([timeInfo.value])
.select()
if(error) {
console.log(error)
toast.add({title: "Fehler beim starten der Zeit",color:"rose"})
} else if(data) {
toast.add({title: "Zeit erfolgreich gestartet"})
runningTimeInfo.value = data[0]
}
}
const stopStartedTime = async () => {
runningTimeInfo.value.endDate = dayjs()
runningTimeInfo.value.state = "Im Web gestoppt"
const {data,error} = await supabase
.from("times")
.update(runningTimeInfo.value)
.eq('id',runningTimeInfo.value.id)
.select()
if(error) {
console.log(error)
} else {
toast.add({title: "Zeit erfolgreich gestoppt"})
runningTimeInfo.value = null
setup()
}
}
const createTime = async () => {
const {data,error} = await supabase
.from("times")
.insert({...itemInfo.value, tenant: profileStore.currentTenant})
.select()
if(error) {
console.log(error)
} else if(data) {
itemInfo.value = {}
toast.add({title: "Zeit erfolgreich erstellt"})
showConfigTimeModal.value = false
setup()
}
}
const openTime = (row) => {
itemInfo.value = row
itemInfo.value.project = itemInfo.value.project.id
itemInfo.value.profile = itemInfo.value.profile.id
showConfigTimeModal.value = true
configTimeMode.value = "edit"
}
const updateTime = async () => {
let data = itemInfo.value
data.profile = data.profile.id
data.project = data.project.id
const {error} = await supabase
.from("times")
.update(data)
.eq('id',itemInfo.value.id)
if(error) {
console.log(error)
}
toast.add({title: "Zeit erfolgreich gespeichert"})
showConfigTimeModal.value = false
setup()
}
const format = (date) => {
let dateFormat = dayjs(date).format("DD.MM.YY HH:mm")
return `${dateFormat}`;
}
/*const getDuration = (time) => {
const dez = dayjs(time.end).diff(time.start,'hour',true).toFixed(2)
const hours = Math.floor(dez)
const minutes = Math.floor((dez - hours) * 60)
return {
dezimal: dez,
hours: hours,
minutes: minutes,
composed: `${hours}:${minutes}`
}
}*/
const setState = async (newState) => {
itemInfo.value.state = newState
await updateTime()
}
const getSecondInfo = (item) => {
let returnArray = []
if(item.type) returnArray.push(item.type)
if(item.project) returnArray.push(item.project.name)
if(item.notes) returnArray.push(item.notes)
return returnArray
}
</script>
<template>
<UDashboardNavbar title="Projektzeiten">
<template #toggle>
<div v-if="platform === 'mobile'"></div>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UButton
@click="startTime"
:disabled="runningTimeInfo "
>
Start
</UButton>
<UButton
@click="stopStartedTime"
:disabled="!runningTimeInfo"
>
Stop
</UButton>
<UButton
v-if="platform !== 'mobile'"
@click="configTimeMode = 'create'; itemInfo = {startDate: new Date(), endDate: new Date()}; showConfigTimeModal = true"
>
Erstellen
</UButton>
<USelectMenu
v-if="platform !== 'mobile'"
:options="profileStore.profiles"
option-attribute="fullName"
value-attribute="id"
v-model="filterUser"
>
<template #label>
{{profileStore.getProfileById(filterUser) ? profileStore.getProfileById(filterUser).fullName : "Kein Benutzer ausgewählt"}}
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<div v-if="runningTimeInfo" class="m-3">
Start: {{dayjs(runningTimeInfo.startDate).format("DD.MM.YY HH:mm")}}
<UFormGroup
label="Notizen:"
>
<UTextarea
v-model="runningTimeInfo.notes"
/>
</UFormGroup>
<UFormGroup
label="Projekt:"
>
<USelectMenu
:options="dataStore.projects"
option-attribute="name"
value-attribute="id"
v-model="runningTimeInfo.project"
>
<template #label>
{{ dataStore.projects.find(project => project.id === runningTimeInfo.project) ? dataStore.projects.find(project => project.id === runningTimeInfo.project).name : "Projekt auswählen" }}
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Kategorie:"
>
<USelectMenu
v-model="runningTimeInfo.type"
:options="timeTypes"
option-attribute="label"
value-attribute="label"
>
<template #label>
{{runningTimeInfo.type ? runningTimeInfo.type : "Kategorie auswählen"}}
</template>
</USelectMenu>
</UFormGroup>
</div>
<UModal
v-model="showConfigTimeModal"
>
<UCard>
<template #header>
Projektzeit {{configTimeMode === 'create' ? "erstellen" : "bearbeiten"}}
</template>
<UFormGroup
label="Start:"
>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.startDate ? dayjs(itemInfo.startDate).format('DD.MM.YYYY HH:mm') : 'Datum auswählen'"
variant="outline"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.startDate" @close="close" mode="dateTime" />
</template>
</UPopover>
</UFormGroup>
<UFormGroup
label="Ende:"
>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.endDate ? dayjs(itemInfo.endDate).format('DD.MM.YYYY HH:mm') : 'Datum auswählen'"
variant="outline"
/>
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.endDate" @close="close" mode="dateTime" />
</template>
</UPopover>
</UFormGroup>
<UFormGroup
label="Benutzer:"
>
<USelectMenu
:options="profileStore.profiles"
v-model="itemInfo.profile"
option-attribute="fullName"
value-attribute="id"
>
<template #label>
{{profileStore.profiles.find(profile => profile.id === itemInfo.profile) ? profileStore.profiles.find(profile => profile.id === itemInfo.profile).fullName : "Benutzer auswählen"}}
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Projekt:"
>
<USelectMenu
:options="projects"
v-model="itemInfo.project"
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Suche..."
:search-attributes="['name']"
>
<template #label>
{{dataStore.projects.find(project => project.id === itemInfo.project) ? dataStore.projects.find(project => project.id === itemInfo.project).name : "Projekt auswählen"}}
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Typ:"
>
<USelectMenu
v-model="itemInfo.type"
:options="timeTypes"
option-attribute="label"
value-attribute="label"
>
<template #label>
{{itemInfo.type ? itemInfo.type : "Kategorie auswählen"}}
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Notizen:"
>
<UTextarea
v-model="itemInfo.notes"
/>
</UFormGroup>
<template #footer>
<InputGroup>
<UButton
@click="createTime"
v-if="configTimeMode === 'create'"
>
Erstellen
</UButton>
<UButton
@click="updateTime"
v-else-if="configTimeMode === 'edit'"
>
Speichern
</UButton>
<UButton
@click="setState('Eingereicht')"
v-if="itemInfo.state === 'Entwurf'"
>
Einreichen
</UButton>
</InputGroup>
</template>
</UCard>
</UModal>
<UDashboardPanelContent class="w-full" v-if="platform === 'mobile'">
<a
v-for="item in filteredRows"
class="my-1"
>
<p class="truncate text-left text-primary text-xl">{{dayjs(item.startDate).format("DD.MM.YYYY HH:mm")}} - {{dayjs(item.endDate).format("HH:mm")}}</p>
<p class="text-sm">
<span v-for="(i,index) in getSecondInfo(item)">
{{i}}{{index < getSecondInfo(item).length - 1 ? " - " : ""}}
</span>
</p>
</a>
</UDashboardPanelContent>
<UTable
v-else
class="mt-3"
:columns="columns"
:rows="filteredRows"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
@select="(row) => openTime(row)"
>
<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 #project-data="{row}">
{{row.project ? row.project.name : "" }}
</template>
</UTable>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,121 @@
<script setup>
definePageMeta({
layout: 'blank', // Kein Menü, keine Sidebar
middleware: [], // Keine Auth-Checks durch Nuxt
auth: false // Falls du das nuxt-auth Modul nutzt
})
const route = useRoute()
const token = route.params.token
const { $api } = useNuxtApp() // Dein Fetch-Wrapper
const toast = useToast()
// States
const status = ref('loading') // loading, pin_required, ready, error, success
const pin = ref('')
const context = ref(null)
const errorMsg = ref('')
// Daten laden
const loadContext = async () => {
status.value = 'loading'
errorMsg.value = ''
try {
const headers = {}
if (pin.value) headers['x-public-pin'] = pin.value
// Abruf an dein Fastify Backend
// Pfad evtl. anpassen, wenn du Proxy nutzt
const res = await $fetch(`http://localhost:3100/workflows/context/${token}`, { headers })
context.value = res
status.value = 'ready'
} catch (err) {
if (err.statusCode === 401) {
status.value = 'pin_required' // PIN nötig (aber noch keine eingegeben)
} else if (err.statusCode === 403) {
status.value = 'pin_required'
errorMsg.value = 'Falsche PIN'
pin.value = ''
} else {
status.value = 'error'
errorMsg.value = 'Link ungültig oder abgelaufen.'
}
}
}
// Initialer Aufruf
onMounted(() => {
loadContext()
})
const handlePinSubmit = () => {
if (pin.value.length >= 4) loadContext()
}
const handleFormSuccess = () => {
status.value = 'success'
}
</script>
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4 font-sans">
<div v-if="status === 'loading'" class="text-center">
<UIcon name="i-heroicons-arrow-path" class="w-10 h-10 animate-spin text-primary-500 mx-auto" />
<p class="mt-4 text-gray-500">Lade Formular...</p>
</div>
<UCard v-else-if="status === 'error'" class="w-full max-w-md border-red-200">
<div class="text-center text-red-600 space-y-2">
<UIcon name="i-heroicons-exclamation-circle" class="w-12 h-12 mx-auto" />
<h3 class="font-bold text-lg">Fehler</h3>
<p>{{ errorMsg }}</p>
</div>
</UCard>
<UCard v-else-if="status === 'pin_required'" class="w-full max-w-sm shadow-xl">
<div class="text-center mb-6">
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
<UIcon name="i-heroicons-lock-closed" class="w-6 h-6 text-primary-600" />
</div>
<h2 class="text-xl font-bold text-gray-900">Geschützter Bereich</h2>
<p class="text-sm text-gray-500">Bitte PIN eingeben</p>
</div>
<form @submit.prevent="handlePinSubmit" class="space-y-4">
<UInput
v-model="pin"
type="password"
placeholder="PIN"
input-class="text-center text-lg tracking-widest"
autofocus
icon="i-heroicons-key"
/>
<div v-if="errorMsg" class="text-red-500 text-xs text-center font-medium">{{ errorMsg }}</div>
<UButton type="submit" block label="Entsperren" size="lg" />
</form>
</UCard>
<UCard v-else-if="status === 'success'" class="w-full max-w-md text-center py-10">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">Gespeichert!</h2>
<p class="text-gray-500 mb-6">Die Daten wurden erfolgreich übertragen.</p>
<UButton variant="outline" @click="() => window.location.reload()">Neuen Eintrag erfassen</UButton>
</UCard>
<div v-else-if="status === 'ready'" class="w-full max-w-lg">
<PublicDynamicForm
v-if="context && token"
:context="context"
:token="token"
:pin="pin"
@success="handleFormSuccess"
/>
</div>
</div>
</template>