Added Frontend
This commit is contained in:
222
frontend/pages/accounts/index.vue
Normal file
222
frontend/pages/accounts/index.vue
Normal 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>
|
||||
170
frontend/pages/accounts/show/[id].vue
Normal file
170
frontend/pages/accounts/show/[id].vue
Normal 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>
|
||||
255
frontend/pages/banking/index.vue
Normal file
255
frontend/pages/banking/index.vue
Normal 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>
|
||||
876
frontend/pages/banking/statements/[mode]/[[id]].vue
Normal file
876
frontend/pages/banking/statements/[mode]/[[id]].vue
Normal 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>
|
||||
<!– <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>–>
|
||||
<span v-if="item.cd_id">
|
||||
{{dataStore.getCreatedDocumentById(item.cd_id).documentNumber}}
|
||||
</span>
|
||||
<span v-else-if="item.ii_id">
|
||||
<!– {{dataStore.getVendorById(dataStore.getIncomingInvoiceById(item.ii_id).vendor).name}} - {{dataStore.getIncomingInvoiceById(item.ii_id).reference}}–>
|
||||
</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>
|
||||
388
frontend/pages/calendar/[mode].vue
Normal file
388
frontend/pages/calendar/[mode].vue
Normal 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>
|
||||
3171
frontend/pages/createDocument/edit/[[id]].vue
Normal file
3171
frontend/pages/createDocument/edit/[[id]].vue
Normal file
File diff suppressed because it is too large
Load Diff
355
frontend/pages/createDocument/index.vue
Normal file
355
frontend/pages/createDocument/index.vue
Normal 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>
|
||||
560
frontend/pages/createDocument/serialInvoice.vue
Normal file
560
frontend/pages/createDocument/serialInvoice.vue
Normal 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>
|
||||
153
frontend/pages/createDocument/show/[id].vue
Normal file
153
frontend/pages/createDocument/show/[id].vue
Normal 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>
|
||||
108
frontend/pages/createdletters/[mode]/[[id]].vue
Normal file
108
frontend/pages/createdletters/[mode]/[[id]].vue
Normal 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>
|
||||
349
frontend/pages/email/new.vue
Normal file
349
frontend/pages/email/new.vue
Normal 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>
|
||||
47
frontend/pages/export/create/sepa.vue
Normal file
47
frontend/pages/export/create/sepa.vue
Normal 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>
|
||||
166
frontend/pages/export/index.vue
Normal file
166
frontend/pages/export/index.vue
Normal 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>
|
||||
535
frontend/pages/files/index.vue
Normal file
535
frontend/pages/files/index.vue
Normal 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
11
frontend/pages/forms.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
325
frontend/pages/helpdesk/[[id]].vue
Normal file
325
frontend/pages/helpdesk/[[id]].vue
Normal 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>
|
||||
20
frontend/pages/historyitems/index.vue
Normal file
20
frontend/pages/historyitems/index.vue
Normal 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>
|
||||
619
frontend/pages/incomingInvoices/[mode]/[id].vue
Normal file
619
frontend/pages/incomingInvoices/[mode]/[id].vue
Normal 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>
|
||||
294
frontend/pages/incomingInvoices/index.vue
Normal file
294
frontend/pages/incomingInvoices/index.vue
Normal 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
95
frontend/pages/index.vue
Normal 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
102
frontend/pages/login.vue
Normal 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>
|
||||
74
frontend/pages/mobile/index.vue
Normal file
74
frontend/pages/mobile/index.vue
Normal 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>
|
||||
81
frontend/pages/mobile/menu.vue
Normal file
81
frontend/pages/mobile/menu.vue
Normal 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>
|
||||
62
frontend/pages/password-change.vue
Normal file
62
frontend/pages/password-change.vue
Normal 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>
|
||||
55
frontend/pages/password-reset.vue
Normal file
55
frontend/pages/password-reset.vue
Normal 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>
|
||||
304
frontend/pages/projecttypes/[mode]/[[id]].vue
Normal file
304
frontend/pages/projecttypes/[mode]/[[id]].vue
Normal 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>
|
||||
107
frontend/pages/projecttypes/index.vue
Normal file
107
frontend/pages/projecttypes/index.vue
Normal 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>
|
||||
198
frontend/pages/roles/[mode]/[[id]].vue
Normal file
198
frontend/pages/roles/[mode]/[[id]].vue
Normal 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>
|
||||
22
frontend/pages/roles/index.vue
Normal file
22
frontend/pages/roles/index.vue
Normal 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>
|
||||
223
frontend/pages/settings/banking/index.vue
Normal file
223
frontend/pages/settings/banking/index.vue
Normal 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>
|
||||
137
frontend/pages/settings/emailaccounts/[mode]/[[id]].vue
Normal file
137
frontend/pages/settings/emailaccounts/[mode]/[[id]].vue
Normal 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>
|
||||
99
frontend/pages/settings/emailaccounts/index.vue
Normal file
99
frontend/pages/settings/emailaccounts/index.vue
Normal 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>
|
||||
66
frontend/pages/settings/externalDevices.vue
Normal file
66
frontend/pages/settings/externalDevices.vue
Normal 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>
|
||||
89
frontend/pages/settings/index.vue
Normal file
89
frontend/pages/settings/index.vue
Normal 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>
|
||||
112
frontend/pages/settings/numberRanges.vue
Normal file
112
frontend/pages/settings/numberRanges.vue
Normal 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>
|
||||
23
frontend/pages/settings/ownfields.vue
Normal file
23
frontend/pages/settings/ownfields.vue
Normal 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>
|
||||
169
frontend/pages/settings/tenant.vue
Normal file
169
frontend/pages/settings/tenant.vue
Normal 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>
|
||||
220
frontend/pages/settings/texttemplates.vue
Normal file
220
frontend/pages/settings/texttemplates.vue
Normal 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>
|
||||
318
frontend/pages/staff/profiles/[id].vue
Normal file
318
frontend/pages/staff/profiles/[id].vue
Normal 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>
|
||||
|
||||
|
||||
52
frontend/pages/staff/profiles/index.vue
Normal file
52
frontend/pages/staff/profiles/index.vue
Normal 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>
|
||||
482
frontend/pages/staff/time/[id]/evaluate.vue
Normal file
482
frontend/pages/staff/time/[id]/evaluate.vue
Normal 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>
|
||||
473
frontend/pages/staff/time/index.vue
Normal file
473
frontend/pages/staff/time/index.vue
Normal 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>
|
||||
94
frontend/pages/standardEntity/[type]/[mode]/[[id]].vue
Normal file
94
frontend/pages/standardEntity/[type]/[mode]/[[id]].vue
Normal 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>
|
||||
627
frontend/pages/standardEntity/[type]/index.vue
Normal file
627
frontend/pages/standardEntity/[type]/index.vue
Normal 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>
|
||||
146
frontend/pages/support/[id].vue
Normal file
146
frontend/pages/support/[id].vue
Normal 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>
|
||||
62
frontend/pages/support/create.vue
Normal file
62
frontend/pages/support/create.vue
Normal 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>
|
||||
110
frontend/pages/support/index.vue
Normal file
110
frontend/pages/support/index.vue
Normal 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
15
frontend/pages/test.vue
Normal 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>
|
||||
503
frontend/pages/times/index.vue
Normal file
503
frontend/pages/times/index.vue
Normal 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>
|
||||
121
frontend/pages/workflows/[token].vue
Normal file
121
frontend/pages/workflows/[token].vue
Normal 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>
|
||||
Reference in New Issue
Block a user