Fixes
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
12
.idea/FEDEO.iml
generated
Normal file
12
.idea/FEDEO.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/FEDEO.iml" filepath="$PROJECT_DIR$/.idea/FEDEO.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-bookworm-slim
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
poppler-utils \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-deu \
|
||||||
|
tesseract-ocr-eng \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Package-Dateien
|
# Package-Dateien
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
|||||||
1
backend/db/migrations/0020_file_extracted_text.sql
Normal file
1
backend/db/migrations/0020_file_extracted_text.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "files" ADD COLUMN "extracted_text" text;
|
||||||
10685
backend/db/migrations/meta/0005_snapshot.json
Normal file
10685
backend/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11525
backend/db/migrations/meta/0017_snapshot.json
Normal file
11525
backend/db/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
|||||||
"when": 1773000900000,
|
"when": 1773000900000,
|
||||||
"tag": "0018_account_chart",
|
"tag": "0018_account_chart",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773572400000,
|
||||||
|
"tag": "0020_file_extracted_text",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const files = pgTable("files", {
|
|||||||
documentbox: uuid("documentbox").references(() => documentboxes.id),
|
documentbox: uuid("documentbox").references(() => documentboxes.id),
|
||||||
|
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
|
extractedText: text("extracted_text"),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|||||||
270
backend/scripts/import-members-csv.ts
Normal file
270
backend/scripts/import-members-csv.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db, pool } from "../db"
|
||||||
|
import { customers, entitybankaccounts } from "../db/schema"
|
||||||
|
import { decrypt, encrypt } from "../src/utils/crypt"
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
type CsvMemberRow = {
|
||||||
|
number: string
|
||||||
|
lastname: string
|
||||||
|
firstname: string
|
||||||
|
street: string
|
||||||
|
zip: string
|
||||||
|
city: string
|
||||||
|
birthdate: string
|
||||||
|
mobile: string
|
||||||
|
email: string
|
||||||
|
bankInstitute: string
|
||||||
|
iban: string
|
||||||
|
bic: string
|
||||||
|
date: string
|
||||||
|
memberStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TENANT_ID = 38
|
||||||
|
const DEFAULT_CSV_PATH = "/Users/florianfederspiel/Downloads/Mitglieder Übersicht 2026_1.csv"
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const dryRun = args.includes("--dry-run")
|
||||||
|
const csvArg = args.find((arg) => !arg.startsWith("--"))
|
||||||
|
const csvPath = csvArg || DEFAULT_CSV_PATH
|
||||||
|
|
||||||
|
function normalizeIban(value: string) {
|
||||||
|
return String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGermanDate(value: string): string | null {
|
||||||
|
const v = String(value || "").trim()
|
||||||
|
if (!v) return null
|
||||||
|
|
||||||
|
const m = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/)
|
||||||
|
if (!m) return null
|
||||||
|
|
||||||
|
const day = m[1].padStart(2, "0")
|
||||||
|
const month = m[2].padStart(2, "0")
|
||||||
|
const yy = m[3]
|
||||||
|
const year = yy.length === 4 ? yy : (Number(yy) >= 70 ? `19${yy}` : `20${yy}`)
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolFromStatus(value: string) {
|
||||||
|
const normalized = String(value || "").trim().toLowerCase()
|
||||||
|
return normalized !== "inaktiv"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(content: string): CsvMemberRow[] {
|
||||||
|
const lines = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.length > 0)
|
||||||
|
|
||||||
|
if (!lines.length) return []
|
||||||
|
|
||||||
|
// Header:
|
||||||
|
// Nr;Name;Vorname;Straße, Hausnr.;PLZ;Wohnort;Geburtsdatum;Mobilfunknummer;Private Mail-Adresse;Kreditinstitut;IBAN;BIC;Datum;Mitgliedsstatus
|
||||||
|
const rows: CsvMemberRow[] = []
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = lines[i].split(";").map((v) => v.trim())
|
||||||
|
if (cols.length < 14) continue
|
||||||
|
|
||||||
|
const number = cols[0]
|
||||||
|
const lastname = cols[1]
|
||||||
|
const firstname = cols[2]
|
||||||
|
if (!number || !lastname || !firstname) continue
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
number,
|
||||||
|
lastname,
|
||||||
|
firstname,
|
||||||
|
street: cols[3] || "",
|
||||||
|
zip: cols[4] || "",
|
||||||
|
city: cols[5] || "",
|
||||||
|
birthdate: cols[6] || "",
|
||||||
|
mobile: cols[7] || "",
|
||||||
|
email: cols[8] || "",
|
||||||
|
bankInstitute: cols[9] || "",
|
||||||
|
iban: cols[10] || "",
|
||||||
|
bic: cols[11] || "",
|
||||||
|
date: cols[12] || "",
|
||||||
|
memberStatus: cols[13] || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBankAccountByIban(tenantId: number) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: entitybankaccounts.id,
|
||||||
|
ibanEncrypted: entitybankaccounts.ibanEncrypted,
|
||||||
|
})
|
||||||
|
.from(entitybankaccounts)
|
||||||
|
.where(eq(entitybankaccounts.tenant, tenantId))
|
||||||
|
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const iban = normalizeIban(decrypt(row.ibanEncrypted as any))
|
||||||
|
if (iban) map.set(iban, Number(row.id))
|
||||||
|
} catch {
|
||||||
|
// skip broken ciphertext rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!secrets.ENCRYPTION_KEY && process.env.ENCRYPTION_KEY) {
|
||||||
|
secrets.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secrets.ENCRYPTION_KEY && process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
|
||||||
|
await loadSecrets()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secrets.ENCRYPTION_KEY) {
|
||||||
|
throw new Error("ENCRYPTION_KEY fehlt. Bitte ENCRYPTION_KEY setzen oder Infisical-Zugang (INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET) bereitstellen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteCsvPath = path.resolve(csvPath)
|
||||||
|
if (!fs.existsSync(absoluteCsvPath)) {
|
||||||
|
throw new Error(`CSV nicht gefunden: ${absoluteCsvPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(absoluteCsvPath, "utf8")
|
||||||
|
const csvRows = parseCsv(raw)
|
||||||
|
if (!csvRows.length) {
|
||||||
|
throw new Error("Keine importierbaren Zeilen gefunden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembers = await db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(and(eq(customers.tenant, TENANT_ID), eq(customers.type, "Mitglied")))
|
||||||
|
|
||||||
|
const memberByNumber = new Map(existingMembers.map((m) => [String(m.customerNumber), m]))
|
||||||
|
const bankAccountByIban = await loadBankAccountByIban(TENANT_ID)
|
||||||
|
|
||||||
|
let createdMembers = 0
|
||||||
|
let updatedMembers = 0
|
||||||
|
let createdBankAccounts = 0
|
||||||
|
let skippedNoIban = 0
|
||||||
|
|
||||||
|
for (const row of csvRows) {
|
||||||
|
const iban = normalizeIban(row.iban)
|
||||||
|
if (!iban) {
|
||||||
|
skippedNoIban += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const fullName = `${row.firstname} ${row.lastname}`.trim()
|
||||||
|
const birthdate = parseGermanDate(row.birthdate)
|
||||||
|
const sepaSignedAt = parseGermanDate(row.date)
|
||||||
|
const active = parseBoolFromStatus(row.memberStatus)
|
||||||
|
|
||||||
|
let bankAccountId = bankAccountByIban.get(iban) || null
|
||||||
|
|
||||||
|
if (!bankAccountId) {
|
||||||
|
if (!dryRun) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(entitybankaccounts)
|
||||||
|
.values({
|
||||||
|
tenant: TENANT_ID,
|
||||||
|
ibanEncrypted: encrypt(iban),
|
||||||
|
bicEncrypted: encrypt(row.bic || "UNBEKANNT"),
|
||||||
|
bankNameEncrypted: encrypt(row.bankInstitute || "Unbekannt"),
|
||||||
|
description: "Import Mitglieder Uebersicht 2026_1",
|
||||||
|
})
|
||||||
|
.returning({ id: entitybankaccounts.id })
|
||||||
|
bankAccountId = created?.id || null
|
||||||
|
} else {
|
||||||
|
bankAccountId = -1
|
||||||
|
}
|
||||||
|
if (bankAccountId) {
|
||||||
|
bankAccountByIban.set(iban, bankAccountId)
|
||||||
|
createdBankAccounts += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = memberByNumber.get(String(row.number))
|
||||||
|
const existingInfo = (existing?.infoData && typeof existing.infoData === "object")
|
||||||
|
? { ...(existing.infoData as Record<string, any>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const existingIds = Array.isArray(existingInfo.bankAccountIds) ? existingInfo.bankAccountIds : []
|
||||||
|
const mergedBankAccountIds = bankAccountId && !existingIds.includes(bankAccountId)
|
||||||
|
? [...existingIds, bankAccountId]
|
||||||
|
: existingIds
|
||||||
|
|
||||||
|
const infoData = {
|
||||||
|
...existingInfo,
|
||||||
|
street: row.street || existingInfo.street || "",
|
||||||
|
zip: row.zip || existingInfo.zip || "",
|
||||||
|
city: row.city || existingInfo.city || "",
|
||||||
|
phone: row.mobile || existingInfo.phone || "",
|
||||||
|
email: row.email || existingInfo.email || "",
|
||||||
|
birthdate: birthdate || existingInfo.birthdate || null,
|
||||||
|
hasSEPA: Boolean(sepaSignedAt || existingInfo.sepaSignedAt || existingInfo.hasSEPA),
|
||||||
|
sepaSignedAt: sepaSignedAt || existingInfo.sepaSignedAt || null,
|
||||||
|
bankAccountIds: mergedBankAccountIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tenant: TENANT_ID,
|
||||||
|
customerNumber: String(row.number),
|
||||||
|
type: "Mitglied",
|
||||||
|
isCompany: false,
|
||||||
|
firstname: row.firstname,
|
||||||
|
lastname: row.lastname,
|
||||||
|
name: fullName,
|
||||||
|
active,
|
||||||
|
infoData,
|
||||||
|
archived: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
if (!dryRun) {
|
||||||
|
const [created] = await db.insert(customers).values(payload).returning()
|
||||||
|
if (created) memberByNumber.set(String(row.number), created)
|
||||||
|
}
|
||||||
|
createdMembers += 1
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
|
await db
|
||||||
|
.update(customers)
|
||||||
|
.set({
|
||||||
|
...payload,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(customers.id, existing.id), eq(customers.tenant, TENANT_ID)))
|
||||||
|
}
|
||||||
|
updatedMembers += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("")
|
||||||
|
console.log(`[IMPORT MEMBERS] Tenant: ${TENANT_ID}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] CSV: ${absoluteCsvPath}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] Zeilen: ${csvRows.length}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] Mitglieder erstellt: ${createdMembers}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] Mitglieder aktualisiert: ${updatedMembers}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] Bankkonten erstellt: ${createdBankAccounts}`)
|
||||||
|
console.log(`[IMPORT MEMBERS] Ohne IBAN übersprungen: ${skippedNoIban}`)
|
||||||
|
console.log("")
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[IMPORT MEMBERS] Fehler:", err)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await pool.end()
|
||||||
|
})
|
||||||
265
backend/scripts/import-skr42-accounts.ts
Normal file
265
backend/scripts/import-skr42-accounts.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import zlib from "node:zlib"
|
||||||
|
|
||||||
|
type ParsedAccount = {
|
||||||
|
number: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PDF_PATH = "/Users/florianfederspiel/Downloads/12901_DATEV-Kontenrahmen SKR 42 Vereine, Stiftungen, gGmbH (Bilanz).pdf"
|
||||||
|
const ACCOUNT_CHART = "skr42"
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const dryRun = args.includes("--dry-run")
|
||||||
|
const parseOnly = args.includes("--parse-only")
|
||||||
|
const pdfArg = args.find((arg) => !arg.startsWith("--"))
|
||||||
|
const pdfPath = path.resolve(pdfArg || DEFAULT_PDF_PATH)
|
||||||
|
|
||||||
|
function decodePdfString(raw: string) {
|
||||||
|
let out = ""
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
const ch = raw[i]
|
||||||
|
|
||||||
|
if (ch !== "\\") {
|
||||||
|
out += ch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = raw[i + 1]
|
||||||
|
if (!next) break
|
||||||
|
|
||||||
|
if (next === "n") {
|
||||||
|
out += "\n"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "r") {
|
||||||
|
out += "\r"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "t") {
|
||||||
|
out += "\t"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "b") {
|
||||||
|
out += "\b"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "f") {
|
||||||
|
out += "\f"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "(" || next === ")" || next === "\\") {
|
||||||
|
out += next
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[0-7]/.test(next)) {
|
||||||
|
let oct = next
|
||||||
|
let advance = 1
|
||||||
|
|
||||||
|
for (let j = 2; j <= 3; j += 1) {
|
||||||
|
const c = raw[i + j]
|
||||||
|
if (!c || !/[0-7]/.test(c)) break
|
||||||
|
oct += c
|
||||||
|
advance += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
out += String.fromCharCode(parseInt(oct, 8))
|
||||||
|
i += advance
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out += next
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromTjOperator(segment: string) {
|
||||||
|
const parts = segment.match(/\((?:\\.|[^\\)])*\)/g)
|
||||||
|
if (!parts) return ""
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((p) => decodePdfString(p.slice(1, -1)))
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPdfTextStreams(pdfBuffer: Buffer) {
|
||||||
|
const pdfLatin = pdfBuffer.toString("latin1")
|
||||||
|
const texts: string[] = []
|
||||||
|
|
||||||
|
let cursor = 0
|
||||||
|
while (true) {
|
||||||
|
const streamPos = pdfLatin.indexOf("stream", cursor)
|
||||||
|
if (streamPos < 0) break
|
||||||
|
|
||||||
|
let dataStart = streamPos + 6
|
||||||
|
if (pdfLatin[dataStart] === "\r" && pdfLatin[dataStart + 1] === "\n") {
|
||||||
|
dataStart += 2
|
||||||
|
} else if (pdfLatin[dataStart] === "\n") {
|
||||||
|
dataStart += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamEnd = pdfLatin.indexOf("endstream", dataStart)
|
||||||
|
if (streamEnd < 0) break
|
||||||
|
|
||||||
|
const sliceEnd = streamEnd > dataStart && pdfBuffer[streamEnd - 1] === 0x0d
|
||||||
|
? streamEnd - 1
|
||||||
|
: streamEnd
|
||||||
|
|
||||||
|
const compressed = pdfBuffer.subarray(dataStart, sliceEnd)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inflated = zlib.inflateSync(compressed).toString("latin1")
|
||||||
|
texts.push(inflated)
|
||||||
|
} catch {
|
||||||
|
// ignore non-flate streams
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = streamEnd + 9
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/\s+-\s+/g, "-")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeAccountLabel(value: string) {
|
||||||
|
const letters = (value.match(/[A-Za-zÄÖÜäöüß]/g) || []).length
|
||||||
|
return letters >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccountsFromPdf(pdfBuffer: Buffer): ParsedAccount[] {
|
||||||
|
const streams = extractPdfTextStreams(pdfBuffer)
|
||||||
|
const found = new Map<string, string>()
|
||||||
|
|
||||||
|
const accountPattern = /^\s*([A-Z])?\s*(\d{3,5})\s+0\s+(.+)$/
|
||||||
|
|
||||||
|
for (const stream of streams) {
|
||||||
|
const operators = stream.match(/\[(?:.|\r|\n)*?\]TJ|\((?:\\.|[^\\)])*\)Tj/g)
|
||||||
|
if (!operators) continue
|
||||||
|
|
||||||
|
for (const op of operators) {
|
||||||
|
const text = normalizeLabel(extractTextFromTjOperator(op))
|
||||||
|
if (!text) continue
|
||||||
|
|
||||||
|
const m = text.match(accountPattern)
|
||||||
|
if (m) {
|
||||||
|
const number = m[2]
|
||||||
|
const label = normalizeLabel(m[3])
|
||||||
|
if (!looksLikeAccountLabel(label)) continue
|
||||||
|
|
||||||
|
const existing = found.get(number)
|
||||||
|
if (!existing || label.length > existing.length) {
|
||||||
|
found.set(number, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...found.entries()]
|
||||||
|
.map(([number, label]) => ({ number, label }))
|
||||||
|
.sort((a, b) => Number(a.number) - Number(b.number))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(pdfPath)) {
|
||||||
|
throw new Error(`PDF nicht gefunden: ${pdfPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBuffer = fs.readFileSync(pdfPath)
|
||||||
|
const parsed = parseAccountsFromPdf(pdfBuffer)
|
||||||
|
|
||||||
|
if (!parsed.length) {
|
||||||
|
throw new Error("Keine Konten aus PDF extrahiert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseOnly) {
|
||||||
|
console.log("")
|
||||||
|
console.log(`[SKR42 IMPORT] PDF: ${pdfPath}`)
|
||||||
|
console.log(`[SKR42 IMPORT] Gefundene Konten: ${parsed.length}`)
|
||||||
|
console.log(`[SKR42 IMPORT] Parse-Only: JA`)
|
||||||
|
console.log("")
|
||||||
|
console.log("[SKR42 IMPORT] Beispiel (erste 15):")
|
||||||
|
for (const item of parsed.slice(0, 15)) {
|
||||||
|
console.log(` ${item.number} ${item.label}`)
|
||||||
|
}
|
||||||
|
console.log("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { eq } = await import("drizzle-orm")
|
||||||
|
const { db, pool } = await import("../db")
|
||||||
|
const { accounts } = await import("../db/schema")
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ number: accounts.number })
|
||||||
|
.from(accounts)
|
||||||
|
.where(eq(accounts.accountChart, ACCOUNT_CHART))
|
||||||
|
|
||||||
|
const existingSet = new Set(existing.map((r) => String(r.number)))
|
||||||
|
|
||||||
|
const toInsert = parsed
|
||||||
|
.filter((a) => !existingSet.has(a.number))
|
||||||
|
.map((a) => ({
|
||||||
|
number: a.number,
|
||||||
|
label: a.label,
|
||||||
|
accountChart: ACCOUNT_CHART,
|
||||||
|
description: "DATEV SKR42 Import",
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!dryRun && toInsert.length > 0) {
|
||||||
|
const batchSize = 500
|
||||||
|
for (let i = 0; i < toInsert.length; i += batchSize) {
|
||||||
|
const batch = toInsert.slice(i, i + batchSize)
|
||||||
|
await db.insert(accounts).values(batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("")
|
||||||
|
console.log(`[SKR42 IMPORT] PDF: ${pdfPath}`)
|
||||||
|
console.log(`[SKR42 IMPORT] Gefundene Konten: ${parsed.length}`)
|
||||||
|
console.log(`[SKR42 IMPORT] Bereits vorhanden (skr42): ${existing.length}`)
|
||||||
|
console.log(`[SKR42 IMPORT] Neu einzufuegen: ${toInsert.length}`)
|
||||||
|
console.log(`[SKR42 IMPORT] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
|
||||||
|
console.log("")
|
||||||
|
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
console.log("[SKR42 IMPORT] Beispiel (erste 15):")
|
||||||
|
for (const item of parsed.slice(0, 15)) {
|
||||||
|
console.log(` ${item.number} ${item.label}`)
|
||||||
|
}
|
||||||
|
console.log("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[SKR42 IMPORT] Fehler:", err)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
if (!parseOnly) {
|
||||||
|
const { pool } = await import("../db")
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
BIN
backend/scripts/skr42.pdf
Normal file
BIN
backend/scripts/skr42.pdf
Normal file
Binary file not shown.
@@ -8,108 +8,9 @@ import {
|
|||||||
files,
|
files,
|
||||||
filetags,
|
filetags,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
vendors,
|
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import { eq, and, isNull, not, desc } from "drizzle-orm"
|
import { eq, and, isNull, not } from "drizzle-orm"
|
||||||
|
|
||||||
type InvoiceAccount = {
|
|
||||||
account?: number | null
|
|
||||||
description?: string | null
|
|
||||||
taxType?: string | number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeAccounts = (accounts: unknown): InvoiceAccount[] => {
|
|
||||||
if (!Array.isArray(accounts)) return []
|
|
||||||
return accounts
|
|
||||||
.map((entry: any) => ({
|
|
||||||
account: typeof entry?.account === "number" ? entry.account : null,
|
|
||||||
description: typeof entry?.description === "string" ? entry.description : null,
|
|
||||||
taxType: entry?.taxType ?? null,
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.account !== null || entry.description || entry.taxType !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildLearningContext = (historicalInvoices: any[]) => {
|
|
||||||
if (!historicalInvoices.length) return null
|
|
||||||
|
|
||||||
const vendorProfiles = new Map<number, {
|
|
||||||
vendorName: string
|
|
||||||
paymentTypes: Map<string, number>
|
|
||||||
accountUsage: Map<number, number>
|
|
||||||
sampleDescriptions: string[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const recentExamples: any[] = []
|
|
||||||
|
|
||||||
for (const invoice of historicalInvoices) {
|
|
||||||
const accounts = normalizeAccounts(invoice.accounts)
|
|
||||||
const vendorId = typeof invoice.vendorId === "number" ? invoice.vendorId : null
|
|
||||||
const vendorName = typeof invoice.vendorName === "string" ? invoice.vendorName : "Unknown"
|
|
||||||
|
|
||||||
if (vendorId) {
|
|
||||||
if (!vendorProfiles.has(vendorId)) {
|
|
||||||
vendorProfiles.set(vendorId, {
|
|
||||||
vendorName,
|
|
||||||
paymentTypes: new Map(),
|
|
||||||
accountUsage: new Map(),
|
|
||||||
sampleDescriptions: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = vendorProfiles.get(vendorId)!
|
|
||||||
if (invoice.paymentType) {
|
|
||||||
const key = String(invoice.paymentType)
|
|
||||||
profile.paymentTypes.set(key, (profile.paymentTypes.get(key) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
for (const account of accounts) {
|
|
||||||
if (typeof account.account === "number") {
|
|
||||||
profile.accountUsage.set(account.account, (profile.accountUsage.get(account.account) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (invoice.description && profile.sampleDescriptions.length < 3) {
|
|
||||||
profile.sampleDescriptions.push(String(invoice.description).slice(0, 120))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentExamples.length < 20) {
|
|
||||||
recentExamples.push({
|
|
||||||
vendorId,
|
|
||||||
vendorName,
|
|
||||||
paymentType: invoice.paymentType ?? null,
|
|
||||||
accounts: accounts.map((entry) => ({
|
|
||||||
account: entry.account,
|
|
||||||
description: entry.description ?? null,
|
|
||||||
taxType: entry.taxType ?? null,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendorPatterns = Array.from(vendorProfiles.entries())
|
|
||||||
.map(([vendorId, profile]) => {
|
|
||||||
const commonPaymentType = Array.from(profile.paymentTypes.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
|
|
||||||
const topAccounts = Array.from(profile.accountUsage.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 4)
|
|
||||||
.map(([accountId, count]) => ({ accountId, count }))
|
|
||||||
|
|
||||||
return {
|
|
||||||
vendorId,
|
|
||||||
vendorName: profile.vendorName,
|
|
||||||
commonPaymentType,
|
|
||||||
topAccounts,
|
|
||||||
sampleDescriptions: profile.sampleDescriptions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.slice(0, 50)
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
vendorPatterns,
|
|
||||||
recentExamples,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number) => {
|
||||||
@@ -171,34 +72,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const historicalInvoices = await server.db
|
|
||||||
.select({
|
|
||||||
vendorId: incominginvoices.vendor,
|
|
||||||
vendorName: vendors.name,
|
|
||||||
paymentType: incominginvoices.paymentType,
|
|
||||||
description: incominginvoices.description,
|
|
||||||
accounts: incominginvoices.accounts,
|
|
||||||
})
|
|
||||||
.from(incominginvoices)
|
|
||||||
.leftJoin(vendors, eq(incominginvoices.vendor, vendors.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(incominginvoices.tenant, tenantId),
|
|
||||||
eq(incominginvoices.archived, false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(incominginvoices.createdAt))
|
|
||||||
.limit(120)
|
|
||||||
|
|
||||||
const learningContext = buildLearningContext(historicalInvoices)
|
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
const data = await getInvoiceDataFromGPT(server,file, tenantId, learningContext ?? undefined)
|
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
@@ -214,9 +94,9 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||||
if (data.invoice_date) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||||
if (data.invoice_duedate) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||||
|
|
||||||
// Payment terms mapping
|
// Payment terms mapping
|
||||||
const mapPayment: any = {
|
const mapPayment: any = {
|
||||||
@@ -229,16 +109,26 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
|
|
||||||
// 3.2 Positionszeilen konvertieren
|
// 3.2 Positionszeilen konvertieren
|
||||||
if (data.invoice_items?.length > 0) {
|
if (data.invoice_items?.length > 0) {
|
||||||
itemInfo.accounts = data.invoice_items.map(item => ({
|
itemInfo.accounts = data.invoice_items
|
||||||
account: item.account_id,
|
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
||||||
description: item.description,
|
.map(item => {
|
||||||
amountNet: item.total_without_tax,
|
const total = typeof item.total === "number" ? item.total : null
|
||||||
amountTax: Number((item.total - item.total_without_tax).toFixed(2)),
|
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||||
taxType: String(item.tax_rate),
|
const amountTax = total !== null && totalWithoutTax !== null
|
||||||
amountGross: item.total,
|
? Number((total - totalWithoutTax).toFixed(2))
|
||||||
costCentre: null,
|
: null
|
||||||
quantity: item.quantity,
|
|
||||||
}))
|
return {
|
||||||
|
account: item.account_id,
|
||||||
|
description: item.description,
|
||||||
|
amountNet: totalWithoutTax,
|
||||||
|
amountTax,
|
||||||
|
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||||
|
amountGross: total,
|
||||||
|
costCentre: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3.3 Beschreibung generieren
|
// 3.3 Beschreibung generieren
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { FastifyInstance } from "fastify"
|
|||||||
import multipart from "@fastify/multipart"
|
import multipart from "@fastify/multipart"
|
||||||
import { s3 } from "../utils/s3"
|
import { s3 } from "../utils/s3"
|
||||||
import {
|
import {
|
||||||
GetObjectCommand,
|
GetObjectCommand
|
||||||
PutObjectCommand
|
|
||||||
} from "@aws-sdk/client-s3"
|
} from "@aws-sdk/client-s3"
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
import archiver from "archiver"
|
import archiver from "archiver"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { saveFile } from "../utils/files"
|
||||||
|
|
||||||
import { eq, inArray } from "drizzle-orm"
|
import { eq, inArray } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
@@ -40,39 +40,28 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
const fileBuffer = await data.toBuffer()
|
const fileBuffer = await data.toBuffer()
|
||||||
|
|
||||||
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
|
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
|
||||||
|
const { folder = null, type = null, ...otherMeta } = meta
|
||||||
|
|
||||||
// 1️⃣ DB-Eintrag erzeugen
|
const created = await saveFile(
|
||||||
const inserted = await server.db
|
server,
|
||||||
.insert(files)
|
tenantId,
|
||||||
.values({ tenant: tenantId })
|
null,
|
||||||
.returning()
|
{
|
||||||
|
filename: data.filename,
|
||||||
|
content: fileBuffer,
|
||||||
|
contentType: data.mimetype
|
||||||
|
},
|
||||||
|
folder,
|
||||||
|
type,
|
||||||
|
otherMeta
|
||||||
|
)
|
||||||
|
|
||||||
const created = inserted[0]
|
|
||||||
if (!created) throw new Error("Could not create DB entry")
|
if (!created) throw new Error("Could not create DB entry")
|
||||||
|
|
||||||
// 2️⃣ Datei in S3 speichern
|
|
||||||
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
|
|
||||||
|
|
||||||
await s3.send(new PutObjectCommand({
|
|
||||||
Bucket: secrets.S3_BUCKET,
|
|
||||||
Key: fileKey,
|
|
||||||
Body: fileBuffer,
|
|
||||||
ContentType: data.mimetype
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 3️⃣ DB updaten: meta + path
|
|
||||||
await server.db
|
|
||||||
.update(files)
|
|
||||||
.set({
|
|
||||||
...meta,
|
|
||||||
path: fileKey
|
|
||||||
})
|
|
||||||
.where(eq(files.id, created.id))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
filename: data.filename,
|
filename: data.filename,
|
||||||
path: fileKey
|
path: created.key
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||||
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
//import { ready as zplReady } from 'zpl-renderer-js'
|
//import { ready as zplReady } from 'zpl-renderer-js'
|
||||||
//import { renderZPL } from "zpl-image";
|
//import { renderZPL } from "zpl-image";
|
||||||
@@ -13,9 +14,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
|||||||
import duration from "dayjs/plugin/duration.js";
|
import duration from "dayjs/plugin/duration.js";
|
||||||
import timezone from "dayjs/plugin/timezone.js";
|
import timezone from "dayjs/plugin/timezone.js";
|
||||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||||
import {citys} from "../../db/schema";
|
import {citys, files} from "../../db/schema";
|
||||||
import {eq} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
|
import { s3 } from "../utils/s3";
|
||||||
|
import { secrets } from "../utils/secrets";
|
||||||
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
dayjs.extend(isoWeek)
|
dayjs.extend(isoWeek)
|
||||||
dayjs.extend(isBetween)
|
dayjs.extend(isBetween)
|
||||||
@@ -25,6 +29,14 @@ dayjs.extend(duration)
|
|||||||
dayjs.extend(timezone)
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
export default async function functionRoutes(server: FastifyInstance) {
|
export default async function functionRoutes(server: FastifyInstance) {
|
||||||
|
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
|
||||||
server.post("/functions/pdf/:type", async (req, reply) => {
|
server.post("/functions/pdf/:type", async (req, reply) => {
|
||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
data: any
|
data: any
|
||||||
@@ -171,6 +183,58 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
||||||
|
const tenantId = req.user.tenant_id
|
||||||
|
|
||||||
|
const pendingFiles = await server.db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(files.tenant, tenantId),
|
||||||
|
eq(files.archived, false),
|
||||||
|
not(isNull(files.path)),
|
||||||
|
isNull(files.extractedText)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
let withText = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
for (const file of pendingFiles) {
|
||||||
|
try {
|
||||||
|
const response: any = await s3.send(new GetObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: file.path!
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fileBuffer = await streamToBuffer(response.Body)
|
||||||
|
const result = await storeExtractedTextForFile(
|
||||||
|
server,
|
||||||
|
file.id,
|
||||||
|
fileBuffer,
|
||||||
|
file.mimeType,
|
||||||
|
file.name || file.path?.split("/").pop()
|
||||||
|
)
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
if (result.text) withText += 1
|
||||||
|
} catch (err) {
|
||||||
|
errors += 1
|
||||||
|
server.log.error(`Failed to backfill extracted text for file ${file.id}`)
|
||||||
|
server.log.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: pendingFiles.length,
|
||||||
|
processed,
|
||||||
|
withText,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post('/functions/services/syncdokubox', async (req, reply) => {
|
server.post('/functions/services/syncdokubox', async (req, reply) => {
|
||||||
|
|
||||||
await server.services.dokuboxSync.run()
|
await server.services.dokuboxSync.run()
|
||||||
|
|||||||
315
backend/src/utils/documentText.ts
Normal file
315
backend/src/utils/documentText.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { files } from "../../db/schema";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
type ExtractionMethod = "text" | "ocr" | "none";
|
||||||
|
|
||||||
|
type ExtractedDocumentText = {
|
||||||
|
text: string | null;
|
||||||
|
method: ExtractionMethod;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeExtractedText(text: string) {
|
||||||
|
return text
|
||||||
|
.replace(/\u0000/g, "")
|
||||||
|
.replace(/\r/g, "\n")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePdfString(raw: string) {
|
||||||
|
let out = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
const ch = raw[i];
|
||||||
|
|
||||||
|
if (ch !== "\\") {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = raw[i + 1];
|
||||||
|
if (!next) break;
|
||||||
|
|
||||||
|
if (next === "n") {
|
||||||
|
out += "\n";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "r") {
|
||||||
|
out += "\r";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "t") {
|
||||||
|
out += "\t";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "b") {
|
||||||
|
out += "\b";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "f") {
|
||||||
|
out += "\f";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "(" || next === ")" || next === "\\") {
|
||||||
|
out += next;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[0-7]/.test(next)) {
|
||||||
|
let oct = next;
|
||||||
|
let advance = 1;
|
||||||
|
|
||||||
|
for (let j = 2; j <= 3; j += 1) {
|
||||||
|
const c = raw[i + j];
|
||||||
|
if (!c || !/[0-7]/.test(c)) break;
|
||||||
|
oct += c;
|
||||||
|
advance += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += String.fromCharCode(parseInt(oct, 8));
|
||||||
|
i += advance;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromTjOperator(segment: string) {
|
||||||
|
const parts = segment.match(/\((?:\\.|[^\\)])*\)/g);
|
||||||
|
if (!parts) return "";
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part) => decodePdfString(part.slice(1, -1)))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextStreamsFromPdf(pdfBuffer: Buffer) {
|
||||||
|
const pdfLatin = pdfBuffer.toString("latin1");
|
||||||
|
const texts: string[] = [];
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
while (true) {
|
||||||
|
const streamPos = pdfLatin.indexOf("stream", cursor);
|
||||||
|
if (streamPos < 0) break;
|
||||||
|
|
||||||
|
let dataStart = streamPos + 6;
|
||||||
|
if (pdfLatin[dataStart] === "\r" && pdfLatin[dataStart + 1] === "\n") {
|
||||||
|
dataStart += 2;
|
||||||
|
} else if (pdfLatin[dataStart] === "\n") {
|
||||||
|
dataStart += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamEnd = pdfLatin.indexOf("endstream", dataStart);
|
||||||
|
if (streamEnd < 0) break;
|
||||||
|
|
||||||
|
const sliceEnd = streamEnd > dataStart && pdfBuffer[streamEnd - 1] === 0x0d
|
||||||
|
? streamEnd - 1
|
||||||
|
: streamEnd;
|
||||||
|
|
||||||
|
const compressed = pdfBuffer.subarray(dataStart, sliceEnd);
|
||||||
|
|
||||||
|
try {
|
||||||
|
texts.push(zlib.inflateSync(compressed).toString("latin1"));
|
||||||
|
} catch {
|
||||||
|
// Ignore non-Flate streams.
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = streamEnd + 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromPdfBufferFallback(pdfBuffer: Buffer) {
|
||||||
|
const streams = extractTextStreamsFromPdf(pdfBuffer);
|
||||||
|
const extracted: string[] = [];
|
||||||
|
|
||||||
|
for (const stream of streams) {
|
||||||
|
const operators = stream.match(/\[(?:.|\r|\n)*?\]TJ|\((?:\\.|[^\\)])*\)Tj/g);
|
||||||
|
if (!operators) continue;
|
||||||
|
|
||||||
|
for (const operator of operators) {
|
||||||
|
const text = extractTextFromTjOperator(operator)
|
||||||
|
.replace(/[ \t]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
extracted.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeExtractedText(extracted.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command: string, args: string[]) {
|
||||||
|
try {
|
||||||
|
return await execFileAsync(command, args, { maxBuffer: 50 * 1024 * 1024 });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "ENOENT") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractPdfTextWithPoppler(pdfPath: string) {
|
||||||
|
const result = await runCommand("pdftotext", ["-layout", "-enc", "UTF-8", pdfPath, "-"]);
|
||||||
|
if (!result) return null;
|
||||||
|
return normalizeExtractedText(result.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPdfPagesToPng(pdfPath: string, outputDir: string) {
|
||||||
|
const pdftoppmResult = await runCommand("pdftoppm", ["-png", "-r", "200", pdfPath, path.join(outputDir, "page")]);
|
||||||
|
if (pdftoppmResult) {
|
||||||
|
return (await fs.readdir(outputDir))
|
||||||
|
.filter((file) => /^page-\d+\.png$/.test(file))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||||
|
.map((file) => path.join(outputDir, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
const qlmanageResult = await runCommand("qlmanage", ["-t", "-s", "2000", "-o", outputDir, pdfPath]);
|
||||||
|
if (!qlmanageResult) return null;
|
||||||
|
|
||||||
|
const quickLookFile = path.join(outputDir, `${path.basename(pdfPath)}.png`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(quickLookFile);
|
||||||
|
return [quickLookFile];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailableTesseractLanguages() {
|
||||||
|
const result = await runCommand("tesseract", ["--list-langs"]);
|
||||||
|
if (!result) return [];
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith("List of available languages"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOcrForPdf(pdfPath: string) {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-ocr-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pagePaths = await renderPdfPagesToPng(pdfPath, tmpDir);
|
||||||
|
if (!pagePaths?.length) return null;
|
||||||
|
|
||||||
|
const texts: string[] = [];
|
||||||
|
const configuredLanguages = (process.env.TESSERACT_LANGS || "deu+eng")
|
||||||
|
.split("+")
|
||||||
|
.map((lang) => lang.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const availableLanguages = await getAvailableTesseractLanguages();
|
||||||
|
const selectedLanguages = configuredLanguages.filter((lang) => availableLanguages.includes(lang));
|
||||||
|
const languages = selectedLanguages.length ? selectedLanguages.join("+") : "eng";
|
||||||
|
|
||||||
|
for (const pagePath of pagePaths) {
|
||||||
|
const result = await runCommand("tesseract", [
|
||||||
|
pagePath,
|
||||||
|
"stdout",
|
||||||
|
"-l",
|
||||||
|
languages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
const pageText = normalizeExtractedText(result.stdout);
|
||||||
|
if (pageText) texts.push(pageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeExtractedText(texts.join("\n\n"));
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractDocumentText(
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
mimeType?: string | null,
|
||||||
|
fileName?: string | null
|
||||||
|
): Promise<ExtractedDocumentText> {
|
||||||
|
const normalizedMimeType = mimeType?.toLowerCase() || "";
|
||||||
|
const normalizedFileName = fileName?.toLowerCase() || "";
|
||||||
|
const isPdf = normalizedMimeType === "application/pdf" || normalizedFileName.endsWith(".pdf");
|
||||||
|
|
||||||
|
if (normalizedMimeType.startsWith("text/")) {
|
||||||
|
const text = normalizeExtractedText(fileBuffer.toString("utf-8"));
|
||||||
|
return { text: text || null, method: text ? "text" : "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPdf) {
|
||||||
|
return { text: null, method: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-pdf-"));
|
||||||
|
const pdfPath = path.join(tmpDir, fileName || "document.pdf");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(pdfPath, fileBuffer);
|
||||||
|
|
||||||
|
const cliText = await extractPdfTextWithPoppler(pdfPath);
|
||||||
|
if (cliText) {
|
||||||
|
return { text: cliText, method: "text" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ocrText = await runOcrForPdf(pdfPath);
|
||||||
|
if (ocrText) {
|
||||||
|
return { text: ocrText, method: "ocr" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackText = extractTextFromPdfBufferFallback(fileBuffer);
|
||||||
|
if (fallbackText) {
|
||||||
|
return { text: fallbackText, method: "text" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: null, method: "none" };
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeExtractedTextForFile(
|
||||||
|
server: FastifyInstance,
|
||||||
|
fileId: string,
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
mimeType?: string | null,
|
||||||
|
fileName?: string | null
|
||||||
|
) {
|
||||||
|
const result = await extractDocumentText(fileBuffer, mimeType, fileName);
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(files)
|
||||||
|
.set({ extractedText: result.text })
|
||||||
|
.where(eq(files.id, fileId));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { secrets } from "./secrets"
|
|||||||
import { files } from "../../db/schema"
|
import { files } from "../../db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { storeExtractedTextForFile } from "./documentText"
|
||||||
|
|
||||||
export const saveFile = async (
|
export const saveFile = async (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
@@ -17,6 +18,13 @@ export const saveFile = async (
|
|||||||
other: Record<string, any> = {}
|
other: Record<string, any> = {}
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const {
|
||||||
|
filename: providedFilename,
|
||||||
|
filesize: _providedFilesize,
|
||||||
|
mimeType: providedMimeType,
|
||||||
|
...dbFields
|
||||||
|
} = other
|
||||||
|
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
// 1️⃣ FILE ENTRY ANLEGEN
|
// 1️⃣ FILE ENTRY ANLEGEN
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
@@ -26,7 +34,7 @@ export const saveFile = async (
|
|||||||
tenant,
|
tenant,
|
||||||
folder,
|
folder,
|
||||||
type,
|
type,
|
||||||
...other
|
...dbFields
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
@@ -38,13 +46,13 @@ export const saveFile = async (
|
|||||||
|
|
||||||
// Name ermitteln (Fallback Logik)
|
// Name ermitteln (Fallback Logik)
|
||||||
// Wenn attachment ein Buffer ist, muss der Name in 'other' stehen oder generiert werden
|
// Wenn attachment ein Buffer ist, muss der Name in 'other' stehen oder generiert werden
|
||||||
const filename = attachment.filename || other.filename || `${created.id}.pdf`
|
const filename = attachment.filename || providedFilename || `${created.id}.pdf`
|
||||||
|
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
// 2️⃣ BODY & CONTENT TYPE ERMITTELN
|
// 2️⃣ BODY & CONTENT TYPE ERMITTELN
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
let body: Buffer | Uint8Array | string
|
let body: Buffer | Uint8Array | string
|
||||||
let contentType = type || "application/octet-stream"
|
let contentType = providedMimeType || "application/octet-stream"
|
||||||
|
|
||||||
if (Buffer.isBuffer(attachment)) {
|
if (Buffer.isBuffer(attachment)) {
|
||||||
// FALL 1: RAW BUFFER (von finishManualGeneration)
|
// FALL 1: RAW BUFFER (von finishManualGeneration)
|
||||||
@@ -83,9 +91,22 @@ export const saveFile = async (
|
|||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
await server.db
|
await server.db
|
||||||
.update(files)
|
.update(files)
|
||||||
.set({ path: key })
|
.set({
|
||||||
|
path: key,
|
||||||
|
mimeType: contentType,
|
||||||
|
name: filename,
|
||||||
|
size: body.length
|
||||||
|
})
|
||||||
.where(eq(files.id, created.id))
|
.where(eq(files.id, created.id))
|
||||||
|
|
||||||
|
await storeExtractedTextForFile(
|
||||||
|
server,
|
||||||
|
created.id,
|
||||||
|
Buffer.isBuffer(body) ? body : Buffer.from(body),
|
||||||
|
contentType,
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
|
||||||
console.log(`File saved: ${key}`)
|
console.log(`File saved: ${key}`)
|
||||||
return { id: created.id, key }
|
return { id: created.id, key }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import axios from "axios";
|
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
import { zodResponseFormat } from "openai/helpers/zod";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { Blob } from "buffer";
|
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
import { s3 } from "./s3";
|
import { s3 } from "./s3";
|
||||||
import { secrets } from "./secrets";
|
import { secrets } from "./secrets";
|
||||||
|
import { storeExtractedTextForFile } from "./documentText";
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import { vendors, accounts, tenants } from "../../db/schema";
|
import { vendors, accounts, tenants } from "../../db/schema";
|
||||||
@@ -16,6 +15,9 @@ import {eq} from "drizzle-orm";
|
|||||||
|
|
||||||
let openai: OpenAI | null = null;
|
let openai: OpenAI | null = null;
|
||||||
|
|
||||||
|
const nullableString = z.string().trim().nullable();
|
||||||
|
const nullableNumber = z.number().nullable();
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// INITIALIZE OPENAI
|
// INITIALIZE OPENAI
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -41,48 +43,48 @@ async function streamToBuffer(stream: any): Promise<Buffer> {
|
|||||||
// GPT RESPONSE FORMAT (Zod Schema)
|
// GPT RESPONSE FORMAT (Zod Schema)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
const InstructionFormat = z.object({
|
const InstructionFormat = z.object({
|
||||||
invoice_number: z.string(),
|
invoice_number: nullableString,
|
||||||
invoice_date: z.string(),
|
invoice_date: nullableString,
|
||||||
invoice_duedate: z.string(),
|
invoice_duedate: nullableString,
|
||||||
invoice_type: z.string(),
|
invoice_type: nullableString,
|
||||||
delivery_type: z.string(),
|
delivery_type: nullableString,
|
||||||
delivery_note_number: z.string(),
|
delivery_note_number: nullableString,
|
||||||
reference: z.string(),
|
reference: nullableString,
|
||||||
issuer: z.object({
|
issuer: z.object({
|
||||||
id: z.number().nullable().optional(),
|
id: nullableNumber.optional(),
|
||||||
name: z.string(),
|
name: nullableString,
|
||||||
address: z.string(),
|
address: nullableString,
|
||||||
phone: z.string(),
|
phone: nullableString,
|
||||||
email: z.string(),
|
email: nullableString,
|
||||||
bank: z.string(),
|
bank: nullableString,
|
||||||
bic: z.string(),
|
bic: nullableString,
|
||||||
iban: z.string(),
|
iban: nullableString,
|
||||||
}),
|
}),
|
||||||
recipient: z.object({
|
recipient: z.object({
|
||||||
name: z.string(),
|
name: nullableString,
|
||||||
address: z.string(),
|
address: nullableString,
|
||||||
phone: z.string(),
|
phone: nullableString,
|
||||||
email: z.string(),
|
email: nullableString,
|
||||||
}),
|
}),
|
||||||
invoice_items: z.array(
|
invoice_items: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
description: z.string(),
|
description: nullableString,
|
||||||
unit: z.string(),
|
unit: nullableString,
|
||||||
quantity: z.number(),
|
quantity: nullableNumber,
|
||||||
total: z.number(),
|
total: nullableNumber,
|
||||||
total_without_tax: z.number(),
|
total_without_tax: nullableNumber,
|
||||||
tax_rate: z.number(),
|
tax_rate: nullableNumber,
|
||||||
ean: z.number().nullable().optional(),
|
ean: nullableNumber.optional(),
|
||||||
article_number: z.number().nullable().optional(),
|
article_number: nullableNumber.optional(),
|
||||||
account_number: z.number().nullable().optional(),
|
account_number: nullableNumber.optional(),
|
||||||
account_id: z.number().nullable().optional(),
|
account_id: nullableNumber.optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
subtotal: z.number(),
|
subtotal: nullableNumber,
|
||||||
tax_rate: z.number(),
|
tax_rate: nullableNumber,
|
||||||
tax: z.number(),
|
tax: nullableNumber,
|
||||||
total: z.number(),
|
total: nullableNumber,
|
||||||
terms: z.string(),
|
terms: nullableString,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -91,8 +93,7 @@ const InstructionFormat = z.object({
|
|||||||
export const getInvoiceDataFromGPT = async function (
|
export const getInvoiceDataFromGPT = async function (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
file: any,
|
file: any,
|
||||||
tenantId: number,
|
tenantId: number
|
||||||
learningContext?: string
|
|
||||||
) {
|
) {
|
||||||
await initOpenAi();
|
await initOpenAi();
|
||||||
|
|
||||||
@@ -126,32 +127,27 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileBlob = new Blob([fileData], { type: "application/pdf" });
|
let extractedText = file.extractedText;
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
if (!extractedText?.trim()) {
|
||||||
// 2) SEND FILE TO PDF → TEXT API
|
try {
|
||||||
// ---------------------------------------------------------
|
const result = await storeExtractedTextForFile(
|
||||||
const form = new FormData();
|
server,
|
||||||
form.append("fileInput", fileBlob, file.path.split("/").pop());
|
file.id,
|
||||||
form.append("outputFormat", "txt");
|
fileData,
|
||||||
|
file.mimeType,
|
||||||
|
file.name || file.path?.split("/").pop()
|
||||||
|
);
|
||||||
|
extractedText = result.text;
|
||||||
|
server.log.info(`Invoice text extraction for file ${file.id} used method: ${result.method}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.log("❌ Local PDF text extraction failed", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let extractedText: string;
|
if (!extractedText?.trim()) {
|
||||||
|
server.log.warn(`No extractable PDF text found for file ${file.id}. Scanned PDFs require OCR.`);
|
||||||
try {
|
|
||||||
const res = await axios.post(
|
|
||||||
"http://23.88.52.85:8080/api/v1/convert/pdf/text",
|
|
||||||
form,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
Authorization: `Bearer ${secrets.STIRLING_API_KEY}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
extractedText = res.data;
|
|
||||||
} catch (err) {
|
|
||||||
console.log("❌ PDF OCR API failed", err);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,13 +194,16 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
"You extract structured invoice data.\n\n" +
|
"You extract structured invoice data.\n\n" +
|
||||||
`VENDORS: ${JSON.stringify(vendorList)}\n` +
|
`VENDORS: ${JSON.stringify(vendorList)}\n` +
|
||||||
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
|
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
|
||||||
(learningContext
|
"Use only values that are explicitly present in the invoice text.\n" +
|
||||||
? `HISTORICAL_PATTERNS: ${learningContext}\n\n`
|
"If a field is missing or unclear, return null. If line items are missing or unclear, return an empty array.\n" +
|
||||||
: "") +
|
"Do not guess invoice numbers, dates, totals, payment terms, bank data, or references.\n" +
|
||||||
|
"Do not derive values from vendor defaults or likely patterns.\n" +
|
||||||
|
"Only set issuer.id when the issuer name clearly matches a vendor name from VENDORS.\n" +
|
||||||
|
"Only set account_id when the invoice line clearly matches an account label or number from ACCOUNTS.\n" +
|
||||||
|
"If multiple accounts are plausible, set account_id to null.\n" +
|
||||||
|
"Do not merge summary totals into fabricated invoice_items.\n" +
|
||||||
"Match issuer by name to vendor.id.\n" +
|
"Match issuer by name to vendor.id.\n" +
|
||||||
"Match invoice items to account id based on label/number.\n" +
|
"Match invoice items to account id based on label/number.\n" +
|
||||||
"Use historical patterns as soft hints for vendor/account/payment mapping.\n" +
|
|
||||||
"Do not invent values when the invoice text contradicts the hints.\n" +
|
|
||||||
"Convert dates to YYYY-MM-DD.\n" +
|
"Convert dates to YYYY-MM-DD.\n" +
|
||||||
"Keep invoice items in original order.\n",
|
"Keep invoice items in original order.\n",
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
backend/tmp-invoice-1453.png
Normal file
BIN
backend/tmp-invoice-1453.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
22180
frontend/package-lock.json
generated
Normal file
22180
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
186
mobile/app/more/account.tsx
Normal file
186
mobile/app/more/account.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Redirect, router } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
export default function AccountScreen() {
|
||||||
|
const { token, user, tenants, activeTenantId, activeTenant, switchTenant, logout } = useAuth();
|
||||||
|
const storageInfo = useTokenStorageInfo();
|
||||||
|
|
||||||
|
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
|
||||||
|
const [switchError, setSwitchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Redirect href="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = String(user?.id || 'unbekannt');
|
||||||
|
|
||||||
|
async function onSwitchTenant(tenantId: number) {
|
||||||
|
setSwitchingTenantId(tenantId);
|
||||||
|
setSwitchError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await switchTenant(tenantId);
|
||||||
|
} catch (err) {
|
||||||
|
setSwitchError(err instanceof Error ? err.message : 'Tenant konnte nicht gewechselt werden.');
|
||||||
|
} finally {
|
||||||
|
setSwitchingTenantId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await logout();
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.label}>Token vorhanden</Text>
|
||||||
|
<Text style={styles.value}>{token ? 'ja' : 'nein'}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>User ID</Text>
|
||||||
|
<Text style={styles.value}>{userId}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Aktiver Tenant</Text>
|
||||||
|
<Text style={styles.value}>{activeTenant ? `${activeTenant.name} (#${activeTenantId})` : 'nicht gesetzt'}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Storage Modus</Text>
|
||||||
|
<Text style={styles.value}>{storageInfo.mode}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>Tenant wechseln</Text>
|
||||||
|
|
||||||
|
{switchError ? <Text style={styles.error}>{switchError}</Text> : null}
|
||||||
|
|
||||||
|
{tenants.map((tenant) => {
|
||||||
|
const tenantId = Number(tenant.id);
|
||||||
|
const isActive = tenantId === activeTenantId;
|
||||||
|
const isSwitching = switchingTenantId === tenantId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={String(tenant.id)}
|
||||||
|
style={[
|
||||||
|
styles.tenantButton,
|
||||||
|
isActive ? styles.tenantButtonActive : null,
|
||||||
|
isSwitching ? styles.tenantButtonDisabled : null,
|
||||||
|
]}
|
||||||
|
onPress={() => onSwitchTenant(tenantId)}
|
||||||
|
disabled={isActive || switchingTenantId !== null}>
|
||||||
|
<View style={styles.tenantInfo}>
|
||||||
|
<Text style={styles.tenantName} numberOfLines={2} ellipsizeMode="tail">
|
||||||
|
{tenant.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.tenantMeta}>ID: {tenantId}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tenantActionWrap}>
|
||||||
|
<Text style={styles.tenantAction}>{isActive ? 'Aktiv' : isSwitching ? 'Wechsel...' : 'Wechseln'}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.logoutButton} onPress={onLogout}>
|
||||||
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6b7280',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
tenantButton: {
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tenantButtonActive: {
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
},
|
||||||
|
tenantButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
tenantInfo: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
paddingRight: 10,
|
||||||
|
},
|
||||||
|
tenantName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
tenantMeta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
marginTop: 3,
|
||||||
|
},
|
||||||
|
tenantActionWrap: {
|
||||||
|
minWidth: 84,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
tenantAction: {
|
||||||
|
color: PRIMARY,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: '#dc2626',
|
||||||
|
minHeight: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
logoutText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
881
mobile/app/more/customer/[id].tsx
Normal file
881
mobile/app/more/customer/[id].tsx
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCustomerInventoryItem,
|
||||||
|
CreatedDocument,
|
||||||
|
Customer,
|
||||||
|
CustomerInventoryItem,
|
||||||
|
fetchCustomerById,
|
||||||
|
fetchCustomerCreatedDocuments,
|
||||||
|
fetchCreatedDocumentFiles,
|
||||||
|
fetchCustomerFiles,
|
||||||
|
fetchCustomerInventoryItems,
|
||||||
|
ProjectFile,
|
||||||
|
uploadCustomerFile,
|
||||||
|
} from '@/src/lib/api';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
const SCAN_TYPES: BarcodeType[] = [
|
||||||
|
'datamatrix',
|
||||||
|
'code128',
|
||||||
|
'code39',
|
||||||
|
'code93',
|
||||||
|
'ean13',
|
||||||
|
'ean8',
|
||||||
|
'upc_a',
|
||||||
|
'upc_e',
|
||||||
|
'itf14',
|
||||||
|
'qr',
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDateTime(value: unknown): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(String(value));
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDocType(value: unknown): string {
|
||||||
|
const type = String(value || '');
|
||||||
|
if (!type) return 'Beleg';
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
invoices: 'Rechnung',
|
||||||
|
advanceInvoices: 'Abschlagsrechnung',
|
||||||
|
cancellationInvoices: 'Stornorechnung',
|
||||||
|
quotes: 'Angebot',
|
||||||
|
confirmationOrders: 'Auftragsbestätigung',
|
||||||
|
deliveryNotes: 'Lieferschein',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomerDetailScreen() {
|
||||||
|
const params = useLocalSearchParams<{ id?: string }>();
|
||||||
|
const customerId = Number(params.id);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||||||
|
|
||||||
|
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||||
|
const [createdDocuments, setCreatedDocuments] = useState<CreatedDocument[]>([]);
|
||||||
|
const [files, setFiles] = useState<ProjectFile[]>([]);
|
||||||
|
const [inventoryItems, setInventoryItems] = useState<CustomerInventoryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [inventorySearch, setInventorySearch] = useState('');
|
||||||
|
const [showArchivedInventory, setShowArchivedInventory] = useState(false);
|
||||||
|
|
||||||
|
const [createInventoryModalOpen, setCreateInventoryModalOpen] = useState(false);
|
||||||
|
const [creatingInventory, setCreatingInventory] = useState(false);
|
||||||
|
const [createInventoryError, setCreateInventoryError] = useState<string | null>(null);
|
||||||
|
const [newInventoryName, setNewInventoryName] = useState('');
|
||||||
|
const [newInventoryId, setNewInventoryId] = useState('');
|
||||||
|
const [newInventorySerial, setNewInventorySerial] = useState('');
|
||||||
|
const [newInventoryDescription, setNewInventoryDescription] = useState('');
|
||||||
|
|
||||||
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
|
const [hasScanned, setHasScanned] = useState(false);
|
||||||
|
const [scanTarget, setScanTarget] = useState<'customerInventoryId' | 'serialNumber' | 'search'>('serialNumber');
|
||||||
|
|
||||||
|
const validId = useMemo(() => Number.isFinite(customerId) && customerId > 0, [customerId]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!customer) return [];
|
||||||
|
return [
|
||||||
|
{ label: 'Name', value: String(customer.name || '-') },
|
||||||
|
{ label: 'Kundennummer', value: String(customer.customerNumber || '-') },
|
||||||
|
{ label: 'Typ', value: String(customer.type || '-') },
|
||||||
|
{ label: 'E-Mail', value: String(customer.email || '-') },
|
||||||
|
{ label: 'Telefon', value: String(customer.phone || '-') },
|
||||||
|
{ label: 'Erstellt', value: formatDateTime(customer.createdAt || customer.created_at) },
|
||||||
|
{ label: 'Aktualisiert', value: formatDateTime(customer.updatedAt || customer.updated_at) },
|
||||||
|
];
|
||||||
|
}, [customer]);
|
||||||
|
|
||||||
|
const filteredInventoryItems = useMemo(() => {
|
||||||
|
const terms = inventorySearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
return (inventoryItems || [])
|
||||||
|
.filter((item) => {
|
||||||
|
if (!showArchivedInventory && item.archived) return false;
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
item.name,
|
||||||
|
item.customerInventoryId,
|
||||||
|
item.serialNumber,
|
||||||
|
item.description,
|
||||||
|
item.manufacturer,
|
||||||
|
item.manufacturerNumber,
|
||||||
|
]
|
||||||
|
.map((value) => String(value || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
})
|
||||||
|
.sort((a, b) => Number(b.id) - Number(a.id));
|
||||||
|
}, [inventoryItems, inventorySearch, showArchivedInventory]);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (showSpinner = true) => {
|
||||||
|
if (!token || !validId) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [customerData, createdDocumentData, fileData, customerInventoryData] = await Promise.all([
|
||||||
|
fetchCustomerById(token, customerId),
|
||||||
|
fetchCustomerCreatedDocuments(token, customerId),
|
||||||
|
fetchCustomerFiles(token, customerId),
|
||||||
|
fetchCustomerInventoryItems(token, customerId, true),
|
||||||
|
]);
|
||||||
|
setCustomer(customerData);
|
||||||
|
setCreatedDocuments(createdDocumentData);
|
||||||
|
setFiles(fileData);
|
||||||
|
setInventoryItems(customerInventoryData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Kundendaten konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[customerId, token, validId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !validId) return;
|
||||||
|
void load(true);
|
||||||
|
}, [load, token, validId]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateInventoryModal() {
|
||||||
|
setCreateInventoryModalOpen(false);
|
||||||
|
setCreateInventoryError(null);
|
||||||
|
setNewInventoryName('');
|
||||||
|
setNewInventoryId('');
|
||||||
|
setNewInventorySerial('');
|
||||||
|
setNewInventoryDescription('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateInventoryItem() {
|
||||||
|
if (!token || !validId || creatingInventory) return;
|
||||||
|
|
||||||
|
const name = newInventoryName.trim();
|
||||||
|
if (!name) {
|
||||||
|
setCreateInventoryError('Bitte einen Namen eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingInventory(true);
|
||||||
|
setCreateInventoryError(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCustomerInventoryItem(token, {
|
||||||
|
customer: customerId,
|
||||||
|
name,
|
||||||
|
customerInventoryId: newInventoryId || null,
|
||||||
|
serialNumber: newInventorySerial || null,
|
||||||
|
description: newInventoryDescription || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
closeCreateInventoryModal();
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCreateInventoryError(err instanceof Error ? err.message : 'Kundeninventar konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setCreatingInventory(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScanner(target: 'customerInventoryId' | 'serialNumber' | 'search') {
|
||||||
|
const granted = cameraPermission?.granted;
|
||||||
|
if (!granted) {
|
||||||
|
const request = await requestCameraPermission();
|
||||||
|
if (!request.granted) {
|
||||||
|
setCreateInventoryError('Bitte erlaube den Zugriff auf die Kamera zum Scannen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setScanTarget(target);
|
||||||
|
setHasScanned(false);
|
||||||
|
setScannerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScanner() {
|
||||||
|
setScannerOpen(false);
|
||||||
|
setHasScanned(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBarcodeScanned(result: BarcodeScanningResult) {
|
||||||
|
if (hasScanned) return;
|
||||||
|
const code = String(result.data || '').trim();
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
setHasScanned(true);
|
||||||
|
|
||||||
|
if (scanTarget === 'customerInventoryId') {
|
||||||
|
setNewInventoryId(code);
|
||||||
|
} else if (scanTarget === 'search') {
|
||||||
|
setInventorySearch(code);
|
||||||
|
} else {
|
||||||
|
setNewInventorySerial(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeScanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenFile(file: ProjectFile) {
|
||||||
|
if (!file.url) return;
|
||||||
|
await WebBrowser.openBrowserAsync(file.url, {
|
||||||
|
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||||
|
controlsColor: PRIMARY,
|
||||||
|
showTitle: true,
|
||||||
|
enableDefaultShareMenuItem: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickAndUpload() {
|
||||||
|
if (!token || !validId || uploading) return;
|
||||||
|
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
multiple: false,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
type: ['image/*', 'application/pdf', '*/*'],
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.name || `upload-${Date.now()}`;
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadCustomerFile(token, {
|
||||||
|
customerId,
|
||||||
|
uri: asset.uri,
|
||||||
|
filename,
|
||||||
|
mimeType: asset.mimeType || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenCreatedDocument(doc: CreatedDocument) {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const linkedFiles = await fetchCreatedDocumentFiles(token, doc.id);
|
||||||
|
const fileToOpen = linkedFiles.find((file) => Boolean(file.url));
|
||||||
|
|
||||||
|
if (!fileToOpen?.url) {
|
||||||
|
setError('Kein verknüpftes Dokument zum Öffnen gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onOpenFile(fileToOpen);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Dokument konnte nicht geöffnet werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Kunde wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && customer ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.title}>Informationen</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.uploadButton}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/more/wiki',
|
||||||
|
params: {
|
||||||
|
entityType: 'customers',
|
||||||
|
entityId: String(customerId),
|
||||||
|
title: `Kunden-Wiki: ${String(customer.name || customerId)}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<Text style={styles.uploadButtonText}>Wiki</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.table}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<View key={row.label} style={styles.row}>
|
||||||
|
<Text style={styles.label}>{row.label}</Text>
|
||||||
|
<Text style={styles.value}>{row.value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{customer.notes ? (
|
||||||
|
<View style={styles.notesWrap}>
|
||||||
|
<Text style={styles.notesLabel}>Notizen</Text>
|
||||||
|
<Text style={styles.notes}>{String(customer.notes)}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Kundeninventar ({inventoryItems.length})</Text>
|
||||||
|
<View style={styles.inventoryHeaderActions}>
|
||||||
|
<Pressable style={styles.secondaryActionButton} onPress={() => openScanner('search')}>
|
||||||
|
<Text style={styles.secondaryActionButtonText}>Scannen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.uploadButton} onPress={() => setCreateInventoryModalOpen(true)}>
|
||||||
|
<Text style={styles.uploadButtonText}>Neu</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Inventar suchen"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={inventorySearch}
|
||||||
|
onChangeText={setInventorySearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.filterButton, showArchivedInventory ? styles.filterButtonActive : null]}
|
||||||
|
onPress={() => setShowArchivedInventory((prev) => !prev)}>
|
||||||
|
<Text style={[styles.filterButtonText, showArchivedInventory ? styles.filterButtonTextActive : null]}>
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{filteredInventoryItems.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Kein Kundeninventar gefunden.</Text>
|
||||||
|
) : (
|
||||||
|
filteredInventoryItems.map((item) => (
|
||||||
|
<View key={String(item.id)} style={styles.fileRow}>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<View style={styles.inventoryTitleRow}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
|
{item.name || '-'}
|
||||||
|
</Text>
|
||||||
|
{item.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||||
|
ID: {item.customerInventoryId || '-'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||||
|
Seriennummer: {item.serialNumber || '-'}
|
||||||
|
</Text>
|
||||||
|
{item.description ? (
|
||||||
|
<Text style={styles.fileMeta} numberOfLines={2}>
|
||||||
|
{String(item.description)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>Ausgangsbelege ({createdDocuments.length})</Text>
|
||||||
|
|
||||||
|
{createdDocuments.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Keine Ausgangsbelege vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
createdDocuments.map((doc) => (
|
||||||
|
<Pressable key={String(doc.id)} style={styles.fileRow} onPress={() => onOpenCreatedDocument(doc)}>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
|
{doc.documentNumber || doc.title || `#${doc.id}`}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||||
|
{formatDocType(doc.type)}
|
||||||
|
{doc.state ? ` · ${String(doc.state)}` : ''}
|
||||||
|
{doc.documentDate ? ` · ${formatDateTime(doc.documentDate)}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Dokumente ({files.length})</Text>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onPickAndUpload}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Hochladen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Noch keine Dokumente vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<Pressable key={file.id} style={styles.fileRow} onPress={() => onOpenFile(file)}>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={2}>
|
||||||
|
{file.name || file.path?.split('/').pop() || file.id}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fileMeta}>{file.mimeType || 'Datei'}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={createInventoryModalOpen}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeCreateInventoryModal}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
style={styles.modalKeyboardWrap}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Neuer Kundeninventarartikel</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={newInventoryName}
|
||||||
|
onChangeText={setNewInventoryName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Kundeninventar-ID (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={newInventoryId}
|
||||||
|
onChangeText={setNewInventoryId}
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.secondaryActionButton} onPress={() => openScanner('customerInventoryId')}>
|
||||||
|
<Text style={styles.secondaryActionButtonText}>Scannen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Seriennummer (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={newInventorySerial}
|
||||||
|
onChangeText={setNewInventorySerial}
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.secondaryActionButton} onPress={() => openScanner('serialNumber')}>
|
||||||
|
<Text style={styles.secondaryActionButtonText}>Scannen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.searchInput, styles.multilineInput]}
|
||||||
|
value={newInventoryDescription}
|
||||||
|
onChangeText={setNewInventoryDescription}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{createInventoryError ? <Text style={styles.error}>{createInventoryError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.secondaryButton, creatingInventory ? styles.buttonDisabled : null]}
|
||||||
|
onPress={closeCreateInventoryModal}
|
||||||
|
disabled={creatingInventory}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, creatingInventory ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onCreateInventoryItem}
|
||||||
|
disabled={creatingInventory}>
|
||||||
|
<Text style={styles.primaryButtonText}>{creatingInventory ? 'Speichere...' : 'Anlegen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal visible={scannerOpen} animationType="slide" onRequestClose={closeScanner}>
|
||||||
|
<View style={styles.scannerScreen}>
|
||||||
|
<View style={styles.scannerHeader}>
|
||||||
|
<Text style={styles.scannerTitle}>
|
||||||
|
{scanTarget === 'customerInventoryId' ? 'Kundeninventar-ID scannen' : 'Seriennummer scannen'}
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.scannerCloseButton} onPress={closeScanner}>
|
||||||
|
<Text style={styles.scannerCloseButtonText}>Schließen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CameraView
|
||||||
|
style={styles.scannerCamera}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: SCAN_TYPES }}
|
||||||
|
onBarcodeScanned={onBarcodeScanned}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.scannerHint}>Barcode oder DataMatrix mittig ins Bild halten.</Text>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
notesWrap: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
gap: 4,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
},
|
||||||
|
notesLabel: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
inventoryHeaderActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
uploadButton: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
uploadButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
filterButton: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
filterButtonActive: {
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
},
|
||||||
|
filterButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
filterButtonTextActive: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
fileRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
inventoryTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
fileMeta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
modalKeyboardWrap: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
secondaryActionButton: {
|
||||||
|
minHeight: 36,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
secondaryActionButtonText: {
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
multilineInput: {
|
||||||
|
minHeight: 72,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
scannerScreen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
},
|
||||||
|
scannerHeader: {
|
||||||
|
paddingTop: 56,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
scannerTitle: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
scannerCloseButton: {
|
||||||
|
minHeight: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scannerCloseButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
scannerCamera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scannerHint: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
backgroundColor: 'rgba(17,24,39,0.85)',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
369
mobile/app/more/customers.tsx
Normal file
369
mobile/app/more/customers.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
import { createCustomer, Customer, fetchCustomers } from '@/src/lib/api';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
export default function CustomersScreen() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [nameInput, setNameInput] = useState('');
|
||||||
|
const [numberInput, setNumberInput] = useState('');
|
||||||
|
const [notesInput, setNotesInput] = useState('');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
return customers.filter((customer) => {
|
||||||
|
if (!showArchived && customer.archived) return false;
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
|
||||||
|
const haystack = [customer.name, customer.customerNumber, customer.notes]
|
||||||
|
.map((value) => String(value || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}, [customers, search, showArchived]);
|
||||||
|
|
||||||
|
const load = useCallback(async (showSpinner = true) => {
|
||||||
|
if (!token) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await fetchCustomers(token, true);
|
||||||
|
setCustomers(rows);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Kunden konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
void load(true);
|
||||||
|
}, [load, token]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateError(null);
|
||||||
|
setNameInput('');
|
||||||
|
setNumberInput('');
|
||||||
|
setNotesInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateCustomer() {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const name = nameInput.trim();
|
||||||
|
if (!name) {
|
||||||
|
setCreateError('Bitte einen Namen eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setCreateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCustomer(token, {
|
||||||
|
name,
|
||||||
|
customerNumber: numberInput.trim() || null,
|
||||||
|
notes: notesInput.trim() || null,
|
||||||
|
});
|
||||||
|
closeCreateModal();
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : 'Kunde konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<View style={styles.searchWrap}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Kunden suchen"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
|
||||||
|
onPress={() => setShowArchived((prev) => !prev)}>
|
||||||
|
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.list}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Kunden werden geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && filtered.length === 0 ? <Text style={styles.empty}>Keine Kunden gefunden.</Text> : null}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
filtered.map((customer) => (
|
||||||
|
<Pressable
|
||||||
|
key={String(customer.id)}
|
||||||
|
style={({ pressed }) => [styles.row, pressed ? styles.rowPressed : null]}
|
||||||
|
onPress={() => router.push(`/more/customer/${customer.id}`)}>
|
||||||
|
<View style={styles.rowHeader}>
|
||||||
|
<Text style={styles.rowTitle} numberOfLines={1}>{customer.name}</Text>
|
||||||
|
{customer.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
|
||||||
|
</View>
|
||||||
|
{customer.customerNumber ? (
|
||||||
|
<Text style={styles.rowSubtitle} numberOfLines={1}>Nr.: {customer.customerNumber}</Text>
|
||||||
|
) : null}
|
||||||
|
{customer.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(customer.notes)}</Text> : null}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
|
||||||
|
<Text style={styles.fabText}>+</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Modal visible={createOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
style={styles.modalKeyboardWrap}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Neuer Kunde</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={nameInput}
|
||||||
|
onChangeText={setNameInput}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Kundennummer (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={numberInput}
|
||||||
|
onChangeText={setNumberInput}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Notizen (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.searchInput, styles.multilineInput]}
|
||||||
|
value={notesInput}
|
||||||
|
onChangeText={setNotesInput}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{createError ? <Text style={styles.error}>{createError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onCreateCustomer}
|
||||||
|
disabled={saving}>
|
||||||
|
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: { flex: 1, backgroundColor: '#ffffff' },
|
||||||
|
searchWrap: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
list: { flex: 1, backgroundColor: '#ffffff' },
|
||||||
|
listContent: { paddingBottom: 96 },
|
||||||
|
row: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: '#f3f4f6' },
|
||||||
|
rowHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
rowTitle: { flex: 1, color: '#111827', fontSize: 15, fontWeight: '600' },
|
||||||
|
rowSubtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
|
||||||
|
badge: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
toggleButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
|
||||||
|
toggleButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
|
||||||
|
toggleButtonTextActive: { color: '#3d7a30' },
|
||||||
|
loadingBox: { paddingVertical: 20, alignItems: 'center', gap: 8 },
|
||||||
|
loadingText: { color: '#6b7280' },
|
||||||
|
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 20 },
|
||||||
|
error: { color: '#dc2626', fontSize: 13, paddingHorizontal: 16, paddingTop: 10 },
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 18,
|
||||||
|
bottom: 20,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#111827',
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
fabText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 30,
|
||||||
|
lineHeight: 30,
|
||||||
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(17, 24, 39, 0.45)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalKeyboardWrap: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
multilineInput: {
|
||||||
|
minHeight: 92,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
borderRadius: 10,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
541
mobile/app/more/inventory.tsx
Normal file
541
mobile/app/more/inventory.tsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
|
||||||
|
import { Customer, CustomerInventoryItem, fetchAllCustomerInventoryItems, fetchCustomers, renderPrintLabel } from '@/src/lib/api';
|
||||||
|
import { getActiveNimbotConnection, printNimbotEncodedLabel } from '@/src/lib/nimbot';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
const SCAN_TYPES: BarcodeType[] = [
|
||||||
|
'datamatrix',
|
||||||
|
'code128',
|
||||||
|
'code39',
|
||||||
|
'code93',
|
||||||
|
'ean13',
|
||||||
|
'ean8',
|
||||||
|
'upc_a',
|
||||||
|
'upc_e',
|
||||||
|
'itf14',
|
||||||
|
'qr',
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveCustomerId(item: CustomerInventoryItem): number | null {
|
||||||
|
const raw = item.customer;
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
return raw.id ? Number(raw.id) : null;
|
||||||
|
}
|
||||||
|
return Number(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCustomerName(item: CustomerInventoryItem, customersById: Map<number, Customer>): string {
|
||||||
|
const raw = item.customer;
|
||||||
|
if (raw && typeof raw === 'object' && raw.name) {
|
||||||
|
return String(raw.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = resolveCustomerId(item);
|
||||||
|
if (!id) return '-';
|
||||||
|
return String(customersById.get(id)?.name || `#${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InventoryScreen() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const params = useLocalSearchParams<{ action?: string | string[] }>();
|
||||||
|
|
||||||
|
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||||||
|
const scanTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<CustomerInventoryItem[]>([]);
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [printingItemId, setPrintingItemId] = useState<number | null>(null);
|
||||||
|
const [printInfo, setPrintInfo] = useState<string | null>(null);
|
||||||
|
const [printError, setPrintError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
|
const [hasScanned, setHasScanned] = useState(false);
|
||||||
|
|
||||||
|
const incomingAction = useMemo(() => {
|
||||||
|
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
|
||||||
|
return String(raw || '').toLowerCase();
|
||||||
|
}, [params.action]);
|
||||||
|
|
||||||
|
const customersById = useMemo(() => {
|
||||||
|
const map = new Map<number, Customer>();
|
||||||
|
(customers || []).forEach((customer) => {
|
||||||
|
if (Number.isFinite(Number(customer.id))) {
|
||||||
|
map.set(Number(customer.id), customer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [customers]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
return (items || [])
|
||||||
|
.filter((item) => {
|
||||||
|
if (!showArchived && item.archived) return false;
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
|
||||||
|
const customerName = resolveCustomerName(item, customersById);
|
||||||
|
const haystack = [
|
||||||
|
item.name,
|
||||||
|
item.customerInventoryId,
|
||||||
|
item.serialNumber,
|
||||||
|
item.description,
|
||||||
|
item.manufacturer,
|
||||||
|
item.manufacturerNumber,
|
||||||
|
customerName,
|
||||||
|
]
|
||||||
|
.map((value) => String(value || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
})
|
||||||
|
.sort((a, b) => Number(b.id) - Number(a.id));
|
||||||
|
}, [customersById, items, search, showArchived]);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (showSpinner = true) => {
|
||||||
|
if (!token) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [inventoryRows, customerRows] = await Promise.all([
|
||||||
|
fetchAllCustomerInventoryItems(token, true),
|
||||||
|
fetchCustomers(token, true),
|
||||||
|
]);
|
||||||
|
setItems(inventoryRows || []);
|
||||||
|
setCustomers(customerRows || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Kundeninventar konnte nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
void load(true);
|
||||||
|
}, [load, token]);
|
||||||
|
|
||||||
|
const openScanner = useCallback(async () => {
|
||||||
|
const granted = cameraPermission?.granted;
|
||||||
|
if (!granted) {
|
||||||
|
const request = await requestCameraPermission();
|
||||||
|
if (!request.granted) {
|
||||||
|
setError('Bitte erlaube den Zugriff auf die Kamera zum Scannen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasScanned(false);
|
||||||
|
setScannerOpen(true);
|
||||||
|
}, [cameraPermission?.granted, requestCameraPermission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (incomingAction !== 'scan') return;
|
||||||
|
if (scanTriggeredRef.current) return;
|
||||||
|
scanTriggeredRef.current = true;
|
||||||
|
void openScanner();
|
||||||
|
}, [incomingAction, openScanner]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScanner() {
|
||||||
|
setScannerOpen(false);
|
||||||
|
setHasScanned(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBarcodeScanned(result: BarcodeScanningResult) {
|
||||||
|
if (hasScanned) return;
|
||||||
|
const code = String(result.data || '').trim();
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
setHasScanned(true);
|
||||||
|
setSearch(code);
|
||||||
|
closeScanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNimbotForItem(item: CustomerInventoryItem, customerName: string) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/more/nimbot',
|
||||||
|
params: {
|
||||||
|
itemName: item.name || '',
|
||||||
|
itemId: item.customerInventoryId || '',
|
||||||
|
serial: item.serialNumber || '',
|
||||||
|
customerInventoryId: item.customerInventoryId || '',
|
||||||
|
serialNumber: item.serialNumber || '',
|
||||||
|
customerName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printItemLabel(item: CustomerInventoryItem, customerName: string) {
|
||||||
|
setPrintError(null);
|
||||||
|
setPrintInfo(null);
|
||||||
|
|
||||||
|
const connected = getActiveNimbotConnection();
|
||||||
|
if (!connected) {
|
||||||
|
openNimbotForItem(item, customerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setPrintError('Nicht angemeldet. Bitte erneut einloggen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrintingItemId(Number(item.id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
id: item.id,
|
||||||
|
customerInventoryId: item.customerInventoryId || null,
|
||||||
|
name: item.name || null,
|
||||||
|
customerName: customerName === '-' ? null : customerName,
|
||||||
|
serialNumber: item.serialNumber || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendered = await renderPrintLabel(token, context, 584, 354);
|
||||||
|
await printNimbotEncodedLabel(rendered.encoded, { density: 5, copies: 1, labelType: 1 });
|
||||||
|
|
||||||
|
setPrintInfo(`Label gedruckt: ${item.name || item.customerInventoryId || `#${item.id}`}`);
|
||||||
|
} catch (err) {
|
||||||
|
setPrintError(err instanceof Error ? err.message : 'Label konnte nicht gedruckt werden.');
|
||||||
|
} finally {
|
||||||
|
setPrintingItemId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrinterConnected = Boolean(getActiveNimbotConnection()?.device.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<View style={styles.searchWrap}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Kundeninventar suchen"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
<View style={styles.searchActions}>
|
||||||
|
<Pressable style={styles.scanButton} onPress={openScanner}>
|
||||||
|
<Text style={styles.scanButtonText}>Scannen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
|
||||||
|
onPress={() => setShowArchived((prev) => !prev)}>
|
||||||
|
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.list}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
{printError ? <Text style={styles.error}>{printError}</Text> : null}
|
||||||
|
{printInfo ? <Text style={styles.info}>{printInfo}</Text> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Kundeninventar wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && filteredItems.length === 0 ? <Text style={styles.empty}>Kein Kundeninventar gefunden.</Text> : null}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
filteredItems.map((item) => {
|
||||||
|
const customerId = resolveCustomerId(item);
|
||||||
|
const customerName = resolveCustomerName(item, customersById);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={String(item.id)}
|
||||||
|
style={({ pressed }) => [styles.row, pressed ? styles.rowPressed : null]}
|
||||||
|
onPress={() => {
|
||||||
|
if (customerId) {
|
||||||
|
router.push(`/more/customer/${customerId}`);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<View style={styles.rowHeader}>
|
||||||
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||||
|
{item.name || '-'}
|
||||||
|
</Text>
|
||||||
|
{item.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
||||||
|
Kunde: {customerName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
||||||
|
ID: {item.customerInventoryId || '-'} · Seriennummer: {item.serialNumber || '-'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.rowActions}>
|
||||||
|
{(() => {
|
||||||
|
const isPrinting = printingItemId === Number(item.id);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.printButton, isPrinting ? styles.printButtonDisabled : null]}
|
||||||
|
onPress={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void printItemLabel(item, customerName);
|
||||||
|
}}
|
||||||
|
disabled={isPrinting}>
|
||||||
|
<Text style={styles.printButtonText}>
|
||||||
|
{isPrinting ? 'Drucke...' : isPrinterConnected ? 'Label drucken' : 'Mit Nimbot verbinden'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal visible={scannerOpen} animationType="slide" onRequestClose={closeScanner}>
|
||||||
|
<View style={styles.scannerScreen}>
|
||||||
|
<View style={styles.scannerHeader}>
|
||||||
|
<Text style={styles.scannerTitle}>Kundeninventar scannen</Text>
|
||||||
|
<Pressable style={styles.scannerCloseButton} onPress={closeScanner}>
|
||||||
|
<Text style={styles.scannerCloseButtonText}>Schließen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CameraView
|
||||||
|
style={styles.scannerCamera}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: SCAN_TYPES }}
|
||||||
|
onBarcodeScanned={onBarcodeScanned}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.scannerHint}>Barcode oder DataMatrix mittig ins Bild halten.</Text>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: { flex: 1, backgroundColor: '#ffffff' },
|
||||||
|
searchWrap: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
searchActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
scanButton: {
|
||||||
|
minHeight: 36,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scanButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
toggleButtonActive: {
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
},
|
||||||
|
toggleButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
toggleButtonTextActive: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
},
|
||||||
|
list: { flex: 1, backgroundColor: '#ffffff' },
|
||||||
|
listContent: { paddingBottom: 96 },
|
||||||
|
row: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: '#f3f4f6' },
|
||||||
|
rowHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
rowTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
rowSubtitle: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
rowActions: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
printButton: {
|
||||||
|
minHeight: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
printButtonText: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#6b7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
color: '#166534',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
printButtonDisabled: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
scannerScreen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
},
|
||||||
|
scannerHeader: {
|
||||||
|
paddingTop: 56,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
scannerTitle: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
scannerCloseButton: {
|
||||||
|
minHeight: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scannerCloseButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
scannerCamera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scannerHint: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
backgroundColor: 'rgba(17,24,39,0.85)',
|
||||||
|
},
|
||||||
|
});
|
||||||
410
mobile/app/more/nimbot.tsx
Normal file
410
mobile/app/more/nimbot.tsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
|
import { renderPrintLabel } from '@/src/lib/api';
|
||||||
|
import {
|
||||||
|
connectNimbotDevice,
|
||||||
|
disconnectNimbotDevice,
|
||||||
|
getActiveNimbotConnection,
|
||||||
|
printNimbotEncodedLabel,
|
||||||
|
scanNimbotDevices,
|
||||||
|
} from '@/src/lib/nimbot';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
type ListedDevice = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rssi: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NimbotScreen() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
itemName?: string;
|
||||||
|
itemId?: string;
|
||||||
|
serial?: string;
|
||||||
|
customerName?: string;
|
||||||
|
customerInventoryId?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<ListedDevice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [manualLabelText, setManualLabelText] = useState('');
|
||||||
|
|
||||||
|
const activeConnection = getActiveNimbotConnection();
|
||||||
|
|
||||||
|
const normalizedInventoryId = useMemo(() => String(params.customerInventoryId || params.itemId || '').trim(), [params.customerInventoryId, params.itemId]);
|
||||||
|
const normalizedSerial = useMemo(() => String(params.serialNumber || params.serial || '').trim(), [params.serial, params.serialNumber]);
|
||||||
|
|
||||||
|
const prefilledText = useMemo(() => {
|
||||||
|
const parts = [params.itemName, normalizedInventoryId ? `ID: ${normalizedInventoryId}` : null, normalizedSerial ? `SN: ${normalizedSerial}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => String(value));
|
||||||
|
return parts.join(' | ');
|
||||||
|
}, [normalizedInventoryId, normalizedSerial, params.itemName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefilledText) return;
|
||||||
|
setManualLabelText(prefilledText);
|
||||||
|
}, [prefilledText]);
|
||||||
|
|
||||||
|
const loadDevices = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
setScanning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await scanNimbotDevices();
|
||||||
|
setDevices(result);
|
||||||
|
if (result.length === 0) {
|
||||||
|
setInfo('Kein Nimbot gefunden. Drucker einschalten und nah an das iPhone halten.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Nimbot-Suche fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDevices();
|
||||||
|
}, [loadDevices]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConnect(deviceId: string) {
|
||||||
|
setConnectingId(deviceId);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connected = await connectNimbotDevice(deviceId);
|
||||||
|
setInfo(`Verbunden mit ${connected.device.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setConnectingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDisconnect() {
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await disconnectNimbotDevice();
|
||||||
|
setInfo('Verbindung getrennt.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Trennen fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPrint() {
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
setPrinting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Nicht angemeldet. Bitte erneut einloggen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = manualLabelText.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Bitte Label-Inhalt eingeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
text,
|
||||||
|
name: params.itemName || text,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (normalizedInventoryId) {
|
||||||
|
context.customerInventoryId = normalizedInventoryId;
|
||||||
|
}
|
||||||
|
if (normalizedSerial) {
|
||||||
|
context.serialNumber = normalizedSerial;
|
||||||
|
}
|
||||||
|
if (params.customerName) {
|
||||||
|
context.customerName = String(params.customerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = await renderPrintLabel(token, context, 584, 354);
|
||||||
|
|
||||||
|
await printNimbotEncodedLabel(rendered.encoded, {
|
||||||
|
density: 5,
|
||||||
|
copies: 1,
|
||||||
|
labelType: 1,
|
||||||
|
});
|
||||||
|
setInfo('Label wurde an den Nimbot gesendet.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Label konnte nicht gedruckt werden.');
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedId = activeConnection?.device.id || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Nimbot M2</Text>
|
||||||
|
<Text style={styles.subtitle}>Bluetooth-Anbindung (Beta)</Text>
|
||||||
|
|
||||||
|
{connectedId ? (
|
||||||
|
<Text style={styles.connectedText}>Verbunden: {activeConnection?.device.name || connectedId}</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.disconnectedText}>Nicht verbunden</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, scanning ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => void loadDevices()}
|
||||||
|
disabled={scanning}>
|
||||||
|
<Text style={styles.primaryButtonText}>{scanning ? 'Suche...' : 'Geräte suchen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.secondaryButton, !connectedId ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => void onDisconnect()}
|
||||||
|
disabled={!connectedId}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Trennen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={manualLabelText}
|
||||||
|
onChangeText={setManualLabelText}
|
||||||
|
placeholder="Label-Inhalt für nächsten Druck"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, (!connectedId || printing) ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => void onPrint()}
|
||||||
|
disabled={!connectedId || printing}>
|
||||||
|
<Text style={styles.primaryButtonText}>{printing ? 'Drucke...' : 'Label drucken'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{info ? <Text style={styles.info}>{info}</Text> : null}
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>Gefundene Geräte</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Suche läuft...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && devices.length === 0 ? <Text style={styles.empty}>Keine Geräte gefunden.</Text> : null}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
devices.map((device) => {
|
||||||
|
const isConnected = connectedId === device.id;
|
||||||
|
const isConnecting = connectingId === device.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={device.id} style={styles.deviceRow}>
|
||||||
|
<View style={styles.deviceInfo}>
|
||||||
|
<Text style={styles.deviceName} numberOfLines={1}>
|
||||||
|
{device.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.deviceMeta} numberOfLines={1}>
|
||||||
|
{device.id}
|
||||||
|
{typeof device.rssi === 'number' ? ` · RSSI ${device.rssi}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.connectButton,
|
||||||
|
isConnected ? styles.connectButtonConnected : null,
|
||||||
|
isConnecting ? styles.buttonDisabled : null,
|
||||||
|
]}
|
||||||
|
onPress={() => void onConnect(device.id)}
|
||||||
|
disabled={isConnecting || isConnected}>
|
||||||
|
<Text style={styles.connectButtonText}>
|
||||||
|
{isConnected ? 'Verbunden' : isConnecting ? 'Verbinde...' : 'Verbinden'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
padding: 12,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
connectedText: {
|
||||||
|
color: '#166534',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
disconnectedText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
minHeight: 72,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
minHeight: 40,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
minHeight: 40,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
deviceRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
deviceInfo: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
deviceName: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
deviceMeta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
connectButton: {
|
||||||
|
minHeight: 36,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
},
|
||||||
|
connectButtonConnected: {
|
||||||
|
borderColor: '#9ca3af',
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
},
|
||||||
|
connectButtonText: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
padding: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
color: '#1d4ed8',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
396
mobile/app/more/plant/[id].tsx
Normal file
396
mobile/app/more/plant/[id].tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
|
||||||
|
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
function formatDateTime(value: unknown): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(String(value));
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerName(raw: Plant['customer']): string {
|
||||||
|
if (!raw) return '-';
|
||||||
|
if (typeof raw === 'object') return String(raw.name || raw.id || '-');
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlantDetailScreen() {
|
||||||
|
const params = useLocalSearchParams<{ id?: string }>();
|
||||||
|
const plantId = Number(params.id);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const [plant, setPlant] = useState<Plant | null>(null);
|
||||||
|
const [files, setFiles] = useState<ProjectFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const validId = useMemo(() => Number.isFinite(plantId) && plantId > 0, [plantId]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!plant) return [];
|
||||||
|
return [
|
||||||
|
{ label: 'Name', value: String(plant.name || '-') },
|
||||||
|
{ label: 'Kunde', value: getCustomerName(plant.customer) },
|
||||||
|
{ label: 'Typ', value: String(plant.type || '-') },
|
||||||
|
{ label: 'Stadt', value: String(plant.city || '-') },
|
||||||
|
{ label: 'Strasse', value: String(plant.street || '-') },
|
||||||
|
{ label: 'PLZ', value: String(plant.zip || '-') },
|
||||||
|
{ label: 'Erstellt', value: formatDateTime(plant.createdAt || plant.created_at) },
|
||||||
|
{ label: 'Aktualisiert', value: formatDateTime(plant.updatedAt || plant.updated_at) },
|
||||||
|
];
|
||||||
|
}, [plant]);
|
||||||
|
|
||||||
|
const load = useCallback(async (showSpinner = true) => {
|
||||||
|
if (!token || !validId) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [plantData, fileData] = await Promise.all([
|
||||||
|
fetchPlantById(token, plantId),
|
||||||
|
fetchPlantFiles(token, plantId),
|
||||||
|
]);
|
||||||
|
setPlant(plantData);
|
||||||
|
setFiles(fileData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Objektdaten konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [plantId, token, validId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !validId) return;
|
||||||
|
void load(true);
|
||||||
|
}, [load, token, validId]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenFile(file: ProjectFile) {
|
||||||
|
if (!file.url) return;
|
||||||
|
await WebBrowser.openBrowserAsync(file.url, {
|
||||||
|
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||||
|
controlsColor: PRIMARY,
|
||||||
|
showTitle: true,
|
||||||
|
enableDefaultShareMenuItem: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickAndUpload() {
|
||||||
|
if (!token || !validId || uploading) return;
|
||||||
|
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
multiple: false,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
type: ['image/*', 'application/pdf', '*/*'],
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.name || `upload-${Date.now()}`;
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadPlantFile(token, {
|
||||||
|
plantId,
|
||||||
|
uri: asset.uri,
|
||||||
|
filename,
|
||||||
|
mimeType: asset.mimeType || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImageFromUri(uri: string, filename: string, mimeType?: string) {
|
||||||
|
if (!token || !validId) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadPlantFile(token, {
|
||||||
|
plantId,
|
||||||
|
uri,
|
||||||
|
filename,
|
||||||
|
mimeType: mimeType || 'image/jpeg',
|
||||||
|
});
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickImage() {
|
||||||
|
if (!token || !validId || uploading) return;
|
||||||
|
|
||||||
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
setError('Bitte erlaube den Zugriff auf deine Fotos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
quality: 0.85,
|
||||||
|
allowsEditing: false,
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.fileName || `bild-${Date.now()}.jpg`;
|
||||||
|
await uploadImageFromUri(asset.uri, filename, asset.mimeType || 'image/jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTakePhoto() {
|
||||||
|
if (!token || !validId || uploading) return;
|
||||||
|
|
||||||
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
setError('Bitte erlaube den Zugriff auf die Kamera.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
quality: 0.85,
|
||||||
|
allowsEditing: false,
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.fileName || `foto-${Date.now()}.jpg`;
|
||||||
|
await uploadImageFromUri(asset.uri, filename, asset.mimeType || 'image/jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Objekt wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && plant ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.title}>Informationen</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.uploadButton}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/more/wiki',
|
||||||
|
params: {
|
||||||
|
entityType: 'plants',
|
||||||
|
entityId: String(plantId),
|
||||||
|
title: `Objekt-Wiki: ${String(plant.name || plantId)}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<Text style={styles.uploadButtonText}>Wiki</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.table}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<View key={row.label} style={styles.row}>
|
||||||
|
<Text style={styles.label}>{row.label}</Text>
|
||||||
|
<Text style={styles.value}>{row.value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Dokumente ({files.length})</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.uploadActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onTakePhoto}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Foto aufnehmen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onPickImage}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Bild auswählen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onPickAndUpload}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Dokument hochladen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Noch keine Dokumente vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<Pressable key={file.id} style={styles.fileRow} onPress={() => onOpenFile(file)}>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={2}>
|
||||||
|
{file.name || file.path?.split('/').pop() || file.id}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fileMeta}>{file.mimeType || 'Datei'}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
uploadButton: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
uploadActions: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
uploadButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
fileRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
fileMeta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
432
mobile/app/more/plants.tsx
Normal file
432
mobile/app/more/plants.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
import { createPlant, Customer, fetchCustomers, fetchPlants, Plant } from '@/src/lib/api';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
function getCustomerName(raw: Plant['customer']): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === 'object') return raw.name ? String(raw.name) : null;
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlantsScreen() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [plants, setPlants] = useState<Plant[]>([]);
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [nameInput, setNameInput] = useState('');
|
||||||
|
const [descriptionInput, setDescriptionInput] = useState('');
|
||||||
|
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [pickerMode, setPickerMode] = useState<'customer' | null>(null);
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
return plants.filter((plant) => {
|
||||||
|
if (!showArchived && plant.archived) return false;
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
|
||||||
|
const haystack = [plant.name, plant.description, getCustomerName(plant.customer)]
|
||||||
|
.map((value) => String(value || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}, [plants, search, showArchived]);
|
||||||
|
|
||||||
|
const filteredCustomerOptions = useMemo(() => {
|
||||||
|
const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
return customers
|
||||||
|
.filter((customer) => !customer.archived)
|
||||||
|
.filter((customer) => {
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
const haystack = [customer.name, customer.customerNumber]
|
||||||
|
.map((value) => String(value || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}, [customerSearch, customers]);
|
||||||
|
|
||||||
|
const selectedCustomerLabel = useMemo(() => {
|
||||||
|
if (!selectedCustomerId) return 'Kunde auswählen (optional)';
|
||||||
|
const customer = customers.find((item) => Number(item.id) === selectedCustomerId);
|
||||||
|
return customer ? `${customer.name}${customer.customerNumber ? ` · ${customer.customerNumber}` : ''}` : `ID ${selectedCustomerId}`;
|
||||||
|
}, [customers, selectedCustomerId]);
|
||||||
|
|
||||||
|
const load = useCallback(async (showSpinner = true) => {
|
||||||
|
if (!token) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [plantRows, customerRows] = await Promise.all([fetchPlants(token, true), fetchCustomers(token, true)]);
|
||||||
|
setPlants(plantRows);
|
||||||
|
setCustomers(customerRows);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Objekte konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
void load(true);
|
||||||
|
}, [load, token]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateError(null);
|
||||||
|
setNameInput('');
|
||||||
|
setDescriptionInput('');
|
||||||
|
setSelectedCustomerId(null);
|
||||||
|
setCustomerSearch('');
|
||||||
|
setPickerMode(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreatePlant() {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const name = nameInput.trim();
|
||||||
|
if (!name) {
|
||||||
|
setCreateError('Bitte einen Namen eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setCreateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createPlant(token, {
|
||||||
|
name,
|
||||||
|
customer: selectedCustomerId,
|
||||||
|
description: descriptionInput.trim() || null,
|
||||||
|
});
|
||||||
|
closeCreateModal();
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : 'Objekt konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<View style={styles.searchWrap}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Objekte suchen"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
|
||||||
|
onPress={() => setShowArchived((prev) => !prev)}>
|
||||||
|
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.list}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Objekte werden geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && filtered.length === 0 ? <Text style={styles.empty}>Keine Objekte gefunden.</Text> : null}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
filtered.map((plant) => (
|
||||||
|
<Pressable
|
||||||
|
key={String(plant.id)}
|
||||||
|
style={({ pressed }) => [styles.row, pressed ? styles.rowPressed : null]}
|
||||||
|
onPress={() => router.push(`/more/plant/${plant.id}`)}>
|
||||||
|
<View style={styles.rowHeader}>
|
||||||
|
<Text style={styles.rowTitle} numberOfLines={1}>{plant.name}</Text>
|
||||||
|
{plant.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
|
||||||
|
</View>
|
||||||
|
{getCustomerName(plant.customer) ? (
|
||||||
|
<Text style={styles.rowSubtitle} numberOfLines={1}>Kunde: {getCustomerName(plant.customer)}</Text>
|
||||||
|
) : null}
|
||||||
|
{plant.description ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(plant.description)}</Text> : null}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
|
||||||
|
<Text style={styles.fabText}>+</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Modal visible={createOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Neues Objekt</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={nameInput}
|
||||||
|
onChangeText={setNameInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable style={styles.selectButton} onPress={() => setPickerMode('customer')}>
|
||||||
|
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedCustomerLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.searchInput, styles.multilineInput]}
|
||||||
|
value={descriptionInput}
|
||||||
|
onChangeText={setDescriptionInput}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pickerMode === 'customer' ? (
|
||||||
|
<View style={styles.inlinePickerBox}>
|
||||||
|
<Text style={styles.inlinePickerTitle}>Kunde auswählen</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Kunden suchen"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={customerSearch}
|
||||||
|
onChangeText={setCustomerSearch}
|
||||||
|
/>
|
||||||
|
<ScrollView style={styles.pickerList}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.pickerRow}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedCustomerId(null);
|
||||||
|
setPickerMode(null);
|
||||||
|
}}>
|
||||||
|
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
|
||||||
|
</Pressable>
|
||||||
|
{filteredCustomerOptions.map((customer) => (
|
||||||
|
<Pressable
|
||||||
|
key={String(customer.id)}
|
||||||
|
style={styles.pickerRow}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedCustomerId(Number(customer.id));
|
||||||
|
setPickerMode(null);
|
||||||
|
}}>
|
||||||
|
<Text style={styles.pickerRowTitle} numberOfLines={1}>{customer.name}</Text>
|
||||||
|
<Text style={styles.pickerRowMeta} numberOfLines={1}>
|
||||||
|
{customer.customerNumber ? `Nr. ${customer.customerNumber}` : `ID ${customer.id}`}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{createError ? <Text style={styles.error}>{createError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onCreatePlant}
|
||||||
|
disabled={saving}>
|
||||||
|
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: { flex: 1, backgroundColor: '#ffffff' },
|
||||||
|
searchWrap: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
list: { flex: 1, backgroundColor: '#ffffff' },
|
||||||
|
listContent: { paddingBottom: 96 },
|
||||||
|
row: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: '#f3f4f6' },
|
||||||
|
rowHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
rowTitle: { flex: 1, color: '#111827', fontSize: 15, fontWeight: '600' },
|
||||||
|
rowSubtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
|
||||||
|
badge: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
toggleButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
|
||||||
|
toggleButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
|
||||||
|
toggleButtonTextActive: { color: '#3d7a30' },
|
||||||
|
loadingBox: { paddingVertical: 20, alignItems: 'center', gap: 8 },
|
||||||
|
loadingText: { color: '#6b7280' },
|
||||||
|
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 20 },
|
||||||
|
error: { color: '#dc2626', fontSize: 13, paddingHorizontal: 16, paddingTop: 10 },
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 18,
|
||||||
|
bottom: 20,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#111827',
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 },
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(17, 24, 39, 0.45)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalKeyboardWrap: { width: '100%' },
|
||||||
|
modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 10 },
|
||||||
|
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' },
|
||||||
|
multilineInput: { minHeight: 92, textAlignVertical: 'top' },
|
||||||
|
selectButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
selectButtonText: { color: '#111827', fontSize: 15 },
|
||||||
|
inlinePickerBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
},
|
||||||
|
inlinePickerTitle: { color: '#111827', fontSize: 14, fontWeight: '700' },
|
||||||
|
pickerList: { maxHeight: 220 },
|
||||||
|
pickerRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
pickerRowTitle: { color: '#111827', fontSize: 14, fontWeight: '600' },
|
||||||
|
pickerRowMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||||||
|
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 4 },
|
||||||
|
secondaryButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryButtonText: { color: '#374151', fontWeight: '600' },
|
||||||
|
primaryButton: {
|
||||||
|
borderRadius: 10,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
},
|
||||||
|
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
|
||||||
|
buttonDisabled: { opacity: 0.6 },
|
||||||
|
});
|
||||||
223
mobile/app/more/settings.tsx
Normal file
223
mobile/app/more/settings.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { DEFAULT_API_BASE_URL } from '@/src/config/env';
|
||||||
|
import {
|
||||||
|
getApiBaseUrlSync,
|
||||||
|
hydrateApiBaseUrl,
|
||||||
|
resetApiBaseUrl,
|
||||||
|
serverStorageInfo,
|
||||||
|
setApiBaseUrl,
|
||||||
|
} from '@/src/lib/server-config';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
function isValidServerUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\/.+/i.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const { logout, token } = useAuth();
|
||||||
|
const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync());
|
||||||
|
const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync());
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
const current = await hydrateApiBaseUrl();
|
||||||
|
setServerUrlInput(current);
|
||||||
|
setSavedUrl(current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
if (!isValidServerUrl(serverUrl)) {
|
||||||
|
setError('Bitte eine gültige URL mit http:// oder https:// eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const normalized = await setApiBaseUrl(serverUrl);
|
||||||
|
setServerUrlInput(normalized);
|
||||||
|
setSavedUrl(normalized);
|
||||||
|
setSuccess('Server-Instanz gespeichert.');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await logout();
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Server-Instanz konnte nicht gespeichert werden.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onResetToDefault() {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fallback = await resetApiBaseUrl();
|
||||||
|
setServerUrlInput(fallback);
|
||||||
|
setSavedUrl(fallback);
|
||||||
|
setSuccess('Auf Standard-Server zurückgesetzt.');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await logout();
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Zuruecksetzen fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = serverUrl.trim() !== savedUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Server-Instanz</Text>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Hinterlege hier die URL deiner eigenen FEDEO-Server-Instanz. Nach dem Speichern wird die Session
|
||||||
|
neu gestartet.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
value={serverUrl}
|
||||||
|
onChangeText={setServerUrlInput}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder="https://dein-server.tld"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.meta}>Aktiv: {savedUrl}</Text>
|
||||||
|
<Text style={styles.meta}>Standard: {DEFAULT_API_BASE_URL}</Text>
|
||||||
|
<Text style={styles.meta}>Storage: {serverStorageInfo.mode}</Text>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
{success ? <Text style={styles.success}>{success}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.saveButton, (!isDirty || submitting) ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onSave}
|
||||||
|
disabled={!isDirty || submitting}>
|
||||||
|
<Text style={styles.saveButtonText}>{submitting ? 'Speichern...' : 'Speichern'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.resetButton, submitting ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onResetToDefault}
|
||||||
|
disabled={submitting}>
|
||||||
|
<Text style={styles.resetButtonText}>Auf Standard zurücksetzen</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
minHeight: 42,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
color: '#166534',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
804
mobile/app/more/wiki.tsx
Normal file
804
mobile/app/more/wiki.tsx
Normal file
@@ -0,0 +1,804 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createWikiPage,
|
||||||
|
deleteWikiPage,
|
||||||
|
fetchWikiPageById,
|
||||||
|
fetchWikiTree,
|
||||||
|
updateWikiPage,
|
||||||
|
WikiTreeItem,
|
||||||
|
} from '@/src/lib/api';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
|
type FlatNode = WikiTreeItem & { depth: number };
|
||||||
|
|
||||||
|
type TiptapDoc = {
|
||||||
|
type: string;
|
||||||
|
content?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorMessage = {
|
||||||
|
type: 'ready' | 'content' | 'error';
|
||||||
|
content?: unknown;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebViewComponent: any = (() => {
|
||||||
|
try {
|
||||||
|
// `react-native-webview` is optional until dependency install is complete on the user's machine.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
return require('react-native-webview').WebView;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function createEmptyDoc(): TiptapDoc {
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', content: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(value: unknown): TiptapDoc {
|
||||||
|
if (value && typeof value === 'object' && (value as { type?: unknown }).type === 'doc') {
|
||||||
|
return value as TiptapDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return createEmptyDoc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTiptapHtml(initialDoc: TiptapDoc): string {
|
||||||
|
const initialDocJson = JSON.stringify(initialDoc).replace(/</g, '\\u003c');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; background: #ffffff; color: #111827; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||||
|
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
#toolbar { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; }
|
||||||
|
button { border: 1px solid #d1d5db; background: #fff; color: #374151; border-radius: 8px; padding: 6px 10px; font-size: 12px; }
|
||||||
|
button:active { transform: translateY(1px); }
|
||||||
|
#editor { flex: 1; overflow: auto; padding: 12px; font-size: 16px; line-height: 1.5; }
|
||||||
|
.ProseMirror { min-height: 100%; outline: none; }
|
||||||
|
.ProseMirror p { margin: 0 0 10px; }
|
||||||
|
.ProseMirror h1 { font-size: 1.5rem; margin: 0.2em 0 0.5em; }
|
||||||
|
.ProseMirror h2 { font-size: 1.25rem; margin: 0.2em 0 0.5em; }
|
||||||
|
.ProseMirror ul, .ProseMirror ol { padding-left: 1.2rem; }
|
||||||
|
.ProseMirror a { color: #2563eb; text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="toolbar">
|
||||||
|
<button id="bold" type="button"><b>B</b></button>
|
||||||
|
<button id="italic" type="button"><i>I</i></button>
|
||||||
|
<button id="h1" type="button">H1</button>
|
||||||
|
<button id="h2" type="button">H2</button>
|
||||||
|
<button id="bullet" type="button">Liste</button>
|
||||||
|
<button id="ordered" type="button">Nummeriert</button>
|
||||||
|
<button id="undo" type="button">↶</button>
|
||||||
|
<button id="redo" type="button">↷</button>
|
||||||
|
</div>
|
||||||
|
<div id="editor"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { Editor } from 'https://esm.sh/@tiptap/core@2.10.0';
|
||||||
|
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.10.0';
|
||||||
|
import Link from 'https://esm.sh/@tiptap/extension-link@2.10.0';
|
||||||
|
|
||||||
|
const initialContent = ${initialDocJson};
|
||||||
|
|
||||||
|
const send = (payload) => {
|
||||||
|
if (window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function') {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const editor = new Editor({
|
||||||
|
element: document.querySelector('#editor'),
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Link.configure({ openOnClick: false, autolink: true, linkOnPaste: true }),
|
||||||
|
],
|
||||||
|
content: initialContent,
|
||||||
|
autofocus: false,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
spellcheck: 'true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
send({ type: 'content', content: editor.getJSON() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const click = (id, handler) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
handler();
|
||||||
|
editor.commands.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
click('bold', () => editor.chain().focus().toggleBold().run());
|
||||||
|
click('italic', () => editor.chain().focus().toggleItalic().run());
|
||||||
|
click('h1', () => editor.chain().focus().toggleHeading({ level: 1 }).run());
|
||||||
|
click('h2', () => editor.chain().focus().toggleHeading({ level: 2 }).run());
|
||||||
|
click('bullet', () => editor.chain().focus().toggleBulletList().run());
|
||||||
|
click('ordered', () => editor.chain().focus().toggleOrderedList().run());
|
||||||
|
click('undo', () => editor.chain().focus().undo().run());
|
||||||
|
click('redo', () => editor.chain().focus().redo().run());
|
||||||
|
|
||||||
|
send({ type: 'ready' });
|
||||||
|
send({ type: 'content', content: editor.getJSON() });
|
||||||
|
} catch (error) {
|
||||||
|
send({ type: 'error', message: String(error?.message || error) });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVirtualNode(node: WikiTreeItem): boolean {
|
||||||
|
return Boolean(node.isVirtual) || String(node.id).startsWith('virtual-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WikiScreen() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const params = useLocalSearchParams<{ entityType?: string; entityId?: string; entityUuid?: string; title?: string }>();
|
||||||
|
|
||||||
|
const entityType = useMemo(() => String(params.entityType || '').trim(), [params.entityType]);
|
||||||
|
const entityId = useMemo(() => {
|
||||||
|
const value = Number(params.entityId);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
}, [params.entityId]);
|
||||||
|
const entityUuid = useMemo(() => String(params.entityUuid || '').trim() || null, [params.entityUuid]);
|
||||||
|
const screenTitle = useMemo(() => String(params.title || '').trim() || 'Wiki', [params.title]);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<WikiTreeItem[]>([]);
|
||||||
|
const [expandedIds, setExpandedIds] = useState<string[]>([]);
|
||||||
|
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [selectedTitle, setSelectedTitle] = useState('');
|
||||||
|
const [selectedDoc, setSelectedDoc] = useState<TiptapDoc>(createEmptyDoc());
|
||||||
|
const [editorVersion, setEditorVersion] = useState(0);
|
||||||
|
const [editorError, setEditorError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingPage, setLoadingPage] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newIsFolder, setNewIsFolder] = useState(false);
|
||||||
|
|
||||||
|
const webViewRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const flatNodes = useMemo(() => {
|
||||||
|
const byParent = new Map<string, WikiTreeItem[]>();
|
||||||
|
|
||||||
|
(items || []).forEach((item) => {
|
||||||
|
const parentId = String(item.parentId || 'root');
|
||||||
|
if (!byParent.has(parentId)) byParent.set(parentId, []);
|
||||||
|
byParent.get(parentId)?.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortNodes = (nodes: WikiTreeItem[]) =>
|
||||||
|
nodes.sort((a, b) => {
|
||||||
|
if ((a.isFolder ? 1 : 0) !== (b.isFolder ? 1 : 0)) return (b.isFolder ? 1 : 0) - (a.isFolder ? 1 : 0);
|
||||||
|
const sortA = Number(a.sortOrder || 0);
|
||||||
|
const sortB = Number(b.sortOrder || 0);
|
||||||
|
if (sortA !== sortB) return sortA - sortB;
|
||||||
|
return String(a.title || '').localeCompare(String(b.title || ''), 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
byParent.forEach((nodes, key) => {
|
||||||
|
byParent.set(key, sortNodes(nodes));
|
||||||
|
});
|
||||||
|
|
||||||
|
const out: FlatNode[] = [];
|
||||||
|
|
||||||
|
const walk = (parentId: string, depth: number) => {
|
||||||
|
const children = byParent.get(parentId) || [];
|
||||||
|
children.forEach((child) => {
|
||||||
|
out.push({ ...child, depth });
|
||||||
|
const isExpandable = Boolean(child.isFolder) || (byParent.get(String(child.id)) || []).length > 0;
|
||||||
|
if (isExpandable && expandedIds.includes(String(child.id))) {
|
||||||
|
walk(String(child.id), depth + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
walk('root', 0);
|
||||||
|
return out;
|
||||||
|
}, [expandedIds, items]);
|
||||||
|
|
||||||
|
const selectedNode = useMemo(
|
||||||
|
() => (selectedPageId ? (items || []).find((item) => String(item.id) === String(selectedPageId)) || null : null),
|
||||||
|
[items, selectedPageId]
|
||||||
|
);
|
||||||
|
const selectedNodeIsVirtual = useMemo(() => (selectedNode ? isVirtualNode(selectedNode) : false), [selectedNode]);
|
||||||
|
|
||||||
|
const isEntityScope = Boolean(entityType && (entityId || entityUuid));
|
||||||
|
|
||||||
|
const loadTree = useCallback(
|
||||||
|
async (showSpinner = true) => {
|
||||||
|
if (!token) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await fetchWikiTree(token, {
|
||||||
|
entityType: entityType || undefined,
|
||||||
|
entityId,
|
||||||
|
entityUuid,
|
||||||
|
});
|
||||||
|
setItems(rows || []);
|
||||||
|
|
||||||
|
const autoExpand = new Set<string>();
|
||||||
|
(rows || []).forEach((item) => {
|
||||||
|
if (item.isFolder || isVirtualNode(item)) autoExpand.add(String(item.id));
|
||||||
|
});
|
||||||
|
setExpandedIds(Array.from(autoExpand));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Wiki konnte nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[entityId, entityType, entityUuid, token]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadPage = useCallback(
|
||||||
|
async (pageId: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoadingPage(true);
|
||||||
|
setError(null);
|
||||||
|
setEditorError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await fetchWikiPageById(token, pageId);
|
||||||
|
setSelectedPageId(String(page.id));
|
||||||
|
setSelectedTitle(String(page.title || ''));
|
||||||
|
setSelectedDoc(normalizeDoc(page.content));
|
||||||
|
setEditorVersion((prev) => prev + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoadingPage(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
void loadTree(true);
|
||||||
|
}, [loadTree, token]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadTree(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSavePage() {
|
||||||
|
if (!token || !selectedPageId || saving) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateWikiPage(token, selectedPageId, {
|
||||||
|
title: selectedTitle.trim() || 'Ohne Titel',
|
||||||
|
content: selectedDoc,
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
String(item.id) === String(selectedPageId) ? { ...item, title: selectedTitle.trim() || 'Ohne Titel' } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setInfo('Wiki-Seite gespeichert.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht gespeichert werden.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeletePage() {
|
||||||
|
if (!token || !selectedPageId || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteWikiPage(token, selectedPageId);
|
||||||
|
setSelectedPageId(null);
|
||||||
|
setSelectedTitle('');
|
||||||
|
setSelectedDoc(createEmptyDoc());
|
||||||
|
setEditorVersion((prev) => prev + 1);
|
||||||
|
await loadTree(false);
|
||||||
|
setInfo('Wiki-Seite gelöscht.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht gelöscht werden.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setNewTitle('');
|
||||||
|
setNewIsFolder(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreatePage() {
|
||||||
|
if (!token || creating) return;
|
||||||
|
const title = newTitle.trim();
|
||||||
|
if (!title) {
|
||||||
|
setError('Bitte einen Titel eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await createWikiPage(token, {
|
||||||
|
title,
|
||||||
|
parentId: null,
|
||||||
|
isFolder: newIsFolder,
|
||||||
|
entityType: entityType || undefined,
|
||||||
|
entityId,
|
||||||
|
entityUuid,
|
||||||
|
});
|
||||||
|
closeCreateModal();
|
||||||
|
await loadTree(false);
|
||||||
|
|
||||||
|
if (!newIsFolder && page?.id) {
|
||||||
|
await loadPage(String(page.id));
|
||||||
|
}
|
||||||
|
setInfo(newIsFolder ? 'Wiki-Ordner erstellt.' : 'Wiki-Seite erstellt.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
<View style={styles.headerCard}>
|
||||||
|
<Text style={styles.headerTitle}>{screenTitle}</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>{isEntityScope ? 'Entity-Wiki' : 'Zentrale Wissensübersicht'}</Text>
|
||||||
|
|
||||||
|
<Pressable style={styles.primaryButton} onPress={() => setCreateModalOpen(true)}>
|
||||||
|
<Text style={styles.primaryButtonText}>Neue Seite</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
{info ? <Text style={styles.info}>{info}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>Seiten</Text>
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Wiki wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : flatNodes.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Noch keine Wiki-Seiten vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
<View style={styles.treeWrap}>
|
||||||
|
{flatNodes.map((node) => {
|
||||||
|
const id = String(node.id);
|
||||||
|
const isSelected = selectedPageId === id;
|
||||||
|
const childrenExist = flatNodes.some((item) => String(item.parentId || '') === id);
|
||||||
|
const expandable = Boolean(node.isFolder) || childrenExist || isVirtualNode(node);
|
||||||
|
const expanded = expandedIds.includes(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={id}
|
||||||
|
style={[styles.treeRow, isSelected ? styles.treeRowActive : null]}
|
||||||
|
onPress={() => {
|
||||||
|
if (expandable) {
|
||||||
|
setExpandedIds((prev) => (prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.isFolder || isVirtualNode(node)) return;
|
||||||
|
void loadPage(id);
|
||||||
|
}}>
|
||||||
|
<View style={[styles.treeRowInner, { paddingLeft: 10 + node.depth * 16 }]}>
|
||||||
|
<Text style={styles.treeArrow}>{expandable ? (expanded ? '⌄' : '›') : '·'}</Text>
|
||||||
|
<Text style={styles.treeIcon}>{node.isFolder || isVirtualNode(node) ? '📁' : '📄'}</Text>
|
||||||
|
<Text style={styles.treeTitle} numberOfLines={1}>
|
||||||
|
{String(node.title || 'Ohne Titel')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>Inhalt</Text>
|
||||||
|
|
||||||
|
{!selectedPageId ? (
|
||||||
|
<Text style={styles.empty}>Wähle eine Wiki-Seite aus der Liste aus.</Text>
|
||||||
|
) : loadingPage ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Inhalt wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
value={selectedTitle}
|
||||||
|
onChangeText={setSelectedTitle}
|
||||||
|
placeholder="Titel"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.editorWrap}>
|
||||||
|
{WebViewComponent ? (
|
||||||
|
<WebViewComponent
|
||||||
|
ref={webViewRef}
|
||||||
|
key={`${selectedPageId}-${editorVersion}`}
|
||||||
|
originWhitelist={['*']}
|
||||||
|
source={{ html: buildTiptapHtml(selectedDoc) }}
|
||||||
|
javaScriptEnabled
|
||||||
|
domStorageEnabled
|
||||||
|
onMessage={(event: any) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(String(event?.nativeEvent?.data || '{}')) as EditorMessage;
|
||||||
|
if (payload.type === 'content' && payload.content) {
|
||||||
|
setSelectedDoc(normalizeDoc(payload.content));
|
||||||
|
} else if (payload.type === 'error') {
|
||||||
|
setEditorError(payload.message || 'Editor-Fehler');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setEditorError('Editor-Nachricht konnte nicht gelesen werden.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onError={() => setEditorError('TipTap Editor konnte nicht geladen werden.')}
|
||||||
|
setSupportMultipleWindows={false}
|
||||||
|
style={styles.webView}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.missingWebView}>
|
||||||
|
<Text style={styles.error}>`react-native-webview` ist noch nicht installiert.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{editorError ? <Text style={styles.error}>{editorError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.editorActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => void onSavePage()}
|
||||||
|
disabled={saving}>
|
||||||
|
<Text style={styles.primaryButtonText}>{saving ? 'Speichert...' : 'Speichern'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.deleteButton, deleting ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => void onDeletePage()}
|
||||||
|
disabled={deleting || Boolean(selectedNode?.isFolder) || selectedNodeIsVirtual}>
|
||||||
|
<Text style={styles.deleteButtonText}>{deleting ? 'Löscht...' : 'Löschen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Neue Wiki-Seite</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={newTitle}
|
||||||
|
onChangeText={setNewTitle}
|
||||||
|
placeholder="Titel"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEntityScope ? (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.filterButton, newIsFolder ? styles.filterButtonActive : null]}
|
||||||
|
onPress={() => setNewIsFolder((prev) => !prev)}>
|
||||||
|
<Text style={[styles.filterButtonText, newIsFolder ? styles.filterButtonTextActive : null]}>
|
||||||
|
Als Ordner erstellen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={closeCreateModal}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, creating ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => void onCreatePage()}
|
||||||
|
disabled={creating}>
|
||||||
|
<Text style={styles.primaryButtonText}>{creating ? 'Erstelle...' : 'Erstellen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
headerCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
treeWrap: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
treeRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
treeRowActive: {
|
||||||
|
backgroundColor: '#f0f9eb',
|
||||||
|
},
|
||||||
|
treeRowInner: {
|
||||||
|
minHeight: 40,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingRight: 10,
|
||||||
|
},
|
||||||
|
treeArrow: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 15,
|
||||||
|
width: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
treeIcon: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
treeTitle: {
|
||||||
|
flex: 1,
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
editorWrap: {
|
||||||
|
height: 320,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
webView: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
missingWebView: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
editorActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ef4444',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
deleteButtonText: {
|
||||||
|
color: '#b91c1c',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
color: '#166534',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
modalKeyboardWrap: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
filterButton: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
filterButtonActive: {
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
},
|
||||||
|
filterButtonText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
filterButtonTextActive: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
},
|
||||||
|
});
|
||||||
802
mobile/app/project/[id].tsx
Normal file
802
mobile/app/project/[id].tsx
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createProjectTask,
|
||||||
|
fetchProjectById,
|
||||||
|
fetchProjectFiles,
|
||||||
|
fetchProjectTasks,
|
||||||
|
Project,
|
||||||
|
ProjectFile,
|
||||||
|
Task,
|
||||||
|
TaskStatus,
|
||||||
|
uploadProjectFile,
|
||||||
|
updateTask,
|
||||||
|
} from '@/src/lib/api';
|
||||||
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
|
||||||
|
const PRIMARY = '#69c350';
|
||||||
|
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
|
||||||
|
|
||||||
|
function normalizeTaskStatus(status: unknown): TaskStatus {
|
||||||
|
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
|
||||||
|
return 'Offen';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: unknown): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(String(value));
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefName(value: unknown): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const record = value as { name?: string; full_name?: string; email?: string; id?: string | number };
|
||||||
|
return String(record.name || record.full_name || record.email || record.id || '-');
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetailScreen() {
|
||||||
|
const params = useLocalSearchParams<{ id?: string }>();
|
||||||
|
const projectId = Number(params.id);
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [files, setFiles] = useState<ProjectFile[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
|
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
|
||||||
|
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
|
||||||
|
const [createTaskError, setCreateTaskError] = useState<string | null>(null);
|
||||||
|
const [newTaskName, setNewTaskName] = useState('');
|
||||||
|
const [newTaskDescription, setNewTaskDescription] = useState('');
|
||||||
|
const [showCompletedTasks, setShowCompletedTasks] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const validProjectId = useMemo(() => Number.isFinite(projectId) && projectId > 0, [projectId]);
|
||||||
|
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
|
||||||
|
const infoRows = useMemo(() => {
|
||||||
|
if (!project) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Projektname', value: project.name || '-' },
|
||||||
|
{ label: 'Projekt-Nr.', value: String(project.projectNumber || '-') },
|
||||||
|
{ label: 'Aktive Phase', value: String(project.active_phase || '-') },
|
||||||
|
{ label: 'Kunde', value: getRefName(project.customer) },
|
||||||
|
{ label: 'Objekt', value: getRefName(project.plant) },
|
||||||
|
{ label: 'Vertrag', value: getRefName(project.contract) },
|
||||||
|
{ label: 'Projekttyp', value: getRefName(project.projecttype || project.projectType) },
|
||||||
|
{ label: 'Kunden-Ref.', value: String(project.customerRef || '-') },
|
||||||
|
{ label: 'Leistung', value: String(project.measure || '-') },
|
||||||
|
{ label: 'Erstellt', value: formatDateTime(project.createdAt || project.created_at) },
|
||||||
|
{ label: 'Aktualisiert', value: formatDateTime(project.updatedAt || project.updated_at) },
|
||||||
|
];
|
||||||
|
}, [project]);
|
||||||
|
const visibleTasks = useMemo(() => {
|
||||||
|
return (tasks || [])
|
||||||
|
.filter((task) => {
|
||||||
|
const status = normalizeTaskStatus(task.categorie);
|
||||||
|
return showCompletedTasks || status !== 'Abgeschlossen';
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const statusDiff =
|
||||||
|
TASK_STATUS_ORDER.indexOf(normalizeTaskStatus(a.categorie)) -
|
||||||
|
TASK_STATUS_ORDER.indexOf(normalizeTaskStatus(b.categorie));
|
||||||
|
if (statusDiff !== 0) return statusDiff;
|
||||||
|
return Number(b.id) - Number(a.id);
|
||||||
|
});
|
||||||
|
}, [showCompletedTasks, tasks]);
|
||||||
|
|
||||||
|
const load = useCallback(async (showSpinner = true) => {
|
||||||
|
if (!token || !validProjectId) return;
|
||||||
|
if (showSpinner) setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [projectData, fileData, taskData] = await Promise.all([
|
||||||
|
fetchProjectById(token, projectId),
|
||||||
|
fetchProjectFiles(token, projectId),
|
||||||
|
fetchProjectTasks(token, projectId),
|
||||||
|
]);
|
||||||
|
setProject(projectData);
|
||||||
|
setFiles(fileData);
|
||||||
|
setTasks(taskData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [projectId, token, validProjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !validProjectId) return;
|
||||||
|
void load(true);
|
||||||
|
}, [load, token, validProjectId]);
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateTaskModal() {
|
||||||
|
setCreateTaskModalOpen(false);
|
||||||
|
setCreateTaskError(null);
|
||||||
|
setNewTaskName('');
|
||||||
|
setNewTaskDescription('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateTask() {
|
||||||
|
if (!token || !validProjectId || creatingTask) return;
|
||||||
|
|
||||||
|
const name = newTaskName.trim();
|
||||||
|
if (!name) {
|
||||||
|
setCreateTaskError('Bitte einen Aufgabentitel eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingTask(true);
|
||||||
|
setCreateTaskError(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createProjectTask(token, {
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
description: newTaskDescription.trim() || null,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
closeCreateTaskModal();
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCreateTaskError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setCreatingTask(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenFile(file: ProjectFile) {
|
||||||
|
if (!file.url) return;
|
||||||
|
await WebBrowser.openBrowserAsync(file.url, {
|
||||||
|
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||||
|
controlsColor: PRIMARY,
|
||||||
|
showTitle: true,
|
||||||
|
enableDefaultShareMenuItem: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCompleteTask(task: Task) {
|
||||||
|
if (!token || !task?.id) return;
|
||||||
|
if (normalizeTaskStatus(task.categorie) === 'Abgeschlossen') return;
|
||||||
|
|
||||||
|
setUpdatingTaskId(Number(task.id));
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTask(token, Number(task.id), { categorie: 'Abgeschlossen' });
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((item) => (Number(item.id) === Number(task.id) ? { ...item, categorie: 'Abgeschlossen' } : item))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Aufgabe konnte nicht abgeschlossen werden.');
|
||||||
|
} finally {
|
||||||
|
setUpdatingTaskId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickAndUpload() {
|
||||||
|
if (!token || !validProjectId || uploading) return;
|
||||||
|
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
multiple: false,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
type: ['image/*', 'application/pdf', '*/*'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.name || `upload-${Date.now()}`;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadProjectFile(token, {
|
||||||
|
projectId,
|
||||||
|
uri: asset.uri,
|
||||||
|
filename,
|
||||||
|
mimeType: asset.mimeType || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImageFromUri(uri: string, filename: string, mimeType?: string) {
|
||||||
|
if (!token || !validProjectId) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadProjectFile(token, {
|
||||||
|
projectId,
|
||||||
|
uri,
|
||||||
|
filename,
|
||||||
|
mimeType: mimeType || 'image/jpeg',
|
||||||
|
});
|
||||||
|
await load(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickImage() {
|
||||||
|
if (!token || !validProjectId || uploading) return;
|
||||||
|
|
||||||
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
setError('Bitte erlaube den Zugriff auf deine Fotos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
quality: 0.85,
|
||||||
|
allowsEditing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.fileName || `bild-${Date.now()}.jpg`;
|
||||||
|
await uploadImageFromUri(asset.uri, filename, asset.mimeType || 'image/jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTakePhoto() {
|
||||||
|
if (!token || !validProjectId || uploading) return;
|
||||||
|
|
||||||
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
setError('Bitte erlaube den Zugriff auf die Kamera.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
quality: 0.85,
|
||||||
|
allowsEditing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const filename = asset.fileName || `foto-${Date.now()}.jpg`;
|
||||||
|
await uploadImageFromUri(asset.uri, filename, asset.mimeType || 'image/jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validProjectId) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={styles.error}>Ungültige Projekt-ID.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBox}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
<Text style={styles.loadingText}>Projekt wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && project ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.title}>Informationen</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.smallPrimaryButton}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/more/wiki',
|
||||||
|
params: {
|
||||||
|
entityType: 'projects',
|
||||||
|
entityId: String(projectId),
|
||||||
|
title: `Projekt-Wiki: ${String(project.name || projectId)}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<Text style={styles.smallPrimaryButtonText}>Wiki</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoTable}>
|
||||||
|
{infoRows.map((row) => (
|
||||||
|
<View key={row.label} style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{row.label}</Text>
|
||||||
|
<Text style={styles.infoValue}>{row.value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{project.notes ? (
|
||||||
|
<View style={styles.notesBox}>
|
||||||
|
<Text style={styles.notesLabel}>Notizen</Text>
|
||||||
|
<Text style={styles.notes}>{String(project.notes)}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
|
||||||
|
<View style={styles.sectionHeaderActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.filterChip, showCompletedTasks ? styles.filterChipActive : null]}
|
||||||
|
onPress={() => setShowCompletedTasks((prev) => !prev)}>
|
||||||
|
<Text style={[styles.filterChipText, showCompletedTasks ? styles.filterChipTextActive : null]}>
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.smallPrimaryButton} onPress={() => setCreateTaskModalOpen(true)}>
|
||||||
|
<Text style={styles.smallPrimaryButtonText}>Neue Aufgabe</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{visibleTasks.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Keine Aufgaben vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
visibleTasks.map((task) => {
|
||||||
|
const status = normalizeTaskStatus(task.categorie);
|
||||||
|
const isUpdatingTask = updatingTaskId === Number(task.id);
|
||||||
|
const canComplete = status !== 'Abgeschlossen';
|
||||||
|
return (
|
||||||
|
<View key={String(task.id)} style={styles.taskRow}>
|
||||||
|
<View style={styles.taskRowMain}>
|
||||||
|
<Text style={styles.taskTitle} numberOfLines={2}>
|
||||||
|
{task.name || '-'}
|
||||||
|
</Text>
|
||||||
|
{task.description ? (
|
||||||
|
<Text style={styles.taskDescription} numberOfLines={3}>
|
||||||
|
{String(task.description)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.taskRowFooter}>
|
||||||
|
<Text style={styles.statusBadge}>{status}</Text>
|
||||||
|
{canComplete ? (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.completeButton, isUpdatingTask ? styles.buttonDisabled : null]}
|
||||||
|
onPress={() => onCompleteTask(task)}
|
||||||
|
disabled={isUpdatingTask}>
|
||||||
|
<Text style={styles.completeButtonText}>{isUpdatingTask ? '...' : 'Abhaken'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Dokumente ({files.length})</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.uploadActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onTakePhoto}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Foto aufnehmen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onPickImage}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Bild auswählen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.uploadButton, uploading ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onPickAndUpload}
|
||||||
|
disabled={uploading}>
|
||||||
|
<Text style={styles.uploadButtonText}>{uploading ? 'Upload...' : 'Dokument hochladen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>Noch keine Dokumente vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
files.map((file) => (
|
||||||
|
<Pressable key={file.id} style={styles.fileRow} onPress={() => onOpenFile(file)}>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={2}>
|
||||||
|
{file.name || file.path?.split('/').pop() || file.id}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fileMeta}>{file.mimeType || 'Datei'}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal visible={createTaskModalOpen} transparent animationType="fade" onRequestClose={closeCreateTaskModal}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
style={styles.modalKeyboardWrap}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalTitle}>Neue Projekt-Aufgabe</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Titel"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={styles.input}
|
||||||
|
value={newTaskName}
|
||||||
|
onChangeText={setNewTaskName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
style={[styles.input, styles.inputMultiline]}
|
||||||
|
multiline
|
||||||
|
value={newTaskDescription}
|
||||||
|
onChangeText={setNewTaskDescription}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{createTaskError ? <Text style={styles.error}>{createTaskError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.secondaryButton, creatingTask ? styles.buttonDisabled : null]}
|
||||||
|
onPress={closeCreateTaskModal}
|
||||||
|
disabled={creatingTask}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.primaryButton, creatingTask ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onCreateTask}
|
||||||
|
disabled={creatingTask}>
|
||||||
|
<Text style={styles.primaryButtonText}>{creatingTask ? 'Speichere...' : 'Anlegen'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
infoTable: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 2,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
notesBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
notesLabel: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
sectionHeaderActions: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
filterChip: {
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
filterChipActive: {
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
},
|
||||||
|
filterChipText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
filterChipTextActive: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
},
|
||||||
|
smallPrimaryButton: {
|
||||||
|
minHeight: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
smallPrimaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
taskRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
taskRowMain: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
taskTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
taskDescription: {
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
color: '#3d7a30',
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
taskRowFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
completeButton: {
|
||||||
|
minHeight: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: PRIMARY,
|
||||||
|
backgroundColor: '#eff9ea',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
completeButtonText: {
|
||||||
|
color: '#3d7a30',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
uploadButton: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
uploadActions: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
uploadButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
fileRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
fileMeta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
loadingBox: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
modalKeyboardWrap: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#111827',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
inputMultiline: {
|
||||||
|
minHeight: 72,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
backgroundColor: PRIMARY,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
23
mobile/eas.json
Normal file
23
mobile/eas.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.14.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
628
mobile/src/lib/nimbot.ts
Normal file
628
mobile/src/lib/nimbot.ts
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
import { PermissionsAndroid, Platform } from 'react-native';
|
||||||
|
import { BleManager, Device, Subscription } from 'react-native-ble-plx';
|
||||||
|
|
||||||
|
import { EncodedLabelImage, EncodedLabelRow } from '@/src/lib/api';
|
||||||
|
|
||||||
|
const NIMBOT_NAME_MARKERS = ['NIIMBOT', 'NIMBOT', 'M2', 'D11', 'D110', 'B21'];
|
||||||
|
const SCAN_TIMEOUT_MS = 12_000;
|
||||||
|
const SCAN_AFTER_FIRST_FOUND_MS = 2_200;
|
||||||
|
|
||||||
|
type DiscoveredNimbotDevice = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rssi: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NimbotConnectionInfo = {
|
||||||
|
device: DiscoveredNimbotDevice;
|
||||||
|
writeServiceId: string | null;
|
||||||
|
writeCharacteristicId: string | null;
|
||||||
|
writeWithoutResponse: boolean;
|
||||||
|
writeWithResponse: boolean;
|
||||||
|
notifyServiceId: string | null;
|
||||||
|
notifyCharacteristicId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NimbotPrintOptions = {
|
||||||
|
density?: number;
|
||||||
|
labelType?: number;
|
||||||
|
copies?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
packetIntervalMs?: number;
|
||||||
|
printheadPixels?: number;
|
||||||
|
useIndexedRows?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NimbotPacket = {
|
||||||
|
command: number;
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PacketWaiter = {
|
||||||
|
ids: number[];
|
||||||
|
resolve: (packet: NimbotPacket) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let manager: BleManager | null = null;
|
||||||
|
let disconnectSubscription: Subscription | null = null;
|
||||||
|
let notifySubscription: Subscription | null = null;
|
||||||
|
let activeConnection: NimbotConnectionInfo | null = null;
|
||||||
|
let packetBuffer = new Uint8Array();
|
||||||
|
|
||||||
|
const packetWaiters: PacketWaiter[] = [];
|
||||||
|
|
||||||
|
function getManager(): BleManager {
|
||||||
|
if (!manager) manager = new BleManager();
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(device: Device): string {
|
||||||
|
return String(device.localName || device.name || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNimbotDevice(device: Device): boolean {
|
||||||
|
const name = normalizeName(device).toUpperCase();
|
||||||
|
if (!name) return false;
|
||||||
|
return NIMBOT_NAME_MARKERS.some((marker) => name.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDiscoveredDevice(device: Device): DiscoveredNimbotDevice {
|
||||||
|
return {
|
||||||
|
id: device.id,
|
||||||
|
name: normalizeName(device) || device.id,
|
||||||
|
rssi: typeof device.rssi === 'number' ? device.rssi : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toU16BE(value: number): [number, number] {
|
||||||
|
const v = clamp(Math.floor(value), 0, 0xffff);
|
||||||
|
return [(v >> 8) & 0xff, v & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toU16Parts(value: number): [number, number] {
|
||||||
|
return toU16BE(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPixelsForBitmapPacketWithMode(
|
||||||
|
buf: Uint8Array,
|
||||||
|
printheadPixels: number,
|
||||||
|
mode: 'auto' | 'split' | 'total' = 'auto'
|
||||||
|
): { total: number; parts: [number, number, number] } {
|
||||||
|
let total = 0;
|
||||||
|
const parts: [number, number, number] = [0, 0, 0];
|
||||||
|
const chunkSize = Math.floor(printheadPixels / 8 / 3);
|
||||||
|
|
||||||
|
let split = buf.byteLength <= chunkSize * 3;
|
||||||
|
if (mode === 'total') {
|
||||||
|
split = false;
|
||||||
|
} else if (mode === 'split') {
|
||||||
|
split = buf.byteLength <= chunkSize * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.forEach((value, byteN) => {
|
||||||
|
const chunkIdx = chunkSize > 0 ? Math.floor(byteN / chunkSize) : 0;
|
||||||
|
for (let bitN = 0; bitN < 8; bitN += 1) {
|
||||||
|
if ((value & (1 << bitN)) === 0) continue;
|
||||||
|
total += 1;
|
||||||
|
if (!split) continue;
|
||||||
|
if (chunkIdx > 2) continue;
|
||||||
|
parts[chunkIdx] = (parts[chunkIdx] + 1) & 0xff;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (split) {
|
||||||
|
return { total, parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hi, lo] = toU16BE(total);
|
||||||
|
return { total, parts: [0x00, lo, hi] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexPixels(buf: Uint8Array): Uint8Array {
|
||||||
|
const result: number[] = [];
|
||||||
|
|
||||||
|
for (let bytePos = 0; bytePos < buf.byteLength; bytePos += 1) {
|
||||||
|
const b = buf[bytePos];
|
||||||
|
for (let bitPos = 0; bitPos < 8; bitPos += 1) {
|
||||||
|
if (b & (1 << (7 - bitPos))) {
|
||||||
|
const pixelIndex = bytePos * 8 + bitPos;
|
||||||
|
const [hi, lo] = toU16BE(pixelIndex);
|
||||||
|
result.push(hi, lo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8Array.from(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRowData(rowData: EncodedLabelRow['rowData']): Uint8Array {
|
||||||
|
if (!rowData) return new Uint8Array();
|
||||||
|
if (rowData instanceof Uint8Array) return rowData;
|
||||||
|
if (Array.isArray(rowData)) return Uint8Array.from(rowData.map((v) => Number(v) & 0xff));
|
||||||
|
|
||||||
|
return Uint8Array.from(
|
||||||
|
Object.keys(rowData)
|
||||||
|
.sort((a, b) => Number(a) - Number(b))
|
||||||
|
.map((key) => Number((rowData as Record<string, number>)[key]) & 0xff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNimbotPacket(command: number, payload: number[]): Uint8Array {
|
||||||
|
const cmd = command & 0xff;
|
||||||
|
const length = payload.length & 0xff;
|
||||||
|
let checksum = cmd ^ length;
|
||||||
|
for (const b of payload) checksum ^= b & 0xff;
|
||||||
|
return Uint8Array.from([0x55, 0x55, cmd, length, ...payload, checksum & 0xff, 0xaa, 0xaa]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64(bytes: Uint8Array): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const b0 = bytes[i] ?? 0;
|
||||||
|
const b1 = bytes[i + 1] ?? 0;
|
||||||
|
const b2 = bytes[i + 2] ?? 0;
|
||||||
|
const chunk = (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
|
||||||
|
output += chars[(chunk >> 18) & 63];
|
||||||
|
output += chars[(chunk >> 12) & 63];
|
||||||
|
output += i + 1 < bytes.length ? chars[(chunk >> 6) & 63] : '=';
|
||||||
|
output += i + 2 < bytes.length ? chars[chunk & 63] : '=';
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(input: string): Uint8Array {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||||
|
const out: number[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < clean.length) {
|
||||||
|
const c0 = chars.indexOf(clean[i] || 'A');
|
||||||
|
const c1 = chars.indexOf(clean[i + 1] || 'A');
|
||||||
|
const c2 = chars.indexOf(clean[i + 2] || 'A');
|
||||||
|
const c3 = chars.indexOf(clean[i + 3] || 'A');
|
||||||
|
|
||||||
|
const n = (Math.max(c0, 0) << 18) | (Math.max(c1, 0) << 12) | ((Math.max(c2, 0) & 63) << 6) | (Math.max(c3, 0) & 63);
|
||||||
|
out.push((n >> 16) & 0xff);
|
||||||
|
if (clean[i + 2] !== '=') out.push((n >> 8) & 0xff);
|
||||||
|
if (clean[i + 3] !== '=') out.push(n & 0xff);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPacketWaiters(message: string): void {
|
||||||
|
while (packetWaiters.length > 0) {
|
||||||
|
const waiter = packetWaiters.shift();
|
||||||
|
if (!waiter) continue;
|
||||||
|
clearTimeout(waiter.timeoutId);
|
||||||
|
waiter.reject(new Error(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNimbotPackets(chunk: Uint8Array): NimbotPacket[] {
|
||||||
|
packetBuffer = Uint8Array.from([...packetBuffer, ...chunk]);
|
||||||
|
const packets: NimbotPacket[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor + 8 <= packetBuffer.length) {
|
||||||
|
if (packetBuffer[cursor] !== 0x55 || packetBuffer[cursor + 1] !== 0x55) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = packetBuffer[cursor + 2] & 0xff;
|
||||||
|
const len = packetBuffer[cursor + 3] & 0xff;
|
||||||
|
const end = cursor + 8 + len;
|
||||||
|
if (end > packetBuffer.length) break;
|
||||||
|
|
||||||
|
if (packetBuffer[end - 2] !== 0xaa || packetBuffer[end - 1] !== 0xaa) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStart = cursor + 4;
|
||||||
|
const dataEnd = dataStart + len;
|
||||||
|
const data = packetBuffer.slice(dataStart, dataEnd);
|
||||||
|
const checksum = packetBuffer[dataEnd] & 0xff;
|
||||||
|
|
||||||
|
let calculated = command ^ len;
|
||||||
|
for (let i = 0; i < data.length; i += 1) {
|
||||||
|
calculated ^= data[i] & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((calculated & 0xff) === checksum) {
|
||||||
|
packets.push({ command, data });
|
||||||
|
cursor = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
packetBuffer = packetBuffer.slice(cursor);
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRawNotification(base64Value: string): void {
|
||||||
|
if (!base64Value) return;
|
||||||
|
const packets = parseNimbotPackets(decodeBase64(base64Value));
|
||||||
|
if (packets.length === 0) return;
|
||||||
|
|
||||||
|
for (const packet of packets) {
|
||||||
|
if (packet.command === 0xdb || packet.command === 0x00) {
|
||||||
|
clearPacketWaiters(`Drucker meldet Fehler (0x${packet.command.toString(16).padStart(2, '0')}).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiterIndex = packetWaiters.findIndex((waiter) => waiter.ids.length === 0 || waiter.ids.includes(packet.command));
|
||||||
|
if (waiterIndex < 0) continue;
|
||||||
|
|
||||||
|
const waiter = packetWaiters.splice(waiterIndex, 1)[0];
|
||||||
|
clearTimeout(waiter.timeoutId);
|
||||||
|
waiter.resolve(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForPacket(ids: number[] = [], timeoutMs = 1800): Promise<NimbotPacket> {
|
||||||
|
return new Promise<NimbotPacket>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const idx = packetWaiters.findIndex((waiter) => waiter.timeoutId === timeoutId);
|
||||||
|
if (idx >= 0) packetWaiters.splice(idx, 1);
|
||||||
|
reject(new Error(`Timeout auf Druckerantwort (${ids.map((id) => `0x${id.toString(16)}`).join(', ') || 'any'})`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
packetWaiters.push({ ids, resolve, reject, timeoutId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCharacteristics(device: Device): Promise<NimbotConnectionInfo> {
|
||||||
|
const services = await device.services();
|
||||||
|
let writeServiceId: string | null = null;
|
||||||
|
let writeCharacteristicId: string | null = null;
|
||||||
|
let writeWithoutResponse = false;
|
||||||
|
let writeWithResponse = false;
|
||||||
|
let notifyServiceId: string | null = null;
|
||||||
|
let notifyCharacteristicId: string | null = null;
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
|
if (!service.uuid || service.uuid.length < 5) continue;
|
||||||
|
const characteristics = await service.characteristics();
|
||||||
|
|
||||||
|
for (const characteristic of characteristics) {
|
||||||
|
if (characteristic.isNotifiable && characteristic.isWritableWithoutResponse) {
|
||||||
|
return {
|
||||||
|
device: toDiscoveredDevice(device),
|
||||||
|
writeServiceId: service.uuid,
|
||||||
|
writeCharacteristicId: characteristic.uuid,
|
||||||
|
writeWithoutResponse: true,
|
||||||
|
writeWithResponse: Boolean(characteristic.isWritableWithResponse),
|
||||||
|
notifyServiceId: service.uuid,
|
||||||
|
notifyCharacteristicId: characteristic.uuid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const characteristic of characteristics) {
|
||||||
|
if (!writeCharacteristicId && (characteristic.isWritableWithResponse || characteristic.isWritableWithoutResponse)) {
|
||||||
|
writeServiceId = service.uuid;
|
||||||
|
writeCharacteristicId = characteristic.uuid;
|
||||||
|
writeWithoutResponse = Boolean(characteristic.isWritableWithoutResponse);
|
||||||
|
writeWithResponse = Boolean(characteristic.isWritableWithResponse);
|
||||||
|
}
|
||||||
|
if (!notifyCharacteristicId && characteristic.isNotifiable) {
|
||||||
|
notifyServiceId = service.uuid;
|
||||||
|
notifyCharacteristicId = characteristic.uuid;
|
||||||
|
}
|
||||||
|
if (writeCharacteristicId && notifyCharacteristicId) {
|
||||||
|
return {
|
||||||
|
device: toDiscoveredDevice(device),
|
||||||
|
writeServiceId,
|
||||||
|
writeCharacteristicId,
|
||||||
|
writeWithoutResponse,
|
||||||
|
writeWithResponse,
|
||||||
|
notifyServiceId,
|
||||||
|
notifyCharacteristicId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
device: toDiscoveredDevice(device),
|
||||||
|
writeServiceId,
|
||||||
|
writeCharacteristicId,
|
||||||
|
writeWithoutResponse,
|
||||||
|
writeWithResponse,
|
||||||
|
notifyServiceId,
|
||||||
|
notifyCharacteristicId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestBluetoothPermissions(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') return true;
|
||||||
|
|
||||||
|
if (Platform.Version >= 31) {
|
||||||
|
const result = await PermissionsAndroid.requestMultiple([
|
||||||
|
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
||||||
|
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
result[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] === PermissionsAndroid.RESULTS.GRANTED &&
|
||||||
|
result[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] === PermissionsAndroid.RESULTS.GRANTED &&
|
||||||
|
result[PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION] === PermissionsAndroid.RESULTS.GRANTED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION);
|
||||||
|
return location === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanNimbotDevices(): Promise<DiscoveredNimbotDevice[]> {
|
||||||
|
const hasPermission = await requestBluetoothPermissions();
|
||||||
|
if (!hasPermission) throw new Error('Bluetooth-Berechtigungen fehlen.');
|
||||||
|
|
||||||
|
const ble = getManager();
|
||||||
|
const found = new Map<string, DiscoveredNimbotDevice>();
|
||||||
|
await ble.stopDeviceScan();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||||
|
ble.stopDeviceScan();
|
||||||
|
resolve();
|
||||||
|
}, SCAN_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const finalizeSoon = () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
ble.stopDeviceScan();
|
||||||
|
resolve();
|
||||||
|
}, SCAN_AFTER_FIRST_FOUND_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
ble.startDeviceScan(null, null, (error, device) => {
|
||||||
|
if (error) {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
ble.stopDeviceScan();
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device || !isNimbotDevice(device)) return;
|
||||||
|
found.set(device.id, toDiscoveredDevice(device));
|
||||||
|
finalizeSoon();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(found.values()).sort((a, b) => {
|
||||||
|
const rssiA = typeof a.rssi === 'number' ? a.rssi : -200;
|
||||||
|
const rssiB = typeof b.rssi === 'number' ? b.rssi : -200;
|
||||||
|
return rssiB - rssiA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectNimbotDevice(deviceId: string): Promise<NimbotConnectionInfo> {
|
||||||
|
const ble = getManager();
|
||||||
|
await ble.stopDeviceScan();
|
||||||
|
|
||||||
|
if (activeConnection?.device.id && activeConnection.device.id !== deviceId) {
|
||||||
|
try {
|
||||||
|
await ble.cancelDeviceConnection(activeConnection.device.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await ble.connectToDevice(deviceId, { autoConnect: false });
|
||||||
|
await connected.discoverAllServicesAndCharacteristics();
|
||||||
|
|
||||||
|
const metadata = await findCharacteristics(connected);
|
||||||
|
activeConnection = metadata;
|
||||||
|
|
||||||
|
if (metadata.notifyServiceId && metadata.notifyCharacteristicId) {
|
||||||
|
notifySubscription?.remove();
|
||||||
|
notifySubscription = ble.monitorCharacteristicForDevice(
|
||||||
|
connected.id,
|
||||||
|
metadata.notifyServiceId,
|
||||||
|
metadata.notifyCharacteristicId,
|
||||||
|
(error, characteristic) => {
|
||||||
|
if (error) return;
|
||||||
|
if (!characteristic?.value) return;
|
||||||
|
onRawNotification(characteristic.value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectSubscription?.remove();
|
||||||
|
disconnectSubscription = ble.onDeviceDisconnected(connected.id, () => {
|
||||||
|
clearPacketWaiters('Bluetooth-Verbindung getrennt.');
|
||||||
|
notifySubscription?.remove();
|
||||||
|
notifySubscription = null;
|
||||||
|
packetBuffer = new Uint8Array();
|
||||||
|
activeConnection = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveNimbotConnection(): NimbotConnectionInfo | null {
|
||||||
|
return activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectNimbotDevice(): Promise<void> {
|
||||||
|
if (!activeConnection?.device.id) return;
|
||||||
|
const ble = getManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ble.cancelDeviceConnection(activeConnection.device.id);
|
||||||
|
} finally {
|
||||||
|
clearPacketWaiters('Verbindung beendet.');
|
||||||
|
notifySubscription?.remove();
|
||||||
|
notifySubscription = null;
|
||||||
|
packetBuffer = new Uint8Array();
|
||||||
|
activeConnection = null;
|
||||||
|
disconnectSubscription?.remove();
|
||||||
|
disconnectSubscription = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNimbotRawCommand(payloadBase64: string): Promise<void> {
|
||||||
|
if (!activeConnection) throw new Error('Kein Nimbot verbunden.');
|
||||||
|
|
||||||
|
const ble = getManager();
|
||||||
|
const writeServiceId = activeConnection.writeServiceId;
|
||||||
|
const writeCharId = activeConnection.writeCharacteristicId;
|
||||||
|
if (!writeServiceId || !writeCharId) throw new Error('Kein schreibbares Characteristic gefunden.');
|
||||||
|
|
||||||
|
if (activeConnection.writeWithoutResponse) {
|
||||||
|
await ble.writeCharacteristicWithoutResponseForDevice(activeConnection.device.id, writeServiceId, writeCharId, payloadBase64);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection.writeWithResponse) {
|
||||||
|
await ble.writeCharacteristicWithResponseForDevice(activeConnection.device.id, writeServiceId, writeCharId, payloadBase64);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Characteristic ist nicht schreibbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNimbotPacket(
|
||||||
|
command: number,
|
||||||
|
payload: number[],
|
||||||
|
options: { expectedResponseIds?: number[]; timeoutMs?: number; connectPrefix03?: boolean; allowResponseTimeout?: boolean } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const packet = buildNimbotPacket(command, payload);
|
||||||
|
const payloadBytes = options.connectPrefix03 ? Uint8Array.from([0x03, ...Array.from(packet)]) : packet;
|
||||||
|
const waiter = options.expectedResponseIds ? waitForPacket(options.expectedResponseIds, options.timeoutMs ?? 1800) : null;
|
||||||
|
|
||||||
|
await sendNimbotRawCommand(encodeBase64(payloadBytes));
|
||||||
|
|
||||||
|
if (waiter) {
|
||||||
|
try {
|
||||||
|
await waiter;
|
||||||
|
} catch (error) {
|
||||||
|
if (!options.allowResponseTimeout) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNimbotConnectHandshake(): Promise<void> {
|
||||||
|
await sendNimbotPacket(0xc1, [0x01], {
|
||||||
|
expectedResponseIds: [0xc2],
|
||||||
|
timeoutMs: 2500,
|
||||||
|
connectPrefix03: true,
|
||||||
|
allowResponseTimeout: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImagePackets(
|
||||||
|
encoded: EncodedLabelImage,
|
||||||
|
options: { printheadPixels?: number; useIndexedRows?: boolean } = {}
|
||||||
|
): { cmd: number; payload: number[] }[] {
|
||||||
|
const packets: { cmd: number; payload: number[] }[] = [];
|
||||||
|
const printheadPixels = clamp(Math.floor(options.printheadPixels ?? 567), 32, 2048);
|
||||||
|
const useIndexedRows = Boolean(options.useIndexedRows);
|
||||||
|
|
||||||
|
for (const row of encoded.rowsData || []) {
|
||||||
|
if (row.dataType === 'void') {
|
||||||
|
const [rowHi, rowLo] = toU16Parts(row.rowNumber);
|
||||||
|
const repeat = clamp(row.repeat || 1, 1, 255);
|
||||||
|
packets.push({ cmd: 0x84, payload: [rowHi, rowLo, repeat] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.dataType !== 'pixels') continue;
|
||||||
|
|
||||||
|
const rowData = normalizeRowData(row.rowData);
|
||||||
|
const [rowHi, rowLo] = toU16Parts(row.rowNumber);
|
||||||
|
const repeat = clamp(row.repeat || 1, 1, 255);
|
||||||
|
const counts = countPixelsForBitmapPacketWithMode(rowData, printheadPixels, 'auto');
|
||||||
|
|
||||||
|
if (useIndexedRows && (row.blackPixelsCount || 0) <= 6) {
|
||||||
|
const indexes = Array.from(indexPixels(rowData));
|
||||||
|
packets.push({
|
||||||
|
cmd: 0x83,
|
||||||
|
payload: [rowHi, rowLo, ...counts.parts, repeat, ...indexes],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
packets.push({
|
||||||
|
cmd: 0x85,
|
||||||
|
payload: [rowHi, rowLo, ...counts.parts, repeat, ...Array.from(rowData)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function printNimbotEncodedLabel(encoded: EncodedLabelImage, options: NimbotPrintOptions = {}): Promise<void> {
|
||||||
|
if (!activeConnection) throw new Error('Kein Nimbot verbunden.');
|
||||||
|
|
||||||
|
const density = clamp(Math.floor(options.density ?? 5), 1, 5);
|
||||||
|
const labelType = clamp(Math.floor(options.labelType ?? 1), 1, 11);
|
||||||
|
const copies = clamp(Math.floor(options.copies ?? 1), 1, 20);
|
||||||
|
const totalPages = clamp(Math.floor(options.totalPages ?? 1), 1, 20);
|
||||||
|
const packetIntervalMs = clamp(Math.floor(options.packetIntervalMs ?? 10), 2, 80);
|
||||||
|
const printheadPixels = clamp(Math.floor(options.printheadPixels ?? 567), 32, 2048);
|
||||||
|
const useIndexedRows = Boolean(options.useIndexedRows ?? false);
|
||||||
|
|
||||||
|
if (!encoded?.rows || !encoded?.cols || !Array.isArray(encoded.rowsData)) {
|
||||||
|
throw new Error('Ungültiges Label-Format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Number(encoded.rows);
|
||||||
|
const cols = Number(encoded.cols);
|
||||||
|
const [rowsHi, rowsLo] = toU16Parts(rows);
|
||||||
|
const [colsHi, colsLo] = toU16Parts(cols);
|
||||||
|
const [copiesHi, copiesLo] = toU16Parts(copies);
|
||||||
|
|
||||||
|
await sendNimbotConnectHandshake();
|
||||||
|
await sleep(30);
|
||||||
|
|
||||||
|
await sendNimbotPacket(0x21, [density], { expectedResponseIds: [0x31], allowResponseTimeout: true });
|
||||||
|
await sleep(packetIntervalMs);
|
||||||
|
await sendNimbotPacket(0x23, [labelType], { expectedResponseIds: [0x33], allowResponseTimeout: true });
|
||||||
|
await sleep(packetIntervalMs);
|
||||||
|
await sendNimbotPacket(0x01, [0x00, totalPages, 0x00, 0x00, 0x00, 0x00, 0x00], {
|
||||||
|
expectedResponseIds: [0x02],
|
||||||
|
allowResponseTimeout: true,
|
||||||
|
});
|
||||||
|
await sleep(packetIntervalMs);
|
||||||
|
await sendNimbotPacket(0x03, [0x01], { expectedResponseIds: [0x04], allowResponseTimeout: true });
|
||||||
|
await sleep(packetIntervalMs);
|
||||||
|
await sendNimbotPacket(0x13, [rowsHi, rowsLo, colsHi, colsLo, copiesHi, copiesLo], {
|
||||||
|
expectedResponseIds: [0x14],
|
||||||
|
allowResponseTimeout: true,
|
||||||
|
});
|
||||||
|
await sleep(packetIntervalMs);
|
||||||
|
|
||||||
|
const imagePackets = buildImagePackets(encoded, { printheadPixels, useIndexedRows });
|
||||||
|
for (let i = 0; i < imagePackets.length; i += 1) {
|
||||||
|
const p = imagePackets[i];
|
||||||
|
await sendNimbotPacket(p.cmd, p.payload);
|
||||||
|
await sleep(packetIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendNimbotPacket(0xe3, [0x01], { expectedResponseIds: [0xe4], allowResponseTimeout: true });
|
||||||
|
await sleep(25);
|
||||||
|
await sendNimbotPacket(0xf3, [0x01], { expectedResponseIds: [0xf4], timeoutMs: 3000, allowResponseTimeout: true });
|
||||||
|
}
|
||||||
91
mobile/src/lib/server-config.ts
Normal file
91
mobile/src/lib/server-config.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
import { DEFAULT_API_BASE_URL } from '@/src/config/env';
|
||||||
|
|
||||||
|
const SERVER_BASE_KEY = 'fedeo.mobile.server.base';
|
||||||
|
const SERVER_SETUP_DONE_KEY = 'fedeo.mobile.server.setup.done';
|
||||||
|
|
||||||
|
let memoryApiBaseUrl = DEFAULT_API_BASE_URL;
|
||||||
|
let memorySetupDone = false;
|
||||||
|
|
||||||
|
function normalizeApiBaseUrl(value: string): string {
|
||||||
|
return value.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasSecureStore(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await SecureStore.isAvailableAsync();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiBaseUrlSync(): string {
|
||||||
|
return memoryApiBaseUrl || DEFAULT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateApiBaseUrl(): Promise<string> {
|
||||||
|
if (await hasSecureStore()) {
|
||||||
|
const stored = await SecureStore.getItemAsync(SERVER_BASE_KEY);
|
||||||
|
const setupDone = await SecureStore.getItemAsync(SERVER_SETUP_DONE_KEY);
|
||||||
|
memorySetupDone = setupDone === '1';
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
memoryApiBaseUrl = normalizeApiBaseUrl(stored);
|
||||||
|
return memoryApiBaseUrl;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
memorySetupDone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryApiBaseUrl = DEFAULT_API_BASE_URL;
|
||||||
|
return memoryApiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isServerSetupDone(): Promise<boolean> {
|
||||||
|
if (await hasSecureStore()) {
|
||||||
|
const setupDone = await SecureStore.getItemAsync(SERVER_SETUP_DONE_KEY);
|
||||||
|
memorySetupDone = setupDone === '1';
|
||||||
|
return memorySetupDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return memorySetupDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markServerSetupDone(): Promise<void> {
|
||||||
|
memorySetupDone = true;
|
||||||
|
|
||||||
|
if (await hasSecureStore()) {
|
||||||
|
await SecureStore.setItemAsync(SERVER_SETUP_DONE_KEY, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setApiBaseUrl(nextApiBaseUrl: string): Promise<string> {
|
||||||
|
const normalized = normalizeApiBaseUrl(nextApiBaseUrl);
|
||||||
|
memoryApiBaseUrl = normalized;
|
||||||
|
|
||||||
|
if (await hasSecureStore()) {
|
||||||
|
await SecureStore.setItemAsync(SERVER_BASE_KEY, normalized);
|
||||||
|
await SecureStore.setItemAsync(SERVER_SETUP_DONE_KEY, '1');
|
||||||
|
}
|
||||||
|
memorySetupDone = true;
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetApiBaseUrl(): Promise<string> {
|
||||||
|
memoryApiBaseUrl = DEFAULT_API_BASE_URL;
|
||||||
|
|
||||||
|
if (await hasSecureStore()) {
|
||||||
|
await SecureStore.deleteItemAsync(SERVER_BASE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memoryApiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverStorageInfo = {
|
||||||
|
mode: 'secure-store',
|
||||||
|
key: SERVER_BASE_KEY,
|
||||||
|
setupKey: SERVER_SETUP_DONE_KEY,
|
||||||
|
fallback: DEFAULT_API_BASE_URL,
|
||||||
|
} as const;
|
||||||
102
skills/openclaw-m2m-openapi/SKILL.md
Normal file
102
skills/openclaw-m2m-openapi/SKILL.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-m2m-openapi
|
||||||
|
description: Implement and maintain OpenAPI plus machine-to-machine access for FEDEO/OpenClaw-style Fastify backends. Use when creating tenant-bound API keys, authenticating M2M callers, exchanging API keys for short-lived JWTs, exposing OpenAPI docs/spec endpoints, and ensuring req.user is populated for impersonated tenant/user execution.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw M2M + OpenAPI
|
||||||
|
|
||||||
|
Implement OpenAPI publication and M2M authentication with tenant+user impersonation.
|
||||||
|
|
||||||
|
## Define Data Model
|
||||||
|
|
||||||
|
Create a DB table for M2M API keys with at least:
|
||||||
|
- Key identity: `id`, `name`, `key_prefix`, `key_hash`
|
||||||
|
- Scope: `tenant_id`, `user_id`
|
||||||
|
- Lifecycle: `active`, `expires_at`, `last_used_at`, `created_at`, `updated_at`
|
||||||
|
- Auditing: `created_by`
|
||||||
|
|
||||||
|
Store only a hash (`sha256`) of the key, never plaintext.
|
||||||
|
|
||||||
|
## Publish OpenAPI
|
||||||
|
|
||||||
|
Configure Fastify Swagger dynamically and expose:
|
||||||
|
- UI endpoint: `/docs`
|
||||||
|
- Raw spec endpoint: `/openapi.json`
|
||||||
|
|
||||||
|
Define OpenAPI metadata and bearer auth security schema (`bearerAuth`).
|
||||||
|
|
||||||
|
## Implement M2M Authentication
|
||||||
|
|
||||||
|
In M2M auth plugin:
|
||||||
|
1. Read API key from `x-api-key`.
|
||||||
|
2. Hash and compare against `key_hash` in DB.
|
||||||
|
3. Reject missing/invalid/inactive/expired keys with 401.
|
||||||
|
4. Load mapped tenant/user.
|
||||||
|
5. Populate `req.user` with:
|
||||||
|
- `user_id`
|
||||||
|
- `email`
|
||||||
|
- `tenant_id`
|
||||||
|
6. Update key usage metadata (`last_used_at`, `updated_at`).
|
||||||
|
|
||||||
|
Always keep `req.user` compatible with existing JWT-authenticated route expectations.
|
||||||
|
|
||||||
|
## Implement API Key Management
|
||||||
|
|
||||||
|
Expose tenant-scoped management endpoints on authenticated API routes:
|
||||||
|
- `GET /api/tenant/api-keys`
|
||||||
|
- `POST /api/tenant/api-keys`
|
||||||
|
- `PATCH /api/tenant/api-keys/:id`
|
||||||
|
- `DELETE /api/tenant/api-keys/:id`
|
||||||
|
|
||||||
|
For create:
|
||||||
|
- Generate random plaintext key once.
|
||||||
|
- Return plaintext only in create response.
|
||||||
|
- Persist only hash + prefix.
|
||||||
|
- Enforce that selected `user_id` belongs to current tenant.
|
||||||
|
|
||||||
|
## Add Token Exchange (Preferred M2M Flow)
|
||||||
|
|
||||||
|
Expose internal route:
|
||||||
|
- `POST /internal/auth/m2m/token`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
1. Require M2M-authenticated request.
|
||||||
|
2. Revalidate tenant membership of impersonated user.
|
||||||
|
3. Issue short-lived JWT signed with app JWT secret.
|
||||||
|
4. JWT payload must include `user_id`, `email`, `tenant_id`.
|
||||||
|
|
||||||
|
Use this JWT for normal `/api/*` requests via `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
## Security Rules
|
||||||
|
|
||||||
|
- Never log full API keys.
|
||||||
|
- Enforce TTL bounds for exchanged JWTs (e.g. 60..3600 seconds).
|
||||||
|
- Treat expired keys as unauthorized.
|
||||||
|
- Keep key hash comparison deterministic and normalized.
|
||||||
|
- Keep management endpoints tenant-isolated.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
Run and verify:
|
||||||
|
1. Backend build passes.
|
||||||
|
2. OpenAPI UI and raw spec are reachable.
|
||||||
|
3. API key create/list/update/delete works per tenant.
|
||||||
|
4. Exchange endpoint returns JWT.
|
||||||
|
5. JWT can call `/api/me` and shows impersonated tenant/user.
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build backend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Exchange key for JWT
|
||||||
|
curl -X POST http://localhost:3000/internal/auth/m2m/token \
|
||||||
|
-H "x-api-key: <M2M_API_KEY>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"expires_in_seconds":900}'
|
||||||
|
|
||||||
|
# Use exchanged JWT on normal API
|
||||||
|
curl http://localhost:3000/api/me \
|
||||||
|
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
||||||
|
```
|
||||||
7
skills/openclaw-m2m-openapi/agents/openai.yaml
Normal file
7
skills/openclaw-m2m-openapi/agents/openai.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "OpenClaw M2M + OpenAPI"
|
||||||
|
short_description: "Set up tenant-bound M2M auth and OpenAPI endpoints"
|
||||||
|
default_prompt: "Use $openclaw-m2m-openapi to add tenant-scoped API keys, token exchange, and OpenAPI publishing to this backend."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
Reference in New Issue
Block a user