599 lines
16 KiB
Vue
599 lines
16 KiB
Vue
<script setup>
|
|
import { setPageLayout } from "#app"
|
|
import "gridstack/dist/gridstack.min.css"
|
|
|
|
import DisplayIncomeAndExpenditure from "~/components/displayIncomeAndExpenditure.vue"
|
|
import DisplayOpenBalances from "~/components/displayOpenBalances.vue"
|
|
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
|
|
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
|
|
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
|
|
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
|
|
import DisplayBWASummary from "~/components/displayBWASummary.vue"
|
|
|
|
setPageLayout("default")
|
|
|
|
const toast = useToast()
|
|
const tempStore = useTempStore()
|
|
|
|
const gridElement = ref(null)
|
|
const grid = shallowRef(null)
|
|
const isEditMode = ref(false)
|
|
const manageCardsOpen = ref(false)
|
|
const widgets = ref([])
|
|
|
|
let gridStackClass = null
|
|
let persistTimeout = null
|
|
const isSyncingGrid = ref(false)
|
|
|
|
const DASHBOARD_WIDGETS = [
|
|
{
|
|
id: "income-expense",
|
|
title: "Einnahmen und Ausgaben",
|
|
description: "Umsatz- und Ausgabenentwicklung",
|
|
component: markRaw(DisplayIncomeAndExpenditure),
|
|
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
|
|
minW: 4,
|
|
minH: 4
|
|
},
|
|
{
|
|
id: "open-balances",
|
|
title: "Buchhaltung",
|
|
description: "Offene Rechnungen und Entwurfsstatus",
|
|
component: markRaw(DisplayOpenBalances),
|
|
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
|
|
minW: 3,
|
|
minH: 3
|
|
},
|
|
{
|
|
id: "bankaccounts",
|
|
title: "Bank",
|
|
description: "Kontostand und offene Zuordnungen",
|
|
component: markRaw(DisplayBankaccounts),
|
|
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
|
|
minW: 3,
|
|
minH: 3
|
|
},
|
|
{
|
|
id: "projects",
|
|
title: "Projekte",
|
|
description: "Aktuelle Projektphasen",
|
|
component: markRaw(DisplayProjectsInPhases),
|
|
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
|
|
minW: 3,
|
|
minH: 3
|
|
},
|
|
{
|
|
id: "tasks",
|
|
title: "Aufgaben",
|
|
description: "Offene Aufgaben des aktuellen Nutzers",
|
|
component: markRaw(DisplayOpenTasks),
|
|
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
|
|
minW: 3,
|
|
minH: 3
|
|
},
|
|
{
|
|
id: "tax-summary",
|
|
title: "USt aktuell",
|
|
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
|
|
component: markRaw(DisplayTaxSummary),
|
|
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
|
|
minW: 3,
|
|
minH: 3
|
|
},
|
|
{
|
|
id: "bwa-summary",
|
|
title: "BWA aktuell",
|
|
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
|
|
component: markRaw(DisplayBWASummary),
|
|
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
|
|
minW: 3,
|
|
minH: 3
|
|
}
|
|
]
|
|
|
|
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
|
|
|
|
function getDefaultDashboardWidgets() {
|
|
return DASHBOARD_WIDGETS.map((definition) => ({
|
|
id: definition.id,
|
|
x: definition.defaultLayout.x,
|
|
y: definition.defaultLayout.y,
|
|
w: definition.defaultLayout.w,
|
|
h: definition.defaultLayout.h,
|
|
visible: true
|
|
}))
|
|
}
|
|
|
|
function normalizeNumber(value, fallback) {
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed) ? parsed : fallback
|
|
}
|
|
|
|
function serializeWidgets(input) {
|
|
return input.map((widget) => ({
|
|
id: widget.id,
|
|
x: widget.x,
|
|
y: widget.y,
|
|
w: widget.w,
|
|
h: widget.h,
|
|
visible: widget.visible
|
|
}))
|
|
}
|
|
|
|
function normalizeDashboardWidgets(storedWidgets) {
|
|
const storedById = new Map(
|
|
(Array.isArray(storedWidgets) ? storedWidgets : [])
|
|
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
|
|
.map((widget) => [widget.id, widget])
|
|
)
|
|
|
|
return DASHBOARD_WIDGETS.map((definition) => {
|
|
const stored = storedById.get(definition.id) || {}
|
|
|
|
return {
|
|
id: definition.id,
|
|
x: normalizeNumber(stored.x, definition.defaultLayout.x),
|
|
y: normalizeNumber(stored.y, definition.defaultLayout.y),
|
|
w: normalizeNumber(stored.w, definition.defaultLayout.w),
|
|
h: normalizeNumber(stored.h, definition.defaultLayout.h),
|
|
visible: typeof stored.visible === "boolean" ? stored.visible : true
|
|
}
|
|
})
|
|
}
|
|
|
|
function widgetsSignature(input) {
|
|
return JSON.stringify(serializeWidgets(input))
|
|
}
|
|
|
|
function getWidgetLayout(id) {
|
|
return widgets.value.find((widget) => widget.id === id)
|
|
}
|
|
|
|
function updateWidget(id, patch) {
|
|
widgets.value = widgets.value.map((widget) => {
|
|
if (widget.id !== id) return widget
|
|
return { ...widget, ...patch }
|
|
})
|
|
}
|
|
|
|
function persistWidgets() {
|
|
tempStore.modifySettings("dashboard", {
|
|
version: 1,
|
|
widgets: serializeWidgets(widgets.value)
|
|
})
|
|
}
|
|
|
|
function schedulePersistWidgets() {
|
|
clearTimeout(persistTimeout)
|
|
persistTimeout = setTimeout(() => {
|
|
persistWidgets()
|
|
}, 120)
|
|
}
|
|
|
|
async function ensureGridStack() {
|
|
if (gridStackClass) return gridStackClass
|
|
const gridStackModule = await import("gridstack")
|
|
gridStackClass = gridStackModule.GridStack
|
|
return gridStackClass
|
|
}
|
|
|
|
function gridOptionsFor(widget) {
|
|
const definition = widgetDefinitions[widget.id]
|
|
|
|
return {
|
|
x: widget.x,
|
|
y: widget.y,
|
|
w: widget.w,
|
|
h: widget.h,
|
|
minW: definition.minW,
|
|
minH: definition.minH,
|
|
id: widget.id
|
|
}
|
|
}
|
|
|
|
function handleGridChange(_event, items = []) {
|
|
if (isSyncingGrid.value || !isEditMode.value) return
|
|
|
|
let hasChanges = false
|
|
|
|
items.forEach((item) => {
|
|
const widgetId = item.el?.dataset?.widgetId || item.id
|
|
if (!widgetId) return
|
|
|
|
const current = getWidgetLayout(widgetId)
|
|
if (!current) return
|
|
|
|
const nextPatch = {
|
|
x: normalizeNumber(item.x, current.x),
|
|
y: normalizeNumber(item.y, current.y),
|
|
w: normalizeNumber(item.w, current.w),
|
|
h: normalizeNumber(item.h, current.h)
|
|
}
|
|
|
|
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
|
|
return
|
|
}
|
|
|
|
updateWidget(widgetId, nextPatch)
|
|
hasChanges = true
|
|
})
|
|
|
|
if (hasChanges) schedulePersistWidgets()
|
|
}
|
|
|
|
function syncGridInteractivity() {
|
|
if (!grid.value) return
|
|
grid.value.enableMove(isEditMode.value)
|
|
grid.value.enableResize(isEditMode.value)
|
|
}
|
|
|
|
async function syncGridWithDom() {
|
|
if (!import.meta.client) return
|
|
|
|
await nextTick()
|
|
|
|
if (!gridElement.value) return
|
|
|
|
const GridStack = await ensureGridStack()
|
|
|
|
if (!grid.value) {
|
|
isSyncingGrid.value = true
|
|
grid.value = GridStack.init(
|
|
{
|
|
column: 12,
|
|
float: true,
|
|
margin: 16,
|
|
cellHeight: 96,
|
|
disableOneColumnMode: false,
|
|
handle: ".dashboard-widget-drag-handle",
|
|
resizable: {
|
|
handles: "all"
|
|
}
|
|
},
|
|
gridElement.value
|
|
)
|
|
grid.value.on("change", handleGridChange)
|
|
syncGridInteractivity()
|
|
isSyncingGrid.value = false
|
|
}
|
|
|
|
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
|
|
|
|
isSyncingGrid.value = true
|
|
grid.value.batchUpdate()
|
|
|
|
;[...grid.value.engine.nodes].forEach((node) => {
|
|
const widgetId = node.el?.dataset?.widgetId || node.id
|
|
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
|
|
if (node.el) grid.value.removeWidget(node.el, false, false)
|
|
}
|
|
})
|
|
|
|
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
|
|
|
|
domWidgets.forEach((element) => {
|
|
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
|
|
if (!widget) return
|
|
|
|
if (!element.gridstackNode) {
|
|
grid.value.makeWidget(element)
|
|
}
|
|
|
|
grid.value.update(element, gridOptionsFor(widget))
|
|
})
|
|
|
|
grid.value.batchUpdate(false)
|
|
isSyncingGrid.value = false
|
|
}
|
|
|
|
function addWidget(id) {
|
|
const widget = getWidgetLayout(id)
|
|
if (!widget || widget.visible) return
|
|
|
|
updateWidget(id, { visible: true })
|
|
persistWidgets()
|
|
}
|
|
|
|
function toggleEditMode() {
|
|
isEditMode.value = !isEditMode.value
|
|
if (!isEditMode.value) manageCardsOpen.value = false
|
|
}
|
|
|
|
function removeWidget(id) {
|
|
if (visibleWidgets.value.length <= 1) {
|
|
toast.add({
|
|
title: "Letzte Karte",
|
|
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
|
|
color: "orange"
|
|
})
|
|
return
|
|
}
|
|
|
|
updateWidget(id, { visible: false })
|
|
persistWidgets()
|
|
}
|
|
|
|
function resetDashboard() {
|
|
widgets.value = getDefaultDashboardWidgets()
|
|
persistWidgets()
|
|
toast.add({
|
|
title: "Dashboard zurückgesetzt",
|
|
description: "Das Standardlayout wurde wiederhergestellt.",
|
|
color: "primary"
|
|
})
|
|
}
|
|
|
|
const visibleWidgets = computed(() =>
|
|
widgets.value
|
|
.filter((widget) => widget.visible)
|
|
.map((widget) => ({
|
|
...widgetDefinitions[widget.id],
|
|
...widget
|
|
}))
|
|
)
|
|
|
|
const hiddenWidgets = computed(() =>
|
|
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
|
|
)
|
|
|
|
watch(
|
|
() => tempStore.settings?.dashboard,
|
|
(storedDashboard) => {
|
|
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
|
|
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
|
|
widgets.value = nextWidgets
|
|
},
|
|
{ immediate: true, deep: true }
|
|
)
|
|
|
|
watch(
|
|
visibleWidgets,
|
|
async () => {
|
|
await syncGridWithDom()
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(isEditMode, () => {
|
|
syncGridInteractivity()
|
|
})
|
|
|
|
onMounted(async () => {
|
|
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
|
|
await syncGridWithDom()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTimeout(persistTimeout)
|
|
if (grid.value) {
|
|
grid.value.off("change", handleGridChange)
|
|
grid.value.destroy(false)
|
|
grid.value = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar title="Home">
|
|
<template #right>
|
|
<div class="flex items-center gap-2">
|
|
<UButton
|
|
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
|
|
:color="isEditMode ? 'primary' : 'gray'"
|
|
:variant="isEditMode ? 'solid' : 'ghost'"
|
|
@click="toggleEditMode"
|
|
>
|
|
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
|
|
</UButton>
|
|
<UButton
|
|
v-if="isEditMode && hiddenWidgets.length > 0"
|
|
icon="i-heroicons-plus"
|
|
color="white"
|
|
variant="soft"
|
|
@click="manageCardsOpen = true"
|
|
>
|
|
Karte hinzufügen
|
|
</UButton>
|
|
<UButton
|
|
v-if="isEditMode"
|
|
icon="i-heroicons-arrow-path"
|
|
color="gray"
|
|
variant="ghost"
|
|
@click="resetDashboard"
|
|
>
|
|
Standardlayout
|
|
</UButton>
|
|
<UButton
|
|
v-if="isEditMode"
|
|
icon="i-heroicons-squares-2x2"
|
|
color="gray"
|
|
variant="ghost"
|
|
@click="manageCardsOpen = true"
|
|
>
|
|
Karten verwalten
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
|
|
<div
|
|
v-for="widget in visibleWidgets"
|
|
:key="widget.id"
|
|
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
|
|
:data-widget-id="widget.id"
|
|
:gs-x="widget.x"
|
|
:gs-y="widget.y"
|
|
:gs-w="widget.w"
|
|
:gs-h="widget.h"
|
|
:gs-min-w="widget.minW"
|
|
:gs-min-h="widget.minH"
|
|
>
|
|
<div class="grid-stack-item-content dashboard-grid-item">
|
|
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
|
|
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
|
|
{{ widget.title }}
|
|
</div>
|
|
<p class="mt-1 text-sm">
|
|
{{ widget.description }}
|
|
</p>
|
|
</div>
|
|
<div class="dashboard-widget-header-actions">
|
|
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
|
|
<UButtonGroup v-if="isEditMode" size="xs">
|
|
<UButton
|
|
color="gray"
|
|
variant="soft"
|
|
icon="i-heroicons-arrows-pointing-out"
|
|
class="dashboard-widget-drag-handle"
|
|
/>
|
|
<UButton
|
|
color="gray"
|
|
variant="soft"
|
|
icon="i-heroicons-x-mark"
|
|
:disabled="visibleWidgets.length <= 1"
|
|
@click="removeWidget(widget.id)"
|
|
/>
|
|
</UButtonGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="dashboard-widget-body">
|
|
<component
|
|
:is="widget.component"
|
|
v-bind="widget.id === 'income-expense'
|
|
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
|
|
: {}"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
|
|
<p class="text-sm">
|
|
Es sind aktuell keine Dashboard-Karten sichtbar.
|
|
</p>
|
|
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
|
|
Karte hinzufügen
|
|
</UButton>
|
|
</div>
|
|
|
|
<UModal v-model:open="manageCardsOpen">
|
|
<template #content>
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 class="font-semibold">Dashboard-Karten</h2>
|
|
<p class="text-sm">
|
|
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
|
|
</p>
|
|
</div>
|
|
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
|
|
Zurücksetzen
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="definition in DASHBOARD_WIDGETS"
|
|
:key="definition.id"
|
|
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
|
|
>
|
|
<div>
|
|
<p class="font-medium">{{ definition.title }}</p>
|
|
<p class="text-sm">{{ definition.description }}</p>
|
|
</div>
|
|
|
|
<UButton
|
|
v-if="getWidgetLayout(definition.id)?.visible"
|
|
color="gray"
|
|
variant="soft"
|
|
icon="i-heroicons-minus"
|
|
:disabled="visibleWidgets.length <= 1"
|
|
@click="removeWidget(definition.id)"
|
|
>
|
|
Entfernen
|
|
</UButton>
|
|
<UButton
|
|
v-else
|
|
color="primary"
|
|
variant="soft"
|
|
icon="i-heroicons-plus"
|
|
@click="addWidget(definition.id)"
|
|
>
|
|
Hinzufügen
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dashboard-grid {
|
|
min-height: 60vh;
|
|
}
|
|
|
|
.dashboard-grid-item {
|
|
height: 100%;
|
|
padding: 0.25rem;
|
|
}
|
|
|
|
.dashboard-widget-card {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border-radius: 0.75rem;
|
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
}
|
|
|
|
.dashboard-widget-header {
|
|
flex: 0 0 auto;
|
|
padding: 1rem 1rem 0.875rem;
|
|
}
|
|
|
|
.dashboard-widget-header-actions {
|
|
display: flex;
|
|
flex: 0 0 auto;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.dashboard-widget-header-target {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.dashboard-widget-body {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
padding: 1rem;
|
|
}
|
|
|
|
:deep(.grid-stack-item-content) {
|
|
inset: 0;
|
|
background: transparent;
|
|
overflow-y: hidden !important;
|
|
}
|
|
|
|
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
|
|
display: none;
|
|
}
|
|
|
|
</style>
|