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>
|
||||
import { setPageLayout } from "#app"
|
||||
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"))
|
||||
import DashboardHome from "~/components/DashboardHome.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
|
||||
}
|
||||
})
|
||||
const auth = useAuthStore()
|
||||
</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>
|
||||
<DashboardHome v-if="auth.activeTenant > 0" :key="auth.activeTenant" />
|
||||
<template v-else>
|
||||
<UDashboardNavbar title="Home" />
|
||||
<UDashboardPanelContent>
|
||||
<UCard class="mx-auto mt-8 max-w-lg">
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-lg font-semibold">Mandant auswählen</h1>
|
||||
<template v-if="auth.tenants.length">
|
||||
<p class="text-sm text-muted">Wähle zuerst einen Mandanten, um dessen Dashboard zu laden.</p>
|
||||
<TenantDropdown class="w-full" />
|
||||
</template>
|
||||
<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>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</UDashboardPanelContent>
|
||||
</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 }
|
||||
})
|
||||
|
||||
console.log("Token: " + token)
|
||||
|
||||
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
|
||||
this.setSession(token, refreshToken, rememberMe)
|
||||
@@ -289,7 +288,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
// 2. User Daten laden
|
||||
await this.fetchMe(token)
|
||||
|
||||
console.log(this.user)
|
||||
|
||||
// 3. WICHTIG: Jetzt explizit weiterleiten!
|
||||
// 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) ...
|
||||
console.log(me)
|
||||
this.user = me.user
|
||||
this.permissions = me.permissions
|
||||
this.tenants = me.tenants
|
||||
@@ -394,6 +391,9 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
|
||||
|
||||
this.activeTenant = null
|
||||
this.activeTenantData = null
|
||||
|
||||
if(me.activeTenant > 0) {
|
||||
const normalizedActiveTenant = String(me.activeTenant)
|
||||
this.activeTenant = normalizedActiveTenant
|
||||
@@ -417,7 +417,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
this.scheduleSessionTimers(tokenToUse)
|
||||
|
||||
console.log(this)
|
||||
|
||||
} catch (err: any) {
|
||||
// WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert!
|
||||
@@ -477,7 +476,6 @@ export const useAuthStore = defineStore("auth", {
|
||||
refreshToken: this.getStoredRefreshToken()
|
||||
}
|
||||
})
|
||||
console.log(res)
|
||||
|
||||
const {token, refreshToken} = res
|
||||
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