Compare commits
13 Commits
4e3f1e0c15
...
d8273e794d
| Author | SHA1 | Date | |
|---|---|---|---|
| d8273e794d | |||
| 1711f975bb | |||
| 31ef408b66 | |||
| 5f66f81ade | |||
| b3067e0e00 | |||
| e30722995e | |||
| bbef553e6b | |||
| 53632d25ac | |||
| 2eaa49bf05 | |||
| 14026181c2 | |||
| d67da7e02a | |||
| 3b3a879b02 | |||
| 365cec206d |
@@ -12,6 +12,7 @@
|
|||||||
"start": "node dist/src/index.js",
|
"start": "node dist/src/index.js",
|
||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
|
"invoices:delete-range": "tsx scripts/delete-createddocument-range.ts",
|
||||||
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
||||||
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
||||||
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||||
|
|||||||
359
backend/scripts/delete-createddocument-range.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import { Pool, type PoolClient } from "pg"
|
||||||
|
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
tenantId?: number
|
||||||
|
fromNumber?: string
|
||||||
|
toNumber?: string
|
||||||
|
fromId?: number
|
||||||
|
toId?: number
|
||||||
|
types: string[]
|
||||||
|
execute: boolean
|
||||||
|
unlinkRelated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TargetDocument = {
|
||||||
|
id: number
|
||||||
|
documentNumber: string | null
|
||||||
|
type: string
|
||||||
|
title: string | null
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TYPES = ["invoices"]
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Löscht einen Bereich von Ausgangsbelegen und deren direkt verknüpfte DB-Dokumente.
|
||||||
|
|
||||||
|
Standardmäßig läuft das Skript als Dry-Run. Erst --execute löscht wirklich.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010
|
||||||
|
npm run invoices:delete-range -- --tenant 41 --from-id 1200 --to-id 1210 --execute
|
||||||
|
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 --type invoices --type advanceInvoices --execute
|
||||||
|
|
||||||
|
Optionen:
|
||||||
|
--tenant <id> Pflicht: Tenant-ID
|
||||||
|
--from <nummer> Erste Rechnungsnummer im Bereich
|
||||||
|
--to <nummer> Letzte Rechnungsnummer im Bereich
|
||||||
|
--from-id <id> Erste Beleg-ID im Bereich
|
||||||
|
--to-id <id> Letzte Beleg-ID im Bereich
|
||||||
|
--type <type> Belegtyp, mehrfach möglich. Standard: invoices
|
||||||
|
--execute Wirklich löschen. Ohne diese Option nur Vorschau.
|
||||||
|
--unlink-related Externe Belege, die auf Zielbelege zeigen, werden entkoppelt.
|
||||||
|
--help Hilfe anzeigen
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValue(args: string[], index: number, flag: string) {
|
||||||
|
const value = args[index + 1]
|
||||||
|
if (!value || value.startsWith("--")) {
|
||||||
|
throw new Error(`Für ${flag} fehlt ein Wert.`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(args: string[], index: number, flag: string) {
|
||||||
|
const value = Number(readValue(args, index, flag))
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${flag} muss eine positive Ganzzahl sein.`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Args {
|
||||||
|
const parsed: Args = {
|
||||||
|
types: [],
|
||||||
|
execute: false,
|
||||||
|
unlinkRelated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]
|
||||||
|
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
printUsage()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--tenant") {
|
||||||
|
parsed.tenantId = readNumber(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--from") {
|
||||||
|
parsed.fromNumber = readValue(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--to") {
|
||||||
|
parsed.toNumber = readValue(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--from-id") {
|
||||||
|
parsed.fromId = readNumber(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--to-id") {
|
||||||
|
parsed.toId = readNumber(argv, i, arg)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--type") {
|
||||||
|
parsed.types.push(readValue(argv, i, arg))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--execute") {
|
||||||
|
parsed.execute = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--unlink-related") {
|
||||||
|
parsed.unlinkRelated = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unbekannte Option: ${arg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.tenantId) throw new Error("--tenant ist Pflicht.")
|
||||||
|
|
||||||
|
const hasNumberRange = parsed.fromNumber !== undefined || parsed.toNumber !== undefined
|
||||||
|
const hasIdRange = parsed.fromId !== undefined || parsed.toId !== undefined
|
||||||
|
|
||||||
|
if (hasNumberRange && hasIdRange) {
|
||||||
|
throw new Error("Bitte entweder Rechnungsnummernbereich oder ID-Bereich verwenden, nicht beides.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNumberRange && (!parsed.fromNumber || !parsed.toNumber)) {
|
||||||
|
throw new Error("Für einen Rechnungsnummernbereich sind --from und --to Pflicht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIdRange && (!parsed.fromId || !parsed.toId)) {
|
||||||
|
throw new Error("Für einen ID-Bereich sind --from-id und --to-id Pflicht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNumberRange && !hasIdRange) {
|
||||||
|
throw new Error("Bitte einen Bereich mit --from/--to oder --from-id/--to-id angeben.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.fromNumber && parsed.toNumber && parsed.fromNumber > parsed.toNumber) {
|
||||||
|
throw new Error("--from darf bei Textvergleich nicht größer als --to sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.fromId && parsed.toId && parsed.fromId > parsed.toId) {
|
||||||
|
throw new Error("--from-id darf nicht größer als --to-id sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.types.length === 0) parsed.types = DEFAULT_TYPES
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConnectionString() {
|
||||||
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||||
|
|
||||||
|
await loadSecrets()
|
||||||
|
if (secrets.DATABASE_URL) return secrets.DATABASE_URL
|
||||||
|
|
||||||
|
throw new Error("DATABASE_URL ist nicht konfiguriert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetWhere(args: Args, startParam = 1) {
|
||||||
|
const values: unknown[] = [args.tenantId, args.types]
|
||||||
|
const clauses = [
|
||||||
|
`"tenant" = $${startParam}`,
|
||||||
|
`"type" = ANY($${startParam + 1}::text[])`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (args.fromNumber && args.toNumber) {
|
||||||
|
values.push(args.fromNumber, args.toNumber)
|
||||||
|
clauses.push(`"documentNumber" >= $${startParam + 2}`)
|
||||||
|
clauses.push(`"documentNumber" <= $${startParam + 3}`)
|
||||||
|
} else {
|
||||||
|
values.push(args.fromId, args.toId)
|
||||||
|
clauses.push(`"id" >= $${startParam + 2}`)
|
||||||
|
clauses.push(`"id" <= $${startParam + 3}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
whereSql: clauses.join(" AND "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTargetDocuments(client: PoolClient, args: Args) {
|
||||||
|
const target = buildTargetWhere(args)
|
||||||
|
const result = await client.query<TargetDocument>(
|
||||||
|
`
|
||||||
|
SELECT "id", "documentNumber", "type", "title", "state"
|
||||||
|
FROM "createddocuments"
|
||||||
|
WHERE ${target.whereSql}
|
||||||
|
ORDER BY "id"
|
||||||
|
`,
|
||||||
|
target.values
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
|
||||||
|
const result = await client.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::int AS "count" FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
return Number(result.rows[0]?.count || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findExternalLinkedDocuments(client: PoolClient, targetIds: number[]) {
|
||||||
|
const result = await client.query<TargetDocument>(
|
||||||
|
`
|
||||||
|
SELECT "id", "documentNumber", "type", "title", "state"
|
||||||
|
FROM "createddocuments"
|
||||||
|
WHERE "linkedDocument" = ANY($1::bigint[])
|
||||||
|
AND NOT ("id" = ANY($1::bigint[]))
|
||||||
|
ORDER BY "id"
|
||||||
|
`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
|
||||||
|
const result = await client.query(
|
||||||
|
`DELETE FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rowCount || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTargets(targets: TargetDocument[]) {
|
||||||
|
console.table(
|
||||||
|
targets.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
nummer: item.documentNumber,
|
||||||
|
typ: item.type,
|
||||||
|
status: item.state,
|
||||||
|
titel: item.title,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
const connectionString = await loadConnectionString()
|
||||||
|
const pool = new Pool({ connectionString, max: 1 })
|
||||||
|
const client = await pool.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN")
|
||||||
|
|
||||||
|
const targets = await selectTargetDocuments(client, args)
|
||||||
|
if (targets.length === 0) {
|
||||||
|
console.log("Keine passenden Rechnungen gefunden.")
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIds = targets.map((item) => item.id)
|
||||||
|
const [fileCount, historyCount, allocationCount, externalLinks] = await Promise.all([
|
||||||
|
countByTarget(client, "files", "createddocument", targetIds),
|
||||||
|
countByTarget(client, "historyitems", "createddocument", targetIds),
|
||||||
|
countByTarget(client, "statementallocations", "cd_id", targetIds),
|
||||||
|
findExternalLinkedDocuments(client, targetIds),
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`Tenant: ${args.tenantId}`)
|
||||||
|
console.log(`Belegtypen: ${args.types.join(", ")}`)
|
||||||
|
console.log(`Modus: ${args.execute ? "Löschen" : "Dry-Run"}`)
|
||||||
|
console.log("")
|
||||||
|
printTargets(targets)
|
||||||
|
console.log("")
|
||||||
|
console.log("Betroffene verknüpfte DB-Zeilen:")
|
||||||
|
console.log(`- Dateien: ${fileCount}`)
|
||||||
|
console.log(`- Historie: ${historyCount}`)
|
||||||
|
console.log(`- Bank-Zuordnungen: ${allocationCount}`)
|
||||||
|
|
||||||
|
if (externalLinks.length > 0 && !args.unlinkRelated) {
|
||||||
|
console.log("")
|
||||||
|
console.log("Abbruch: Andere Belege verweisen auf Zielbelege.")
|
||||||
|
console.log("Nutze --unlink-related, wenn diese Verknüpfungen vor dem Löschen entfernt werden sollen.")
|
||||||
|
printTargets(externalLinks)
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.execute) {
|
||||||
|
console.log("")
|
||||||
|
console.log("Dry-Run abgeschlossen. Es wurde nichts gelöscht. Zum Löschen --execute ergänzen.")
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalLinks.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE "createddocuments"
|
||||||
|
SET "linkedDocument" = NULL
|
||||||
|
WHERE "linkedDocument" = ANY($1::bigint[])
|
||||||
|
AND NOT ("id" = ANY($1::bigint[]))
|
||||||
|
`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "createddocuments" SET "linkedDocument" = NULL WHERE "id" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deletedFiles = await deleteByTarget(client, "files", "createddocument", targetIds)
|
||||||
|
const deletedHistory = await deleteByTarget(client, "historyitems", "createddocument", targetIds)
|
||||||
|
const deletedAllocations = await deleteByTarget(client, "statementallocations", "cd_id", targetIds)
|
||||||
|
|
||||||
|
const deletedDocuments = await client.query(
|
||||||
|
`DELETE FROM "createddocuments" WHERE "id" = ANY($1::bigint[])`,
|
||||||
|
[targetIds]
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.query("COMMIT")
|
||||||
|
|
||||||
|
console.log("")
|
||||||
|
console.log("Löschung abgeschlossen:")
|
||||||
|
console.log(`- Rechnungen: ${deletedDocuments.rowCount || 0}`)
|
||||||
|
console.log(`- Dateien: ${deletedFiles}`)
|
||||||
|
console.log(`- Historie: ${deletedHistory}`)
|
||||||
|
console.log(`- Bank-Zuordnungen: ${deletedAllocations}`)
|
||||||
|
if (externalLinks.length > 0) {
|
||||||
|
console.log(`- Entkoppelte externe Belege: ${externalLinks.length}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Fehler beim Löschen der Rechnungen:")
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -351,6 +351,10 @@ const normalizeCreatedDocumentRow = (row) => {
|
|||||||
const normalizeCreatedDocumentRows = (rows) => Array.isArray(rows)
|
const normalizeCreatedDocumentRows = (rows) => Array.isArray(rows)
|
||||||
? rows.map((row) => normalizeCreatedDocumentRow(row))
|
? rows.map((row) => normalizeCreatedDocumentRow(row))
|
||||||
: []
|
: []
|
||||||
|
const isImportedAdvanceInvoiceRow = (row) => {
|
||||||
|
return Array.isArray(row.linkedEntitys)
|
||||||
|
&& row.linkedEntitys.some(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")
|
||||||
|
}
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
|
|
||||||
await setupData()
|
await setupData()
|
||||||
@@ -507,6 +511,8 @@ const setupPage = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for await (const doc of linkedDocuments.filter(i => i.type === "advanceInvoices")) {
|
for await (const doc of linkedDocuments.filter(i => i.type === "advanceInvoices")) {
|
||||||
|
const advanceInvoiceNet = useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1
|
||||||
|
|
||||||
itemInfo.value.rows.push({
|
itemInfo.value.rows.push({
|
||||||
mode: "free",
|
mode: "free",
|
||||||
text: `Abschlagsrechnung ${doc.documentNumber}`,
|
text: `Abschlagsrechnung ${doc.documentNumber}`,
|
||||||
@@ -514,7 +520,8 @@ const setupPage = async () => {
|
|||||||
taxPercent: 19, // TODO TAX PERCENTAGE
|
taxPercent: 19, // TODO TAX PERCENTAGE
|
||||||
discountPercent: 0,
|
discountPercent: 0,
|
||||||
unit: 10,
|
unit: 10,
|
||||||
inputPrice: useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1,
|
inputPrice: advanceInvoiceNet,
|
||||||
|
price: advanceInvoiceNet,
|
||||||
linkedEntitys: [
|
linkedEntitys: [
|
||||||
{
|
{
|
||||||
type: "createddocuments",
|
type: "createddocuments",
|
||||||
@@ -819,21 +826,18 @@ const getRowAmountUndiscounted = (row) => {
|
|||||||
return String(Number(Number(row.quantity) * Number(row.price)).toFixed(2)).replace('.', ',')
|
return String(Number(Number(row.quantity) * Number(row.price)).toFixed(2)).replace('.', ',')
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPosition = (mode) => {
|
const positionAddOptions = [
|
||||||
|
{ mode: 'service', label: 'Leistung', icon: 'i-heroicons-wrench-screwdriver' },
|
||||||
let lastId = 0
|
{ mode: 'normal', label: 'Artikel', icon: 'i-heroicons-cube' },
|
||||||
itemInfo.value.rows.forEach(row => {
|
{ mode: 'free', label: 'Freie Position', icon: 'i-heroicons-pencil-square' },
|
||||||
if (row.id > lastId) lastId = row.id
|
{ mode: 'pagebreak', label: 'Seitenumbruch', icon: 'i-heroicons-document-minus' },
|
||||||
})
|
{ mode: 'title', label: 'Titel', icon: 'i-heroicons-bars-3-bottom-left' },
|
||||||
|
{ mode: 'text', label: 'Text', icon: 'i-heroicons-document-text' }
|
||||||
let taxPercentage = 19
|
]
|
||||||
|
|
||||||
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
|
|
||||||
taxPercentage = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const createPositionRow = (mode, taxPercentage) => {
|
||||||
if (mode === 'free') {
|
if (mode === 'free') {
|
||||||
let rowData = {
|
const rowData = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
mode: "free",
|
mode: "free",
|
||||||
text: "",
|
text: "",
|
||||||
@@ -848,10 +852,11 @@ const addPosition = (mode) => {
|
|||||||
linkedEntitys: []
|
linkedEntitys: []
|
||||||
}
|
}
|
||||||
|
|
||||||
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
|
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
|
||||||
|
}
|
||||||
|
|
||||||
} else if (mode === 'normal') {
|
if (mode === 'normal') {
|
||||||
itemInfo.value.rows.push({
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
@@ -862,9 +867,11 @@ const addPosition = (mode) => {
|
|||||||
unit: 1,
|
unit: 1,
|
||||||
costCentre: itemInfo.value.costcentre,
|
costCentre: itemInfo.value.costcentre,
|
||||||
linkedEntitys: []
|
linkedEntitys: []
|
||||||
})
|
}
|
||||||
} else if (mode === 'service') {
|
}
|
||||||
let rowData = {
|
|
||||||
|
if (mode === 'service') {
|
||||||
|
const rowData = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
mode: "service",
|
mode: "service",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
@@ -877,26 +884,63 @@ const addPosition = (mode) => {
|
|||||||
linkedEntitys: []
|
linkedEntitys: []
|
||||||
}
|
}
|
||||||
|
|
||||||
//Push Agriculture Holder only if Module is activated
|
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
|
||||||
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
|
}
|
||||||
} else if (mode === "pagebreak") {
|
|
||||||
itemInfo.value.rows.push({
|
if (mode === "pagebreak") {
|
||||||
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
mode: "pagebreak",
|
mode: "pagebreak",
|
||||||
linkedEntitys: []
|
linkedEntitys: []
|
||||||
})
|
}
|
||||||
} else if (mode === "title") {
|
}
|
||||||
itemInfo.value.rows.push({
|
|
||||||
|
if (mode === "title") {
|
||||||
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
mode: "title",
|
mode: "title",
|
||||||
linkedEntitys: []
|
linkedEntitys: []
|
||||||
})
|
}
|
||||||
} else if (mode === "text") {
|
}
|
||||||
itemInfo.value.rows.push({
|
|
||||||
|
if (mode === "text") {
|
||||||
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
mode: "text",
|
mode: "text",
|
||||||
linkedEntitys: []
|
linkedEntitys: []
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPositionAddMenuItems = (row) => [
|
||||||
|
positionAddOptions.map(option => ({
|
||||||
|
...option,
|
||||||
|
onSelect: () => addPosition(option.mode, row?.id)
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
|
||||||
|
const getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8
|
||||||
|
|
||||||
|
const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7
|
||||||
|
|
||||||
|
const addPosition = (mode, afterRowId = null) => {
|
||||||
|
let taxPercentage = 19
|
||||||
|
|
||||||
|
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
|
||||||
|
taxPercentage = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRow = createPositionRow(mode, taxPercentage)
|
||||||
|
if (!newRow) return
|
||||||
|
|
||||||
|
const insertAfterIndex = afterRowId ? itemInfo.value.rows.findIndex(row => row.id === afterRowId) : -1
|
||||||
|
|
||||||
|
if (insertAfterIndex >= 0) {
|
||||||
|
itemInfo.value.rows.splice(insertAfterIndex + 1, 0, newRow)
|
||||||
|
} else {
|
||||||
|
itemInfo.value.rows.push(newRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
setPosNumbers()
|
setPosNumbers()
|
||||||
@@ -1799,15 +1843,15 @@ const checkCompatibilityWithInputPrice = () => {
|
|||||||
|
|
||||||
const updateCustomSurcharge = () => {
|
const updateCustomSurcharge = () => {
|
||||||
itemInfo.value.rows.forEach(row => {
|
itemInfo.value.rows.forEach(row => {
|
||||||
if (!["pagebreak", "title", "text"].includes(row.mode) /*&& !row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*/) {
|
if (!["pagebreak", "title", "text"].includes(row.mode) && isImportedAdvanceInvoiceRow(row)) {
|
||||||
|
row.price = Number(row.inputPrice || 0)
|
||||||
|
} else if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
//setRowData(row)
|
//setRowData(row)
|
||||||
|
|
||||||
row.price = Number((row.inputPrice * (1 + itemInfo.value.customSurchargePercentage / 100)).toFixed(2))
|
row.price = Number((row.inputPrice * (1 + itemInfo.value.customSurchargePercentage / 100)).toFixed(2))
|
||||||
|
|
||||||
|
|
||||||
}/* else if(!["pagebreak","title","text"].includes(row.mode) /!*&& row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*!/) {
|
}
|
||||||
row.price = row.inputPrice
|
|
||||||
}*/
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2790,6 +2834,8 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Rabatt</th>-->
|
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Rabatt</th>-->
|
||||||
<th class="pl-2"></th>
|
<th class="pl-2"></th>
|
||||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Gesamt</th>
|
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Gesamt</th>
|
||||||
|
<th class="pl-2"></th>
|
||||||
|
<th class="pl-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<draggable
|
<draggable
|
||||||
@@ -2810,13 +2856,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="row.mode === 'pagebreak'"
|
v-if="row.mode === 'pagebreak'"
|
||||||
colspan="8"
|
:colspan="getFullWidthPositionColspan()"
|
||||||
>
|
>
|
||||||
<USeparator/>
|
<USeparator/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="row.mode === 'text'"
|
v-if="row.mode === 'text'"
|
||||||
colspan="8"
|
:colspan="getFullWidthPositionColspan()"
|
||||||
>
|
>
|
||||||
<!-- <UInput
|
<!-- <UInput
|
||||||
v-model="row.text"
|
v-model="row.text"
|
||||||
@@ -3289,7 +3335,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="row.linkedEntitys && row.linkedEntitys.find(i => i.type === 'createddocuments' && i.subtype === 'advanceInvoices')"
|
v-if="isImportedAdvanceInvoiceRow(row)"
|
||||||
title="Berechnung des Individuellen Aufschlags für die Zeile gesperrt"
|
title="Berechnung des Individuellen Aufschlags für die Zeile gesperrt"
|
||||||
color="orange"
|
color="orange"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -3423,7 +3469,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="row.mode === 'title'"
|
v-if="row.mode === 'title'"
|
||||||
colspan="7"
|
:colspan="getTitlePositionColspan()"
|
||||||
>
|
>
|
||||||
<UInput
|
<UInput
|
||||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||||
@@ -3432,6 +3478,19 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<UDropdownMenu
|
||||||
|
:items="getPositionAddMenuItems(row)"
|
||||||
|
:content="{ align: 'end' }"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<UButton
|
<UButton
|
||||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||||
@@ -3455,46 +3514,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
|
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<UButton
|
<UButton
|
||||||
@click="addPosition('service')"
|
v-for="option in positionAddOptions"
|
||||||
|
:key="option.mode"
|
||||||
|
@click="addPosition(option.mode)"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||||
>
|
>
|
||||||
+ Leistung
|
+ {{ option.label }}
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="addPosition('normal')"
|
|
||||||
class="mt-3"
|
|
||||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
|
||||||
>
|
|
||||||
+ Artikel
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="addPosition('free')"
|
|
||||||
class="mt-3"
|
|
||||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
|
||||||
>
|
|
||||||
+ Freie Position
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="addPosition('pagebreak')"
|
|
||||||
class="mt-3"
|
|
||||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
|
||||||
>
|
|
||||||
+ Seitenumbruch
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="addPosition('title')"
|
|
||||||
class="mt-3"
|
|
||||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
|
||||||
>
|
|
||||||
+ Titel
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="addPosition('text')"
|
|
||||||
class="mt-3"
|
|
||||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
|
||||||
>
|
|
||||||
+ Text
|
|
||||||
</UButton>
|
</UButton>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "software.federspiel.fedeo",
|
"bundleIdentifier": "software.federspiel.fedeo",
|
||||||
"buildNumber": "5",
|
"buildNumber": "7",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
||||||
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||||
@@ -28,9 +28,8 @@
|
|||||||
"android.permission.ACCESS_FINE_LOCATION"
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
],
|
],
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#69c350",
|
"backgroundColor": "#ffffff",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
@@ -50,7 +49,8 @@
|
|||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"dark": {
|
"dark": {
|
||||||
"backgroundColor": "#0f1f0d"
|
"image": "./assets/images/splash-icon-dark.png",
|
||||||
|
"backgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
import { useAuth } from '@/src/providers/auth-provider';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
export default function IndexScreen() {
|
export default function IndexScreen() {
|
||||||
const { isBootstrapping, token, requiresTenantSelection } = useAuth();
|
const { isBootstrapping, bootstrapError, token, requiresTenantSelection, retryBootstrap, resetLocalSession } = useAuth();
|
||||||
|
|
||||||
if (isBootstrapping) {
|
if (isBootstrapping) {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +15,23 @@ export default function IndexScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bootstrapError) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={styles.title}>Session konnte nicht gestartet werden</Text>
|
||||||
|
<Text style={styles.copy}>{bootstrapError}</Text>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={styles.primaryButton} onPress={retryBootstrap}>
|
||||||
|
<Text style={styles.primaryButtonText}>Erneut versuchen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={resetLocalSession}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Lokale Session löschen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return <Redirect href="/login" />;
|
return <Redirect href="/login" />;
|
||||||
}
|
}
|
||||||
@@ -37,5 +54,43 @@ const styles = StyleSheet.create({
|
|||||||
copy: {
|
copy: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#4b5563',
|
color: '#4b5563',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111827',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
width: '100%',
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#69c350',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 52 KiB |
BIN
mobile/assets/images/splash-icon-dark.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 39 KiB |
980
mobile/package-lock.json
generated
@@ -25,17 +25,18 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"expo": "~54.0.34",
|
"expo": "~54.0.35",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-document-picker": "^14.0.8",
|
"expo-document-picker": "^14.0.8",
|
||||||
"expo-font": "~14.0.11",
|
"expo-file-system": "~19.0.23",
|
||||||
|
"expo-font": "~14.0.12",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.24",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ type RequestOptions = {
|
|||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MatrixStatus = {
|
export type MatrixStatus = {
|
||||||
@@ -314,17 +315,46 @@ async function parseJson(response: Response): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
function createTimeoutSignal(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): { signal: AbortSignal; cleanup: () => void } {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
cleanup: () => clearTimeout(timeout),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const hasJsonBody = options.body !== undefined;
|
const hasJsonBody = options.body !== undefined;
|
||||||
const response = await fetch(buildUrl(path), {
|
const { signal, cleanup } = createTimeoutSignal(options.timeoutMs);
|
||||||
method: options.method || 'GET',
|
let response: Response;
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
try {
|
||||||
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
response = await fetch(buildUrl(path), {
|
||||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
method: options.method || 'GET',
|
||||||
},
|
headers: {
|
||||||
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
|
Accept: 'application/json',
|
||||||
});
|
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||||
|
},
|
||||||
|
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
throw new Error(`Zeitüberschreitung beim Verbinden mit dem FEDEO-Server (${path}).`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await parseJson(response);
|
const payload = await parseJson(response);
|
||||||
|
|
||||||
@@ -340,14 +370,27 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
|
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
|
||||||
const response = await fetch(buildUrl(path), {
|
const { signal, cleanup } = createTimeoutSignal();
|
||||||
method: 'POST',
|
let response: Response;
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
try {
|
||||||
Authorization: `Bearer ${token}`,
|
response = await fetch(buildUrl(path), {
|
||||||
},
|
method: 'POST',
|
||||||
body: formData,
|
headers: {
|
||||||
});
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await parseJson(response);
|
const payload = await parseJson(response);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
|
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
|
||||||
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
|
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
|
||||||
@@ -8,6 +8,7 @@ export type AuthUser = MeResponse['user'];
|
|||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
isBootstrapping: boolean;
|
isBootstrapping: boolean;
|
||||||
|
bootstrapError: string | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
tenants: Tenant[];
|
tenants: Tenant[];
|
||||||
@@ -20,9 +21,12 @@ type AuthContextValue = {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
switchTenant: (tenantId: number) => Promise<void>;
|
switchTenant: (tenantId: number) => Promise<void>;
|
||||||
|
retryBootstrap: () => Promise<void>;
|
||||||
|
resetLocalSession: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
const BOOTSTRAP_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
@@ -33,14 +37,27 @@ function normalizeTenantId(value: number | string | null | undefined): number |
|
|||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => clearTimeout(timeout));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||||
|
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
||||||
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
||||||
const [permissions, setPermissions] = useState<string[]>([]);
|
const [permissions, setPermissions] = useState<string[]>([]);
|
||||||
|
const bootstrapRunRef = useRef(0);
|
||||||
|
|
||||||
const resetSession = useCallback(() => {
|
const resetSession = useCallback(() => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -65,6 +82,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await clearStoredToken();
|
await clearStoredToken();
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setBootstrapError(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
}, [resetSession]);
|
}, [resetSession]);
|
||||||
|
|
||||||
@@ -81,31 +99,59 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [hydrateSession, logout, resetSession, token]);
|
}, [hydrateSession, logout, resetSession, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
const resetLocalSession = useCallback(async () => {
|
||||||
async function bootstrap() {
|
setToken(null);
|
||||||
await hydrateApiBaseUrl();
|
setBootstrapError(null);
|
||||||
const storedToken = await getStoredToken();
|
setIsBootstrapping(false);
|
||||||
|
resetSession();
|
||||||
|
void clearStoredToken().catch(() => undefined);
|
||||||
|
}, [resetSession]);
|
||||||
|
|
||||||
if (!storedToken) {
|
const bootstrap = useCallback(async () => {
|
||||||
setIsBootstrapping(false);
|
const runId = bootstrapRunRef.current + 1;
|
||||||
return;
|
bootstrapRunRef.current = runId;
|
||||||
}
|
setIsBootstrapping(true);
|
||||||
|
setBootstrapError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await hydrateSession(storedToken);
|
await withTimeout(
|
||||||
setToken(storedToken);
|
(async () => {
|
||||||
} catch {
|
await hydrateApiBaseUrl();
|
||||||
await clearStoredToken();
|
const storedToken = await getStoredToken();
|
||||||
|
|
||||||
|
if (!storedToken) {
|
||||||
|
if (bootstrapRunRef.current !== runId) return;
|
||||||
|
setToken(null);
|
||||||
|
resetSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await hydrateSession(storedToken);
|
||||||
|
|
||||||
|
if (bootstrapRunRef.current !== runId) return;
|
||||||
|
setToken(storedToken);
|
||||||
|
})(),
|
||||||
|
BOOTSTRAP_TIMEOUT_MS,
|
||||||
|
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (bootstrapRunRef.current === runId) {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
} finally {
|
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
|
||||||
|
void clearStoredToken().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (bootstrapRunRef.current === runId) {
|
||||||
setIsBootstrapping(false);
|
setIsBootstrapping(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
|
||||||
}, [hydrateSession, resetSession]);
|
}, [hydrateSession, resetSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void bootstrap();
|
||||||
|
}, [bootstrap]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
const nextToken = await loginWithEmailPassword(email, password);
|
const nextToken = await loginWithEmailPassword(email, password);
|
||||||
@@ -140,6 +186,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
|
bootstrapError,
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
tenants,
|
tenants,
|
||||||
@@ -152,10 +199,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
logout,
|
logout,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
switchTenant,
|
switchTenant,
|
||||||
|
retryBootstrap: bootstrap,
|
||||||
|
resetLocalSession,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeTenant,
|
activeTenant,
|
||||||
activeTenantId,
|
activeTenantId,
|
||||||
|
bootstrap,
|
||||||
|
bootstrapError,
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
@@ -163,6 +214,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
profile,
|
profile,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
requiresTenantSelection,
|
requiresTenantSelection,
|
||||||
|
resetLocalSession,
|
||||||
switchTenant,
|
switchTenant,
|
||||||
tenants,
|
tenants,
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ read_interactive() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
docker_engine() {
|
||||||
|
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||||
|
sudo docker "$@"
|
||||||
|
else
|
||||||
|
docker "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
compose() {
|
compose() {
|
||||||
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||||
sudo docker compose "$@"
|
sudo docker compose "$@"
|
||||||
@@ -44,12 +52,27 @@ compose_stack() {
|
|||||||
|
|
||||||
compose_start_command() {
|
compose_start_command() {
|
||||||
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||||
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
|
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
|
||||||
else
|
else
|
||||||
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
|
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove_selfhost_networks() {
|
||||||
|
local network
|
||||||
|
|
||||||
|
for network in fedeo_web fedeo_internal; do
|
||||||
|
if docker_engine network inspect "$network" >/dev/null 2>&1; then
|
||||||
|
if docker_engine network rm "$network" >/dev/null 2>&1; then
|
||||||
|
echo "Docker-Netzwerk entfernt: $network"
|
||||||
|
else
|
||||||
|
echo "Docker-Netzwerk konnte nicht entfernt werden: $network"
|
||||||
|
echo "Prüfe laufende Container mit: docker network inspect $network"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
FEDEO Selfhost Setup
|
FEDEO Selfhost Setup
|
||||||
@@ -461,6 +484,8 @@ uninstall_stack() {
|
|||||||
"$ROOT_DIR/traefik/letsencrypt" \
|
"$ROOT_DIR/traefik/letsencrypt" \
|
||||||
"$ROOT_DIR/traefik/logs"
|
"$ROOT_DIR/traefik/logs"
|
||||||
|
|
||||||
|
remove_selfhost_networks
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Uninstall inklusive lokaler Daten abgeschlossen."
|
echo "Uninstall inklusive lokaler Daten abgeschlossen."
|
||||||
}
|
}
|
||||||
@@ -597,7 +622,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$START_STACK" == "yes" ]]; then
|
if [[ "$START_STACK" == "yes" ]]; then
|
||||||
compose_stack up -d
|
compose_stack up -d --remove-orphans
|
||||||
else
|
else
|
||||||
echo
|
echo
|
||||||
echo "Start später mit:"
|
echo "Start später mit:"
|
||||||
|
|||||||