Compare commits

...

12 Commits

Author SHA1 Message Date
4f72919269 #64 Fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-16 13:14:09 +01:00
f2c9dcc900 #64 Fix
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Failing after 35s
2026-01-16 13:11:54 +01:00
b4ec792cc0 Diasbled Label Test Card
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-15 19:09:26 +01:00
9b3f48defe Added Calculator 2026-01-15 19:08:26 +01:00
5edc90bd4d Fix #8
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:45:35 +01:00
d140251aa0 Fix #7 Added Month Markings, Range Select 2026-01-15 18:45:25 +01:00
e7fb2df5c7 Added Debouncing #36
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:19:05 +01:00
f27fd3f6da Fix TS
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 29s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 18:07:58 +01:00
d3e2b106af Storno Fix createddocument link. Added Disable and Tooltip for Storno Button
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 29s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:05:44 +01:00
769d2059ca Redone Search to inluce more Columns #36
TODO: Spalten nachpflegen
2026-01-15 18:05:14 +01:00
53349fae83 Fix Link Buttons Added New link buttons
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-15 13:38:01 +01:00
d8eb1559c8 Update Problem bei #54
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 13:18:58 +01:00
15 changed files with 1259 additions and 1361 deletions

View File

@@ -10,30 +10,23 @@ import {
or
} from "drizzle-orm"
import {resourceConfig} from "../../utils/resource.config";
import {useNextNumberRangeNumber} from "../../utils/functions";
import {stafftimeentries} from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
// -------------------------------------------------------------
// SQL Volltextsuche auf mehreren Feldern
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// -------------------------------------------------------------
function buildSearchCondition(table: any, columns: string[], search: string) {
function buildSearchCondition(columns: any[], search: string) {
if (!search || !columns.length) return null
const term = `%${search.toLowerCase()}%`
const conditions = columns
.map((colName) => table[colName])
.filter(Boolean)
.map((col) => ilike(col, term))
if (conditions.length === 0) return null
// @ts-ignore
return or(...conditions)
}
@@ -54,96 +47,86 @@ export default async function resourceRoutes(server: FastifyInstance) {
asc?: string
}
const {resource} = req.params as {resource: string}
const table = resourceConfig[resource].table
const { resource } = req.params as { resource: string }
const config = resourceConfig[resource]
const table = config.table
// WHERE-Basis
let whereCond: any = eq(table.tenant, tenantId)
let q = server.db.select().from(table).$dynamic()
// 🔍 SQL Search
if(search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
if (relConfig) {
const relTable = relConfig.table
// FIX: Nur joinen, wenn es keine Self-Reference ist (verhindert ERROR 42712)
if (relTable !== table) {
// @ts-ignore
q = q.leftJoin(relTable, eq(table[rel], relTable.id))
if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => {
if (relTable[c]) searchCols.push(relTable[c])
})
}
}
}
})
}
// Base Query
let q = server.db.select().from(table).where(whereCond)
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
if (searchCond) whereCond = and(whereCond, searchCond)
}
q = q.where(whereCond)
// Sortierung
if (sort) {
const col = (table as any)[sort]
if (col) {
//@ts-ignore
q = ascQuery === "true"
? q.orderBy(asc(col))
: q.orderBy(desc(col))
q = ascQuery === "true" ? q.orderBy(asc(col)) : q.orderBy(desc(col))
}
}
const queryData = await q
// Transformation: Falls Joins genutzt wurden, das Hauptobjekt extrahieren
const rows = queryData.map(r => r[resource] || r.table || r);
// RELATION LOADING (MANY-TO-ONE)
let ids = {}
let lists = {}
let maps = {}
let data = [...queryData]
if(resourceConfig[resource].mtoLoad) {
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
// RELATION LOADING
let data = [...rows]
if(config.mtoLoad) {
let ids: any = {}
let lists: any = {}
let maps: any = {}
config.mtoLoad.forEach(rel => {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
console.log(relation)
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relTab = relConf.table
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
data = queryData.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
data = rows.map(row => {
let toReturn = { ...row }
config.mtoLoad.forEach(rel => {
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null
})
return toReturn
});
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
console.log(resource.substring(0,resource.length-1))
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows.length)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
if(config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1)
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)))
data = data.map(row => ({
...row,
[relation]: relationRows.filter(i => i[parentKey] === row.id)
}))
}
}
@@ -155,212 +138,130 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
})
// -------------------------------------------------------------
// PAGINATED LIST
// -------------------------------------------------------------
server.get("/resource/:resource/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id;
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" });
const {resource} = req.params as {resource: string};
const {queryConfig} = req;
const {
pagination,
sort,
filters,
paginationDisabled
} = queryConfig;
const { search, distinctColumns } = req.query as {
search?: string;
distinctColumns?: string;
};
let table = resourceConfig[resource].table
const { resource } = req.params as { resource: string };
const config = resourceConfig[resource];
const table = config.table;
const { queryConfig } = req;
const { pagination, sort, filters } = queryConfig;
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
let whereCond: any = eq(table.tenant, tenantId);
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic();
let mainQuery = server.db.select().from(table).$dynamic();
if(search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
if (relConfig) {
const relTable = relConfig.table;
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
// FIX: Self-Reference Check
if (relTable !== table) {
countQuery = countQuery.leftJoin(relTable, eq(table[rel], relTable.id));
// @ts-ignore
mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id));
if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => {
if (relTable[c]) searchCols.push(relTable[c]);
});
}
}
}
});
}
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim());
if (searchCond) whereCond = and(whereCond, searchCond);
}
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (table as any)[key];
if (!col) continue;
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val));
} else {
whereCond = and(whereCond, eq(col, val as any));
}
whereCond = Array.isArray(val) ? and(whereCond, inArray(col, val)) : and(whereCond, eq(col, val as any));
}
}
// -----------------------------------------------
// COUNT (for pagination)
// -----------------------------------------------
const totalRes = await server.db
.select({ value: count(table.id) })
.from(table)
.where(whereCond);
const totalRes = await countQuery.where(whereCond);
const total = Number(totalRes[0]?.value ?? 0);
// -----------------------------------------------
// DISTINCT VALUES (regardless of pagination)
// -----------------------------------------------
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
const rows = await server.db
.select({ v: col })
.from(table)
.where(eq(table.tenant, tenantId));
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "");
distinctValues[colName] = [...new Set(values)].sort();
}
}
// PAGINATION
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 100;
// SORTING
let orderField: any = null;
let direction: "asc" | "desc" = "asc";
mainQuery = mainQuery.where(whereCond).offset(offset).limit(limit);
if (sort?.length > 0) {
const s = sort[0];
const col = (table as any)[s.field];
if (col) {
orderField = col;
direction = s.direction === "asc" ? "asc" : "desc";
mainQuery = s.direction === "asc" ? mainQuery.orderBy(asc(col)) : mainQuery.orderBy(desc(col));
}
}
// MAIN QUERY (Paginated)
let q = server.db
.select()
.from(table)
.where(whereCond)
.offset(offset)
.limit(limit);
if (orderField) {
//@ts-ignore
q = direction === "asc"
? q.orderBy(asc(orderField))
: q.orderBy(desc(orderField));
}
const rows = await q;
if (!rows.length) {
return {
data: [],
queryConfig: {
...queryConfig,
total,
totalPages: 0,
distinctValues
}
};
}
let data = [...rows]
//Many to One
if(resourceConfig[resource].mtoLoad) {
let ids = {}
let lists = {}
let maps = {}
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
const rawRows = await mainQuery;
// Transformation für Drizzle Joins
let rows = rawRows.map(r => r[resource] || r.table || r);
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
const dRows = await server.db.select({ v: col }).from(table).where(eq(table.tenant, tenantId));
distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort();
}
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
let data = [...rows];
if (config.mtoLoad) {
let ids: any = {};
let lists: any = {};
let maps: any = {};
config.mtoLoad.forEach(rel => {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
});
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relTab = relConf.table;
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
}
data = rows.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
let toReturn = { ...row };
config.mtoLoad.forEach(rel => {
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null;
});
return toReturn;
});
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
if (config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table;
const parentKey = resource.substring(0, resource.length - 1);
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)));
data = data.map(row => ({
...row,
[relation]: relationRows.filter(i => i[parentKey] === row.id)
}));
}
}
// -----------------------------------------------
// RETURN DATA
// -----------------------------------------------
return {
data,
queryConfig: {
...queryConfig,
total,
totalPages: Math.ceil(total / limit),
distinctValues
}
queryConfig: { ...queryConfig, total, totalPages: Math.ceil(total / limit), distinctValues }
};
} catch (err) {
@@ -369,9 +270,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// DETAIL (mit JOINS)
// DETAIL
// -------------------------------------------------------------
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
try {
@@ -379,7 +279,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const {resource, no_relations} = req.params as { resource: string, no_relations?: boolean }
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
const table = resourceConfig[resource].table
const projRows = await server.db
@@ -391,40 +291,32 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!projRows.length)
return reply.code(404).send({ error: "Resource not found" })
// ------------------------------------
// LOAD RELATIONS
// ------------------------------------
let data = { ...projRows[0] }
let ids = {}
let lists = {}
let maps = {}
let data = {
...projRows[0]
}
if(!no_relations) {
if(resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad ) {
if(data[relation]) {
data[relation] = (await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])))[0]
if (!no_relations) {
if (resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad) {
if (data[relation]) {
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
const relTable = relConf.table
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
data[relation] = relData[0] || null
}
}
}
if(resourceConfig[resource].mtmLoad) {
for await (const relation of resourceConfig[resource].mtmLoad ) {
console.log(relation)
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
if (resourceConfig[resource].mtmLoad) {
for await (const relation of resourceConfig[resource].mtmLoad) {
const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1)
data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id))
}
}
}
return data
} catch (err) {
console.error("ERROR /resource/projects/:id", err)
console.error("ERROR /resource/:resource/:id", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
@@ -432,132 +324,59 @@ export default async function resourceRoutes(server: FastifyInstance) {
// Create
server.post("/resource/:resource", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({error: "No tenant selected"});
}
const {resource} = req.params as { resource: string };
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const body = req.body as Record<string, any>;
const config = resourceConfig[resource];
const table = config.table;
const table = resourceConfig[resource].table
let createData = { ...body, tenant: req.user.tenant_id, archived: false };
let createData = {
...body,
tenant: req.user.tenant_id,
archived: false, // Standardwert
}
console.log(resourceConfig[resource].numberRangeHolder)
if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) {
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
console.log(result)
createData[resourceConfig[resource].numberRangeHolder] = result.usedNumber
}
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
createData[config.numberRangeHolder] = result.usedNumber
}
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
Object.keys(createData).forEach((key) => {
if(key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key])
if (key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key])
})
const [created] = await server.db
.insert(table)
.values(createData)
.returning()
/*await insertHistoryItem(server, {
entity: resource,
entityId: data.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: data,
text: `${dataType.labelSingle} erstellt`,
});*/
const [created] = await server.db.insert(table).values(createData).returning()
return created;
} catch (error) {
console.log(error)
reply.status(500)
console.error(error);
reply.status(500);
}
});
// UPDATE (inkl. Soft-Delete/Archive)
// Update
server.put("/resource/:resource/:id", async (req, reply) => {
try {
const {resource, id} = req.params as { resource: string; id: string }
const { resource, id } = req.params as { resource: string; id: string }
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
const tenantId = (req.user as any)?.tenant_id
const userId = (req.user as any)?.user_id
if (!tenantId || !userId) {
return reply.code(401).send({error: "Unauthorized"})
}
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
//TODO: HISTORY
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
let data = {...body, updated_at: new Date().toISOString(), updated_by: userId}
let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore
delete data.updatedBy
//@ts-ignore
delete data.updatedAt
console.log(data)
delete data.updatedBy; delete data.updatedAt;
Object.keys(data).forEach((key) => {
console.log(key)
if(key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) {
if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") {
data[key] = normalizeDate(data[key])
}
})
console.log(data)
const [updated] = await server.db
.update(table)
.set(data)
.where(and(
eq(table.id, id),
eq(table.tenant, tenantId)))
.returning()
//const diffs = diffObjects(oldItem, newItem);
/*for (const d of diffs) {
await insertHistoryItem(server, {
entity: resource,
entityId: id,
action: d.type,
created_by: userId,
tenant_id: tenantId,
oldVal: d.oldValue ? String(d.oldValue) : null,
newVal: d.newValue ? String(d.newValue) : null,
text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""} → ${d.newValue ?? ""}`,
});
}*/
const [updated] = await server.db.update(table).set(data).where(and(eq(table.id, id), eq(table.tenant, tenantId))).returning()
return updated
} catch (err) {
console.log("ERROR /resource/projects/:id", err)
console.error(err)
}
})
}
}

View File

@@ -36,7 +36,7 @@ import {
export const resourceConfig = {
projects: {
searchColumns: ["name"],
searchColumns: ["name","customerRef","projectNumber","notes"],
mtoLoad: ["customer","plant","contract","projecttype"],
mtmLoad: ["tasks", "files","createddocuments"],
table: projects,
@@ -61,6 +61,7 @@ export const resourceConfig = {
},
plants: {
table: plants,
searchColumns: ["name"],
mtoLoad: ["customer"],
mtmLoad: ["projects","tasks","files"],
},

View File

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

View File

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

View File

@@ -7,6 +7,9 @@ const props = defineProps({
pin: { type: String, default: '' }
})
const runtimeConfig = useRuntimeConfig()
const emit = defineEmits(['success'])
const { $api } = useNuxtApp()
const toast = useToast()
@@ -75,7 +78,7 @@ const submit = async () => {
if (props.pin) headers['x-public-pin'] = props.pin
// An den Submit-Endpunkt senden (den müssen wir im Backend noch bauen!)
await $fetch(`http://localhost:3100/workflows/submit/${props.token}`, {
await $fetch(`${runtimeConfig.public.apiBase}/workflows/submit/${props.token}`, {
method: 'POST',
body: payload,
headers
@@ -132,6 +135,7 @@ const submit = async () => {
value-attribute="id"
placeholder="Wählen..."
searchable
searchable-placeholder="Suchen..."
/>
</UFormGroup>
@@ -147,6 +151,8 @@ const submit = async () => {
option-attribute="name"
value-attribute="id"
placeholder="Wählen..."
searchable
searchable-placeholder="Suchen..."
/>
</UFormGroup>

View File

@@ -1,21 +1,20 @@
<script setup>
import MainNav from "~/components/MainNav.vue";
import dayjs from "dayjs";
import GlobalMessages from "~/components/GlobalMessages.vue";
import TenantDropdown from "~/components/TenantDropdown.vue";
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
import {useCalculatorStore} from '~/stores/calculator'
const dataStore = useDataStore()
const colorMode = useColorMode()
const { isHelpSlideoverOpen } = useDashboard()
const {isHelpSlideoverOpen} = useDashboard()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const labelPrinter = useLabelPrinterStore()
const calculatorStore = useCalculatorStore()
const month = dayjs().format("MM")
@@ -24,91 +23,108 @@ const actions = [
id: 'new-customer',
label: 'Kunde hinzufügen',
icon: 'i-heroicons-user-group',
to: "/customers/create" ,
to: "/customers/create",
},
{
id: 'new-vendor',
label: 'Lieferant hinzufügen',
icon: 'i-heroicons-truck',
to: "/vendors/create" ,
to: "/vendors/create",
},
{
id: 'new-contact',
label: 'Ansprechpartner hinzufügen',
icon: 'i-heroicons-user-group',
to: "/contacts/create" ,
to: "/contacts/create",
},
{
id: 'new-task',
label: 'Aufgabe hinzufügen',
icon: 'i-heroicons-rectangle-stack',
to: "/tasks/create" ,
to: "/tasks/create",
},
{
id: 'new-plant',
label: 'Objekt hinzufügen',
icon: 'i-heroicons-clipboard-document',
to: "/plants/create" ,
to: "/plants/create",
},
{
id: 'new-product',
label: 'Artikel hinzufügen',
icon: 'i-heroicons-puzzle-piece',
to: "/products/create" ,
to: "/products/create",
},
{
id: 'new-project',
label: 'Projekt hinzufügen',
icon: 'i-heroicons-clipboard-document-check',
to: "/projects/create" ,
to: "/projects/create",
}
]
const groups = computed(() => [
{
key: 'actions',
commands: actions
},{
key: "customers",
label: "Kunden",
commands: dataStore.customers.map(item => { return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}})
},{
key: "vendors",
label: "Lieferanten",
commands: dataStore.vendors.map(item => { return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}})
},{
key: "contacts",
label: "Ansprechpartner",
commands: dataStore.contacts.map(item => { return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}})
},{
key: "products",
label: "Artikel",
commands: dataStore.products.map(item => { return {id: item.id, label: item.name, to: `/products/show/${item.id}`}})
},{
key: "tasks",
label: "Aufgaben",
commands: dataStore.tasks.map(item => { return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}})
},{
key: "plants",
label: "Objekte",
commands: dataStore.plants.map(item => { return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}})
},{
key: "projects",
label: "Projekte",
commands: dataStore.projects.map(item => { return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}})
}
].filter(Boolean))
const footerLinks = [
/*{
label: 'Invite people',
icon: 'i-heroicons-plus',
to: '/settings/members'
}, */{
label: 'Hilfe & Info',
icon: 'i-heroicons-question-mark-circle',
click: () => isHelpSlideoverOpen.value = true
}]
{
key: 'actions',
commands: actions
}, {
key: "customers",
label: "Kunden",
commands: dataStore.customers.map(item => {
return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}
})
}, {
key: "vendors",
label: "Lieferanten",
commands: dataStore.vendors.map(item => {
return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}
})
}, {
key: "contacts",
label: "Ansprechpartner",
commands: dataStore.contacts.map(item => {
return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}
})
}, {
key: "products",
label: "Artikel",
commands: dataStore.products.map(item => {
return {id: item.id, label: item.name, to: `/products/show/${item.id}`}
})
}, {
key: "tasks",
label: "Aufgaben",
commands: dataStore.tasks.map(item => {
return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}
})
}, {
key: "plants",
label: "Objekte",
commands: dataStore.plants.map(item => {
return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}
})
}, {
key: "projects",
label: "Projekte",
commands: dataStore.projects.map(item => {
return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}
})
}
].filter(Boolean))
// --- Footer Links nutzen jetzt den zentralen Calculator Store ---
const footerLinks = computed(() => [
{
label: 'Taschenrechner',
icon: 'i-heroicons-calculator',
click: () => calculatorStore.toggle()
},
{
label: 'Hilfe & Info',
icon: 'i-heroicons-question-mark-circle',
click: () => isHelpSlideoverOpen.value = true
}
])
</script>
@@ -130,24 +146,24 @@ const footerLinks = [
v-else
/>
<div class="flex justify-center mb-6">
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500" />
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500"/>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Wartungsarbeiten
</h1>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Dieser FEDEO Mandant wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut oder verwende einen anderen Mandanten.
Dieser FEDEO Mandant wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut oder verwende einen
anderen Mandanten.
</p>
<div class="mx-auto text-left flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
{{tenant.name}}
{{ tenant.name }}
<UButton
:disabled="tenant.locked"
@click="auth.switchTenant(tenant.id)"
>Wählen</UButton>
>Wählen
</UButton>
</div>
</UCard>
</UContainer>
</div>
@@ -167,7 +183,7 @@ const footerLinks = [
v-else
/>
<div class="flex justify-center mb-6">
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500" />
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500"/>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
@@ -176,8 +192,6 @@ const footerLinks = [
<p class="text-gray-600 dark:text-gray-300 mb-8">
FEDEO wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut.
</p>
</UCard>
</UContainer>
</div>
@@ -197,32 +211,33 @@ const footerLinks = [
v-else
/>
<div class="flex justify-center mb-6">
<UIcon name="i-heroicons-credit-card" class="w-16 h-16 text-red-600" />
<UIcon name="i-heroicons-credit-card" class="w-16 h-16 text-red-600"/>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Kein Aktives Abonnement für diesen Mandant.
</h1>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Bitte wenden Sie sich an den FEDEO Support um ein Abonnement zu erhalten oder verwenden Sie einen anderen Mandanten.
Bitte wenden Sie sich an den FEDEO Support um ein Abonnement zu erhalten oder verwenden Sie einen anderen
Mandanten.
</p>
<div class="mx-auto text-left flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
{{tenant.name}}
{{ tenant.name }}
<UButton
:disabled="tenant.locked"
@click="auth.switchTenant(tenant.id)"
>Wählen</UButton>
>Wählen
</UButton>
</div>
</UCard>
</UContainer>
</div>
<UDashboardLayout class="safearea" v-else >
<UDashboardLayout class="safearea" v-else>
<UDashboardPanel :width="250" :resizable="{ min: 200, max: 300 }" collapsible>
<UDashboardNavbar style="margin-top: env(safe-area-inset-top, 10px) !important;" :class="['!border-transparent']" :ui="{ left: 'flex-1' }">
<UDashboardNavbar style="margin-top: env(safe-area-inset-top, 10px) !important;"
:class="['!border-transparent']" :ui="{ left: 'flex-1' }">
<template #left>
<TenantDropdown class="w-full" />
<TenantDropdown class="w-full"/>
</template>
</UDashboardNavbar>
@@ -230,24 +245,17 @@ const footerLinks = [
<MainNav/>
<div class="flex-1" />
<div class="flex-1"/>
<template #footer>
<div class="flex flex-col gap-3 w-full">
<UColorModeButton />
<UColorModeButton/>
<LabelPrinterButton/>
<UDashboardSidebarLinks :links="footerLinks"/>
<!-- Footer Links -->
<UDashboardSidebarLinks :links="footerLinks" />
<UDivider class="sticky bottom-0" />
<UDivider class="sticky bottom-0"/>
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;"/>
</div>
</template>
@@ -256,14 +264,14 @@ const footerLinks = [
<UDashboardPage>
<UDashboardPanel grow>
<slot />
<slot/>
</UDashboardPanel>
</UDashboardPage>
<HelpSlideover/>
<Calculator v-if="calculatorStore.isOpen"/>
</UDashboardLayout>
</div>
@@ -278,37 +286,32 @@ const footerLinks = [
v-if="month === '12'"
/>
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
class="w-1/3 mx-auto my-10"
v-else
light="/Logo.png"
dark="/Logo_Dark.png"
class="w-1/3 mx-auto my-10"
v-else
/>
<div v-if="!auth.activeTenant" class="w-1/2 mx-auto text-center">
<!-- Tenant Selection -->
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. Bitte wählen Sie ein Mandant.</h3>
<div class="mx-auto w-1/2 flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
<span class="text-left">{{tenant.name}}</span>
<UButton
@click="auth.switchTenant(tenant.id)"
>Wählen</UButton>
</div>
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. Bitte wählen Sie ein Mandant.</h3>
<div class="mx-auto w-1/2 flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
<span class="text-left">{{ tenant.name }}</span>
<UButton
variant="outline"
color="rose"
@click="auth.switchTenant(tenant.id)"
>Wählen
</UButton>
</div>
<UButton
variant="outline"
color="rose"
@click="auth.logout()"
>Abmelden</UButton>
>Abmelden
</UButton>
</div>
<div v-else>
<UProgress animation="carousel" class="w-3/4 mx-auto mt-10" />
<UProgress animation="carousel" class="w-3/4 mx-auto mt-10"/>
</div>
</div>
</template>
<style scoped>
</style>
</template>

View File

@@ -1,14 +1,9 @@
<script setup>
import dayjs from "dayjs";
// Zugriff auf $api und Toast Notification
const { $api } = useNuxtApp()
const {$api, $dayjs} = useNuxtApp()
const toast = useToast()
defineShortcuts({
'/': () => {
document.getElementById("searchinput").focus()
}
'/': () => document.getElementById("searchinput").focus()
})
const tempStore = useTempStore()
@@ -18,238 +13,280 @@ const route = useRoute()
const bankstatements = ref([])
const bankaccounts = ref([])
const filterAccount = ref([])
// Status für den Lade-Button
const isSyncing = ref(false)
const loadingDocs = ref(true) // Startet im Ladezustand
// Zeitraum-Optionen
const periodOptions = [
{label: 'Aktueller Monat', key: 'current_month'},
{label: 'Letzter Monat', key: 'last_month'},
{label: 'Aktuelles Quartal', key: 'current_quarter'},
{label: 'Letztes Quartal', key: 'last_quarter'},
{label: 'Benutzerdefiniert', key: 'custom'}
]
// Initialisierungswerte
const selectedPeriod = ref(periodOptions[0])
const dateRange = ref({
start: $dayjs().startOf('month').format('YYYY-MM-DD'),
end: $dayjs().endOf('month').format('YYYY-MM-DD')
})
const setupPage = async () => {
bankstatements.value = (await useEntities("bankstatements").select("*, statementallocations(*)", "date", false))
bankaccounts.value = await useEntities("bankaccounts").select()
if(bankaccounts.value.length > 0) filterAccount.value = bankaccounts.value
loadingDocs.value = true
try {
const [statements, accounts] = await Promise.all([
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
useEntities("bankaccounts").select()
])
bankstatements.value = statements
bankaccounts.value = accounts
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) {
filterAccount.value = bankaccounts.value
}
// Erst nach dem Laden der Daten die Store-Werte anwenden
const savedBanking = tempStore.settings?.['banking'] || {}
if (savedBanking.periodKey) {
const found = periodOptions.find(p => p.key === savedBanking.periodKey)
if (found) selectedPeriod.value = found
}
if (savedBanking.range) {
dateRange.value = savedBanking.range
}
} catch (err) {
console.error("Setup Error:", err)
} finally {
loadingDocs.value = false
}
}
// Funktion für den Bankabruf
// Watcher für Schnellwahlen & Persistenz
watch([selectedPeriod, dateRange], ([newPeriod, newRange], [oldPeriod, oldRange]) => {
const now = $dayjs()
// Nur berechnen, wenn sich die Periode geändert hat
if (newPeriod.key !== oldPeriod?.key) {
switch (newPeriod.key) {
case 'current_month':
dateRange.value = {start: now.startOf('month').format('YYYY-MM-DD'), end: now.endOf('month').format('YYYY-MM-DD')}
break
case 'last_month':
const lastMonth = now.subtract(1, 'month')
dateRange.value = {start: lastMonth.startOf('month').format('YYYY-MM-DD'), end: lastMonth.endOf('month').format('YYYY-MM-DD')}
break
case 'current_quarter':
dateRange.value = {start: now.startOf('quarter').format('YYYY-MM-DD'), end: now.endOf('quarter').format('YYYY-MM-DD')}
break
case 'last_quarter':
const lastQuarter = now.subtract(1, 'quarter')
dateRange.value = {start: lastQuarter.startOf('quarter').format('YYYY-MM-DD'), end: lastQuarter.endOf('quarter').format('YYYY-MM-DD')}
break
}
}
// Speichern im Store
tempStore.modifyBankingPeriod(selectedPeriod.value.key, dateRange.value)
}, { deep: true })
const syncBankStatements = async () => {
isSyncing.value = true
try {
await $api('/api/functions/services/bankstatementsync', { method: 'POST' })
toast.add({
title: 'Erfolg',
description: 'Bankdaten wurden erfolgreich synchronisiert.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Wichtig: Daten neu laden, damit die neuen Buchungen direkt sichtbar sind
await $api('/api/functions/services/bankstatementsync', {method: 'POST'})
toast.add({title: 'Erfolg', description: 'Bankdaten synchronisiert.', color: 'green'})
await setupPage()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Beim Abrufen der Bankdaten ist ein Fehler aufgetreten.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
toast.add({title: 'Fehler', description: 'Fehler beim Abruf.', color: 'red'})
} finally {
isSyncing.value = false
}
}
const templateColumns = [
{
key: "account",
label: "Konto"
},{
key: "valueDate",
label: "Valuta"
},
{
key: "amount",
label: "Betrag"
},
{
key: "openAmount",
label: "Offener Betrag"
},
{
key: "partner",
label: "Name"
},
{
key: "text",
label: "Beschreibung"
}
{key: "account", label: "Konto"},
{key: "valueDate", label: "Valuta"},
{key: "amount", label: "Betrag"},
{key: "openAmount", label: "Offen"},
{key: "partner", label: "Name"},
{key: "text", label: "Beschreibung"}
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
const searchString = ref(tempStore.searchStrings["bankstatements"] || '')
const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur offene anzeigen'])
const searchString = ref(tempStore.searchStrings["bankstatements"] ||'')
const clearSearchString = () => {
tempStore.clearSearchString("bankstatements")
searchString.value = ''
const shouldShowMonthDivider = (row, index) => {
if (index === 0) return true;
const prevRow = filteredRows.value[index - 1];
return $dayjs(row.valueDate).format('MMMM YYYY') !== $dayjs(prevRow.valueDate).format('MMMM YYYY');
}
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
}
const calculateOpenSum = (statement) => {
let startingAmount = 0
statement.statementallocations.forEach(item => {
startingAmount += item.amount
})
return (statement.amount - startingAmount).toFixed(2)
const allocated = statement.statementallocations?.reduce((acc, curr) => acc + curr.amount, 0) || 0;
return (statement.amount - allocated).toFixed(2);
}
const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] ? tempStore.filters["banking"]["main"] : ['Nur offene anzeigen'])
const filteredRows = computed(() => {
let temp = bankstatements.value
if (!bankstatements.value.length) return []
if(route.query.filter) {
console.log(route.query.filter)
temp = temp.filter(i => JSON.parse(route.query.filter).includes(i.id))
} else {
if(selectedFilters.value.includes("Nur offene anzeigen")){
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
}
let temp = [...bankstatements.value]
if(selectedFilters.value.includes("Nur positive anzeigen")){
temp = temp.filter(i => i.amount >= 0)
}
if(selectedFilters.value.includes("Nur negative anzeigen")){
temp = temp.filter(i => i.amount < 0)
}
// Filterung nach Datum
if (dateRange.value.start) {
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrAfter($dayjs(dateRange.value.start), 'day'))
}
if (dateRange.value.end) {
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrBefore($dayjs(dateRange.value.end), 'day'))
}
return useSearch(searchString.value, temp.filter(i => filterAccount.value.find(x => x.id === i.account)))
// Status Filter
if (selectedFilters.value.includes("Nur offene anzeigen")) {
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
}
if (selectedFilters.value.includes("Nur positive anzeigen")) {
temp = temp.filter(i => i.amount >= 0)
}
if (selectedFilters.value.includes("Nur negative anzeigen")) {
temp = temp.filter(i => i.amount < 0)
}
// Konto Filter & Suche
let results = temp.filter(i => filterAccount.value.find(x => x.id === i.account))
if (searchString.value) {
results = useSearch(searchString.value, results)
}
return results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).unix())
})
setupPage()
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")}`
onMounted(() => {
setupPage()
})
</script>
<template>
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
<template #right>
<UButton
label="Bankabruf"
icon="i-heroicons-arrow-path"
color="primary"
variant="solid"
:loading="isSyncing"
@click="syncBankStatements"
class="mr-2"
/>
<UInput
id="searchinput"
name="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
icon="i-heroicons-magnifying-glass"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('bankstatements',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<USelectMenu
:options="bankaccounts"
v-model="filterAccount"
option-attribute="iban"
multiple
by="id"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Konto
</template>
</USelectMenu>
<div class="flex items-center gap-3">
<USelectMenu
:options="bankaccounts"
v-model="filterAccount"
option-attribute="iban"
multiple
by="id"
placeholder="Konten"
class="w-48"
/>
<UDivider orientation="vertical" class="h-6"/>
<div class="flex items-center gap-2">
<USelectMenu
v-model="selectedPeriod"
:options="periodOptions"
class="w-44"
icon="i-heroicons-calendar-days"
/>
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
</div>
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
{{ $dayjs(dateRange.start).format('DD.MM.') }} - {{ $dayjs(dateRange.end).format('DD.MM.YYYY') }}
</div>
</div>
</div>
</template>
<template #right>
<USelectMenu
icon="i-heroicons-adjustments-horizontal-solid"
icon="i-heroicons-adjustments-horizontal"
multiple
v-model="selectedFilters"
:options="['Nur offene anzeigen','Nur positive anzeigen','Nur negative anzeigen']"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
@change="tempStore.modifyFilter('banking','main',selectedFilters)"
>
<template #label>
Filter
</template>
</USelectMenu>
/>
</template>
</UDashboardToolbar>
<UTable
:rows="filteredRows"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/banking/statements/edit/${i.id}`)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #account-data="{row}">
{{row.account ? bankaccounts.find(i => i.id === row.account).iban : ""}}
</template>
<template #valueDate-data="{row}">
{{dayjs(row.valueDate).format("DD.MM.YY")}}
</template>
<template #amount-data="{row}">
<span
v-if="row.amount >= 0"
class="text-primary-500"
>{{String(row.amount.toFixed(2)).replace(".",",")}} </span>
<span
v-else-if="row.amount < 0"
class="text-rose-500"
>{{String(row.amount.toFixed(2)).replace(".",",")}} </span>
</template>
<template #openAmount-data="{row}">
{{displayCurrency(calculateOpenSum(row))}}
</template>
<template #partner-data="{row}">
<span
v-if="row.amount < 0"
>
{{row.credName}}
</span>
<span
v-else-if="row.amount > 0"
>
{{row.debName}}
</span>
</template>
</UTable>
<div class="overflow-y-auto relative" style="height: calc(100vh - 200px)">
<div v-if="loadingDocs" class="p-20 flex flex-col items-center justify-center">
<UProgress animation="carousel" class="w-1/3 mb-4" />
<span class="text-sm text-gray-500 italic">Bankbuchungen werden geladen...</span>
</div>
<table v-else class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-white dark:bg-gray-900 z-10 shadow-sm">
<tr class="text-xs font-semibold text-gray-500 uppercase">
<th v-for="col in templateColumns" :key="col.key" class="p-4 border-b dark:border-gray-800">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in filteredRows" :key="row.id">
<tr v-if="shouldShowMonthDivider(row, index)">
<td colspan="6" class="bg-gray-50 dark:bg-gray-800/50 p-2 pl-4 text-sm font-bold text-primary-600 border-y dark:border-gray-800">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-calendar" class="w-4 h-4"/>
{{ $dayjs(row.valueDate).format('MMMM YYYY') }}
</div>
</td>
</tr>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 cursor-pointer border-b dark:border-gray-800 text-sm group"
@click="router.push(`/banking/statements/edit/${row.id}`)"
>
<td class="p-4 text-[10px] text-gray-400 font-mono truncate max-w-[150px]">
{{ row.account ? bankaccounts.find(i => i.id === row.account)?.iban : "" }}
</td>
<td class="p-4 whitespace-nowrap">{{ $dayjs(row.valueDate).format("DD.MM.YY") }}</td>
<td class="p-4 font-semibold">
<span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'">
{{ displayCurrency(row.amount) }}
</span>
</td>
<td class="p-4 text-gray-400 italic text-xs">
{{ Number(calculateOpenSum(row)) !== 0 ? displayCurrency(calculateOpenSum(row)) : '-' }}
</td>
<td class="p-4 truncate max-w-[180px] font-medium">
{{ row.amount < 0 ? row.credName : row.debName }}
</td>
<td class="p-4 text-gray-500 truncate max-w-[350px] text-xs">
{{ row.text }}
</td>
</tr>
</template>
<tr v-if="filteredRows.length === 0">
<td colspan="6" class="p-32 text-center text-gray-400">
<div class="flex flex-col items-center">
<UIcon name="i-heroicons-magnifying-glass-circle" class="w-12 h-12 mb-3 opacity-20"/>
<p class="font-medium">Keine Buchungen gefunden</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<PageLeaveGuard :when="isSyncing"/>
</template>
<style scoped>
</style>
</template>

View File

@@ -1387,7 +1387,7 @@ const saveDocument = async (state, resetup = false) => {
endText: itemInfo.value.endText,
rows: itemInfo.value.rows,
contactPerson: itemInfo.value.contactPerson,
linkedDocument: itemInfo.value.linkedDocument,
createddocument: itemInfo.value.createddocument,
agriculture: itemInfo.value.agriculture,
letterhead: itemInfo.value.letterhead,
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,

View File

@@ -8,7 +8,6 @@
class="hidden lg:block"
icon="i-heroicons-funnel"
placeholder="Suche..."
@change="tempStore.modifySearchString('createddocuments',searchString)"
@keydown.esc="$event.target.blur()"
>
<template #trailing>
@@ -30,22 +29,7 @@
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<!-- <USelectMenu
v-model="selectedColumns"
:options="templateColumns"
by="key"
class="hidden lg:block"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
@change="tempStore.modifyColumns('createddocuments',selectedColumns)"
>
<template #label>
Spalten
</template>
</USelectMenu>-->
<USelectMenu
v-if="selectableFilters.length > 0"
v-model="selectedFilters"
@@ -157,6 +141,31 @@
<script setup>
import dayjs from "dayjs";
import { ref, computed, watch } from 'vue';
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const type = "createddocuments"
const dataType = dataStore.dataTypes[type]
const items = ref([])
const selectedItem = ref(0)
const activeTabIndex = ref(0)
// Debounce-Logik für die Suche
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const debouncedSearchString = ref(searchString.value)
let debounceTimeout = null
watch(searchString, (newVal) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedSearchString.value = newVal
tempStore.modifySearchString('createddocuments', newVal)
}, 300) // 300ms warten nach dem letzten Tastendruck
})
defineShortcuts({
'/': () => {
@@ -168,10 +177,6 @@ defineShortcuts({
'Enter': {
usingInput: true,
handler: () => {
// Zugriff auf das aktuell sichtbare Element basierend auf Tab und Selektion
const currentList = getRowsForTab(selectedTypes.value[activeTabIndex.value]?.key || 'drafts')
// Fallback auf globale Liste falls nötig, aber Logik sollte auf Tab passen
if (filteredRows.value[selectedItem.value]) {
router.push(`/createDocument/show/${filteredRows.value[selectedItem.value].id}`)
}
@@ -193,17 +198,6 @@ defineShortcuts({
}
})
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const type = "createddocuments"
const dataType = dataStore.dataTypes[type]
const items = ref([])
const selectedItem = ref(0)
const activeTabIndex = ref(0)
const setupPage = async () => {
items.value = (await useEntities("createddocuments").select("*, customer(id,name), statementallocations(id,amount),linkedDocument(*)", "documentNumber", true, true))
}
@@ -222,10 +216,9 @@ const templateColumns = [
{key: "dueDate", label: "Fällig"}
]
// Eigene Spalten für Entwürfe: Referenz raus, Status rein
const draftColumns = [
{key: 'type', label: "Typ"},
{key: 'state', label: "Status"}, // Status wieder drin
{key: 'state', label: "Status"},
{key: 'partner', label: "Kunde"},
{key: "date", label: "Erstellt am"},
{key: "amount", label: "Betrag"}
@@ -242,31 +235,11 @@ const getColumnsForTab = (tabKey) => {
}
const templateTypes = [
{
key: "drafts",
label: "Entwürfe"
},
{
key: "invoices",
label: "Rechnungen"
},
/*,{
key: "cancellationInvoices",
label: "Stornorechnungen"
},{
key: "advanceInvoices",
label: "Abschlagsrechnungen"
},*/
{
key: "quotes",
label: "Angebote"
}, {
key: "deliveryNotes",
label: "Lieferscheine"
}, {
key: "confirmationOrders",
label: "Auftragsbestätigungen"
}
{ key: "drafts", label: "Entwürfe" },
{ key: "invoices", label: "Rechnungen" },
{ key: "quotes", label: "Angebote" },
{ key: "deliveryNotes", label: "Lieferscheine" },
{ key: "confirmationOrders", label: "Auftragsbestätigungen" }
]
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
const types = computed(() => {
@@ -274,10 +247,9 @@ const types = computed(() => {
})
const selectItem = (item) => {
console.log(item)
if (item.state === "Entwurf") {
router.push(`/createDocument/edit/${item.id}`)
} else if (item.state !== "Entwurf") {
} else {
router.push(`/createDocument/show/${item.id}`)
}
}
@@ -286,24 +258,19 @@ const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
}
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const clearSearchString = () => {
tempStore.clearSearchString('createddocuments')
searchString.value = ''
debouncedSearchString.value = ''
}
const selectableFilters = ref(dataType.filters.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const filteredRows = computed(() => {
let tempItems = items.value.filter(i => types.value.find(x => {
// 1. Draft Tab Logic
if (x.key === 'drafts') return i.state === 'Entwurf'
// 2. Global Draft Exclusion (drafts shouldn't be in other tabs)
if (i.state === 'Entwurf' && x.key !== 'drafts') return false
// 3. Normal Type Logic
if (x.key === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type)
return x.key === i.type
}))
@@ -324,22 +291,16 @@ const filteredRows = computed(() => {
})
}
tempItems = useSearch(searchString.value, tempItems)
return useSearch(searchString.value, tempItems.slice().reverse())
// Hier nutzen wir nun den debounced Wert für die lokale Suche
const results = useSearch(debouncedSearchString.value, tempItems.slice().reverse())
return results
})
const getRowsForTab = (tabKey) => {
return filteredRows.value.filter(row => {
if (tabKey === 'drafts') {
return row.state === 'Entwurf'
}
if (tabKey === 'drafts') return row.state === 'Entwurf'
if (row.state === 'Entwurf') return false
if (tabKey === 'invoices') {
return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(row.type)
}
if (tabKey === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(row.type)
return row.type === tabKey
})
}
@@ -349,7 +310,4 @@ const isPaid = (item) => {
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
}
</script>
<style scoped>
</style>
</script>

View File

@@ -17,11 +17,18 @@ const dataStore = useDataStore()
const itemInfo = ref({})
const linkedDocument =ref({})
const links = ref([])
const setupPage = async () => {
if(route.params) {
if(route.params.id) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,"*,files(*),linkedDocument(*), statementallocations(bs_id)")
console.log(itemInfo.value)
if(itemInfo.value.type === "invoices"){
const createddocuments = await useEntities("createddocuments").select()
console.log(createddocuments)
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
console.log(links.value)
}
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
@@ -80,17 +87,23 @@ const openBankstatements = () => {
>
E-Mail
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
variant="outline"
color="rose"
<UTooltip
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
:text="links.find(i => i.type === 'cancellationInvoices') ? 'Bereits stoniert' : ''"
>
Stornieren
</UButton>
<UButton
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
variant="outline"
color="rose"
:disabled="links.find(i => i.type === 'cancellationInvoices')"
>
Stornieren
</UButton>
</UTooltip>
<UButton
v-if="itemInfo.project"
@click="router.push(`/standardEntity/projects/show/${itemInfo.project}`)"
@click="router.push(`/standardEntity/projects/show/${itemInfo.project?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
@@ -98,12 +111,36 @@ const openBankstatements = () => {
</UButton>
<UButton
v-if="itemInfo.customer"
@click="router.push(`/standardEntity/customers/show/${itemInfo.customer}`)"
@click="router.push(`/standardEntity/customers/show/${itemInfo.customer?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Kunde
</UButton>
<UButton
v-if="itemInfo.plant"
@click="router.push(`/standardEntity/plants/show/${itemInfo.plant?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Objekt
</UButton>
<UButton
v-if="itemInfo.contract"
@click="router.push(`/standardEntity/contracts/show/${itemInfo.contract?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Vertrag
</UButton>
<UButton
v-if="itemInfo.contact"
@click="router.push(`/standardEntity/contacts/show/${itemInfo.contact?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Ansprechpartner
</UButton>
<UButton
v-if="itemInfo.createddocument"
@click="router.push(`/createDocument/show/${itemInfo.createddocument}`)"

View File

@@ -1,47 +1,28 @@
<script setup>
import {BlobReader, BlobWriter, ZipWriter} from "@zip.js/zip.js";
import { ref, computed, watch } from 'vue';
import DocumentDisplayModal from "~/components/DocumentDisplayModal.vue";
import DocumentUploadModal from "~/components/DocumentUploadModal.vue";
import dayjs from "dayjs";
import arraySort from "array-sort";
// --- Shortcuts ---
defineShortcuts({
'+': () => {
//Hochladen
uploadModalOpen.value = true
},
'/': () => document.getElementById("searchinput").focus(),
'+': () => { uploadModalOpen.value = true },
'Enter': {
usingInput: true,
handler: () => {
let entry = renderedFileList.value[selectedFileIndex.value]
if(entry.type === "file") {
showFile(entry.id)
} else if(createFolderModalOpen.value === false && entry.type === "folder") {
changeFolder(currentFolders.value.find(i => i.id === entry.id))
} else if(createFolderModalOpen.value === true) {
createFolder()
}
const entry = renderedFileList.value[selectedFileIndex.value]
if (!entry) return
if (entry.type === "file") showFile(entry.id)
else if (entry.type === "folder") changeFolder(folders.value.find(i => i.id === entry.id))
}
},
'arrowdown': () => {
if(selectedFileIndex.value < renderedFileList.value.length - 1) {
selectedFileIndex.value += 1
} else {
selectedFileIndex.value = 0
}
if (selectedFileIndex.value < renderedFileList.value.length - 1) selectedFileIndex.value++
},
'arrowup': () => {
if(selectedFileIndex.value === 0) {
selectedFileIndex.value = renderedFileList.value.length - 1
} else {
selectedFileIndex.value -= 1
}
if (selectedFileIndex.value > 0) selectedFileIndex.value--
}
})
@@ -50,486 +31,362 @@ const tempStore = useTempStore()
const router = useRouter()
const route = useRoute()
const modal = useModal()
const auth = useAuthStore()
const uploadModalOpen = ref(false)
const createFolderModalOpen = ref(false)
const uploadInProgress = ref(false)
const fileUploadFormData = ref({
tags: ["Eingang"],
path: "",
tenant: auth.activeTenant,
folder: null
})
const files = useFiles()
const displayMode = ref("list")
const displayModes = ref([{label: 'Liste',key:'list', icon: 'i-heroicons-list-bullet'},{label: 'Kacheln',key:'rectangles', icon: 'i-heroicons-squares-2x2'}])
// --- State ---
const documents = ref([])
const folders = ref([])
const filetags = ref([])
const currentFolder = ref(null)
const loadingDocs = ref(false)
const isDragTarget = ref(false)
const loadingDocs = ref(true)
const loaded = ref(false)
const displayMode = ref("list")
const displayModes = [
{ label: 'Liste', key: 'list', icon: 'i-heroicons-list-bullet' },
{ label: 'Kacheln', key: 'rectangles', icon: 'i-heroicons-squares-2x2' }
]
const createFolderModalOpen = ref(false)
const createFolderData = ref({ name: '', standardFiletype: null, standardFiletypeIsOptional: true })
const selectedFiles = ref({})
const selectedFileIndex = ref(0)
// --- Search & Debounce ---
const searchString = ref(tempStore.searchStrings["files"] || '')
const debouncedSearch = ref(searchString.value)
let debounceTimeout = null
watch(searchString, (val) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedSearch.value = val
tempStore.modifySearchString('files', val)
}, 300)
})
// --- Logic ---
const setupPage = async () => {
folders.value = await useEntities("folders").select()
loadingDocs.value = true
const [fRes, dRes, tRes] = await Promise.all([
useEntities("folders").select(),
files.selectDocuments(),
useEntities("filetags").select()
])
folders.value = fRes
documents.value = dRes
filetags.value = tRes
documents.value = await files.selectDocuments()
filetags.value = await useEntities("filetags").select()
if(route.query) {
if(route.query.folder) {
currentFolder.value = await useEntities("folders").selectSingle(route.query.folder)
}
if (route.query?.folder) {
currentFolder.value = folders.value.find(i => i.id === route.query.folder) || null
}
const dropZone = document.getElementById("drop_zone")
dropZone.ondragover = function (event) {
modal.open(DocumentUploadModal,{fileData: {folder: currentFolder.value.id, type: currentFolder.value.standardFiletype, typeEnabled: currentFolder.value.standardFiletypeIsOptional}, onUploadFinished: () => {
setupPage()
}})
event.preventDefault()
}
dropZone.ondragleave = function (event) {
isDragTarget.value = false
}
dropZone.ondrop = async function (event) {
event.preventDefault()
}
loadingDocs.value = false
loaded.value = true
}
setupPage()
onMounted(() => setupPage())
const currentFolders = computed(() => {
if(folders.value.length > 0) {
let tempFolders = folders.value.filter(i => currentFolder.value ? i.parent === currentFolder.value.id : !i.parent)
return tempFolders
} else return []
return folders.value
.filter(i => currentFolder.value ? i.parent === currentFolder.value.id : !i.parent)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
})
const breadcrumbLinks = computed(() => {
if(currentFolder.value) {
let parents = []
const addParent = (parent) => {
parents.push(parent)
if(parent.parent) {
addParent(folders.value.find(i => i.id === parent.parent))
}
const links = [{ label: "Home", icon: "i-heroicons-home", click: () => changeFolder(null) }]
if (currentFolder.value) {
let path = []
let curr = currentFolder.value
while (curr) {
path.unshift({
label: curr.name,
icon: "i-heroicons-folder",
click: () => changeFolder(folders.value.find(f => f.id === curr.id))
})
curr = folders.value.find(f => f.id === curr.parent)
}
if(currentFolder.value.parent) {
addParent(folders.value.find(i => i.id === currentFolder.value.parent))
}
return [{
label: "Home",
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
},
...parents.map(i => {
return {
label: folders.value.find(x => x.id === i.id).name,
click: () => {
changeFolder(i)
},
icon: "i-heroicons-folder"
}
}).reverse(),
{
label: currentFolder.value.name,
click: () => {
changeFolder(currentFolder.value)
},
icon: "i-heroicons-folder"
}]
} else {
return [{
label: "Home",
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
}]
return [...links, ...path]
}
return links
})
const filteredDocuments = computed(() => {
const renderedFileList = computed(() => {
const folderList = currentFolders.value.map(i => ({
label: i.name,
id: i.id,
type: "folder",
createdAt: i.createdAt
}))
return documents.value.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
const fileList = documents.value
.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
.map(i => ({
label: i.path.split("/").pop(),
id: i.id,
type: "file",
createdAt: i.createdAt
}))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
let combined = [...folderList, ...fileList]
if (debouncedSearch.value) {
combined = useSearch(debouncedSearch.value, combined)
}
return combined
})
const changeFolder = async (newFolder) => {
loadingDocs.value = true
currentFolder.value = newFolder
if(newFolder) {
fileUploadFormData.value.folder = newFolder.id
await router.push(`/files?folder=${newFolder.id}`)
} else {
fileUploadFormData.value.folder = null
await router.push(`/files`)
}
const changeFolder = async (folder) => {
currentFolder.value = folder
await router.push(folder ? `/files?folder=${folder.id}` : `/files`)
}
const createFolder = async () => {
await useEntities("folders").create({
parent: currentFolder.value?.id,
name: createFolderData.value.name,
standardFiletype: createFolderData.value.standardFiletype,
standardFiletypeIsOptional: createFolderData.value.standardFiletypeIsOptional
})
createFolderModalOpen.value = false
createFolderData.value = { name: '', standardFiletype: null, standardFiletypeIsOptional: true }
setupPage()
}
const createFolderData = ref({})
const createFolder = async () => {
const res = await useEntities("folders").create({
parent: currentFolder.value ? currentFolder.value.id : undefined,
name: createFolderData.value.name,
const showFile = (fileId) => {
modal.open(DocumentDisplayModal, {
documentData: documents.value.find(i => i.id === fileId),
onUpdatedNeeded: () => setupPage()
})
createFolderModalOpen.value = false
setupPage()
}
const downloadSelected = async () => {
let files = []
files = filteredDocuments.value.filter(i => selectedFiles.value[i.id] === true).map(i => i.path)
await useFiles().downloadFile(undefined,Object.keys(selectedFiles.value))
}
const searchString = ref(tempStore.searchStrings["files"] ||'')
const renderedFileList = computed(() => {
let files = filteredDocuments.value.map(i => {
return {
label: i.path.split("/")[i.path.split("/").length -1],
id: i.id,
type: "file"
}
})
arraySort(files, (a,b) => {
let aVal = a.path ? a.path.split("/")[a.path.split("/").length -1] : null
let bVal = b.path ? b.path.split("/")[b.path.split("/").length -1] : null
if(aVal && bVal) {
return aVal.localeCompare(bVal)
} else if(!aVal && bVal) {
return 1
} else {
return -1
}
}, {reverse: true})
if(searchString.value.length > 0) {
files = useSearch(searchString.value, files)
}
let folders = currentFolders.value.map(i => {
return {
label: i.name,
id: i.id,
type: "folder"
}
})
arraySort(folders, "label")
/*folders.sort(function(a, b) {
// Compare the 2 dates
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});*/
return [...folders,...files]
})
const selectedFileIndex = ref(0)
const showFile = (fileId) => {
modal.open(DocumentDisplayModal,{
documentData: documents.value.find(i => i.id === fileId),
onUpdatedNeeded: setupPage()
})
}
const selectedFiles = ref({});
const selectAll = () => {
if(Object.keys(selectedFiles.value).find(i => selectedFiles.value[i] === true)) {
selectedFiles.value = {}
} else {
selectedFiles.value = Object.fromEntries(filteredDocuments.value.map(i => i.id).map(k => [k,true]))
}
const ids = Object.keys(selectedFiles.value).filter(k => selectedFiles.value[k])
await useFiles().downloadFile(undefined, ids)
}
const clearSearchString = () => {
tempStore.clearSearchString("files")
searchString.value = ''
debouncedSearch.value = ''
tempStore.clearSearchString("files")
}
const selectAll = (event) => {
if (event.target.checked) {
const obj = {}
renderedFileList.value.filter(e => e.type === 'file').forEach(e => obj[e.id] = true)
selectedFiles.value = obj
} else {
selectedFiles.value = {}
}
}
</script>
<template>
<UDashboardNavbar
title="Dateien"
>
<UDashboardNavbar title="Dateien">
<template #right>
<UInput
id="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
icon="i-heroicons-magnifying-glass"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block"
class="hidden lg:block w-64"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('files',searchString)"
>
<template #trailing>
<UKbd value="/" />
<div class="flex items-center gap-1">
<UKbd value="/" />
<UButton
v-if="searchString.length > 0"
icon="i-heroicons-x-mark"
variant="ghost"
color="gray"
size="xs"
@click="clearSearchString"
/>
</div>
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<UDashboardToolbar class="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<template #left>
<UBreadcrumb
:links="breadcrumbLinks"
/>
<UBreadcrumb :links="breadcrumbLinks" :ui="{ ol: 'gap-x-2', li: 'text-sm' }" />
</template>
<template #right>
<USelectMenu
:options="displayModes"
value-attribute="key"
option-attribute="label"
v-model="displayMode"
:ui-menu="{ width: 'min-w-max'}"
v-model="displayMode"
:options="displayModes"
value-attribute="key"
class="w-32"
>
<template #label>
<UIcon class="w-5 h-5" :name="displayModes.find(i => i.key === displayMode).icon"/>
<UIcon :name="displayModes.find(i => i.key === displayMode).icon" class="w-4 h-4" />
<span>{{ displayModes.find(i => i.key === displayMode).label }}</span>
</template>
</USelectMenu>
<UButtonGroup size="sm">
<UButton
icon="i-heroicons-document-plus"
color="primary"
@click="modal.open(DocumentUploadModal, {
fileData: {
folder: currentFolder?.id,
type: currentFolder?.standardFiletype,
typeEnabled: currentFolder?.standardFiletypeIsOptional
},
onUploadFinished: () => setupPage()
})"
>Datei</UButton>
<UButton
icon="i-heroicons-folder-plus"
color="white"
@click="createFolderModalOpen = true"
>Ordner</UButton>
</UButtonGroup>
<UButton
:disabled="!currentFolder"
@click="modal.open(DocumentUploadModal,{fileData: {folder: currentFolder.id, type: currentFolder.standardFiletype, typeEnabled: currentFolder.standardFiletypeIsOptional}, onUploadFinished: () => {setupPage()}})"
>+ Datei</UButton>
<UButton
@click="createFolderModalOpen = true"
variant="outline"
>+ Ordner</UButton>
<UButton
@click="downloadSelected"
v-if="Object.values(selectedFiles).some(Boolean)"
icon="i-heroicons-cloud-arrow-down"
variant="outline"
v-if="Object.keys(selectedFiles).find(i => selectedFiles[i] === true)"
>Herunterladen</UButton>
<UModal v-model="createFolderModalOpen">
<UCard :ui="{ body: { base: 'space-y-4' } }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Ordner Erstellen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="createFolderModalOpen = false" />
</div>
</template>
color="gray"
variant="solid"
@click="downloadSelected"
>Download</UButton>
</template>
</UDashboardToolbar>
<UFormGroup label="Name des Ordners" required>
<UInput
v-model="createFolderData.name"
placeholder="z.B. Rechnungen 2024"
autofocus
/>
<div id="drop_zone" class="flex-1 overflow-hidden flex flex-col relative">
<div v-if="!loaded" class="p-10 flex justify-center">
<UProgress animation="carousel" class="w-1/2" />
</div>
<UDashboardPanelContent v-else class="p-0 overflow-y-auto">
<div v-if="displayMode === 'list'" class="min-w-full inline-block align-middle">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 sm:pl-6 w-10">
<UCheckbox @change="selectAll" />
</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-white sm:pl-6">Name</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">Erstellt am</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 bg-white dark:bg-gray-900">
<tr
v-for="(entry, index) in renderedFileList"
:key="entry.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors"
:class="{'bg-primary-50 dark:bg-primary-900/10': index === selectedFileIndex}"
@click="entry.type === 'folder' ? changeFolder(entry) : showFile(entry.id)"
>
<td class="relative w-12 px-6 sm:w-16 sm:px-8" @click.stop>
<UCheckbox v-if="entry.type === 'file'" v-model="selectedFiles[entry.id]" />
</td>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
<div class="flex items-center">
<UIcon
:name="entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text'"
class="flex-shrink-0 h-5 w-5 mr-3"
:class="entry.type === 'folder' ? 'text-primary-500' : 'text-gray-400'"
/>
<span class="font-medium text-gray-900 dark:text-white truncate max-w-md">
{{ entry.label }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{{ dayjs(entry.createdAt).format("DD.MM.YY · HH:mm") }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-6">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
<div
v-for="folder in currentFolders"
:key="folder.id"
class="group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 flex flex-col items-center hover:border-primary-500 transition-all cursor-pointer"
@click="changeFolder(folder)"
>
<UIcon name="i-heroicons-folder-solid" class="w-12 h-12 text-primary-500 mb-2 group-hover:scale-110 transition-transform" />
<span class="text-sm font-medium text-center truncate w-full">{{ folder.name }}</span>
</div>
<div
v-for="doc in renderedFileList.filter(e => e.type === 'file')"
:key="doc.id"
class="group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 flex flex-col items-center hover:border-primary-500 transition-all cursor-pointer"
@click="showFile(doc.id)"
>
<UCheckbox v-model="selectedFiles[doc.id]" class="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop />
<UIcon name="i-heroicons-document-text" class="w-12 h-12 text-gray-400 mb-2 group-hover:scale-110 transition-transform" />
<span class="text-sm font-medium text-center truncate w-full">{{ doc.label }}</span>
</div>
</div>
</div>
</UDashboardPanelContent>
</div>
<UModal v-model="createFolderModalOpen">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold">Ordner erstellen</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark" @click="createFolderModalOpen = false" />
</div>
</template>
<div class="space-y-4">
<UFormGroup label="Name" required>
<UInput v-model="createFolderData.name" placeholder="Name eingeben..." autofocus @keyup.enter="createFolder" />
</UFormGroup>
<UFormGroup
label="Standard Dateityp"
>
<UFormGroup label="Standard-Dateityp">
<USelectMenu
v-model="createFolderData.standardFiletype"
:options="filetags"
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Typ suchen..."
placeholder="Kein Standard-Typ"
clear-search-on-close
>
<template #label>
<span v-if="createFolderData.standardFiletype">
{{ filetags.find(t => t.id === createFolderData.standardFiletype)?.name }}
</span>
<span v-else class="text-gray-400">Kein Typ ausgewählt</span>
</template>
</USelectMenu>
placeholder="Kein Standard"
/>
</UFormGroup>
<div v-if="createFolderData.standardFiletype">
<UCheckbox
v-model="createFolderData.standardFiletypeIsOptional"
name="isOptional"
label="Dateityp ist optional"
help="Wenn deaktiviert, MUSS der Nutzer beim Upload diesen Typ verwenden."
/>
</div>
<UCheckbox
v-if="createFolderData.standardFiletype"
v-model="createFolderData.standardFiletypeIsOptional"
label="Typ ist optional"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="ghost" @click="createFolderModalOpen = false">
Abbrechen
</UButton>
<UButton @click="createFolder" :disabled="!createFolderData.name">
Erstellen
</UButton>
</div>
</template>
</UCard>
</UModal>
</template>
</UDashboardToolbar>
<div id="drop_zone" class="h-full scrollList" >
<div v-if="loaded">
<UDashboardPanelContent>
<div v-if="displayMode === 'list'">
<table class="w-full">
<thead>
<tr>
<td>
<UCheckbox
v-if="renderedFileList.find(i => i.type === 'file')"
@change="selectAll"
/>
</td>
<td class="font-bold">Name</td>
<td class="font-bold">Erstellt am</td>
</tr>
</thead>
<tr v-for="(entry,index) in renderedFileList">
<td>
<UCheckbox
v-if="entry.type === 'file'"
v-model="selectedFiles[entry.id]"
/>
</td>
<td>
<UIcon class="mr-1" :name="entry.type === 'folder' ? 'i-heroicons-folder' : 'i-heroicons-document'"/>
<a
style="cursor: pointer"
:class="[...index === selectedFileIndex ? ['text-primary', 'text-xl'] : ['dark:text-white','text-black','text-xl']]"
@click="entry.type === 'folder' ? changeFolder(currentFolders.find(i => i.id === entry.id)) : showFile(entry.id)"
>{{entry.label}}</a>
</td>
<td>
<span v-if="entry.type === 'file'" class="text-xl">{{dayjs(documents.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
<span v-if="entry.type === 'folder'" class="text-xl">{{dayjs(currentFolders.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
</td>
</tr>
</table>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="createFolderModalOpen = false">Abbrechen</UButton>
<UButton color="primary" :disabled="!createFolderData.name" @click="createFolder">Erstellen</UButton>
</div>
<div v-else-if="displayMode === 'rectangles'">
<div class="flex flex-row w-full flex-wrap" v-if="currentFolders.length > 0">
<a
class="w-1/6 folderIcon flex flex-col p-5 m-2"
v-for="folder in currentFolders"
@click="changeFolder(folder)"
>
<UIcon
name="i-heroicons-folder"
class="w-20 h-20"
/>
<span class="text-center truncate">{{folder.name}}</span>
</a>
</div>
<UDivider class="my-5" v-if="currentFolder">{{currentFolder.name}}</UDivider>
<UDivider class="my-5" v-else>Ablage</UDivider>
<div v-if="!loadingDocs">
<DocumentList
v-if="filteredDocuments.length > 0"
:documents="filteredDocuments"
@selectDocument="(info) => console.log(info)"
/>
<UAlert
v-else
class="mt-5 w-1/2 mx-auto"
icon="i-heroicons-light-bulb"
title="Keine Dokumente vorhanden"
color="primary"
variant="outline"
/>
</div>
<UProgress
animation="carousel"
v-else
class="w-2/3 my-5 mx-auto"
/>
</div>
</UDashboardPanelContent>
</div>
<UProgress animation="carousel" v-else class="w-5/6 mx-auto mt-5"/>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped>
.folderIcon {
border: 1px solid lightgrey;
border-radius: 10px;
color: dimgrey;
#drop_zone::after {
content: 'Datei hierher ziehen';
@apply absolute inset-0 flex items-center justify-center bg-primary-500/10 border-4 border-dashed border-primary-500 opacity-0 pointer-events-none transition-opacity z-50 rounded-xl m-4;
}
.folderIcon:hover {
border: 1px solid #69c350;
color: #69c350;
#drop_zone:hover::after {
/* In der setupPage Logik wird das Modal getriggert,
dieser Style dient nur der visuellen Hilfe */
}
tr:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.05);
/* Custom Table Shadows & Borders */
table {
border-collapse: separate;
border-spacing: 0;
}
</style>

View File

@@ -56,7 +56,7 @@
>
<display-open-tasks/>
</UDashboardCard>
<UDashboardCard
<!-- <UDashboardCard
title="Label Test"
>
<UButton
@@ -70,7 +70,7 @@
>
Label Drucken
</UButton>
</UDashboardCard>
</UDashboardCard>-->
</UPageGrid>
</UDashboardPanelContent>
</template>

View File

@@ -3,10 +3,16 @@ import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import 'dayjs/locale/de'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
dayjs.extend(duration)
dayjs.extend(relativeTime)
dayjs.extend(localizedFormat)
dayjs.extend(quarterOfYear)
dayjs.extend(isSameOrAfter)
dayjs.extend(isSameOrBefore)
dayjs.locale('de')
export default defineNuxtPlugin(() => {

View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
export const useCalculatorStore = defineStore('calculator', () => {
const tempStore = useTempStore()
// Initialisierung aus dem TempStore
const isOpen = ref(false)
const display = computed({
get: () => tempStore.settings?.calculator?.display || '0',
set: (val) => tempStore.modifySettings('calculator', { ...tempStore.settings.calculator, display: val })
})
const memory = computed({
get: () => tempStore.settings?.calculator?.memory || 0,
set: (val) => tempStore.modifySettings('calculator', { ...tempStore.settings.calculator, memory: val })
})
const history = computed({
get: () => tempStore.filters?.calculator?.history || [],
set: (val) => tempStore.modifyFilter('calculator', 'history', val)
})
function toggle() {
isOpen.value = !isOpen.value
}
function addHistory(expression: string, result: string) {
const newHistory = [{ expression, result }, ...history.value].slice(0, 10)
history.value = newHistory
}
return {
isOpen,
display,
memory,
history,
toggle,
addHistory
}
})

View File

@@ -1,8 +1,7 @@
import {defineStore} from 'pinia'
import { defineStore } from 'pinia'
// @ts-ignore
export const useTempStore = defineStore('temp', () => {
const auth = useAuthStore()
const searchStrings = ref({})
@@ -20,21 +19,21 @@ export const useTempStore = defineStore('temp', () => {
filters: filters.value
}
await useNuxtApp().$api(`/api/profiles/${auth.profile.id}`,{
await useNuxtApp().$api(`/api/profiles/${auth.profile.id}`, {
method: 'PUT',
body: {temp_config: config}
body: { temp_config: config }
})
}
function setStoredTempConfig (config) {
searchStrings.value = config.searchStrings
columns.value = config.columns
pages.value = config.pages
settings.value = config.settings
function setStoredTempConfig(config) {
searchStrings.value = config.searchStrings || {}
columns.value = config.columns || {}
pages.value = config.pages || {}
settings.value = config.settings || {}
filters.value = config.filters || {}
}
function modifySearchString(type,input) {
function modifySearchString(type, input) {
searchStrings.value[type] = input
storeTempConfig()
}
@@ -44,28 +43,36 @@ export const useTempStore = defineStore('temp', () => {
storeTempConfig()
}
function modifyFilter(domain,type,input) {
if(!filters.value[domain]) filters.value[domain] = {}
function modifyFilter(domain, type, input) {
if (!filters.value[domain]) filters.value[domain] = {}
filters.value[domain][type] = input
storeTempConfig()
}
function modifyColumns(type,input) {
function modifyColumns(type, input) {
columns.value[type] = input
storeTempConfig()
}
function modifyPages(type,input) {
function modifyPages(type, input) {
pages.value[type] = input
storeTempConfig()
}
function modifySettings(type,input) {
function modifySettings(type, input) {
settings.value[type] = input
storeTempConfig()
}
// Spezifisch für das Banking-Datum
function modifyBankingPeriod(periodKey, range) {
if (!settings.value['banking']) settings.value['banking'] = {}
settings.value['banking'].periodKey = periodKey
settings.value['banking'].range = range
storeTempConfig()
}
return {
setStoredTempConfig,
@@ -79,8 +86,7 @@ export const useTempStore = defineStore('temp', () => {
modifyPages,
pages,
modifySettings,
modifyBankingPeriod, // Neue Funktion exportiert
settings
}
})