Files
FEDEO/mobile/app/more/customer/[id].tsx
2026-03-16 20:46:26 +01:00

882 lines
26 KiB
TypeScript

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