fix(auth): defer dashboard loading until tenant selection
This commit is contained in:
595
frontend/components/DashboardHome.vue
Normal file
595
frontend/components/DashboardHome.vue
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
<script setup>
|
||||||
|
import "gridstack/dist/gridstack.min.css"
|
||||||
|
|
||||||
|
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
|
||||||
|
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
|
||||||
|
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
|
||||||
|
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
|
||||||
|
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
|
||||||
|
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
|
||||||
|
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -1,598 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { setPageLayout } from "#app"
|
import { setPageLayout } from "#app"
|
||||||
import "gridstack/dist/gridstack.min.css"
|
import DashboardHome from "~/components/DashboardHome.vue"
|
||||||
|
|
||||||
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
|
|
||||||
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
|
|
||||||
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
|
|
||||||
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
|
|
||||||
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
|
|
||||||
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
|
|
||||||
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
|
|
||||||
|
|
||||||
setPageLayout("default")
|
setPageLayout("default")
|
||||||
|
const auth = useAuthStore()
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardNavbar title="Home">
|
<DashboardHome v-if="auth.activeTenant > 0" :key="auth.activeTenant" />
|
||||||
<template #right>
|
<template v-else>
|
||||||
<div class="flex items-center gap-2">
|
<UDashboardNavbar title="Home" />
|
||||||
<UButton
|
<UDashboardPanelContent>
|
||||||
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
|
<UCard class="mx-auto mt-8 max-w-lg">
|
||||||
:color="isEditMode ? 'primary' : 'gray'"
|
<div class="space-y-4">
|
||||||
:variant="isEditMode ? 'solid' : 'ghost'"
|
<h1 class="text-lg font-semibold">Mandant auswählen</h1>
|
||||||
@click="toggleEditMode"
|
<template v-if="auth.tenants.length">
|
||||||
>
|
<p class="text-sm text-muted">Wähle zuerst einen Mandanten, um dessen Dashboard zu laden.</p>
|
||||||
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
|
<TenantDropdown class="w-full" />
|
||||||
</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>
|
</template>
|
||||||
</UDashboardNavbar>
|
<p v-else class="text-sm text-muted">Deinem Benutzer ist noch kein Mandant zugeordnet.</p>
|
||||||
|
<UButton v-if="auth.user?.is_admin" to="/administration/tenants" variant="outline">Mandanten verwalten</UButton>
|
||||||
<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>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
</UDashboardPanelContent>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -280,7 +280,6 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
body: { email, password, rememberMe }
|
body: { email, password, rememberMe }
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("Token: " + token)
|
|
||||||
|
|
||||||
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
|
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
|
||||||
this.setSession(token, refreshToken, rememberMe)
|
this.setSession(token, refreshToken, rememberMe)
|
||||||
@@ -289,7 +288,6 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
// 2. User Daten laden
|
// 2. User Daten laden
|
||||||
await this.fetchMe(token)
|
await this.fetchMe(token)
|
||||||
|
|
||||||
console.log(this.user)
|
|
||||||
|
|
||||||
// 3. WICHTIG: Jetzt explizit weiterleiten!
|
// 3. WICHTIG: Jetzt explizit weiterleiten!
|
||||||
// Prüfen, ob der User geladen wurde
|
// Prüfen, ob der User geladen wurde
|
||||||
@@ -380,7 +378,6 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ... (Deine Logik für tenants, sorting etc. bleibt gleich) ...
|
// ... (Deine Logik für tenants, sorting etc. bleibt gleich) ...
|
||||||
console.log(me)
|
|
||||||
this.user = me.user
|
this.user = me.user
|
||||||
this.permissions = me.permissions
|
this.permissions = me.permissions
|
||||||
this.tenants = me.tenants
|
this.tenants = me.tenants
|
||||||
@@ -394,6 +391,9 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
|
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
|
||||||
|
|
||||||
|
this.activeTenant = null
|
||||||
|
this.activeTenantData = null
|
||||||
|
|
||||||
if(me.activeTenant > 0) {
|
if(me.activeTenant > 0) {
|
||||||
const normalizedActiveTenant = String(me.activeTenant)
|
const normalizedActiveTenant = String(me.activeTenant)
|
||||||
this.activeTenant = normalizedActiveTenant
|
this.activeTenant = normalizedActiveTenant
|
||||||
@@ -417,7 +417,6 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
this.scheduleSessionTimers(tokenToUse)
|
this.scheduleSessionTimers(tokenToUse)
|
||||||
|
|
||||||
console.log(this)
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert!
|
// WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert!
|
||||||
@@ -477,7 +476,6 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
refreshToken: this.getStoredRefreshToken()
|
refreshToken: this.getStoredRefreshToken()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log(res)
|
|
||||||
|
|
||||||
const {token, refreshToken} = res
|
const {token, refreshToken} = res
|
||||||
this.setSession(token, refreshToken, this.sessionPersistent)
|
this.setSession(token, refreshToken, this.sessionPersistent)
|
||||||
|
|||||||
132
frontend/tests/dashboard-session.test.cjs
Normal file
132
frontend/tests/dashboard-session.test.cjs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse } = require('@vue/compiler-sfc')
|
||||||
|
const { compile } = require('@vue/compiler-dom')
|
||||||
|
|
||||||
|
function mountHome(auth) {
|
||||||
|
const requests = []
|
||||||
|
const { descriptor } = parse(fs.readFileSync(__dirname + '/../pages/index.client.vue', 'utf8'))
|
||||||
|
const context = vm.createContext({ useAuthStore: () => auth, setPageLayout() {} })
|
||||||
|
vm.runInContext(descriptor.scriptSetup.content.replace(/^import .*$/gm, ''), context)
|
||||||
|
const remove = node => {
|
||||||
|
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||||
|
node.parent = null
|
||||||
|
}
|
||||||
|
const renderer = vue.createRenderer({
|
||||||
|
createElement: type => ({ type, children: [], parent: null }),
|
||||||
|
createText: text => ({ text, children: [], parent: null }),
|
||||||
|
createComment: text => ({ text, children: [], parent: null }),
|
||||||
|
setElementText: (node, text) => { node.text = text },
|
||||||
|
setText: (node, text) => { node.text = text },
|
||||||
|
parentNode: node => node.parent,
|
||||||
|
nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null,
|
||||||
|
insert(node, parent, anchor) {
|
||||||
|
remove(node)
|
||||||
|
node.parent = parent
|
||||||
|
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||||
|
if (index < 0) parent.children.push(node)
|
||||||
|
else parent.children.splice(index, 0, node)
|
||||||
|
},
|
||||||
|
remove, patchProp() {},
|
||||||
|
})
|
||||||
|
const component = {
|
||||||
|
setup: () => vm.runInContext('({ auth })', context),
|
||||||
|
render: new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(vue),
|
||||||
|
}
|
||||||
|
const app = renderer.createApp(component)
|
||||||
|
app.component('DashboardHome', {
|
||||||
|
setup() {
|
||||||
|
requests.push(auth.activeTenant)
|
||||||
|
return () => vue.h('div', 'Dashboard ' + auth.activeTenant)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (const name of ['UDashboardNavbar', 'UDashboardPanelContent', 'UCard', 'UButton', 'TenantDropdown']) {
|
||||||
|
app.component(name, { setup: (_, { slots }) => () => vue.h('div', slots.default?.()) })
|
||||||
|
}
|
||||||
|
const root = { children: [] }
|
||||||
|
app.mount(root)
|
||||||
|
const content = node => [node.text || '', ...node.children.map(content)].join(' ')
|
||||||
|
return { requests, close: () => app.unmount(), text: () => content(root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dashboard data only mounts with a tenant and remounts when the tenant changes', async t => {
|
||||||
|
const auth = vue.reactive({ activeTenant: null, tenants: [{ id: '1' }, { id: '2' }], user: {} })
|
||||||
|
const ui = mountHome(auth)
|
||||||
|
t.after(ui.close)
|
||||||
|
assert.deepEqual(ui.requests, [])
|
||||||
|
assert.match(ui.text(), /Mandant auswählen/)
|
||||||
|
auth.activeTenant = '1'
|
||||||
|
await vue.nextTick()
|
||||||
|
assert.deepEqual(ui.requests, ['1'])
|
||||||
|
auth.activeTenant = '2'
|
||||||
|
await vue.nextTick()
|
||||||
|
assert.deepEqual(ui.requests, ['1', '2'])
|
||||||
|
auth.activeTenant = null
|
||||||
|
await vue.nextTick()
|
||||||
|
assert.deepEqual(ui.requests, ['1', '2'])
|
||||||
|
assert.match(ui.text(), /Mandant auswählen/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an account without assigned tenants receives an explanation without loading widgets', t => {
|
||||||
|
const ui = mountHome(vue.reactive({ activeTenant: null, tenants: [], user: { is_admin: true } }))
|
||||||
|
t.after(ui.close)
|
||||||
|
assert.deepEqual(ui.requests, [])
|
||||||
|
assert.match(ui.text(), /noch kein Mandant zugeordnet/)
|
||||||
|
assert.match(ui.text(), /Mandanten verwalten/)
|
||||||
|
})
|
||||||
|
|
||||||
|
function authFixture() {
|
||||||
|
const logs = [], calls = [], sessions = []
|
||||||
|
const token = 'test-access-token'
|
||||||
|
const refreshToken = 'test-refresh-token'
|
||||||
|
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
|
||||||
|
const context = vm.createContext({
|
||||||
|
exports: {}, process: { client: false },
|
||||||
|
defineStore: (_, definition) => definition,
|
||||||
|
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
|
||||||
|
useTempStore: () => ({ setStoredTempConfig() {} }),
|
||||||
|
navigateTo() {},
|
||||||
|
useNuxtApp: () => ({ $api: async (url, options) => {
|
||||||
|
calls.push({ url, options })
|
||||||
|
if (url === '/api/me') return me
|
||||||
|
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
|
||||||
|
throw Error('Unexpected API request: ' + url)
|
||||||
|
} }),
|
||||||
|
})
|
||||||
|
const source = fs.readFileSync(__dirname + '/../stores/auth.ts', 'utf8').replace(/^import .*$/gm, '')
|
||||||
|
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
|
||||||
|
const definition = context.exports.useAuthStore
|
||||||
|
const store = definition.state()
|
||||||
|
for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store)
|
||||||
|
store.getStoredToken = () => token
|
||||||
|
store.getStoredRefreshToken = () => refreshToken
|
||||||
|
store.scheduleSessionTimers = () => {}
|
||||||
|
store.setSession = (...args) => sessions.push(args)
|
||||||
|
return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refreshing a session without an active tenant clears the previous tenant state', async () => {
|
||||||
|
const f = authFixture()
|
||||||
|
f.store.activeTenant = '12'
|
||||||
|
f.store.activeTenantData = { id: '12', name: 'Old tenant' }
|
||||||
|
await f.store.fetchMe()
|
||||||
|
assert.equal(f.store.activeTenant, null)
|
||||||
|
assert.equal(f.store.activeTenantData, null)
|
||||||
|
assert.deepEqual(f.calls.map(call => call.url), ['/api/me'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('login and tenant switching store credentials without writing them to the console', async () => {
|
||||||
|
const f = authFixture()
|
||||||
|
await f.store.login('test@example.com', 'test-password')
|
||||||
|
await f.store.switchTenant('1')
|
||||||
|
assert.equal(f.sessions.length, 2)
|
||||||
|
assert.equal(f.sessions[0][0], f.token)
|
||||||
|
assert.equal(f.sessions[1][1], f.refreshToken)
|
||||||
|
const output = JSON.stringify(f.logs)
|
||||||
|
assert.ok(!output.includes(f.token))
|
||||||
|
assert.ok(!output.includes(f.refreshToken))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user