Files
FEDEO/frontend/pages/index.client.vue
florianfederspiel 11a242d70d
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m0s
4. Zwischenstand
2026-03-22 17:43:41 +01:00

574 lines
15 KiB
Vue

<script setup>
import { setPageLayout } from "#app"
import "gridstack/dist/gridstack.min.css"
import DisplayIncomeAndExpenditure from "~/components/displayIncomeAndExpenditure.vue"
import DisplayOpenBalances from "~/components/displayOpenBalances.vue"
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
import DisplayBWASummary from "~/components/displayBWASummary.vue"
setPageLayout("default")
const toast = useToast()
const tempStore = useTempStore()
const gridElement = ref(null)
const grid = shallowRef(null)
const isEditMode = ref(false)
const manageCardsOpen = ref(false)
const widgets = ref([])
let gridStackClass = null
let persistTimeout = null
const isSyncingGrid = ref(false)
const DASHBOARD_WIDGETS = [
{
id: "income-expense",
title: "Einnahmen und Ausgaben",
description: "Umsatz- und Ausgabenentwicklung",
component: markRaw(DisplayIncomeAndExpenditure),
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
minW: 4,
minH: 4
},
{
id: "open-balances",
title: "Buchhaltung",
description: "Offene Rechnungen und Entwurfsstatus",
component: markRaw(DisplayOpenBalances),
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bankaccounts",
title: "Bank",
description: "Kontostand und offene Zuordnungen",
component: markRaw(DisplayBankaccounts),
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "projects",
title: "Projekte",
description: "Aktuelle Projektphasen",
component: markRaw(DisplayProjectsInPhases),
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tasks",
title: "Aufgaben",
description: "Offene Aufgaben des aktuellen Nutzers",
component: markRaw(DisplayOpenTasks),
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tax-summary",
title: "USt aktuell",
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
component: markRaw(DisplayTaxSummary),
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bwa-summary",
title: "BWA aktuell",
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
component: markRaw(DisplayBWASummary),
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
}
]
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
function 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 = normalizeDashboardWidgets()
persistWidgets()
}
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-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>