Compare commits
5 Commits
80b2b1d097
...
bb3b842be1
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3b842be1 | |||
| 9c6a6a841a | |||
| b7b913035e | |||
| 454e9ee3c9 | |||
| 01846d488b |
@@ -1,6 +1,6 @@
|
|||||||
# FEDEO Selfhosting
|
# FEDEO Selfhosting
|
||||||
DOMAIN=app.example.com
|
DOMAIN=app.example.com
|
||||||
CONTACT_EMAIL=admin@example.com
|
CONTACT_EMAIL=admin@deine-domain.de
|
||||||
|
|
||||||
DB_NAME=fedeo
|
DB_NAME=fedeo
|
||||||
DB_USER=fedeo
|
DB_USER=fedeo
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ Diese Datei liegt neben der `docker-compose.yml`:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
DOMAIN=app.example.com
|
DOMAIN=app.example.com
|
||||||
CONTACT_EMAIL=admin@example.com
|
CONTACT_EMAIL=admin@deine-domain.de
|
||||||
|
|
||||||
DB_NAME=fedeo
|
DB_NAME=fedeo
|
||||||
DB_USER=fedeo
|
DB_USER=fedeo
|
||||||
@@ -335,6 +335,7 @@ services:
|
|||||||
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
||||||
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
||||||
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
- internal
|
- internal
|
||||||
@@ -356,13 +357,16 @@ services:
|
|||||||
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||||
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||||
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
|
name: fedeo_web
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal:
|
internal:
|
||||||
|
name: fedeo_internal
|
||||||
driver: bridge
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||||
import { sendMail } from "../utils/mailer";
|
import { sendMail } from "../utils/mailer";
|
||||||
import { ensureTenantBaseData } from "../modules/bootstrap.service";
|
import { ensureTenantBaseData } from "../modules/bootstrap.service";
|
||||||
|
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
const deriveNameFromEmail = (email: string) => {
|
const deriveNameFromEmail = (email: string) => {
|
||||||
@@ -929,6 +931,57 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET /admin/tenants/:tenant_id/export
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/admin/tenants/:tenant_id/export", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { tenant_id } = req.params as { tenant_id: string };
|
||||||
|
const tenantId = Number(tenant_id);
|
||||||
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "tenant_id required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = await buildTenantFullExport(server, tenantId);
|
||||||
|
const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId)
|
||||||
|
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
|
||||||
|
reply.header("Content-Type", "application/json");
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
return reply.send(exportData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/tenants/:tenant_id/export:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// POST /admin/tenant-imports
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const exportData = req.body as TenantFullExport;
|
||||||
|
const result = await importTenantFullExport(server, exportData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("ERROR /admin/tenant-imports:", err);
|
||||||
|
return reply.code(500).send({ error: err?.message || "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// PUT /admin/users/:user_id/access
|
// PUT /admin/users/:user_id/access
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
|
|||||||
293
backend/src/utils/tenantFullExport.ts
Normal file
293
backend/src/utils/tenantFullExport.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
|
||||||
|
import { pool } from "../../db"
|
||||||
|
import { s3 } from "./s3"
|
||||||
|
import { secrets } from "./secrets"
|
||||||
|
|
||||||
|
type TableRows = Record<string, Record<string, any>[]>
|
||||||
|
|
||||||
|
export type TenantFullExport = {
|
||||||
|
format: "fedeo.tenant-full-export"
|
||||||
|
version: 1
|
||||||
|
exportedAt: string
|
||||||
|
tenantId: number
|
||||||
|
tables: TableRows
|
||||||
|
files: {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
name: string | null
|
||||||
|
mimeType: string | null
|
||||||
|
size: number | null
|
||||||
|
contentBase64: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
tenantId: number
|
||||||
|
tables: { table: string; rows: number }[]
|
||||||
|
files: { restored: number; skipped: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||||
|
|
||||||
|
const tableColumns = async (client: any) => {
|
||||||
|
const result = await client.query(`
|
||||||
|
select table_name, column_name
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
order by table_name, ordinal_position
|
||||||
|
`)
|
||||||
|
|
||||||
|
const columnsByTable = new Map<string, string[]>()
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const columns = columnsByTable.get(row.table_name) || []
|
||||||
|
columns.push(row.column_name)
|
||||||
|
columnsByTable.set(row.table_name, columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
return columnsByTable
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRows = async (client: any, table: string, whereSql: string, params: any[] = []) => {
|
||||||
|
const result = await client.query(`select * from ${quoteIdent(table)} where ${whereSql}`, params)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectIds = (rows: Record<string, any>[], column: string) => {
|
||||||
|
return Array.from(new Set(rows.map((row) => row[column]).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRows = (tables: TableRows, table: string, rows: Record<string, any>[]) => {
|
||||||
|
if (!rows.length) {
|
||||||
|
if (!tables[table]) tables[table] = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = tables[table] || []
|
||||||
|
const existingKeys = new Set(existingRows.map((row) => JSON.stringify(row)))
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = JSON.stringify(row)
|
||||||
|
if (!existingKeys.has(key)) {
|
||||||
|
existingRows.push(row)
|
||||||
|
existingKeys.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables[table] = existingRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadObjectAsBase64 = async (path: string) => {
|
||||||
|
const { Body } = await s3.send(new GetObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: path,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of Body as any) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks).toString("base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise<TenantFullExport> => {
|
||||||
|
const client = await pool.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columnsByTable = await tableColumns(client)
|
||||||
|
const tables: TableRows = {}
|
||||||
|
|
||||||
|
const tenantRows = await loadRows(client, "tenants", "id = $1", [tenantId])
|
||||||
|
if (!tenantRows.length) throw new Error("Tenant nicht gefunden")
|
||||||
|
|
||||||
|
addRows(tables, "tenants", tenantRows)
|
||||||
|
|
||||||
|
for (const [table, columns] of columnsByTable.entries()) {
|
||||||
|
if (table === "tenants") continue
|
||||||
|
|
||||||
|
const tenantColumn = columns.includes("tenant")
|
||||||
|
? "tenant"
|
||||||
|
: columns.includes("tenant_id")
|
||||||
|
? "tenant_id"
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!tenantColumn) continue
|
||||||
|
|
||||||
|
const rows = await loadRows(client, table, `${quoteIdent(tenantColumn)} = $1`, [tenantId])
|
||||||
|
addRows(tables, table, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileIds = collectIds(tables.auth_profiles || [], "id")
|
||||||
|
const userIds = Array.from(new Set([
|
||||||
|
...collectIds(tables.auth_tenant_users || [], "user_id"),
|
||||||
|
...collectIds(tables.auth_profiles || [], "user_id"),
|
||||||
|
...collectIds(tables.auth_user_roles || [], "user_id"),
|
||||||
|
]))
|
||||||
|
const roleIds = Array.from(new Set([
|
||||||
|
...collectIds(tables.auth_roles || [], "id"),
|
||||||
|
...collectIds(tables.auth_user_roles || [], "role_id"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
if (userIds.length) {
|
||||||
|
addRows(tables, "auth_users", await loadRows(client, "auth_users", "id = any($1::uuid[])", [userIds]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleIds.length) {
|
||||||
|
addRows(tables, "auth_roles", await loadRows(client, "auth_roles", "id = any($1::uuid[])", [roleIds]))
|
||||||
|
addRows(tables, "auth_role_permissions", await loadRows(client, "auth_role_permissions", "role_id = any($1::uuid[])", [roleIds]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileIds.length) {
|
||||||
|
addRows(tables, "auth_profile_branches", await loadRows(client, "auth_profile_branches", "profile_id = any($1::uuid[])", [profileIds]))
|
||||||
|
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRows = tables.files || []
|
||||||
|
const files = []
|
||||||
|
|
||||||
|
for (const file of fileRows) {
|
||||||
|
if (!file.path) continue
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
id: file.id,
|
||||||
|
path: file.path,
|
||||||
|
name: file.name || null,
|
||||||
|
mimeType: file.mimeType || null,
|
||||||
|
size: file.size || null,
|
||||||
|
contentBase64: await loadObjectAsBase64(file.path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: "fedeo.tenant-full-export",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
tenantId,
|
||||||
|
tables,
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreFiles = async (exportData: TenantFullExport) => {
|
||||||
|
let restored = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const file of exportData.files || []) {
|
||||||
|
if (!file.path || !file.contentBase64) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.from(file.contentBase64, "base64")
|
||||||
|
await s3.send(new PutObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: file.path,
|
||||||
|
Body: body,
|
||||||
|
ContentType: file.mimeType || "application/octet-stream",
|
||||||
|
ContentLength: body.length,
|
||||||
|
}))
|
||||||
|
restored += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restored, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertRows = async (client: any, table: string, rows: Record<string, any>[], columns: string[]) => {
|
||||||
|
if (!rows.length) return 0
|
||||||
|
|
||||||
|
let inserted = 0
|
||||||
|
const availableColumns = new Set(columns)
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column))
|
||||||
|
if (!rowColumns.length) continue
|
||||||
|
|
||||||
|
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
|
||||||
|
const values = rowColumns.map((column) => row[column])
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSequences = async (client: any, columnsByTable: Map<string, string[]>) => {
|
||||||
|
for (const [table, columns] of columnsByTable.entries()) {
|
||||||
|
if (!columns.includes("id")) continue
|
||||||
|
|
||||||
|
const sequenceResult = await client.query("select pg_get_serial_sequence($1, $2) as sequence_name", [`public.${table}`, "id"])
|
||||||
|
const sequenceName = sequenceResult.rows[0]?.sequence_name
|
||||||
|
if (!sequenceName) continue
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
select setval(
|
||||||
|
$1::regclass,
|
||||||
|
greatest(coalesce((select max(id) from ${quoteIdent(table)}), 1), 1),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
`, [sequenceName])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importTenantFullExport = async (server: FastifyInstance, exportData: TenantFullExport): Promise<ImportResult> => {
|
||||||
|
if (exportData?.format !== "fedeo.tenant-full-export" || exportData.version !== 1) {
|
||||||
|
throw new Error("Ungültiges FEDEO Mandantenexport-Format")
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect()
|
||||||
|
const importOrder = [
|
||||||
|
"tenants",
|
||||||
|
"auth_users",
|
||||||
|
"auth_roles",
|
||||||
|
"auth_role_permissions",
|
||||||
|
"auth_tenant_users",
|
||||||
|
"auth_profiles",
|
||||||
|
"auth_user_roles",
|
||||||
|
"auth_profile_branches",
|
||||||
|
"auth_profile_teams",
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columnsByTable = await tableColumns(client)
|
||||||
|
const tableNames = [
|
||||||
|
...importOrder,
|
||||||
|
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
|
||||||
|
].filter((table, index, all) => all.indexOf(table) === index)
|
||||||
|
|
||||||
|
await client.query("begin")
|
||||||
|
await client.query("set local session_replication_role = replica")
|
||||||
|
const files = await restoreFiles(exportData)
|
||||||
|
|
||||||
|
const importedTables: { table: string; rows: number }[] = []
|
||||||
|
for (const table of tableNames) {
|
||||||
|
const rows = exportData.tables[table] || []
|
||||||
|
const columns = columnsByTable.get(table)
|
||||||
|
if (!columns) continue
|
||||||
|
|
||||||
|
const count = await insertRows(client, table, rows, columns)
|
||||||
|
importedTables.push({ table, rows: count })
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSequences(client, columnsByTable)
|
||||||
|
await client.query("commit")
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: exportData.tenantId,
|
||||||
|
tables: importedTables,
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("rollback")
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ services:
|
|||||||
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
||||||
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
||||||
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
- internal
|
- internal
|
||||||
@@ -160,11 +161,14 @@ services:
|
|||||||
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||||
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||||
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
|
name: fedeo_web
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal:
|
internal:
|
||||||
|
name: fedeo_internal
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -322,6 +322,11 @@ const links = computed(() => {
|
|||||||
to: "/settings/texttemplates",
|
to: "/settings/texttemplates",
|
||||||
icon: "i-heroicons-clipboard-document-list",
|
icon: "i-heroicons-clipboard-document-list",
|
||||||
} : null,
|
} : null,
|
||||||
|
featureEnabled("settingsLetterheads") ? {
|
||||||
|
label: "Briefpapiere",
|
||||||
|
to: "/settings/letterheads",
|
||||||
|
icon: "i-heroicons-document",
|
||||||
|
} : null,
|
||||||
featureEnabled("settingsTenant") ? {
|
featureEnabled("settingsTenant") ? {
|
||||||
label: "Firmeneinstellungen",
|
label: "Firmeneinstellungen",
|
||||||
to: "/settings/tenant",
|
to: "/settings/tenant",
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export type AdminOverview = {
|
|||||||
unassignedProfiles: AdminUserProfile[]
|
unassignedProfiles: AdminUserProfile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantImportResult = {
|
||||||
|
success: boolean
|
||||||
|
tenantId: number
|
||||||
|
tables: { table: string; rows: number }[]
|
||||||
|
files: { restored: number; skipped: number }
|
||||||
|
}
|
||||||
|
|
||||||
export const useAdmin = () => {
|
export const useAdmin = () => {
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
|
||||||
@@ -110,6 +117,19 @@ export const useAdmin = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportTenant = async (id: number): Promise<Blob> => {
|
||||||
|
return await $api(`/api/admin/tenants/${id}/export`, {
|
||||||
|
responseType: "blob",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const importTenant = async (body: Record<string, any>): Promise<TenantImportResult> => {
|
||||||
|
return await $api("/api/admin/tenant-imports", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getOverview,
|
getOverview,
|
||||||
createUser,
|
createUser,
|
||||||
@@ -119,5 +139,7 @@ export const useAdmin = () => {
|
|||||||
createTenant,
|
createTenant,
|
||||||
invitePortalUser,
|
invitePortalUser,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
|
exportTenant,
|
||||||
|
importTenant,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ const admin = useAdmin()
|
|||||||
const tenantId = Number(route.params.id)
|
const tenantId = Number(route.params.id)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const exportingTenant = ref(false)
|
||||||
|
const importingTenant = ref(false)
|
||||||
const creatingUser = ref(false)
|
const creatingUser = ref(false)
|
||||||
const createUserModalOpen = ref(false)
|
const createUserModalOpen = ref(false)
|
||||||
const createdUserPassword = ref("")
|
const createdUserPassword = ref("")
|
||||||
|
const importFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const lastImportResult = ref<null | {
|
||||||
|
tenantId: number
|
||||||
|
tableCount: number
|
||||||
|
rowCount: number
|
||||||
|
restoredFiles: number
|
||||||
|
skippedFiles: number
|
||||||
|
}>(null)
|
||||||
|
|
||||||
const tenantForm = ref<AdminTenant | null>(null)
|
const tenantForm = ref<AdminTenant | null>(null)
|
||||||
const assignedUsers = ref<AdminUser[]>([])
|
const assignedUsers = ref<AdminUser[]>([])
|
||||||
@@ -79,6 +89,88 @@ const saveTenant = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadTenantExport = async () => {
|
||||||
|
if (!tenantForm.value || exportingTenant.value) return
|
||||||
|
|
||||||
|
exportingTenant.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await admin.exportTenant(tenantForm.value.id)
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
const safeName = (tenantForm.value.short || tenantForm.value.name || tenantForm.value.id)
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
link.href = objectUrl
|
||||||
|
link.download = `fedeo-tenant-${safeName || tenantForm.value.id}-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
|
||||||
|
toast.add({ title: "Mandantenexport erstellt", color: "green" })
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[administration/tenants/export]", err)
|
||||||
|
toast.add({
|
||||||
|
title: "Mandant konnte nicht exportiert werden",
|
||||||
|
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||||
|
color: "red",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
exportingTenant.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openImportFileDialog = () => {
|
||||||
|
importFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const importTenantExport = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file || importingTenant.value) return
|
||||||
|
|
||||||
|
importingTenant.value = true
|
||||||
|
lastImportResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawContent = await file.text()
|
||||||
|
const exportData = JSON.parse(rawContent)
|
||||||
|
const result = await admin.importTenant(exportData)
|
||||||
|
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
|
||||||
|
|
||||||
|
lastImportResult.value = {
|
||||||
|
tenantId: result.tenantId,
|
||||||
|
tableCount: result.tables?.length || 0,
|
||||||
|
rowCount,
|
||||||
|
restoredFiles: result.files?.restored || 0,
|
||||||
|
skippedFiles: result.files?.skipped || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchTenant()
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Mandantenimport abgeschlossen",
|
||||||
|
description: `${rowCount} Datensätze und ${lastImportResult.value.restoredFiles} Dateien verarbeitet.`,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[administration/tenants/import]", err)
|
||||||
|
toast.add({
|
||||||
|
title: "Mandant konnte nicht importiert werden",
|
||||||
|
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||||
|
color: "red",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
importingTenant.value = false
|
||||||
|
input.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createTenantUser = async () => {
|
const createTenantUser = async () => {
|
||||||
if (!tenantForm.value || creatingUser.value) return
|
if (!tenantForm.value || creatingUser.value) return
|
||||||
|
|
||||||
@@ -186,6 +278,59 @@ onMounted(async () => {
|
|||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="!loading && tenantForm" class="mt-3">
|
||||||
|
<USeparator label="Backup und Umzug" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||||
|
<div class="font-medium">Full Export</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Erstellt eine portable JSON-Datei mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateiinhalten.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
class="mt-4"
|
||||||
|
icon="i-heroicons-arrow-down-tray"
|
||||||
|
:loading="exportingTenant"
|
||||||
|
@click="downloadTenantExport"
|
||||||
|
>
|
||||||
|
Export herunterladen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||||
|
<div class="font-medium">Import</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Spielt einen FEDEO-Mandantenexport auf diesem Server ein. Bestehende Datensätze mit gleicher ID werden übersprungen.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref="importFileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="importTenantExport"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
class="mt-4"
|
||||||
|
icon="i-heroicons-arrow-up-tray"
|
||||||
|
color="warning"
|
||||||
|
:loading="importingTenant"
|
||||||
|
@click="openImportFileDialog"
|
||||||
|
>
|
||||||
|
Export importieren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="lastImportResult"
|
||||||
|
class="mt-4"
|
||||||
|
title="Letzter Import"
|
||||||
|
:description="`Tenant ${lastImportResult.tenantId}: ${lastImportResult.rowCount} Datensätze aus ${lastImportResult.tableCount} Tabellen verarbeitet, ${lastImportResult.restoredFiles} Dateien wiederhergestellt, ${lastImportResult.skippedFiles} Dateien übersprungen.`"
|
||||||
|
color="green"
|
||||||
|
variant="soft"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard v-if="!loading && tenantForm" class="mt-3">
|
<UCard v-if="!loading && tenantForm" class="mt-3">
|
||||||
<USeparator label="Zugeordnete Benutzer" />
|
<USeparator label="Zugeordnete Benutzer" />
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ const admin = useAdmin()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const creatingTenant = ref(false)
|
const creatingTenant = ref(false)
|
||||||
|
const importingTenant = ref(false)
|
||||||
const createTenantModalOpen = ref(false)
|
const createTenantModalOpen = ref(false)
|
||||||
|
const importFileInput = ref<HTMLInputElement | null>(null)
|
||||||
const tenants = ref<AdminTenant[]>([])
|
const tenants = ref<AdminTenant[]>([])
|
||||||
const searchString = ref("")
|
const searchString = ref("")
|
||||||
|
|
||||||
@@ -89,6 +91,46 @@ const createTenant = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openImportFileDialog = () => {
|
||||||
|
importFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const importTenantExport = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file || importingTenant.value) return
|
||||||
|
|
||||||
|
importingTenant.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportData = JSON.parse(await file.text())
|
||||||
|
const result = await admin.importTenant(exportData)
|
||||||
|
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
|
||||||
|
|
||||||
|
await fetchTenants()
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Mandantenimport abgeschlossen",
|
||||||
|
description: `Tenant ${result.tenantId}: ${rowCount} Datensätze und ${result.files?.restored || 0} Dateien verarbeitet.`,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.tenantId) {
|
||||||
|
await router.push(`/administration/tenants/${result.tenantId}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[administration/tenants/import]", err)
|
||||||
|
toast.add({
|
||||||
|
title: "Mandant konnte nicht importiert werden",
|
||||||
|
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||||
|
color: "red",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
importingTenant.value = false
|
||||||
|
input.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user?.is_admin) {
|
if (!auth.user?.is_admin) {
|
||||||
await router.push("/")
|
await router.push("/")
|
||||||
@@ -111,6 +153,22 @@ onMounted(async () => {
|
|||||||
<UButton icon="i-heroicons-plus" @click="createTenantModalOpen = true">
|
<UButton icon="i-heroicons-plus" @click="createTenantModalOpen = true">
|
||||||
Tenant
|
Tenant
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<input
|
||||||
|
ref="importFileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="importTenantExport"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-arrow-up-tray"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
:loading="importingTenant"
|
||||||
|
@click="openImportFileDialog"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
|
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
|
||||||
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
const documentStorageFallbackTypes = {
|
const documentStorageFallbackTypes = {
|
||||||
@@ -114,6 +115,18 @@ const serialIntervalItems = ['wöchentlich', '2 - wöchentlich', 'monatlich', 'v
|
|||||||
const serialDateDirectionItems = ['Rückwirkend', 'Im Voraus']
|
const serialDateDirectionItems = ['Rückwirkend', 'Im Voraus']
|
||||||
const taxPercentItems = [19, 7, 0]
|
const taxPercentItems = [19, 7, 0]
|
||||||
const selectedCustomer = computed(() => customers.value.find(i => i.id === itemInfo.value.customer) || null)
|
const selectedCustomer = computed(() => customers.value.find(i => i.id === itemInfo.value.customer) || null)
|
||||||
|
const findById = (items, id) => {
|
||||||
|
if (id === null || typeof id === "undefined") return null
|
||||||
|
|
||||||
|
return items.find((item) => item.id === id) || null
|
||||||
|
}
|
||||||
|
const getContactName = (id) => findById(contacts.value, id)?.fullName || "Kontakt nicht gefunden"
|
||||||
|
const getContractName = (id) => findById(contracts.value, id)?.name || "Vertrag nicht gefunden"
|
||||||
|
const getContractNumber = (id) => findById(contracts.value, id)?.contractNumber || "Vertrag nicht gefunden"
|
||||||
|
const getLetterheadName = (id) => findById(letterheads.value, id)?.name || "Briefpapier nicht gefunden"
|
||||||
|
const getPlantName = (id) => findById(plants.value, id)?.name || "Objekt nicht gefunden"
|
||||||
|
const getProjectName = (id) => findById(projects.value, id)?.name || "Projekt nicht gefunden"
|
||||||
|
const getSelectedLetterhead = () => findById(letterheads.value, itemInfo.value.letterhead)
|
||||||
const normalizeExternalUrl = (value) => {
|
const normalizeExternalUrl = (value) => {
|
||||||
if (!value || typeof value !== "string") return null
|
if (!value || typeof value !== "string") return null
|
||||||
|
|
||||||
@@ -195,7 +208,11 @@ watch(() => itemInfo.value.deliveryDateType, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const setupData = async () => {
|
const setupData = async () => {
|
||||||
letterheads.value = (await useEntities("letterheads").select("*")).filter(i => i.documentTypes.length === 0 || i.documentTypes.includes(itemInfo.value.type))
|
letterheads.value = (await useEntities("letterheads").select("*")).filter(i => {
|
||||||
|
const documentTypes = Array.isArray(i.documentTypes) ? i.documentTypes : []
|
||||||
|
|
||||||
|
return documentTypes.length === 0 || documentTypes.includes(itemInfo.value.type)
|
||||||
|
})
|
||||||
createddocuments.value = await useEntities("createddocuments").select("*")
|
createddocuments.value = await useEntities("createddocuments").select("*")
|
||||||
projects.value = await useEntities("projects").select("*")
|
projects.value = await useEntities("projects").select("*")
|
||||||
plants.value = await useEntities("plants").select("*")
|
plants.value = await useEntities("plants").select("*")
|
||||||
@@ -647,7 +664,9 @@ const setDocumentTypeConfig = (withTexts = false) => {
|
|||||||
//itemInfo.value.endText = texttemplates.value.find(i => i.documentType === itemInfo.value.type && i.default && i.pos === "endText").text
|
//itemInfo.value.endText = texttemplates.value.find(i => i.documentType === itemInfo.value.type && i.default && i.pos === "endText").text
|
||||||
}
|
}
|
||||||
|
|
||||||
itemInfo.value.letterhead = letterheads.value[0].id
|
if (!getSelectedLetterhead()) {
|
||||||
|
itemInfo.value.letterhead = letterheads.value[0]?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
if (itemInfo.value.type === "advanceInvoices" && !itemInfo.value.rows.find(i => i.text === "Abschlagszahlung")) {
|
if (itemInfo.value.type === "advanceInvoices" && !itemInfo.value.rows.find(i => i.text === "Abschlagszahlung")) {
|
||||||
|
|
||||||
@@ -1431,15 +1450,15 @@ const getDocumentData = async () => {
|
|||||||
}] : [],
|
}] : [],
|
||||||
...itemInfo.value.plant ? [{
|
...itemInfo.value.plant ? [{
|
||||||
label: "Objekt",
|
label: "Objekt",
|
||||||
content: plants.value.find(i => i.id === itemInfo.value.plant).name,
|
content: getPlantName(itemInfo.value.plant),
|
||||||
}] : [],
|
}] : [],
|
||||||
...itemInfo.value.project ? [{
|
...itemInfo.value.project ? [{
|
||||||
label: "Projekt",
|
label: "Projekt",
|
||||||
content: projects.value.find(i => i.id === itemInfo.value.project).name
|
content: getProjectName(itemInfo.value.project)
|
||||||
}] : [],
|
}] : [],
|
||||||
...itemInfo.value.contract ? [{
|
...itemInfo.value.contract ? [{
|
||||||
label: "Vertrag",
|
label: "Vertrag",
|
||||||
content: contracts.value.find(i => i.id === itemInfo.value.contract).contractNumber
|
content: getContractNumber(itemInfo.value.contract)
|
||||||
}] : []
|
}] : []
|
||||||
],
|
],
|
||||||
title: itemInfo.value.title,
|
title: itemInfo.value.title,
|
||||||
@@ -1494,7 +1513,14 @@ const showDocument = ref(false)
|
|||||||
const uri = ref("")
|
const uri = ref("")
|
||||||
const generateDocument = async () => {
|
const generateDocument = async () => {
|
||||||
showDocument.value = false
|
showDocument.value = false
|
||||||
const path = letterheads.value.find(i => i.id === itemInfo.value.letterhead).path
|
const selectedLetterhead = getSelectedLetterhead()
|
||||||
|
|
||||||
|
if (!selectedLetterhead?.path) {
|
||||||
|
toast.add({ title: "Briefpapier fehlt", color: "error" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = selectedLetterhead.path
|
||||||
|
|
||||||
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument")
|
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument")
|
||||||
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
|
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
|
||||||
@@ -1984,7 +2010,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
:color="itemInfo.letterhead ? 'primary' : 'error'"
|
:color="itemInfo.letterhead ? 'primary' : 'error'"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
{{ itemInfo.letterhead ? letterheads.find(i => i.id === itemInfo.letterhead).name : "Kein Briefpapier gewählt" }}
|
{{ itemInfo.letterhead ? getLetterheadName(itemInfo.letterhead) : "Kein Briefpapier gewählt" }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -2077,7 +2103,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField
|
<UFormField
|
||||||
label="Ansprechpartner:"
|
label="Ansprechpartner:"
|
||||||
v-if="itemInfo.customer ? customers.find(i => i.id === itemInfo.customer).isCompany : false "
|
v-if="selectedCustomer?.isCompany"
|
||||||
>
|
>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
@@ -2093,7 +2119,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<span
|
<span
|
||||||
class="truncate">{{ itemInfo.contact ? contacts.find(i => i.id === itemInfo.contact).fullName : "Kein Kontakt ausgewählt" }}</span>
|
class="truncate">{{ itemInfo.contact ? getContactName(itemInfo.contact) : "Kein Kontakt ausgewählt" }}</span>
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
<!-- <UButton
|
<!-- <UButton
|
||||||
@@ -2124,14 +2150,14 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
>
|
>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="itemInfo.address.street"
|
v-model="itemInfo.address.street"
|
||||||
:placeholder="itemInfo.customer ? customers.find(i => i.id === itemInfo.customer).infoData.street : 'Straße + Hausnummer'"
|
:placeholder="selectedCustomer?.infoData?.street || 'Straße + Hausnummer'"
|
||||||
:color="itemInfo.address.street ? 'primary' : 'error'"
|
:color="itemInfo.address.street ? 'primary' : 'error'"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="itemInfo.address.special"
|
v-model="itemInfo.address.special"
|
||||||
class="mt-3 w-full"
|
class="mt-3 w-full"
|
||||||
:placeholder="itemInfo.customer ? customers.find(i => i.id === itemInfo.customer).infoData.special : 'Adresszusatz'"
|
:placeholder="selectedCustomer?.infoData?.special || 'Adresszusatz'"
|
||||||
/>
|
/>
|
||||||
<InputGroup class="mt-3 w-full">
|
<InputGroup class="mt-3 w-full">
|
||||||
<UInput
|
<UInput
|
||||||
@@ -2142,13 +2168,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
maxlength="5"
|
maxlength="5"
|
||||||
@input="sanitizeAddressZipInput"
|
@input="sanitizeAddressZipInput"
|
||||||
@change="checkAddressZip"
|
@change="checkAddressZip"
|
||||||
:placeholder="itemInfo.customer ? customers.find(i => i.id === itemInfo.customer).infoData.zip : 'PLZ'"
|
:placeholder="selectedCustomer?.infoData?.zip || 'PLZ'"
|
||||||
:color="itemInfo.address.zip ? 'primary' : 'error'"
|
:color="itemInfo.address.zip ? 'primary' : 'error'"
|
||||||
/>
|
/>
|
||||||
<UInput
|
<UInput
|
||||||
class="w-full min-w-0"
|
class="w-full min-w-0"
|
||||||
v-model="itemInfo.address.city"
|
v-model="itemInfo.address.city"
|
||||||
:placeholder="itemInfo.customer ? customers.find(i => i.id === itemInfo.customer).infoData.city : 'Ort'"
|
:placeholder="selectedCustomer?.infoData?.city || 'Ort'"
|
||||||
:color="itemInfo.address.city ? 'primary' : 'error'"
|
:color="itemInfo.address.city ? 'primary' : 'error'"
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
@@ -2422,7 +2448,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
:disabled="!itemInfo.customer"
|
:disabled="!itemInfo.customer"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
{{ plants.find(i => i.id === itemInfo.plant) ? plants.find(i => i.id === itemInfo.plant).name : "Kein Objekt ausgewählt" }}
|
{{ itemInfo.plant ? getPlantName(itemInfo.plant) : "Kein Objekt ausgewählt" }}
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ item: plant }">
|
<template #item="{ item: plant }">
|
||||||
{{ plant.name }}
|
{{ plant.name }}
|
||||||
@@ -2459,7 +2485,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
:disabled="!itemInfo.customer"
|
:disabled="!itemInfo.customer"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
{{ itemInfo.project ? projects.find(i => i.id === itemInfo.project).name : "Kein Projekt ausgewählt" }}
|
{{ itemInfo.project ? getProjectName(itemInfo.project) : "Kein Projekt ausgewählt" }}
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ item: project }">
|
<template #item="{ item: project }">
|
||||||
{{ customers.find(i => i.id === project.customer) ? customers.find(i => i.id === project.customer).name : "" }}
|
{{ customers.find(i => i.id === project.customer) ? customers.find(i => i.id === project.customer).name : "" }}
|
||||||
@@ -2497,7 +2523,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
:disabled="!itemInfo.customer"
|
:disabled="!itemInfo.customer"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
{{ itemInfo.contract ? contracts.find(i => i.id === itemInfo.contract).name : "Kein Vertrag ausgewählt" }}
|
{{ itemInfo.contract ? getContractName(itemInfo.contract) : "Kein Vertrag ausgewählt" }}
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ item: contract }">
|
<template #item="{ item: contract }">
|
||||||
{{ customers.find(i => i.id === contract.customer) ? customers.find(i => i.id === contract.customer).name : "" }}
|
{{ customers.find(i => i.id === contract.customer) ? customers.find(i => i.id === contract.customer).name : "" }}
|
||||||
|
|||||||
321
frontend/pages/settings/letterheads.vue
Normal file
321
frontend/pages/settings/letterheads.vue
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const { select, create, update } = useEntities("letterheads")
|
||||||
|
|
||||||
|
const letterheads = ref([])
|
||||||
|
const files = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const selectedFile = ref(null)
|
||||||
|
const itemInfo = ref({})
|
||||||
|
|
||||||
|
const documentTypeOptions = computed(() =>
|
||||||
|
Object.entries(dataStore.documentTypesForCreation || {})
|
||||||
|
.filter(([key]) => key !== "serialInvoices")
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: value.label || value.labelSingle || key
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDocumentTypeLabel = (type) =>
|
||||||
|
dataStore.documentTypesForCreation?.[type]?.label || type
|
||||||
|
|
||||||
|
const normalizePath = (path) =>
|
||||||
|
String(path || "").trim().replace(/^\/+/, "")
|
||||||
|
|
||||||
|
const getFileForLetterhead = (letterhead) => {
|
||||||
|
const letterheadPath = normalizePath(letterhead?.path)
|
||||||
|
if (!letterheadPath) return null
|
||||||
|
|
||||||
|
return files.value.find((file) => normalizePath(file.path) === letterheadPath) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileNameForLetterhead = (letterhead) => {
|
||||||
|
const file = getFileForLetterhead(letterhead)
|
||||||
|
if (file?.name) return file.name
|
||||||
|
|
||||||
|
const path = normalizePath(letterhead?.path)
|
||||||
|
return path ? decodeURIComponent(path.split("/").pop()) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedLetterheads = computed(() =>
|
||||||
|
[...letterheads.value].sort((a, b) => {
|
||||||
|
const aAll = !a.documentTypes?.length
|
||||||
|
const bAll = !b.documentTypes?.length
|
||||||
|
if (aAll !== bAll) return aAll ? -1 : 1
|
||||||
|
return String(a.name || "").localeCompare(String(b.name || ""), "de")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [loadedLetterheads, loadedFiles] = await Promise.all([
|
||||||
|
select("*", "name", true),
|
||||||
|
useEntities("files").select()
|
||||||
|
])
|
||||||
|
|
||||||
|
letterheads.value = loadedLetterheads
|
||||||
|
files.value = loadedFiles
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
title: "Briefpapiere konnten nicht geladen werden",
|
||||||
|
description: error?.message,
|
||||||
|
color: "error"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
itemInfo.value = {
|
||||||
|
name: "",
|
||||||
|
documentTypes: []
|
||||||
|
}
|
||||||
|
selectedFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
resetForm()
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (letterhead) => {
|
||||||
|
itemInfo.value = {
|
||||||
|
...JSON.parse(JSON.stringify(letterhead)),
|
||||||
|
documentTypes: Array.isArray(letterhead.documentTypes) ? [...letterhead.documentTypes] : []
|
||||||
|
}
|
||||||
|
selectedFile.value = null
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileChange = (event) => {
|
||||||
|
selectedFile.value = event.target.files?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadLetterheadPdf = async () => {
|
||||||
|
if (!selectedFile.value) return null
|
||||||
|
|
||||||
|
if (selectedFile.value.type !== "application/pdf") {
|
||||||
|
throw new Error("Bitte wähle eine PDF-Datei aus.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", selectedFile.value)
|
||||||
|
formData.append("meta", JSON.stringify({ filename: selectedFile.value.name }))
|
||||||
|
|
||||||
|
return await useNuxtApp().$api("/api/files/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLetterhead = async () => {
|
||||||
|
if (!itemInfo.value.name?.trim()) {
|
||||||
|
toast.add({ title: "Name fehlt", description: "Bitte gib eine Bezeichnung ein.", color: "warning" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemInfo.value.id && !selectedFile.value) {
|
||||||
|
toast.add({ title: "PDF fehlt", description: "Bitte lade ein Briefpapier als PDF hoch.", color: "warning" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadLetterheadPdf()
|
||||||
|
const payload = {
|
||||||
|
name: itemInfo.value.name.trim(),
|
||||||
|
documentTypes: Array.isArray(itemInfo.value.documentTypes) ? itemInfo.value.documentTypes : [],
|
||||||
|
path: uploaded?.path || itemInfo.value.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemInfo.value.id) {
|
||||||
|
await update(itemInfo.value.id, payload, true)
|
||||||
|
} else {
|
||||||
|
await create(payload, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
modalOpen.value = false
|
||||||
|
await refreshData()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
title: "Briefpapier konnte nicht gespeichert werden",
|
||||||
|
description: error?.message,
|
||||||
|
color: "error"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveLetterhead = async (letterhead) => {
|
||||||
|
await update(letterhead.id, { archived: true }, true)
|
||||||
|
await refreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadLetterhead = async (letterhead) => {
|
||||||
|
const file = getFileForLetterhead(letterhead)
|
||||||
|
if (!file?.id) {
|
||||||
|
toast.add({
|
||||||
|
title: "Datei nicht gefunden",
|
||||||
|
description: "Für dieses Briefpapier konnte keine Datei zugeordnet werden.",
|
||||||
|
color: "warning"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await useFiles().downloadFile(file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDashboardNavbar title="Briefpapiere">
|
||||||
|
<template #right>
|
||||||
|
<UButton icon="i-heroicons-plus" @click="openCreateModal">
|
||||||
|
Briefpapier
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
<UDashboardPanelContent>
|
||||||
|
<UTable
|
||||||
|
:data="sortedLetterheads"
|
||||||
|
:loading="loading"
|
||||||
|
:columns="normalizeTableColumns([
|
||||||
|
{ key: 'name', label: 'Bezeichnung' },
|
||||||
|
{ key: 'documentTypes', label: 'Verwendung' },
|
||||||
|
{ key: 'path', label: 'Datei' },
|
||||||
|
{ key: 'actions', label: '' }
|
||||||
|
])"
|
||||||
|
:empty="{ icon: 'i-heroicons-document', label: 'Keine Briefpapiere gefunden' }"
|
||||||
|
>
|
||||||
|
<template #name-cell="{ row }">
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ row.original.name || "Briefpapier" }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #documentTypes-cell="{ row }">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<UBadge v-if="!row.original.documentTypes?.length" color="primary" variant="soft">
|
||||||
|
Alle Dokumente
|
||||||
|
</UBadge>
|
||||||
|
<template v-else>
|
||||||
|
<UBadge
|
||||||
|
v-for="type in row.original.documentTypes"
|
||||||
|
:key="type"
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
>
|
||||||
|
{{ getDocumentTypeLabel(type) }}
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #path-cell="{ row }">
|
||||||
|
<UButton
|
||||||
|
v-if="getFileForLetterhead(row.original)"
|
||||||
|
icon="i-heroicons-arrow-down-tray"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
@click.stop="downloadLetterhead(row.original)"
|
||||||
|
>
|
||||||
|
{{ getFileNameForLetterhead(row.original) || "PDF" }}
|
||||||
|
</UButton>
|
||||||
|
<span v-else-if="getFileNameForLetterhead(row.original)" class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ getFileNameForLetterhead(row.original) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-gray-500">Keine Datei hinterlegt</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-cell="{ row }">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-pencil-square"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
@click="openEditModal(row.original)"
|
||||||
|
/>
|
||||||
|
<ButtonWithConfirm
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
@confirmed="archiveLetterhead(row.original)"
|
||||||
|
>
|
||||||
|
<template #button>Archivieren</template>
|
||||||
|
<template #header>
|
||||||
|
<span class="font-bold">Briefpapier archivieren?</span>
|
||||||
|
</template>
|
||||||
|
Das Briefpapier "{{ row.original.name }}" wird archiviert.
|
||||||
|
</ButtonWithConfirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
|
<UModal v-model:open="modalOpen" :ui="{ width: 'sm:max-w-2xl' }">
|
||||||
|
<template #content>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
{{ itemInfo.id ? "Briefpapier bearbeiten" : "Briefpapier erstellen" }}
|
||||||
|
</h3>
|
||||||
|
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="modalOpen = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UFormField label="Bezeichnung" required>
|
||||||
|
<UInput v-model="itemInfo.name" icon="i-heroicons-tag" placeholder="z.B. Standard Briefpapier" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Verwendung">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="itemInfo.documentTypes"
|
||||||
|
:options="documentTypeOptions"
|
||||||
|
multiple
|
||||||
|
option-attribute="label"
|
||||||
|
value-attribute="key"
|
||||||
|
placeholder="Alle Dokumente"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField :label="itemInfo.id ? 'PDF ersetzen' : 'PDF hochladen'" :required="!itemInfo.id">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
class="block w-full text-sm text-gray-700 file:mr-4 file:rounded-md file:border-0 file:bg-primary-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-200 dark:file:bg-primary-950 dark:file:text-primary-300"
|
||||||
|
@change="onFileChange"
|
||||||
|
>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div v-if="itemInfo.path" class="text-sm text-gray-500">
|
||||||
|
Aktuelle Datei: {{ itemInfo.path.split("/").pop() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton color="gray" variant="ghost" @click="modalOpen = false">
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton :loading="saving" icon="i-heroicons-check" @click="saveLetterhead">
|
||||||
|
Speichern
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -3298,6 +3298,11 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
label: "Textvorlagen",
|
label: "Textvorlagen",
|
||||||
labelSingle: "Textvorlage"
|
labelSingle: "Textvorlage"
|
||||||
},
|
},
|
||||||
|
letterheads: {
|
||||||
|
isArchivable: true,
|
||||||
|
label: "Briefpapiere",
|
||||||
|
labelSingle: "Briefpapier"
|
||||||
|
},
|
||||||
bankstatements: {
|
bankstatements: {
|
||||||
isArchivable: true,
|
isArchivable: true,
|
||||||
label: "Kontobewegungen",
|
label: "Kontobewegungen",
|
||||||
|
|||||||
Reference in New Issue
Block a user