Compare commits

...

13 Commits

Author SHA1 Message Date
d8273e794d Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m0s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-15 12:49:25 +02:00
1711f975bb KI-AGENT: Dropdown zum Einfügen von Dokumentpositionen ergänzt 2026-07-15 10:15:11 +02:00
31ef408b66 Merge remote-tracking branch 'origin/dev' into dev 2026-07-14 16:33:07 +02:00
5f66f81ade Merge remote-tracking branch 'origin/dev' into dev 2026-07-13 21:22:36 +02:00
b3067e0e00 KI-AGENT: Erhöht iOS-Buildnummer nach Icon-Korrektur 2026-07-09 08:13:25 +02:00
e30722995e KI-AGENT: Ergänzt native Expo-Dateisystem-Abhängigkeit 2026-07-09 07:59:28 +02:00
bbef553e6b KI-AGENT: Erhöht iOS-Buildnummer für TestFlight 2026-07-09 07:57:48 +02:00
53632d25ac KI-AGENT: Aktualisiert Expo-Patch-Versionen 2026-07-09 07:46:23 +02:00
2eaa49bf05 KI-AGENT: Aktualisiert Mobile-App-Icons 2026-07-09 07:41:52 +02:00
14026181c2 KI-AGENT: Stabilisiert mobilen Session-Start 2026-07-09 07:34:44 +02:00
d67da7e02a KI-AGENT: Skript zum Löschen von Rechnungsbereichen ergänzen 2026-07-03 20:56:15 +02:00
3b3a879b02 KI-AGENT: Individuellen Aufschlag bei importierten Abschlagsrechnungen verhindern 2026-06-15 10:41:49 +02:00
365cec206d KI-AGENT: Selfhost-Netzwerke beim Setup bereinigen 2026-06-03 15:15:33 +02:00
17 changed files with 1202 additions and 584 deletions

View File

@@ -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"

View 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)
})

View File

@@ -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>

View File

@@ -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"
} }
} }
], ],

View File

@@ -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',
}, },
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 39 KiB

980
mobile/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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);

View File

@@ -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,

View File

@@ -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:"