This commit is contained in:
2026-03-16 20:46:26 +01:00
parent 52c182cb5f
commit 8a08147265
36 changed files with 51386 additions and 237 deletions

186
mobile/app/more/account.tsx Normal file
View 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,
},
});

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

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

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

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

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