Added Calculator

This commit is contained in:
2026-01-15 19:08:26 +01:00
parent 5edc90bd4d
commit 9b3f48defe
4 changed files with 457 additions and 286 deletions

View File

@@ -0,0 +1,235 @@
<template>
<div
ref="el"
:style="style"
class="fixed z-[999] w-72 bg-white dark:bg-gray-900 shadow-2xl rounded-xl border border-gray-200 dark:border-gray-800 p-4 select-none touch-none"
>
<div class="flex items-center justify-between mb-4 cursor-move border-b pb-2 dark:border-gray-800">
<div class="flex items-center gap-2 text-gray-500">
<UIcon name="i-heroicons-calculator" />
<span class="text-xs font-bold uppercase tracking-wider">Kalkulator</span>
</div>
<div class="flex items-center gap-1">
<UTooltip text="Verlauf">
<UButton
color="gray"
variant="ghost"
:icon="showHistory ? 'i-heroicons-clock-solid' : 'i-heroicons-clock'"
size="xs"
@click="showHistory = !showHistory"
/>
</UTooltip>
<UTooltip text="Schließen (Esc)">
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark"
size="xs"
@click="store.isOpen = false"
/>
</UTooltip>
</div>
</div>
<div v-if="!showHistory">
<div
class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg mb-4 text-right border border-gray-200 dark:border-gray-700 cursor-pointer group relative"
@click="copyDisplay"
>
<div class="text-[10px] text-gray-500 h-4 font-mono uppercase tracking-tighter">
Speicher: {{ Number(store.memory).toFixed(2).replace('.', ',') }}
</div>
<div class="text-2xl font-mono truncate tracking-tighter">{{ store.display }}</div>
<div class="absolute inset-0 flex items-center justify-center bg-primary-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg">
<span class="text-[10px] font-bold text-primary-600 uppercase">
{{ copied ? 'Kopiert!' : 'Klicken zum Kopieren' }}
</span>
</div>
</div>
<div class="grid grid-cols-4 gap-2">
<UTooltip text="Brutto (+19%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(19)">+19%</UButton></UTooltip>
<UTooltip text="Brutto (+7%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(7)">+7%</UButton></UTooltip>
<UTooltip text="Netto (-19%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(19)">-19%</UButton></UTooltip>
<UTooltip text="Netto (-7%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(7)">-7%</UButton></UTooltip>
<UTooltip text="Löschen"><UButton color="gray" variant="ghost" block @click="clear">C</UButton></UTooltip>
<UTooltip text="Speicher +"><UButton color="gray" variant="ghost" block @click="addToSum">M+</UButton></UTooltip>
<UTooltip text="Speicher Reset"><UButton color="gray" variant="ghost" block @click="store.memory = 0">MC</UButton></UTooltip>
<UButton color="primary" variant="soft" @click="setOperator('/')">/</UButton>
<UButton v-for="n in [7, 8, 9]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
<UButton color="primary" variant="soft" @click="setOperator('*')">×</UButton>
<UButton v-for="n in [4, 5, 6]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
<UButton color="primary" variant="soft" @click="setOperator('-')">-</UButton>
<UButton v-for="n in [1, 2, 3]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
<UButton color="primary" variant="soft" @click="setOperator('+')">+</UButton>
<UButton color="white" class="col-span-2" @click="appendNumber(0)">0</UButton>
<UButton color="white" @click="addComma">,</UButton>
<UButton color="primary" block @click="calculate">=</UButton>
</div>
</div>
<div v-else class="h-[270px] flex flex-col animate-in fade-in duration-200">
<div class="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
<div v-if="store.history.length === 0" class="text-center text-gray-400 text-xs mt-10 italic">
Keine Berechnungen im Verlauf
</div>
<div
v-for="(item, i) in store.history" :key="i"
class="p-2 bg-gray-50 dark:bg-gray-800 rounded text-right border-l-2 border-primary-500 cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
@click="useHistoryItem(item.result)"
>
<div class="text-[10px] text-gray-400">{{ item.expression }} =</div>
<div class="text-sm font-bold">{{ item.result }}</div>
</div>
</div>
<UButton
color="gray"
variant="ghost"
size="xs"
block
class="mt-2"
icon="i-heroicons-trash"
@click="store.history = []"
>
Verlauf leeren
</UButton>
</div>
</div>
</template>
<script setup>
import { useDraggable, useClipboard } from '@vueuse/core'
import { useCalculatorStore } from '~/stores/calculator'
const store = useCalculatorStore()
const { copy, copied } = useClipboard()
const el = ref(null)
const { style } = useDraggable(el, {
initialValue: { x: window.innerWidth - 350, y: 150 },
})
const shouldResetDisplay = ref(false)
const showHistory = ref(false)
const previousValue = ref(null)
const lastOperator = ref(null)
// --- Logik ---
const appendNumber = (num) => {
if (store.display === '0' || shouldResetDisplay.value) {
store.display = String(num)
shouldResetDisplay.value = false
} else {
store.display += String(num)
}
}
const addComma = () => {
if (!store.display.includes(',')) {
store.display += ','
}
}
const setOperator = (op) => {
previousValue.value = parseFloat(store.display.replace(',', '.'))
lastOperator.value = op
shouldResetDisplay.value = true
}
const calculate = () => {
if (lastOperator.value === null) return
const currentVal = parseFloat(store.display.replace(',', '.'))
const prevVal = previousValue.value
let result = 0
switch (lastOperator.value) {
case '+': result = prevVal + currentVal; break
case '-': result = prevVal - currentVal; break
case '*': result = prevVal * currentVal; break
case '/': result = currentVal !== 0 ? prevVal / currentVal : 0; break
}
const expression = `${prevVal} ${lastOperator.value} ${currentVal}`
const resultString = String(Number(result.toFixed(4))).replace('.', ',')
store.addHistory(expression, resultString)
store.display = resultString
lastOperator.value = null
shouldResetDisplay.value = true
}
const clear = () => {
store.display = '0'
previousValue.value = null
lastOperator.value = null
}
const applyTax = (percent) => {
const current = parseFloat(store.display.replace(',', '.'))
store.display = (current * (1 + percent / 100)).toFixed(2).replace('.', ',')
}
const removeTax = (percent) => {
const current = parseFloat(store.display.replace(',', '.'))
store.display = (current / (1 + percent / 100)).toFixed(2).replace('.', ',')
}
const addToSum = () => {
store.memory += parseFloat(store.display.replace(',', '.'))
shouldResetDisplay.value = true
}
const copyDisplay = () => {
copy(store.display)
}
const useHistoryItem = (val) => {
store.display = val
showHistory.value = false
}
// --- Shortcuts ---
defineShortcuts({
'0': () => appendNumber(0),
'1': () => appendNumber(1),
'2': () => appendNumber(2),
'3': () => appendNumber(3),
'4': () => appendNumber(4),
'5': () => appendNumber(5),
'6': () => appendNumber(6),
'7': () => appendNumber(7),
'8': () => appendNumber(8),
'9': () => appendNumber(9),
'comma': addComma,
'plus': () => setOperator('+'),
'minus': () => setOperator('-'),
'enter': { usingInput: true, handler: calculate },
'backspace': () => {
store.display = store.display.length > 1 ? store.display.slice(0, -1) : '0'
},
// Escape schließt nun das Fenster via Store
'escape': {
usingInput: true,
whenever: [computed(() => store.isOpen)],
handler: () => { store.isOpen = false }
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-200 dark:bg-gray-700 rounded-full;
}
</style>

View File

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